···9898}
9999100100/// Get all captured log entries as a single string.
101101+#[allow(dead_code)]
101102pub fn get_logs() -> String {
102103 LOG_BUFFER.with(|buf| {
103104 let buf = buf.borrow();
···114114///
115115/// Returns the mapping and whether the cursor should snap to the next
116116/// visible position (for invisible content).
117117+#[allow(dead_code)]
117118pub fn find_mapping_for_char(
118119 offset_map: &[OffsetMapping],
119120 char_offset: usize,
···162163163164/// Result of finding a valid cursor position.
164165#[derive(Debug, Clone)]
166166+#[allow(dead_code)]
165167pub struct SnappedPosition<'a> {
166168 pub mapping: &'a OffsetMapping,
167169 pub offset_in_mapping: usize,
168170 pub snapped: Option<SnapDirection>,
169171}
170172173173+#[allow(dead_code)]
171174impl SnappedPosition<'_> {
172175 /// Get the absolute char offset for this position.
173176 pub fn char_offset(&self) -> usize {
···181184/// If the position is already valid, returns it directly. Otherwise,
182185/// searches in the preferred direction first, falling back to the other
183186/// direction if needed.
187187+#[allow(dead_code)]
184188pub fn find_nearest_valid_position(
185189 offset_map: &[OffsetMapping],
186190 char_offset: usize,
···219223}
220224221225/// Search for a valid position in a specific direction.
226226+#[allow(dead_code)]
222227fn find_valid_in_direction(
223228 offset_map: &[OffsetMapping],
224229 char_offset: usize,
···275280}
276281277282/// Check if a char offset is at a valid (non-invisible) cursor position.
283283+#[allow(dead_code)]
278284pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool {
279285 find_mapping_for_char(offset_map, char_offset)
280286 .map(|(m, should_snap)| !should_snap && m.utf16_len > 0)
···473479 let mappings = make_test_mappings();
474480475481 // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5)
476476- let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap();
482482+ let pos =
483483+ find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap();
477484 assert_eq!(pos.char_offset(), 5); // end of "alt" mapping
478485 assert_eq!(pos.snapped, Some(SnapDirection::Backward));
479486 }
···124124}
125125126126/// Check if cursor is in the same paragraph as a syntax span.
127127+#[allow(dead_code)]
127128fn cursor_in_same_paragraph(
128129 cursor_offset: usize,
129130 syntax_range: &Range<usize>,
+351-32
crates/weaver-app/src/components/editor/writer.rs
···55//!
66//! Uses Parser::into_offset_iter() to track gaps between events, which
77//! represent consumed formatting characters.
88-88+#[allow(unused_imports)]
99use super::offset_map::{OffsetMapping, RenderResult};
1010use jacquard::types::{ident::AtIdentifier, string::Rkey};
1111use loro::LoroText;
1212use markdown_weaver::{
1313 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
1414+ WeaverAttributes,
1415};
1516use markdown_weaver_escape::{
1617 StrWrite, escape_href, escape_html, escape_html_body_text,
···3031 segments: Vec<String>,
3132}
32333434+#[allow(dead_code)]
3335impl SegmentedWriter {
3436 pub fn new() -> Self {
3537 Self {
···375377 }
376378}
377379380380+/// Tracks the type of wrapper element emitted for WeaverBlock prefix
381381+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382382+enum WrapperElement {
383383+ Aside,
384384+ Div,
385385+}
386386+378387/// HTML writer that preserves markdown formatting characters.
379388///
380389/// This writer processes offset-iter events to detect gaps (consumed formatting)
···416425417426 // Offset mapping tracking - current paragraph
418427 offset_maps: Vec<OffsetMapping>,
419419- node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs
420420- auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value
428428+ node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs
429429+ auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value
421430 static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index
422422- current_paragraph_index: usize, // which paragraph we're currently building (0-indexed)
431431+ current_paragraph_index: usize, // which paragraph we're currently building (0-indexed)
423432 next_node_id: usize,
424433 current_node_id: Option<String>, // node ID for current text container
425434 current_node_char_offset: usize, // UTF-16 offset within current node
···450459 syntax_spans_by_para: Vec<Vec<SyntaxSpanInfo>>,
451460 refs_by_para: Vec<Vec<weaver_common::ExtractedRef>>,
452461462462+ // WeaverBlock prefix system
463463+ /// Pending WeaverBlock attrs to apply to the next block element
464464+ pending_block_attrs: Option<WeaverAttributes<'static>>,
465465+ /// Type of wrapper element currently open (needs closing on block end)
466466+ active_wrapper: Option<WrapperElement>,
467467+ /// Buffer for WeaverBlock text content (to parse for attrs)
468468+ weaver_block_buffer: String,
469469+ /// Start char offset of current WeaverBlock (for syntax span)
470470+ weaver_block_char_start: Option<usize>,
471471+472472+ // Footnote syntax linking (ref ↔ definition visibility)
473473+ /// Maps footnote name → (syntax_span_index, char_start) for linking ref and def
474474+ footnote_ref_spans: HashMap<String, (usize, usize)>,
475475+ /// Current footnote definition being processed (name, syntax_span_index, char_start)
476476+ current_footnote_def: Option<(String, usize, usize)>,
477477+453478 _phantom: std::marker::PhantomData<&'a ()>,
454479}
455480···519544 offset_maps_by_para: Vec::new(),
520545 syntax_spans_by_para: Vec::new(),
521546 refs_by_para: Vec::new(),
547547+ pending_block_attrs: None,
548548+ active_wrapper: None,
549549+ weaver_block_buffer: String::new(),
550550+ weaver_block_char_start: None,
551551+ footnote_ref_spans: HashMap::new(),
552552+ current_footnote_def: None,
522553 _phantom: std::marker::PhantomData,
523554 }
524555 }
···578609 offset_maps_by_para: self.offset_maps_by_para,
579610 syntax_spans_by_para: self.syntax_spans_by_para,
580611 refs_by_para: self.refs_by_para,
612612+ pending_block_attrs: self.pending_block_attrs,
613613+ active_wrapper: self.active_wrapper,
614614+ weaver_block_buffer: self.weaver_block_buffer,
615615+ weaver_block_char_start: self.weaver_block_char_start,
616616+ footnote_ref_spans: self.footnote_ref_spans,
617617+ current_footnote_def: self.current_footnote_def,
581618 _phantom: std::marker::PhantomData,
582619 }
583620 }
···608645 }
609646610647 /// Get the next paragraph ID that would be assigned (for tracking allocations).
648648+ #[allow(dead_code)]
611649 pub fn next_paragraph_id(&self) -> Option<usize> {
612650 self.auto_increment_prefix
613651 }
···11061144 }
1107114511081146 // Consume raw text events until end tag, for alt attributes
11471147+ #[allow(dead_code)]
11091148 fn raw_text(&mut self) -> Result<(), fmt::Error> {
11101149 use Event::*;
11111150 let mut nest = 0;
···11701209 }
11711210 }
1172121112121212+ /// Parse WeaverBlock text content into attributes.
12131213+ /// Format: comma-separated, colon for key:value, otherwise class.
12141214+ /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")]
12151215+ fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> {
12161216+ let mut classes = Vec::new();
12171217+ let mut attrs = Vec::new();
12181218+12191219+ for part in text.split(',') {
12201220+ let part = part.trim();
12211221+ if part.is_empty() {
12221222+ continue;
12231223+ }
12241224+12251225+ if let Some((key, value)) = part.split_once(':') {
12261226+ let key = key.trim();
12271227+ let value = value.trim();
12281228+ if !key.is_empty() && !value.is_empty() {
12291229+ attrs.push((CowStr::from(key.to_string()), CowStr::from(value.to_string())));
12301230+ }
12311231+ } else {
12321232+ // No colon - treat as class, strip leading dot if present
12331233+ let class = part.strip_prefix('.').unwrap_or(part);
12341234+ if !class.is_empty() {
12351235+ classes.push(CowStr::from(class.to_string()));
12361236+ }
12371237+ }
12381238+ }
12391239+12401240+ WeaverAttributes { classes, attrs }
12411241+ }
12421242+12431243+ /// Emit wrapper element start based on pending block attrs.
12441244+ /// Returns true if a wrapper was emitted.
12451245+ fn emit_wrapper_start(&mut self) -> Result<bool, fmt::Error> {
12461246+ if let Some(attrs) = self.pending_block_attrs.take() {
12471247+ let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside");
12481248+12491249+ if !self.end_newline {
12501250+ self.write("\n")?;
12511251+ }
12521252+12531253+ if is_aside {
12541254+ self.write("<aside")?;
12551255+ self.active_wrapper = Some(WrapperElement::Aside);
12561256+ } else {
12571257+ self.write("<div")?;
12581258+ self.active_wrapper = Some(WrapperElement::Div);
12591259+ }
12601260+12611261+ // Write classes (excluding "aside" if using <aside> element)
12621262+ let classes: Vec<_> = if is_aside {
12631263+ attrs
12641264+ .classes
12651265+ .iter()
12661266+ .filter(|c| c.as_ref() != "aside")
12671267+ .collect()
12681268+ } else {
12691269+ attrs.classes.iter().collect()
12701270+ };
12711271+12721272+ if !classes.is_empty() {
12731273+ self.write(" class=\"")?;
12741274+ for (i, class) in classes.iter().enumerate() {
12751275+ if i > 0 {
12761276+ self.write(" ")?;
12771277+ }
12781278+ escape_html(&mut self.writer, class)?;
12791279+ }
12801280+ self.write("\"")?;
12811281+ }
12821282+12831283+ // Write other attrs
12841284+ for (attr, value) in &attrs.attrs {
12851285+ self.write(" ")?;
12861286+ escape_html(&mut self.writer, attr)?;
12871287+ self.write("=\"")?;
12881288+ escape_html(&mut self.writer, value)?;
12891289+ self.write("\"")?;
12901290+ }
12911291+12921292+ self.write(">\n")?;
12931293+ Ok(true)
12941294+ } else {
12951295+ Ok(false)
12961296+ }
12971297+ }
12981298+12991299+ /// Close active wrapper element if one is open
13001300+ fn close_wrapper(&mut self) -> Result<(), fmt::Error> {
13011301+ if let Some(wrapper) = self.active_wrapper.take() {
13021302+ match wrapper {
13031303+ WrapperElement::Aside => self.write("</aside>\n")?,
13041304+ WrapperElement::Div => self.write("</div>\n")?,
13051305+ }
13061306+ }
13071307+ Ok(())
13081308+ }
13091309+11731310 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> {
11741311 use Event::*;
11751312···15461683 escape_html(&mut self.writer, spaces)?;
15471684 self.write("</span>")?;
1548168515491549- // Record syntax span info
15501550- // self.syntax_spans.push(SyntaxSpanInfo {
15511551- // syn_id,
15521552- // char_range: char_start..char_end,
15531553- // syntax_type: SyntaxType::Inline,
15541554- // formatted_range: None,
15551555- // });
15561556-15571686 // Count this span as a child
15581687 self.current_node_child_count += 1;
15591688···1570169915711700 // After <br>, emit plain zero-width space for cursor positioning
15721701 self.write(" ")?;
15731573- //self.write("\u{200B}")?;
1574170215751703 // Count the zero-width space text node as a child
15761704 self.current_node_child_count += 1;
···16361764 self.write("<div class=\"toggle-block\"><hr /></div>\n")?;
16371765 }
16381766 FootnoteReference(name) => {
17671767+ // Get/create footnote number
16391768 let len = self.numbers.len() + 1;
16401640- self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
16411641- escape_html(&mut self.writer, &name)?;
16421642- self.write("\">")?;
16431769 let number = *self.numbers.entry(name.to_string()).or_insert(len);
16441644- write!(&mut self.writer, "{}", number)?;
16451645- self.write("</a></sup>")?;
17701770+17711771+ // Emit the [^name] syntax as a hideable syntax span
17721772+ let raw_text = &self.source[range.clone()];
17731773+ let char_start = self.last_char_offset;
17741774+ let syntax_char_len = raw_text.chars().count();
17751775+ let char_end = char_start + syntax_char_len;
17761776+ let syn_id = self.gen_syn_id();
17771777+17781778+ write!(
17791779+ &mut self.writer,
17801780+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
17811781+ syn_id, char_start, char_end
17821782+ )?;
17831783+ escape_html(&mut self.writer, raw_text)?;
17841784+ self.write("</span>")?;
17851785+17861786+ // Track this span for linking with the footnote definition later
17871787+ let span_index = self.syntax_spans.len();
17881788+ self.syntax_spans.push(SyntaxSpanInfo {
17891789+ syn_id,
17901790+ char_range: char_start..char_end,
17911791+ syntax_type: SyntaxType::Inline,
17921792+ formatted_range: None, // Set when we see the definition
17931793+ });
17941794+ self.footnote_ref_spans
17951795+ .insert(name.to_string(), (span_index, char_start));
17961796+17971797+ // Record offset mapping for the syntax span content
17981798+ self.record_mapping(range.clone(), char_start..char_end);
17991799+18001800+ // Count as child
18011801+ self.current_node_child_count += 1;
18021802+18031803+ // Emit the visible footnote reference (superscript number)
18041804+ write!(
18051805+ &mut self.writer,
18061806+ "<sup class=\"footnote-reference\"><a href=\"#fn-{}\">{}</a></sup>",
18071807+ name, number
18081808+ )?;
18091809+18101810+ // Update tracking
18111811+ self.last_char_offset = char_end;
18121812+ self.last_byte_offset = range.end;
16461813 }
16471814 TaskListMarker(checked) => {
16481815 // Emit the [ ] or [x] syntax
···16801847 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?;
16811848 }
16821849 }
16831683- WeaverBlock(_) => {}
18501850+ WeaverBlock(text) => {
18511851+ // Buffer WeaverBlock content for parsing on End
18521852+ self.weaver_block_buffer.push_str(&text);
18531853+ }
16841854 }
16851855 Ok(())
16861856 }
···1830200018312001 Ok(())
18322002 }
18331833- Tag::Paragraph => {
20032003+ Tag::Paragraph(_) => {
20042004+ // Handle wrapper before block
20052005+ self.emit_wrapper_start()?;
20062006+18342007 // Record paragraph start for boundary tracking
18352008 // BUT skip if inside a list - list owns the paragraph boundary
18362009 if self.list_depth == 0 {
···18922065 classes,
18932066 attrs,
18942067 } => {
20682068+ // Emit wrapper if pending (but don't close on heading end - wraps following block too)
20692069+ self.emit_wrapper_start()?;
20702070+18952071 // Record paragraph start for boundary tracking
18962072 // Treat headings as paragraph-level blocks
18972073 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
···19802156 self.in_non_writing_block = true; // Suppress content output
19812157 Ok(())
19822158 } else {
21592159+ self.emit_wrapper_start()?;
19832160 self.table_alignments = alignments;
19842161 self.write("<table>")
19852162 }
···20182195 }
20192196 }
20202197 Tag::BlockQuote(kind) => {
21982198+ self.emit_wrapper_start()?;
21992199+20212200 let class_str = match kind {
20222201 None => "",
20232202 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"",
···20372216 Ok(())
20382217 }
20392218 Tag::CodeBlock(info) => {
22192219+ self.emit_wrapper_start()?;
22202220+20402221 // Track code block as paragraph-level block
20412222 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
20422223···21102291 }
21112292 }
21122293 Tag::List(Some(1)) => {
22942294+ self.emit_wrapper_start()?;
21132295 // Track list as paragraph-level block
21142296 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
21152297 self.list_depth += 1;
···21202302 }
21212303 }
21222304 Tag::List(Some(start)) => {
23052305+ self.emit_wrapper_start()?;
21232306 // Track list as paragraph-level block
21242307 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
21252308 self.list_depth += 1;
···21322315 self.write("\">\n")
21332316 }
21342317 Tag::List(None) => {
23182318+ self.emit_wrapper_start()?;
21352319 // Track list as paragraph-level block
21362320 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
21372321 self.list_depth += 1;
···22392423 Ok(())
22402424 }
22412425 Tag::DefinitionList => {
24262426+ self.emit_wrapper_start()?;
22422427 if self.end_newline {
22432428 self.write("<dl>\n")
22442429 } else {
···25072692 id,
25082693 attrs,
25092694 } => self.write_embed(range, embed_type, dest_url, title, id, attrs),
25102510- Tag::WeaverBlock(_, _) => {
26952695+ Tag::WeaverBlock(_, attrs) => {
25112696 self.in_non_writing_block = true;
26972697+ self.weaver_block_buffer.clear();
26982698+ self.weaver_block_char_start = Some(self.last_char_offset);
26992699+ // Store attrs from Start tag, will merge with parsed text on End
27002700+ if !attrs.classes.is_empty() || !attrs.attrs.is_empty() {
27012701+ self.pending_block_attrs = Some(attrs.into_static());
27022702+ }
25122703 Ok(())
25132704 }
25142705 Tag::FootnoteDefinition(name) => {
25152515- if self.end_newline {
25162516- self.write("<div class=\"footnote-definition\" id=\"")?;
25172517- } else {
25182518- self.write("\n<div class=\"footnote-definition\" id=\"")?;
27062706+ // Emit the [^name]: prefix as a hideable syntax span
27072707+ // The source should have "[^name]: " at the start
27082708+ let prefix = format!("[^{}]: ", name);
27092709+ let char_start = self.last_char_offset;
27102710+ let prefix_char_len = prefix.chars().count();
27112711+ let char_end = char_start + prefix_char_len;
27122712+ let syn_id = self.gen_syn_id();
27132713+27142714+ if !self.end_newline {
27152715+ self.write("\n")?;
25192716 }
25202520- escape_html(&mut self.writer, &name)?;
25212521- self.write("\"><sup class=\"footnote-definition-label\">")?;
27172717+27182718+ write!(
27192719+ &mut self.writer,
27202720+ "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
27212721+ syn_id, char_start, char_end
27222722+ )?;
27232723+ escape_html(&mut self.writer, &prefix)?;
27242724+ self.write("</span>")?;
27252725+27262726+ // Track this span for linking with the footnote reference
27272727+ let def_span_index = self.syntax_spans.len();
27282728+ self.syntax_spans.push(SyntaxSpanInfo {
27292729+ syn_id,
27302730+ char_range: char_start..char_end,
27312731+ syntax_type: SyntaxType::Block,
27322732+ formatted_range: None, // Set at FootnoteDefinition end
27332733+ });
27342734+27352735+ // Store the definition info for linking at end
27362736+ self.current_footnote_def = Some((name.to_string(), def_span_index, char_start));
27372737+27382738+ // Record offset mapping for the syntax span
27392739+ self.record_mapping(range.start..range.start + prefix.len(), char_start..char_end);
27402740+27412741+ // Update tracking for the prefix
27422742+ self.last_char_offset = char_end;
27432743+ self.last_byte_offset = range.start + prefix.len();
27442744+27452745+ // Emit the definition container
27462746+ write!(
27472747+ &mut self.writer,
27482748+ "<div class=\"footnote-definition\" id=\"fn-{}\">",
27492749+ name
27502750+ )?;
27512751+27522752+ // Get/create footnote number for the label
25222753 let len = self.numbers.len() + 1;
25232754 let number = *self.numbers.entry(name.to_string()).or_insert(len);
25242524- write!(&mut self.writer, "{}", number)?;
25252525- self.write("</sup>")
27552755+ write!(
27562756+ &mut self.writer,
27572757+ "<sup class=\"footnote-definition-label\">{}</sup>",
27582758+ number
27592759+ )?;
27602760+27612761+ Ok(())
25262762 }
25272763 Tag::MetadataBlock(_) => {
25282764 self.in_non_writing_block = true;
···25662802 }
25672803 Ok(())
25682804 }
25692569- TagEnd::Paragraph => {
28052805+ TagEnd::Paragraph(_) => {
25702806 // Capture paragraph boundary info BEFORE writing closing HTML
25712807 // Skip if inside a list - list owns the paragraph boundary
25722808 let para_boundary = if self.list_depth == 0 {
···25852821 // Write closing HTML to current segment
25862822 self.end_node();
25872823 self.write("</p>\n")?;
28242824+ self.close_wrapper()?;
2588282525892826 // Now finalize paragraph (starts new segment)
25902827 if let Some((byte_range, char_range)) = para_boundary {
···26092846 self.write("</")?;
26102847 write!(&mut self.writer, "{}", level)?;
26112848 self.write(">\n")?;
28492849+ // Note: Don't close wrapper here - headings typically go with following block
2612285026132851 // Now finalize paragraph (starts new segment)
26142852 if let Some((byte_range, char_range)) = para_boundary {
···27012939 }
27022940 }
27032941 self.write("</blockquote>\n")?;
29422942+ self.close_wrapper()?;
2704294327052944 // Now finalize paragraph if we had one
27062945 if let Some((byte_range, char_range)) = para_boundary {
···28573096 });
2858309728593098 self.write("</ol>\n")?;
30993099+ self.close_wrapper()?;
2860310028613101 // Finalize paragraph after closing HTML
28623102 if let Some((byte_range, char_range)) = para_boundary {
···28783118 });
2879311928803120 self.write("</ul>\n")?;
31213121+ self.close_wrapper()?;
2881312228823123 // Finalize paragraph after closing HTML
28833124 if let Some((byte_range, char_range)) = para_boundary {
···28893130 self.end_node();
28903131 self.write("</li>\n")
28913132 }
28922892- TagEnd::DefinitionList => self.write("</dl>\n"),
31333133+ TagEnd::DefinitionList => {
31343134+ self.write("</dl>\n")?;
31353135+ self.close_wrapper()
31363136+ }
28933137 TagEnd::DefinitionListTitle => {
28943138 self.end_node();
28953139 self.write("</dt>\n")
···29563200 TagEnd::Embed => Ok(()),
29573201 TagEnd::WeaverBlock(_) => {
29583202 self.in_non_writing_block = false;
32033203+32043204+ // Emit the { content } as a hideable syntax span
32053205+ if let Some(char_start) = self.weaver_block_char_start.take() {
32063206+ // Build the full syntax text: { buffered_content }
32073207+ let syntax_text = format!("{{{}}}", self.weaver_block_buffer);
32083208+ let syntax_char_len = syntax_text.chars().count();
32093209+ let char_end = char_start + syntax_char_len;
32103210+32113211+ let syn_id = self.gen_syn_id();
32123212+32133213+ write!(
32143214+ &mut self.writer,
32153215+ "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
32163216+ syn_id, char_start, char_end
32173217+ )?;
32183218+ escape_html(&mut self.writer, &syntax_text)?;
32193219+ self.write("</span>")?;
32203220+32213221+ // Track the syntax span
32223222+ self.syntax_spans.push(SyntaxSpanInfo {
32233223+ syn_id,
32243224+ char_range: char_start..char_end,
32253225+ syntax_type: SyntaxType::Block,
32263226+ formatted_range: None,
32273227+ });
32283228+32293229+ // Record offset mapping for the syntax span
32303230+ self.record_mapping(range.clone(), char_start..char_end);
32313231+32323232+ // Update tracking
32333233+ self.last_char_offset = char_end;
32343234+ self.last_byte_offset = range.end;
32353235+ }
32363236+32373237+ // Parse the buffered text for attrs and store for next block
32383238+ if !self.weaver_block_buffer.is_empty() {
32393239+ let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer);
32403240+ self.weaver_block_buffer.clear();
32413241+ // Merge with any existing pending attrs or set new
32423242+ if let Some(ref mut existing) = self.pending_block_attrs {
32433243+ existing.classes.extend(parsed.classes);
32443244+ existing.attrs.extend(parsed.attrs);
32453245+ } else {
32463246+ self.pending_block_attrs = Some(parsed);
32473247+ }
32483248+ }
32493249+29593250 Ok(())
29603251 }
29612961- TagEnd::FootnoteDefinition => self.write("</div>\n"),
32523252+ TagEnd::FootnoteDefinition => {
32533253+ self.write("</div>\n")?;
32543254+32553255+ // Link the footnote definition span with its reference span
32563256+ if let Some((name, def_span_index, _def_char_start)) =
32573257+ self.current_footnote_def.take()
32583258+ {
32593259+ let def_char_end = self.last_char_offset;
32603260+32613261+ // Look up the reference span
32623262+ if let Some(&(ref_span_index, ref_char_start)) =
32633263+ self.footnote_ref_spans.get(&name)
32643264+ {
32653265+ // Create formatted_range spanning from ref start to def end
32663266+ let formatted_range = ref_char_start..def_char_end;
32673267+32683268+ // Update both spans with the same formatted_range
32693269+ // so they show/hide together based on cursor proximity
32703270+ if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) {
32713271+ ref_span.formatted_range = Some(formatted_range.clone());
32723272+ }
32733273+ if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) {
32743274+ def_span.formatted_range = Some(formatted_range);
32753275+ }
32763276+ }
32773277+ }
32783278+32793279+ Ok(())
32803280+ }
29623281 TagEnd::MetadataBlock(_) => {
29633282 self.in_non_writing_block = false;
29643283 Ok(())
+49-20
crates/weaver-app/src/components/entry.rs
···212212213213 tracing::info!("Entry: {book_title} - {title}");
214214215215+ let prev_entry = book_entry_view().prev.clone();
216216+ let next_entry = book_entry_view().next.clone();
217217+215218 rsx! {
216219 EntryOgMeta {
217220 title: title.to_string(),
···223226 }
224227 document::Link { rel: "stylesheet", href: ENTRY_CSS }
225228226226- div { class: "entry-page-layout",
227227- // Left gutter with prev button
228228- if let Some(ref prev) = book_entry_view().prev {
229229- div { class: "nav-gutter nav-prev",
229229+ div { class: "entry-page",
230230+ // Header: nav prev + metadata + nav next
231231+ header { class: "entry-header",
232232+ if let Some(ref prev) = prev_entry {
230233 NavButton {
231234 direction: "prev",
232235 entry: prev.entry.clone(),
···234237 book_title: book_title()
235238 }
236239 }
237237- }
238240239239- // Main content area
240240- div { class: "entry-content-main notebook-content",
241241- // Metadata header
242241 {
243242 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content);
244243 rsx! {
···254253 }
255254 }
256255257257- // Rendered markdown
258258- EntryMarkdown {
259259- content: entry_record,
260260- ident
256256+ if let Some(ref next) = next_entry {
257257+ NavButton {
258258+ direction: "next",
259259+ entry: next.entry.clone(),
260260+ ident: ident(),
261261+ book_title: book_title()
262262+ }
263263+ }
264264+ }
265265+266266+ // Main content area
267267+ div { class: "entry-content-wrapper",
268268+ div { class: "entry-content-main notebook-content",
269269+ EntryMarkdown {
270270+ content: entry_record,
271271+ ident
272272+ }
261273 }
262274 }
263275264264- // Right gutter with next button
265265- if let Some(ref next) = book_entry_view().next {
266266- div { class: "nav-gutter nav-next",
276276+ // Footer navigation
277277+ footer { class: "entry-footer-nav",
278278+ if let Some(ref prev) = prev_entry {
279279+ NavButton {
280280+ direction: "prev",
281281+ entry: prev.entry.clone(),
282282+ ident: ident(),
283283+ book_title: book_title()
284284+ }
285285+ }
286286+287287+ if let Some(ref next) = next_entry {
267288 NavButton {
268289 direction: "next",
269290 entry: next.entry.clone(),
···648669 }
649670}
650671651651-/// Navigation button for prev/next entries
672672+/// Navigation link for prev/next entries (minimal: arrow + title)
652673#[component]
653674pub fn NavButton(
654675 direction: &'static str,
···662683 .map(|t| t.as_ref())
663684 .unwrap_or("Untitled");
664685665665- // Get path from view for URL, fallback to title
666686 let entry_path = entry
667687 .path
668688 .as_ref()
669689 .map(|p| p.as_ref().to_string())
670690 .unwrap_or_else(|| entry_title.to_string());
671691672672- let arrow = if direction == "prev" { "←" } else { "→" };
692692+ let (arrow, title_first) = if direction == "prev" {
693693+ ("←", false)
694694+ } else {
695695+ ("→", true)
696696+ };
673697674698 rsx! {
675699 Link {
···679703 title: entry_path.into()
680704 },
681705 class: "nav-button nav-button-{direction}",
682682- div { class: "nav-arrow", "{arrow}" }
683683- div { class: "nav-title", "{entry_title}" }
706706+ if title_first {
707707+ span { class: "nav-title", "{entry_title}" }
708708+ span { class: "nav-arrow", "{arrow}" }
709709+ } else {
710710+ span { class: "nav-arrow", "{arrow}" }
711711+ span { class: "nav-title", "{entry_title}" }
712712+ }
684713 }
685714 }
686715}
+3
crates/weaver-renderer/src/atproto.rs
···2222pub use preprocess::AtProtoPreprocessContext;
2323pub use types::{BlobInfo, BlobName};
2424pub use writer::{ClientWriter, EmbedContentProvider};
2525+2626+#[cfg(test)]
2727+mod tests;
···11+---
22+source: crates/weaver-renderer/src/atproto/tests.rs
33+expression: output
44+---
55+<p>This is a paragraph.</p>
66+<p>This is another paragraph.</p>
···11+---
22+source: crates/weaver-renderer/src/atproto/tests.rs
33+expression: output
44+---
55+<aside>
66+<p>This paragraph should be in an aside.</p>
77+</aside>
···11+---
22+source: crates/weaver-renderer/src/atproto/tests.rs
33+expression: output
44+---
55+<aside>
66+<h2>Heading in aside</h2>
77+<p>Paragraph also in aside.</p>
88+</aside>
···11+---
22+source: crates/weaver-renderer/src/static_site/tests.rs
33+expression: output
44+---
55+<aside>
66+<p>This paragraph should be in an aside.</p>
77+</aside>
···11+---
22+source: crates/weaver-renderer/src/static_site/tests.rs
33+expression: output
44+---
55+<aside>
66+<h2>Heading in aside</h2>
77+<p>Paragraph also in aside.</p>
88+</aside>
···11+<!DOCTYPE html>
22+<html lang="en">
33+<head>
44+ <meta charset="utf-8">
55+ <meta name="viewport" content="width=device-width, initial-scale=1">
66+ <title>test-sidenotes</title>
77+ <style>
88+/* CSS Reset */
99+*, *::before, *::after {
1010+ box-sizing: border-box;
1111+ margin: 0;
1212+ padding: 0;
1313+}
1414+1515+/* CSS Variables - Light Mode (default) */
1616+:root {
1717+ --color-base: #faf4ed;
1818+ --color-surface: #fffaf3;
1919+ --color-overlay: #f2e9e1;
2020+ --color-text: #1f1d2e;
2121+ --color-muted: #635e74;
2222+ --color-subtle: #4a4560;
2323+ --color-emphasis: #1e1a2d;
2424+ --color-primary: #907aa9;
2525+ --color-secondary: #56949f;
2626+ --color-tertiary: #286983;
2727+ --color-error: #b4637a;
2828+ --color-warning: #ea9d34;
2929+ --color-success: #286983;
3030+ --color-border: #dfdad9;
3131+ --color-link: #d7827e;
3232+ --color-highlight: #cecacd;
3333+3434+ --font-body: 'Adobe Caslon Pro','Latin Modern Roman','Times New Roman','serif';
3535+ --font-heading: 'IBM Plex Sans','system-ui','sans-serif';
3636+ --font-mono: 'Ioskeley Mono','IBM Plex Mono','Berkeley Mono','Consolas','monospace';
3737+3838+ --spacing-base: 16px;
3939+ --spacing-line-height: 1.6;
4040+ --spacing-scale: 1.25;
4141+}
4242+4343+/* CSS Variables - Dark Mode */
4444+@media (prefers-color-scheme: dark) {
4545+ :root {
4646+ --color-base: #191724;
4747+ --color-surface: #1f1d2e;
4848+ --color-overlay: #26233a;
4949+ --color-text: #e0def4;
5050+ --color-muted: #6e6a86;
5151+ --color-subtle: #908caa;
5252+ --color-emphasis: #e0def4;
5353+ --color-primary: #c4a7e7;
5454+ --color-secondary: #9ccfd8;
5555+ --color-tertiary: #ebbcba;
5656+ --color-error: #eb6f92;
5757+ --color-warning: #f6c177;
5858+ --color-success: #31748f;
5959+ --color-border: #403d52;
6060+ --color-link: #ebbcba;
6161+ --color-highlight: #524f67;
6262+ }
6363+}
6464+6565+/* Base Styles */
6666+html {
6767+ font-size: var(--spacing-base);
6868+ line-height: var(--spacing-line-height);
6969+}
7070+7171+/* Scoped to notebook-content container */
7272+.notebook-content {
7373+ font-family: var(--font-body);
7474+ color: var(--color-text);
7575+ background-color: var(--color-base);
7676+ margin: 0 auto;
7777+ padding: 1rem 0rem;
7878+ word-wrap: break-word;
7979+ overflow-wrap: break-word;
8080+ counter-reset: sidenote-counter;
8181+ max-width: 95ch;
8282+}
8383+8484+/* When sidenotes exist, body padding creates the gutter */
8585+/* Left padding shrinks first as viewport narrows, right stays for sidenotes */
8686+body:has(.sidenote) {
8787+ padding-left: clamp(0rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem);
8888+ padding-right: 15.5rem;
8989+}
9090+9191+/* Typography */
9292+h1, h2, h3, h4, h5, h6 {
9393+ font-family: var(--font-heading);
9494+ margin-top: calc(1rem * var(--spacing-scale));
9595+ margin-bottom: 0.5rem;
9696+ line-height: 1.2;
9797+}
9898+9999+h1 {
100100+ font-size: 2rem;
101101+ color: var(--color-secondary);
102102+}
103103+h2 {
104104+ font-size: 1.5rem;
105105+ color: var(--color-primary);
106106+}
107107+h3 {
108108+ font-size: 1.25rem;
109109+ color: var(--color-secondary);
110110+}
111111+h4 {
112112+ font-size: 1.2rem;
113113+ color: var(--color-tertiary);
114114+}
115115+h5 {
116116+ font-size: 1.125rem;
117117+ color: var(--color-secondary);
118118+}
119119+h6 { font-size: 1rem; }
120120+121121+p {
122122+ margin-bottom: 1rem;
123123+ word-wrap: break-word;
124124+ overflow-wrap: break-word;
125125+}
126126+127127+a {
128128+ color: var(--color-link);
129129+ text-decoration: none;
130130+}
131131+132132+.notebook-content a:hover {
133133+ color: var(--color-emphasis);
134134+ text-decoration: underline;
135135+}
136136+137137+/* Wikilink validation (editor) */
138138+.link-valid {
139139+ color: var(--color-link);
140140+}
141141+142142+.link-broken {
143143+ color: var(--color-error);
144144+ text-decoration: underline wavy;
145145+ text-decoration-color: var(--color-error);
146146+ opacity: 0.8;
147147+}
148148+149149+/* Selection */
150150+::selection {
151151+ background: var(--color-highlight);
152152+ color: var(--color-text);
153153+}
154154+155155+/* Lists */
156156+ul, ol {
157157+ margin-left: 1rem;
158158+ margin-bottom: 1rem;
159159+}
160160+161161+li {
162162+ margin-bottom: 0.25rem;
163163+}
164164+165165+/* Code */
166166+code {
167167+ font-family: var(--font-mono);
168168+ background: var(--color-surface);
169169+ padding: 0.125rem 0.25rem;
170170+ border-radius: 4px;
171171+ font-size: 0.9em;
172172+}
173173+174174+pre {
175175+ overflow-x: auto;
176176+ margin-bottom: 1rem;
177177+ border-radius: 5px;
178178+ border: 1px solid var(--color-border);
179179+ box-sizing: border-box;
180180+}
181181+182182+/* Code blocks inside pre are handled by syntax theme */
183183+pre code {
184184+185185+ display: block;
186186+ width: fit-content;
187187+ min-width: 100%;
188188+ padding: 1rem;
189189+ background: var(--color-surface);
190190+}
191191+192192+/* Math */
193193+.math {
194194+ font-family: var(--font-mono);
195195+}
196196+197197+.math-display {
198198+ display: block;
199199+ margin: 1rem 0;
200200+ text-align: center;
201201+}
202202+203203+/* Blockquotes */
204204+blockquote {
205205+ border-left: 2px solid var(--color-secondary);
206206+ background: var(--color-surface);
207207+ padding-left: 1rem;
208208+ padding-right: 1rem;
209209+ padding-top: 0.5rem;
210210+ padding-bottom: 0.04rem;
211211+ margin: 1rem 0;
212212+ font-size: 0.95em;
213213+ border-bottom-right-radius: 5px;
214214+ border-top-right-radius: 5px;
215215+}
216216+}
217217+218218+/* Tables */
219219+table {
220220+ border-collapse: collapse;
221221+ width: 100%;
222222+ margin-bottom: 1rem;
223223+ display: block;
224224+ overflow-x: auto;
225225+ max-width: 100%;
226226+}
227227+228228+th, td {
229229+ border: 1px solid var(--color-border);
230230+ padding: 0.5rem;
231231+ text-align: left;
232232+}
233233+234234+th {
235235+ background: var(--color-surface);
236236+ font-weight: 600;
237237+}
238238+239239+tr:hover {
240240+ background: var(--color-surface);
241241+}
242242+243243+/* Footnotes */
244244+.footnote-reference {
245245+ font-size: 0.8em;
246246+ color: var(--color-subtle);
247247+}
248248+249249+.footnote-definition {
250250+ order: 9999;
251251+ margin: 0;
252252+ padding: 0.5rem 0;
253253+ font-size: 0.9em;
254254+}
255255+256256+.footnote-definition:first-of-type {
257257+ margin-top: 2rem;
258258+ padding-top: 1rem;
259259+ border-top: 2px solid var(--color-border);
260260+}
261261+262262+.footnote-definition:first-of-type::before {
263263+ content: "Footnotes";
264264+ display: block;
265265+ font-weight: 600;
266266+ font-size: 1.1em;
267267+ color: var(--color-subtle);
268268+ margin-bottom: 0.75rem;
269269+}
270270+271271+.footnote-definition-label {
272272+ font-weight: 600;
273273+ margin-right: 0.5rem;
274274+ color: var(--color-primary);
275275+}
276276+277277+/* Aside blocks (via WeaverBlock prefix) */
278278+aside, .aside {
279279+ float: left;
280280+ width: 40%;
281281+ margin: 0 1.5rem 1rem 0;
282282+ padding: 1rem;
283283+ background: var(--color-surface);
284284+ border-right: 3px solid var(--color-primary);
285285+ font-size: 0.9em;
286286+ clear: left;
287287+}
288288+289289+aside > *:first-child,
290290+.aside > *:first-child {
291291+ margin-top: 0;
292292+}
293293+294294+aside > *:last-child,
295295+.aside > *:last-child {
296296+ margin-bottom: 0;
297297+}
298298+299299+/* Reset blockquote styling inside asides */
300300+aside > blockquote,
301301+.aside > blockquote {
302302+ border-left: none;
303303+ background: transparent;
304304+ padding: 0;
305305+ margin: 0;
306306+ font-size: inherit;
307307+}
308308+309309+/* Indent utilities */
310310+.indent-1 { margin-left: 1em; }
311311+.indent-2 { margin-left: 2em; }
312312+.indent-3 { margin-left: 3em; }
313313+314314+/* Tufte-style Sidenotes */
315315+/* Hide checkbox for sidenote toggle */
316316+.margin-toggle {
317317+ display: none;
318318+}
319319+320320+/* Sidenote number marker (inline superscript) */
321321+.sidenote-number {
322322+ counter-increment: sidenote-counter;
323323+}
324324+325325+.sidenote-number::after {
326326+ content: counter(sidenote-counter);
327327+ font-size: 0.7em;
328328+ position: relative;
329329+ top: -0.5em;
330330+ color: var(--color-primary);
331331+ padding-left: 0.1em;
332332+}
333333+334334+/* Sidenote content (margin notes on wide screens) */
335335+.sidenote {
336336+ float: right;
337337+ clear: right;
338338+ margin-right: -15.5rem;
339339+ width: 14rem;
340340+ margin-top: 0.3rem;
341341+ margin-bottom: 1rem;
342342+ font-size: 0.85em;
343343+ line-height: 1.4;
344344+ color: var(--color-subtle);
345345+}
346346+347347+.sidenote::before {
348348+ content: counter(sidenote-counter) ". ";
349349+ color: var(--color-primary);
350350+}
351351+352352+/* Mobile sidenotes: toggle behavior */
353353+@media (max-width: 900px) {
354354+ /* Reset sidenote gutter on mobile */
355355+ body:has(.sidenote) {
356356+ padding-right: 0;
357357+ }
358358+359359+ aside, .aside {
360360+ float: none;
361361+ width: 100%;
362362+ margin: 1rem 0;
363363+ }
364364+365365+ .sidenote {
366366+ display: none;
367367+ }
368368+369369+ .margin-toggle:checked + .sidenote {
370370+ display: block;
371371+ float: none;
372372+ width: 95%;
373373+ margin: 0.5rem 2.5%;
374374+ padding: 0.5rem;
375375+ background: var(--color-surface);
376376+ border-left: 2px solid var(--color-primary);
377377+ }
378378+379379+ label.sidenote-number {
380380+ cursor: pointer;
381381+ }
382382+383383+ label.sidenote-number::after {
384384+ text-decoration: underline;
385385+ }
386386+}
387387+388388+/* Images */
389389+img {
390390+ max-width: 100%;
391391+ height: auto;
392392+ display: block;
393393+ margin: 1rem 0;
394394+ border-radius: 4px;
395395+}
396396+397397+/* Hygiene for iframes */
398398+.html-embed-block {
399399+ max-width: 100%;
400400+ height: auto;
401401+ display: block;
402402+ margin: 1rem 0;
403403+}
404404+405405+/* AT Protocol Embeds - Container */
406406+/* Light mode: paper with shadow, dark mode: blueprint with borders */
407407+.atproto-embed {
408408+ display: block;
409409+ position: relative;
410410+ max-width: 550px;
411411+ margin: 1rem 0;
412412+ padding: 1rem;
413413+ background: var(--color-surface);
414414+ border-left: 2px solid var(--color-secondary);
415415+ box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 8%, transparent);
416416+}
417417+418418+.atproto-embed:hover {
419419+ border-left-color: var(--color-primary);
420420+}
421421+422422+@media (prefers-color-scheme: dark) {
423423+ .atproto-embed {
424424+ box-shadow: none;
425425+ border: 1px solid var(--color-border);
426426+ border-left: 2px solid var(--color-secondary);
427427+ }
428428+}
429429+430430+.atproto-embed-placeholder {
431431+ color: var(--color-muted);
432432+ font-style: italic;
433433+}
434434+435435+.embed-loading {
436436+ display: block;
437437+ padding: 0.5rem 0;
438438+ color: var(--color-subtle);
439439+ font-family: var(--font-mono);
440440+ font-size: 0.85rem;
441441+}
442442+443443+/* Embed Author Block */
444444+.embed-author {
445445+ display: flex;
446446+ align-items: center;
447447+ gap: 0.75rem;
448448+ padding-bottom: 0.5rem;
449449+}
450450+451451+.embed-avatar {
452452+ width: 36px;
453453+ height: 36px;
454454+ max-width: 36px;
455455+ max-height: 36px;
456456+ aspect-ratio: 1;
457457+ margin: 0;
458458+ object-fit: cover;
459459+}
460460+461461+.embed-author-info {
462462+ display: flex;
463463+ flex-direction: column;
464464+ gap: 0;
465465+ min-width: 0;
466466+}
467467+468468+.embed-avatar-link {
469469+ display: block;
470470+ flex-shrink: 0;
471471+}
472472+473473+.embed-author-name {
474474+ font-weight: 600;
475475+ color: var(--color-text);
476476+ overflow: hidden;
477477+ text-overflow: ellipsis;
478478+ white-space: nowrap;
479479+ text-decoration: none;
480480+ line-height: 1.2;
481481+}
482482+483483+a.embed-author-name:hover {
484484+ color: var(--color-link);
485485+}
486486+487487+.embed-author-handle {
488488+ font-size: 0.85em;
489489+ font-family: var(--font-mono);
490490+ color: var(--color-subtle);
491491+ text-decoration: none;
492492+ overflow: hidden;
493493+ text-overflow: ellipsis;
494494+ white-space: nowrap;
495495+ line-height: 1.2;
496496+}
497497+498498+.embed-author-handle:hover {
499499+ color: var(--color-link);
500500+}
501501+502502+/* Card-wide clickable link (sits behind content) */
503503+.embed-card-link {
504504+ position: absolute;
505505+ inset: 0;
506506+ z-index: 0;
507507+}
508508+509509+.embed-card-link:focus {
510510+ outline: 2px solid var(--color-primary);
511511+ outline-offset: 2px;
512512+}
513513+514514+/* Interactive elements sit above the card link */
515515+.embed-author,
516516+.embed-external,
517517+.embed-quote,
518518+.embed-images,
519519+.embed-meta {
520520+ position: relative;
521521+ z-index: 1;
522522+}
523523+524524+/* Embed Content Block */
525525+.embed-content {
526526+ display: block;
527527+ color: var(--color-text);
528528+ line-height: 1.5;
529529+ margin-bottom: 0.75rem;
530530+ white-space: pre-wrap;
531531+}
532532+533533+534534+535535+.embed-description {
536536+ display: block;
537537+ color: var(--color-text);
538538+ font-size: 0.95em;
539539+ line-height: 1.4;
540540+}
541541+542542+/* Embed Metadata Block */
543543+.embed-meta {
544544+ display: flex;
545545+ justify-content: space-between;
546546+ align-items: center;
547547+ font-size: 0.85em;
548548+ color: var(--color-muted);
549549+ margin-top: 0.75rem;
550550+}
551551+552552+.embed-stats {
553553+ display: flex;
554554+ gap: 1rem;
555555+ font-family: var(--font-mono);
556556+}
557557+558558+.embed-stat {
559559+ color: var(--color-subtle);
560560+ font-size: 0.9em;
561561+}
562562+563563+.embed-time {
564564+ color: var(--color-subtle);
565565+ text-decoration: none;
566566+ font-family: var(--font-mono);
567567+ font-size: 0.9em;
568568+}
569569+570570+.embed-time:hover {
571571+ color: var(--color-link);
572572+}
573573+574574+.embed-type {
575575+ font-size: 0.8em;
576576+ color: var(--color-subtle);
577577+ font-family: var(--font-mono);
578578+ text-transform: uppercase;
579579+ letter-spacing: 0.05em;
580580+}
581581+582582+/* Embed URL link (shown with syntax in editor) */
583583+.embed-url {
584584+ color: var(--color-link);
585585+ font-family: var(--font-mono);
586586+ font-size: 0.9em;
587587+ word-break: break-all;
588588+}
589589+590590+/* External link cards */
591591+.embed-external {
592592+ display: flex;
593593+ gap: 0.75rem;
594594+ padding: 0.75rem;
595595+ background: var(--color-surface);
596596+ border: 1px dashed var(--color-border);
597597+ text-decoration: none;
598598+ color: inherit;
599599+ margin-top: 0.5rem;
600600+}
601601+602602+.embed-external:hover {
603603+ border-left: 2px solid var(--color-primary);
604604+ margin-left: -1px;
605605+}
606606+607607+@media (prefers-color-scheme: dark) {
608608+ .embed-external {
609609+ border: 1px solid var(--color-border);
610610+ }
611611+612612+ .embed-external:hover {
613613+ border-left: 2px solid var(--color-primary);
614614+ margin-left: -1px;
615615+ }
616616+}
617617+618618+.embed-external-thumb {
619619+ width: 120px;
620620+ height: 80px;
621621+ object-fit: cover;
622622+ flex-shrink: 0;
623623+}
624624+625625+.embed-external-info {
626626+ display: flex;
627627+ flex-direction: column;
628628+ gap: 0.25rem;
629629+ min-width: 0;
630630+}
631631+632632+.embed-external-title {
633633+ font-weight: 600;
634634+ color: var(--color-text);
635635+ overflow: hidden;
636636+ text-overflow: ellipsis;
637637+ white-space: nowrap;
638638+}
639639+640640+.embed-external-description {
641641+ font-size: 0.9em;
642642+ color: var(--color-muted);
643643+ overflow: hidden;
644644+ text-overflow: ellipsis;
645645+ display: -webkit-box;
646646+ -webkit-line-clamp: 2;
647647+ -webkit-box-orient: vertical;
648648+}
649649+650650+.embed-external-url {
651651+ font-size: 0.8em;
652652+ font-family: var(--font-mono);
653653+ color: var(--color-subtle);
654654+}
655655+656656+/* Image embeds */
657657+.embed-images {
658658+ display: grid;
659659+ gap: 4px;
660660+ margin-top: 0.5rem;
661661+ overflow: hidden;
662662+}
663663+664664+.embed-images-1 {
665665+ grid-template-columns: 1fr;
666666+}
667667+668668+.embed-images-2 {
669669+ grid-template-columns: 1fr 1fr;
670670+}
671671+672672+.embed-images-3 {
673673+ grid-template-columns: 1fr 1fr;
674674+}
675675+676676+.embed-images-4 {
677677+ grid-template-columns: 1fr 1fr;
678678+}
679679+680680+.embed-image-link {
681681+ display: block;
682682+ line-height: 0;
683683+}
684684+685685+.embed-image {
686686+ width: 100%;
687687+ height: auto;
688688+ max-height: 500px;
689689+ object-fit: cover;
690690+ object-position: center;
691691+ margin: 0;
692692+}
693693+694694+/* Quoted records */
695695+.embed-quote {
696696+ display: block;
697697+ margin-top: 0.5rem;
698698+ padding: 0.75rem;
699699+ background: var(--color-overlay);
700700+ border-left: 2px solid var(--color-tertiary);
701701+}
702702+703703+@media (prefers-color-scheme: dark) {
704704+ .embed-quote {
705705+ border: 1px solid var(--color-border);
706706+ border-left: 2px solid var(--color-tertiary);
707707+ }
708708+}
709709+710710+.embed-quote .embed-author {
711711+ margin-bottom: 0.5rem;
712712+}
713713+714714+.embed-quote .embed-avatar {
715715+ width: 24px;
716716+ height: 24px;
717717+ min-width: 24px;
718718+ min-height: 24px;
719719+ max-width: 24px;
720720+ max-height: 24px;
721721+}
722722+723723+.embed-quote .embed-content {
724724+ font-size: 0.95em;
725725+ margin-bottom: 0;
726726+}
727727+728728+/* Placeholder states */
729729+.embed-video-placeholder,
730730+.embed-not-found,
731731+.embed-blocked,
732732+.embed-detached,
733733+.embed-unknown {
734734+ display: block;
735735+ padding: 1rem;
736736+ background: var(--color-overlay);
737737+ border-left: 2px solid var(--color-border);
738738+ color: var(--color-muted);
739739+ font-style: italic;
740740+ margin-top: 0.5rem;
741741+ font-family: var(--font-mono);
742742+ font-size: 0.9em;
743743+}
744744+745745+@media (prefers-color-scheme: dark) {
746746+ .embed-video-placeholder,
747747+ .embed-not-found,
748748+ .embed-blocked,
749749+ .embed-detached,
750750+ .embed-unknown {
751751+ border: 1px dashed var(--color-border);
752752+ }
753753+}
754754+755755+/* Record card embeds (feeds, lists, labelers, starter packs) */
756756+.embed-record-card {
757757+ display: block;
758758+ margin-top: 0.5rem;
759759+ padding: 0.75rem;
760760+ background: var(--color-overlay);
761761+ border-left: 2px solid var(--color-tertiary);
762762+}
763763+764764+.embed-record-card > .embed-author-name {
765765+ display: block;
766766+ font-size: 1.1em;
767767+}
768768+769769+.embed-subtitle {
770770+ display: block;
771771+ font-size: 0.85em;
772772+ color: var(--color-muted);
773773+ margin-bottom: 0.5rem;
774774+}
775775+776776+.embed-record-card .embed-description {
777777+ display: block;
778778+ margin: 0.5rem 0;
779779+}
780780+781781+.embed-record-card .embed-stats {
782782+ display: block;
783783+ margin-top: 0.25rem;
784784+}
785785+786786+/* Generic record fields */
787787+.embed-fields {
788788+ display: block;
789789+ margin-top: 0.5rem;
790790+ font-family: var(--font-ui);
791791+ font-size: 0.85rem;
792792+ color: var(--color-muted);
793793+}
794794+795795+.embed-field {
796796+ display: block;
797797+ margin-top: 0.25rem;
798798+}
799799+800800+/* Nested fields get indentation */
801801+.embed-fields .embed-fields {
802802+ display: block;
803803+ margin-top: 0.5rem;
804804+ margin-left: 1rem;
805805+ padding-left: 0.5rem;
806806+ border-left: 1px solid var(--color-border);
807807+}
808808+809809+/* Type label inside fields should be block with spacing */
810810+.embed-fields > .embed-author-handle {
811811+ display: block;
812812+ margin-bottom: 0.25rem;
813813+}
814814+815815+.embed-field-name {
816816+ color: var(--color-subtle);
817817+}
818818+819819+.embed-field-number {
820820+ color: var(--color-tertiary);
821821+}
822822+823823+.embed-field-date {
824824+ color: var(--color-muted);
825825+}
826826+827827+.embed-field-count {
828828+ color: var(--color-muted);
829829+ font-style: italic;
830830+}
831831+832832+.embed-field-bool-true {
833833+ color: var(--color-success);
834834+}
835835+836836+.embed-field-bool-false {
837837+ color: var(--color-muted);
838838+}
839839+840840+.embed-field-link,
841841+.embed-field-aturi {
842842+ color: var(--color-link);
843843+ text-decoration: none;
844844+}
845845+846846+.embed-field-link:hover,
847847+.embed-field-aturi:hover {
848848+ text-decoration: underline;
849849+}
850850+851851+.embed-field-did {
852852+ font-family: var(--font-mono);
853853+ font-size: 0.9em;
854854+}
855855+856856+.embed-field-did .did-scheme,
857857+.embed-field-did .did-separator {
858858+ color: var(--color-muted);
859859+}
860860+861861+.embed-field-did .did-method {
862862+ color: var(--color-tertiary);
863863+}
864864+865865+.embed-field-did .did-identifier {
866866+ color: var(--color-text);
867867+}
868868+869869+.embed-field-nsid {
870870+ color: var(--color-secondary);
871871+}
872872+873873+.embed-field-handle {
874874+ color: var(--color-link);
875875+}
876876+877877+/* AT URI highlighting */
878878+.aturi-scheme {
879879+ color: var(--color-muted);
880880+}
881881+882882+.aturi-slash {
883883+ color: var(--color-muted);
884884+}
885885+886886+.aturi-authority {
887887+ color: var(--color-link);
888888+}
889889+890890+.aturi-collection {
891891+ color: var(--color-secondary);
892892+}
893893+894894+.aturi-rkey {
895895+ color: var(--color-tertiary);
896896+}
897897+898898+/* Generic AT Protocol record embed */
899899+.atproto-record > .embed-author-handle {
900900+ display: block;
901901+ margin-bottom: 0.25rem;
902902+}
903903+904904+.atproto-record > .embed-author-name {
905905+ display: block;
906906+ margin-bottom: 0.5rem;
907907+}
908908+909909+.atproto-record > .embed-content {
910910+ margin-bottom: 0.5rem;
911911+}
912912+913913+/* Notebook entry embed - full width, expandable */
914914+.atproto-entry {
915915+ max-width: none;
916916+ width: 100%;
917917+ margin: 1.5rem 0;
918918+ padding: 0;
919919+ background: var(--color-surface);
920920+ border: 1px solid var(--color-border);
921921+ border-left: 1px solid var(--color-border);
922922+ box-shadow: none;
923923+ overflow: hidden;
924924+}
925925+926926+.atproto-entry:hover {
927927+ border-left-color: var(--color-border);
928928+}
929929+930930+@media (prefers-color-scheme: dark) {
931931+ .atproto-entry {
932932+ border: 1px solid var(--color-border);
933933+ border-left: 1px solid var(--color-border);
934934+ }
935935+}
936936+937937+.embed-entry-header {
938938+ display: flex;
939939+ flex-wrap: wrap;
940940+ align-items: baseline;
941941+ gap: 0.5rem 1rem;
942942+ padding: 0.75rem 1rem;
943943+ background: var(--color-overlay);
944944+ border-bottom: 1px solid var(--color-border);
945945+}
946946+947947+.embed-entry-title {
948948+ font-size: 1.1em;
949949+ font-weight: 600;
950950+ color: var(--color-text);
951951+}
952952+953953+.embed-entry-author {
954954+ font-size: 0.85em;
955955+ color: var(--color-muted);
956956+}
957957+958958+/* Hidden checkbox for expand/collapse */
959959+.embed-entry-toggle {
960960+ display: none;
961961+}
962962+963963+/* Content wrapper - scrollable when collapsed */
964964+.embed-entry-content {
965965+ max-height: 30rem;
966966+ overflow-y: auto;
967967+ padding: 1rem;
968968+ transition: max-height 0.3s ease;
969969+}
970970+971971+/* When checkbox is checked, expand fully */
972972+.embed-entry-toggle:checked ~ .embed-entry-content {
973973+ max-height: none;
974974+}
975975+976976+/* Expand/collapse button */
977977+.embed-entry-expand {
978978+ display: block;
979979+ width: 100%;
980980+ padding: 0.5rem;
981981+ text-align: center;
982982+ font-size: 0.85em;
983983+ font-family: var(--font-ui);
984984+ color: var(--color-muted);
985985+ background: var(--color-overlay);
986986+ border-top: 1px solid var(--color-border);
987987+ cursor: pointer;
988988+ user-select: none;
989989+}
990990+991991+.embed-entry-expand:hover {
992992+ color: var(--color-text);
993993+ background: var(--color-surface);
994994+}
995995+996996+/* Toggle button text */
997997+.embed-entry-expand::before {
998998+ content: "Expand ↓";
999999+}
10001000+10011001+.embed-entry-toggle:checked ~ .embed-entry-expand::before {
10021002+ content: "Collapse ↑";
10031003+}
10041004+10051005+/* Hide expand button if content doesn't overflow (via JS class) */
10061006+.atproto-entry.no-overflow .embed-entry-expand {
10071007+ display: none;
10081008+}
10091009+10101010+/* Horizontal Rule */
10111011+hr {
10121012+ border: none;
10131013+ border-top: 2px solid var(--color-border);
10141014+ margin: 2rem 0;
10151015+}
10161016+10171017+/* Tablet and mobile responsiveness */
10181018+@media (max-width: 900px) {
10191019+ .notebook-content {
10201020+ padding: 1.5rem 1rem;
10211021+ max-width: 100%;
10221022+ }
10231023+10241024+ h1 { font-size: 1.85rem; }
10251025+ h2 { font-size: 1.4rem; }
10261026+ h3 { font-size: 1.2rem; }
10271027+10281028+ blockquote {
10291029+ margin-left: 0;
10301030+ margin-right: 0;
10311031+ }
10321032+}
10331033+10341034+/* Small mobile phones */
10351035+@media (max-width: 480px) {
10361036+ .notebook-content {
10371037+ padding: 1rem 0.75rem;
10381038+ }
10391039+10401040+ h1 { font-size: 1.65rem; }
10411041+ h2 { font-size: 1.3rem; }
10421042+ h3 { font-size: 1.1rem; }
10431043+10441044+ blockquote {
10451045+ padding-left: 0.75rem;
10461046+ padding-right: 0.75rem;
10471047+ }
10481048+}
10491049+ </style>
10501050+ <style>
10511051+/* Syntax highlighting - Light Mode (default) */
10521052+/*
10531053+ * theme "Rosé Pine Dawn" generated by syntect
10541054+ */
10551055+10561056+.wvc-code {
10571057+ color: #575279;
10581058+ background-color: #faf4ed;
10591059+}
10601060+10611061+.wvc-comment {
10621062+ color: #797593;
10631063+font-style: italic;
10641064+}
10651065+.wvc-string, .wvc-punctuation.wvc-definition.wvc-string {
10661066+ color: #ea9d34;
10671067+}
10681068+.wvc-constant.wvc-numeric {
10691069+ color: #ea9d34;
10701070+}
10711071+.wvc-constant.wvc-language {
10721072+ color: #ea9d34;
10731073+font-weight: bold;
10741074+}
10751075+.wvc-constant.wvc-character, .wvc-constant.wvc-other {
10761076+ color: #ea9d34;
10771077+}
10781078+.wvc-variable {
10791079+ color: #575279;
10801080+font-style: italic;
10811081+}
10821082+.wvc-keyword {
10831083+ color: #286983;
10841084+}
10851085+.wvc-storage {
10861086+ color: #56949f;
10871087+}
10881088+.wvc-storage.wvc-type {
10891089+ color: #56949f;
10901090+}
10911091+.wvc-entity.wvc-name.wvc-class {
10921092+ color: #286983;
10931093+font-weight: bold;
10941094+}
10951095+.wvc-entity.wvc-other.wvc-inherited-class {
10961096+ color: #286983;
10971097+font-style: italic;
10981098+}
10991099+.wvc-entity.wvc-name.wvc-function {
11001100+ color: #d7827e;
11011101+font-style: italic;
11021102+}
11031103+.wvc-variable.wvc-parameter {
11041104+ color: #907aa9;
11051105+}
11061106+.wvc-entity.wvc-name.wvc-tag {
11071107+ color: #286983;
11081108+font-weight: bold;
11091109+}
11101110+.wvc-entity.wvc-other.wvc-attribute-name {
11111111+ color: #907aa9;
11121112+}
11131113+.wvc-support.wvc-function {
11141114+ color: #d7827e;
11151115+font-weight: bold;
11161116+}
11171117+.wvc-support.wvc-constant {
11181118+ color: #ea9d34;
11191119+font-weight: bold;
11201120+}
11211121+.wvc-support.wvc-type, .wvc-support.wvc-class {
11221122+ color: #56949f;
11231123+font-weight: bold;
11241124+}
11251125+.wvc-support.wvc-other.wvc-variable {
11261126+ color: #b4637a;
11271127+font-weight: bold;
11281128+}
11291129+.wvc-invalid {
11301130+ color: #575279;
11311131+ background-color: #b4637a;
11321132+}
11331133+.wvc-invalid.wvc-deprecated {
11341134+ color: #575279;
11351135+ background-color: #907aa9;
11361136+}
11371137+.wvc-punctuation, .wvc-keyword.wvc-operator {
11381138+ color: #797593;
11391139+}
11401140+11411141+11421142+/* Syntax highlighting - Dark Mode */
11431143+@media (prefers-color-scheme: dark) {
11441144+/*
11451145+ * theme "Rosé Pine" generated by syntect
11461146+ */
11471147+11481148+.wvc-code {
11491149+ color: #e0def4;
11501150+ background-color: #191724;
11511151+}
11521152+11531153+.wvc-comment {
11541154+ color: #908caa;
11551155+font-style: italic;
11561156+}
11571157+.wvc-string, .wvc-punctuation.wvc-definition.wvc-string {
11581158+ color: #f6c177;
11591159+}
11601160+.wvc-constant.wvc-numeric {
11611161+ color: #f6c177;
11621162+}
11631163+.wvc-constant.wvc-language {
11641164+ color: #f6c177;
11651165+font-weight: bold;
11661166+}
11671167+.wvc-constant.wvc-character, .wvc-constant.wvc-other {
11681168+ color: #f6c177;
11691169+}
11701170+.wvc-variable {
11711171+ color: #e0def4;
11721172+font-style: italic;
11731173+}
11741174+.wvc-keyword {
11751175+ color: #31748f;
11761176+}
11771177+.wvc-storage {
11781178+ color: #9ccfd8;
11791179+}
11801180+.wvc-storage.wvc-type {
11811181+ color: #9ccfd8;
11821182+}
11831183+.wvc-entity.wvc-name.wvc-class {
11841184+ color: #31748f;
11851185+font-weight: bold;
11861186+}
11871187+.wvc-entity.wvc-other.wvc-inherited-class {
11881188+ color: #31748f;
11891189+font-style: italic;
11901190+}
11911191+.wvc-entity.wvc-name.wvc-function {
11921192+ color: #ebbcba;
11931193+font-style: italic;
11941194+}
11951195+.wvc-variable.wvc-parameter {
11961196+ color: #c4a7e7;
11971197+}
11981198+.wvc-entity.wvc-name.wvc-tag {
11991199+ color: #31748f;
12001200+font-weight: bold;
12011201+}
12021202+.wvc-entity.wvc-other.wvc-attribute-name {
12031203+ color: #c4a7e7;
12041204+}
12051205+.wvc-support.wvc-function {
12061206+ color: #ebbcba;
12071207+font-weight: bold;
12081208+}
12091209+.wvc-support.wvc-constant {
12101210+ color: #f6c177;
12111211+font-weight: bold;
12121212+}
12131213+.wvc-support.wvc-type, .wvc-support.wvc-class {
12141214+ color: #9ccfd8;
12151215+font-weight: bold;
12161216+}
12171217+.wvc-support.wvc-other.wvc-variable {
12181218+ color: #eb6f92;
12191219+font-weight: bold;
12201220+}
12211221+.wvc-invalid {
12221222+ color: #e0def4;
12231223+ background-color: #eb6f92;
12241224+}
12251225+.wvc-invalid.wvc-deprecated {
12261226+ color: #e0def4;
12271227+ background-color: #c4a7e7;
12281228+}
12291229+.wvc-punctuation, .wvc-keyword.wvc-operator {
12301230+ color: #908caa;
12311231+}
12321232+}
12331233+ </style>
12341234+</head>
12351235+<body style="background: var(--color-base); min-height: 100vh;">
12361236+<div class="notebook-content">
12371237+<h1>Weaver: Long-form Writing on AT Protocol</h1>
12381238+<p><em>Or: "Get in kid, we're rebuilding the blogosphere!"</em></p>
12391239+<p>I grew up, like a lot of people on Bluesky, in the era of the internet where most of your online social interactions took place via text. I had a MySpace account, MSN messenger and Google Chat, I first got on Facebook back when they required a school email to sign up, I had a Tumblr, though not a LiveJournal.<label for="sn-1" class="sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Hi Rahaeli. Sorry I was the wrong kind of nerd.</span></p>
12401240+<blockquote>
12411241+<p><img src="weaver_photo_med.jpg" alt="weaver_photo_med.jpg" /><em>The namesake of what I'm building</em></p>
12421242+</blockquote>
12431243+<p>Social media in the conventional sense has been in a lot of ways a small part of the story of my time on the internet. The broader independent blogosphere of my teens and early adulthood shaped my worldview, and I was an avid reader and sometime participant there.</p>
12441244+<h2>The Blogosphere</h2>
12451245+<p>I am an atheist in large part because of a blog called Common Sense Atheism.<label for="sn-2" class="sidenote-number"></label><input type="checkbox" id="sn-2" class="margin-toggle"/><span class="sidenote">The author, Luke Muehlhauser, was criticising both Richard Dawkins <em>and</em> some Christian apologetics I was familiar with.</span> Luke's blog was part of a cluster of blogs out of which grew the rationalists, one of, for better or for worse, the most influential intellectual movements of the 21st century.I also read blogs like boingboing.net, was a big fan of Cory Doctorow. I figured out I am trans in part because of Thing of Things,<label for="sn-3" class="sidenote-number"></label><input type="checkbox" id="sn-3" class="margin-toggle"/><span class="sidenote">Specifically their piece on the <a href="https://thingofthings.wordpress.com/2017/05/05/the-cluster-structure-of-genderspace/">cluster structure of genderspace</a>.</span> a blog by Ozy Frantz, a transmasc person in the broader rationalist and Effective Altruist blogosphere.One thing these all have in common is length. Part of the reason I only really got onto Twitter in 2020 or so was because the concept of microblogging, of having to fit your thoughts into such a small package, baffled me for ages.<label for="sn-4" class="sidenote-number"></label><input type="checkbox" id="sn-4" class="margin-toggle"/><span class="sidenote">Amusingly I now think that being on Twitter and now Bluesky made me a better writer. Restrictions breed creativity, after all.</span></p>
12461246+<aside>
12471247+<blockquote>
12481248+<p><strong>On Platform Decay</strong></p>
12491249+<p>Through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress required too much setup. Tumblr's system for comments remains insane. Hosting my own seemed like too much money to burn on something nobody might read.</p>
12501250+</blockquote>
12511251+</aside>
12521252+<p>But at the same time, Substack's success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts.</p>
12531253+<p>Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.<label for="sn-5" class="sidenote-number"></label><input type="checkbox" id="sn-5" class="margin-toggle"/><span class="sidenote">I am very much a fan of freedom of expression. I'm not so much a fan of paying money to Nazis.</span>That's where the <code>at://</code> protocol and Weaver comes in.</p>
12541254+<h2>The Pitch</h2>
12551255+<p>Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto.<label for="sn-6" class="sidenote-number"></label><input type="checkbox" id="sn-6" class="margin-toggle"/><span class="sidenote">The weaver bird builds intricate, self-contained homes—seemed fitting for a platform about owning your writing.</span> I was inspired by how weaver birds build their own homes, and by the notebooks, physical and virtual, that I create in the course of my work.The initial proof-of-concept is essentially a static site generator, able to turn a Markdown text file or a folder of Markdown files into a static "notebook" site. The intermediate goal is an elegant and intuitive writing platform with collaborative editing and straightforward, immediate publishing via a web-app.</p>
12561256+<aside>
12571257+<blockquote>
12581258+<p><strong>The Ultimate Goal</strong></p>
12591259+<p>Build a platform suitable for professional writers and journalists, an open alternative to platforms like Substack, with ways for readers to support writers, all on the <code>at://</code> protocol.</p>
12601260+</blockquote>
12611261+</aside>
12621262+<h2>How It Works</h2>
12631263+<p>Weaver works on a concept of notebooks with entries, which can be grouped into pages or chapters. They can have multiple attributed authors. You can tear out a metaphorical page and stick it in another notebook.</p>
12641264+<p>You own what you write.<label for="sn-7" class="sidenote-number"></label><input type="checkbox" id="sn-7" class="margin-toggle"/><span class="sidenote">Technically you can include entries you don't control in your notebooks, although this isn't a supported mode—it's about <em>your</em> ownership of <em>your</em> words.</span> And once collaborative editing is in, collaborative work will be resilient against deletion by one author. They can delete their notebook or even their account, but what you write will be safe.Entries are Markdown text—specifically, an extension on the Obsidian flavour of Markdown.<label for="sn-8" class="sidenote-number"></label><input type="checkbox" id="sn-8" class="margin-toggle"/><span class="sidenote">I forked the popular rust markdown processing library <code>pulldown-cmark</code> because it had limited extensibility along the axes I wanted—custom syntax extensions to support Obsidian's Markdown flavour and additional useful features, like some of the ones on show here!</span> They support additional embed types, including atproto record embeds and other markdown documents, as well as resizable images.</p>
12651265+<h2>Why Rust?</h2>
12661266+<p>As to why I'm writing it in Rust (and currently zero Typescript) as opposed to Go and Typescript? Well it comes down to familiarity. Rust isn't necessarily anyone's first choice in a vacuum for a web-native programming language, but it works quite well as one. I can share the vast majority of the protocol code, as well as the markdown rendering engine, between front and back end, with few if any compromises on performance, save a larger bundle size due to the nature of WebAssembly.</p>
12671267+<aside>
12681268+<blockquote>
12691269+<p><strong>On Interoperability</strong></p>
12701270+<p>The <code>at://</code> protocol, while it was developed in concert with a microblogging app, is actually pretty damn good for "macroblogging" too. Weaver's app server can display Whitewind posts. With effort, it can faithfully render Leaflet posts. It doesn't care what app your profile is on.</p>
12711271+</blockquote>
12721272+</aside>
12731273+<h2>Evolution</h2>
12741274+<p>Weaver is therefore very much an evolving thing. It will always have and support the proof-of-concept workflow as a first-class citizen. That's part of the benefit of building this on atproto.</p>
12751275+<p>If I screw this up, not too hard for someone else to pick up the torch and continue.<label for="sn-9" class="sidenote-number"></label><input type="checkbox" id="sn-9" class="margin-toggle"/><span class="sidenote">This is the traditional footnote, at the end, because sometimes you want your citations at the bottom of the page rather than in the margins.</span></p>
12761276+</div>
12771277+</body>
12781278+</html>
test-output/weaver_photo_med.jpg
This is a binary file and will not be displayed.
+71
test-sidenotes.md
···11+# Weaver: Long-form Writing on AT Protocol
22+33+*Or: "Get in kid, we're rebuilding the blogosphere!"*
44+55+I grew up, like a lot of people on Bluesky, in the era of the internet where most of your online social interactions took place via text. I had a MySpace account, MSN messenger and Google Chat, I first got on Facebook back when they required a school email to sign up, I had a Tumblr, though not a LiveJournal.[^nostalgia]
66+[^nostalgia]: Hi Rahaeli. Sorry I was the wrong kind of nerd.
77+88+> ![[weaver_photo_med.jpg]]*The namesake of what I'm building*
99+1010+Social media in the conventional sense has been in a lot of ways a small part of the story of my time on the internet. The broader independent blogosphere of my teens and early adulthood shaped my worldview, and I was an avid reader and sometime participant there.
1111+1212+## The Blogosphere
1313+1414+I am an atheist in large part because of a blog called Common Sense Atheism.[^csa] Luke's blog was part of a cluster of blogs out of which grew the rationalists, one of, for better or for worse, the most influential intellectual movements of the 21st century.
1515+[^csa]: The author, Luke Muehlhauser, was criticising both Richard Dawkins *and* some Christian apologetics I was familiar with.
1616+1717+I also read blogs like boingboing.net, was a big fan of Cory Doctorow. I figured out I am trans in part because of Thing of Things,[^ozy] a blog by Ozy Frantz, a transmasc person in the broader rationalist and Effective Altruist blogosphere.
1818+[^ozy]: Specifically their piece on the [cluster structure of genderspace](https://thingofthings.wordpress.com/2017/05/05/the-cluster-structure-of-genderspace/).
1919+2020+One thing these all have in common is length. Part of the reason I only really got onto Twitter in 2020 or so was because the concept of microblogging, of having to fit your thoughts into such a small package, baffled me for ages.[^irony]
2121+[^irony]: Amusingly I now think that being on Twitter and now Bluesky made me a better writer. Restrictions breed creativity, after all.
2222+2323+{.aside}
2424+> **On Platform Decay**
2525+>
2626+> Through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress required too much setup. Tumblr's system for comments remains insane. Hosting my own seemed like too much money to burn on something nobody might read.
2727+2828+But at the same time, Substack's success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts.
2929+3030+Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.[^substack]
3131+[^substack]: I am very much a fan of freedom of expression. I'm not so much a fan of paying money to Nazis.
3232+3333+That's where the `at://` protocol and Weaver comes in.
3434+3535+## The Pitch
3636+3737+Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto.[^namesake] I was inspired by how weaver birds build their own homes, and by the notebooks, physical and virtual, that I create in the course of my work.
3838+[^namesake]: The weaver bird builds intricate, self-contained homes—seemed fitting for a platform about owning your writing.
3939+4040+The initial proof-of-concept is essentially a static site generator, able to turn a Markdown text file or a folder of Markdown files into a static "notebook" site. The intermediate goal is an elegant and intuitive writing platform with collaborative editing and straightforward, immediate publishing via a web-app.
4141+4242+{.aside}
4343+> **The Ultimate Goal**
4444+>
4545+> Build a platform suitable for professional writers and journalists, an open alternative to platforms like Substack, with ways for readers to support writers, all on the `at://` protocol.
4646+4747+## How It Works
4848+4949+Weaver works on a concept of notebooks with entries, which can be grouped into pages or chapters. They can have multiple attributed authors. You can tear out a metaphorical page and stick it in another notebook.
5050+5151+You own what you write.[^ownership] And once collaborative editing is in, collaborative work will be resilient against deletion by one author. They can delete their notebook or even their account, but what you write will be safe.
5252+[^ownership]: Technically you can include entries you don't control in your notebooks, although this isn't a supported mode—it's about *your* ownership of *your* words.
5353+5454+Entries are Markdown text—specifically, an extension on the Obsidian flavour of Markdown.[^markdown] They support additional embed types, including atproto record embeds and other markdown documents, as well as resizable images.
5555+[^markdown]: I forked the popular rust markdown processing library `pulldown-cmark` because it had limited extensibility along the axes I wanted—custom syntax extensions to support Obsidian's Markdown flavour and additional useful features, like some of the ones on show here!
5656+5757+## Why Rust?
5858+5959+As to why I'm writing it in Rust (and currently zero Typescript) as opposed to Go and Typescript? Well it comes down to familiarity. Rust isn't necessarily anyone's first choice in a vacuum for a web-native programming language, but it works quite well as one. I can share the vast majority of the protocol code, as well as the markdown rendering engine, between front and back end, with few if any compromises on performance, save a larger bundle size due to the nature of WebAssembly.
6060+6161+{.aside}
6262+> **On Interoperability**
6363+>
6464+> The `at://` protocol, while it was developed in concert with a microblogging app, is actually pretty damn good for "macroblogging" too. Weaver's app server can display Whitewind posts. With effort, it can faithfully render Leaflet posts. It doesn't care what app your profile is on.
6565+6666+## Evolution
6767+6868+Weaver is therefore very much an evolving thing. It will always have and support the proof-of-concept workflow as a first-class citizen. That's part of the benefit of building this on atproto.
6969+7070+If I screw this up, not too hard for someone else to pick up the torch and continue.[^open]
7171+[^open]: This is the traditional footnote, at the end, because sometimes you want your citations at the bottom of the page rather than in the margins.