atproto blogging
1//! Weaver renderer
2//!
3//! This crate works with the weaver-markdown crate to render and optionally upload markdown notebooks to your Atproto PDS.
4//!
5
6use markdown_weaver::CowStr;
7use markdown_weaver::Event;
8use markdown_weaver::Tag;
9use n0_future::Stream;
10use yaml_rust2::Yaml;
11use yaml_rust2::YamlLoader;
12
13#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
14use regex::Regex;
15#[cfg(all(target_family = "wasm", target_os = "unknown"))]
16use regex_lite::Regex;
17
18use std::iter::Iterator;
19use std::ops::Range;
20use std::path::PathBuf;
21use std::pin::Pin;
22use std::sync::Arc;
23use std::sync::LazyLock;
24use std::sync::RwLock;
25use std::task::Poll;
26
27pub mod atproto;
28pub mod base_html;
29#[cfg(feature = "syntax-highlighting")]
30pub mod code_pretty;
31#[cfg(feature = "syntax-css")]
32pub mod css;
33pub mod facet;
34pub mod leaflet;
35pub mod math;
36#[cfg(feature = "pckt")]
37pub mod pckt;
38#[cfg(all(not(target_family = "wasm"), feature = "syntax-highlighting"))]
39pub mod static_site;
40pub mod theme;
41#[cfg(feature = "themes")]
42pub mod colour_gen;
43#[cfg(feature = "themes")]
44pub mod themes;
45pub mod types;
46pub mod utils;
47#[cfg(not(target_family = "wasm"))]
48pub mod walker;
49
50pub static OBSIDIAN_NOTE_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
51 Regex::new(r"^(?P<file>[^#|]+)??(#(?P<section>.+?))??(\|(?P<label>.+?))??$").unwrap()
52});
53
54#[derive(Debug, Default)]
55pub struct ContextIterator<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>> {
56 pub context: Option<EventContext>,
57 pub iter: I,
58 _phantom: std::marker::PhantomData<&'a ()>,
59}
60
61impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>> ContextIterator<'a, I> {
62 pub fn new(context: EventContext, iter: I) -> Self {
63 Self {
64 context: Some(context),
65 iter,
66 _phantom: std::marker::PhantomData,
67 }
68 }
69
70 pub fn default(iter: I) -> Self {
71 Self {
72 context: None,
73 iter,
74 _phantom: std::marker::PhantomData,
75 }
76 }
77}
78
79impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>> Iterator for ContextIterator<'a, I> {
80 type Item = (Event<'a>, Range<usize>, EventContext);
81
82 fn next(&mut self) -> Option<Self::Item> {
83 if let Some((next, range)) = self.iter.next() {
84 let ctxt = EventContext::get_context(&next, self.context.as_ref());
85 self.context = Some(ctxt);
86 Some((next, range, ctxt))
87 } else {
88 None
89 }
90 }
91}
92
93#[pin_project::pin_project]
94pub struct NotebookProcessor<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, CTX> {
95 context: CTX,
96 iter: ContextIterator<'a, I>,
97 #[pin]
98 pending_future:
99 Option<Pin<Box<dyn std::future::Future<Output = (Event<'a>, Range<usize>)> + 'a>>>,
100}
101
102impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, CTX> NotebookProcessor<'a, I, CTX> {
103 pub fn new(ctx: CTX, iter: ContextIterator<'a, I>) -> Self {
104 Self {
105 context: ctx,
106 iter,
107 pending_future: None,
108 }
109 }
110}
111
112impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, CTX: NotebookContext + Clone + 'a> Stream
113 for NotebookProcessor<'a, I, CTX>
114{
115 type Item = (Event<'a>, Range<usize>);
116
117 fn size_hint(&self) -> (usize, Option<usize>) {
118 self.iter.size_hint()
119 }
120 fn poll_next(
121 mut self: Pin<&mut Self>,
122 cx: &mut std::task::Context<'_>,
123 ) -> Poll<Option<Self::Item>> {
124 // First, poll any pending future to completion
125 if let Some(fut) = self.as_mut().project().pending_future.as_mut().as_pin_mut() {
126 match fut.poll(cx) {
127 Poll::Ready(event_with_range) => {
128 // Clear the future and return the result
129 self.as_mut().project().pending_future.set(None);
130 return Poll::Ready(Some(event_with_range));
131 }
132 Poll::Pending => {
133 // Keep the future for next poll
134 return Poll::Pending;
135 }
136 }
137 }
138
139 let mut this = self.project();
140 let iter: &mut ContextIterator<'a, I> = this.iter;
141 if let Some((event, range, ctxt)) = iter.next() {
142 match ctxt {
143 EventContext::EmbedLink => match event {
144 Event::Start(ref tag) => match tag {
145 Tag::Embed { .. } => {
146 let tag_clone = tag.clone();
147 let ctx_clone = this.context.clone();
148 let range_clone = range.clone();
149 let fut = async move {
150 let processed_tag = ctx_clone.handle_embed(tag_clone).await;
151 (Event::Start(processed_tag.into_static()), range_clone)
152 };
153
154 *this.pending_future = Some(Box::pin(fut));
155
156 if let Some(fut) = this.pending_future.as_mut().as_pin_mut() {
157 match fut.poll(cx) {
158 Poll::Ready(event_with_range) => {
159 *this.pending_future = None;
160 Poll::Ready(Some(event_with_range))
161 }
162 Poll::Pending => Poll::Pending,
163 }
164 } else {
165 unreachable!()
166 }
167 }
168 _ => Poll::Ready(Some((event, range))),
169 },
170 _ => Poll::Ready(Some((event, range))),
171 },
172 EventContext::CodeBlock => Poll::Ready(Some((event, range))),
173 EventContext::Text => Poll::Ready(Some((event, range))),
174 EventContext::Html => Poll::Ready(Some((event, range))),
175 EventContext::Heading => Poll::Ready(Some((event, range))),
176 EventContext::Reference => match event {
177 Event::Start(ref tag) => match tag {
178 Tag::Link { .. } => {
179 let tag_clone = tag.clone();
180 let ctx_clone = this.context.clone();
181 let range_clone = range.clone();
182 let fut = async move {
183 let processed_tag = ctx_clone.handle_link(tag_clone).await;
184 (Event::Start(processed_tag), range_clone)
185 };
186
187 *this.pending_future = Some(Box::pin(fut));
188
189 if let Some(fut) = this.pending_future.as_mut().as_pin_mut() {
190 match fut.poll(cx) {
191 Poll::Ready(event_with_range) => {
192 *this.pending_future = None;
193 Poll::Ready(Some(event_with_range))
194 }
195 Poll::Pending => Poll::Pending,
196 }
197 } else {
198 unreachable!()
199 }
200 }
201 _ => Poll::Ready(Some((event, range))),
202 },
203 Event::FootnoteReference(ref name) => {
204 this.context.handle_reference(name.clone());
205 Poll::Ready(Some((event, range)))
206 }
207 _ => Poll::Ready(Some((event, range))),
208 },
209 EventContext::RefDef => match event {
210 Event::Start(ref tag) => match tag {
211 Tag::FootnoteDefinition(name) => {
212 this.context.add_reference(name.clone());
213 Poll::Ready(Some((event, range)))
214 }
215 _ => Poll::Ready(Some((event, range))),
216 },
217 _ => Poll::Ready(Some((event, range))),
218 },
219 EventContext::Link => match event {
220 Event::Start(ref tag) => match tag {
221 Tag::Link { .. } => {
222 let tag_clone = tag.clone();
223 let ctx_clone = this.context.clone();
224 let range_clone = range.clone();
225 let fut = async move {
226 let processed_tag = ctx_clone.handle_link(tag_clone).await;
227 (Event::Start(processed_tag), range_clone)
228 };
229
230 *this.pending_future = Some(Box::pin(fut));
231
232 if let Some(fut) = this.pending_future.as_mut().as_pin_mut() {
233 match fut.poll(cx) {
234 Poll::Ready(event_with_range) => {
235 *this.pending_future = None;
236 Poll::Ready(Some(event_with_range))
237 }
238 Poll::Pending => Poll::Pending,
239 }
240 } else {
241 unreachable!()
242 }
243 }
244 _ => Poll::Ready(Some((event, range))),
245 },
246 _ => Poll::Ready(Some((event, range))),
247 },
248 EventContext::Image => match event {
249 Event::Start(ref tag) => match tag {
250 Tag::Image { .. } => {
251 // Create future that handles the image and wraps result in Event::Start
252 let tag_clone = tag.clone();
253 let ctx_clone = this.context.clone();
254 let range_clone = range.clone();
255 let fut = async move {
256 let processed_tag = ctx_clone.handle_image(tag_clone).await;
257 (Event::Start(processed_tag), range_clone)
258 };
259
260 // Store the future and poll it
261 *this.pending_future = Some(Box::pin(fut));
262
263 // Immediately poll the stored future
264 if let Some(fut) = this.pending_future.as_mut().as_pin_mut() {
265 match fut.poll(cx) {
266 Poll::Ready(event_with_range) => {
267 *this.pending_future = None;
268 Poll::Ready(Some(event_with_range))
269 }
270 Poll::Pending => Poll::Pending,
271 }
272 } else {
273 unreachable!()
274 }
275 }
276 _ => Poll::Ready(Some((event, range))),
277 },
278 _ => Poll::Ready(Some((event, range))),
279 },
280
281 EventContext::Table => Poll::Ready(Some((event, range))),
282 EventContext::Metadata => match event {
283 Event::Text(ref text) => {
284 let frontmatter = Frontmatter::new(&text);
285 this.context.set_frontmatter(frontmatter);
286 Poll::Ready(Some((event, range)))
287 }
288 _ => Poll::Ready(Some((event, range))),
289 },
290 EventContext::Other => Poll::Ready(Some((event, range))),
291 EventContext::None => Poll::Ready(Some((event, range))),
292 }
293 } else {
294 Poll::Ready(None)
295 }
296 }
297}
298
299pub trait NotebookContext {
300 fn set_entry_title(&self, title: CowStr<'_>);
301 fn entry_title(&self) -> CowStr<'_>;
302 fn normalized_entry_title(&self) -> CowStr<'_> {
303 let title = self.entry_title();
304 let mut normalized = String::new();
305 for c in title.chars() {
306 if c.is_ascii_alphanumeric() {
307 normalized.push(c);
308 } else if c.is_whitespace() && !normalized.is_empty() && !(c == '\n' || c == '\r') {
309 normalized.push('-');
310 } else if c == '\n' {
311 normalized.push('_');
312 } else if c == '\r' {
313 continue;
314 } else if !crate::utils::AVOID_URL_CHARS.contains(&c) {
315 normalized.push(c);
316 }
317 }
318 CowStr::Boxed(normalized.into_boxed_str())
319 }
320 fn frontmatter(&self) -> Frontmatter;
321 fn set_frontmatter(&self, frontmatter: Frontmatter);
322 fn handle_link<'s>(&self, link: Tag<'s>) -> impl Future<Output = Tag<'s>>;
323 fn handle_image<'s>(&self, image: Tag<'s>) -> impl Future<Output = Tag<'s>>;
324 fn handle_embed<'s>(&self, embed: Tag<'s>) -> impl Future<Output = Tag<'s>>;
325 fn handle_reference(&self, reference: CowStr<'_>) -> CowStr<'_>;
326 fn add_reference(&self, reference: CowStr<'_>);
327}
328
329#[derive(Debug, Clone)]
330pub struct Frontmatter {
331 yaml: Arc<RwLock<Vec<Yaml>>>,
332}
333
334impl Frontmatter {
335 pub fn new(text: &str) -> Self {
336 let yaml = YamlLoader::load_from_str(text).unwrap_or_else(|_| vec![Yaml::BadValue]);
337 Self {
338 yaml: Arc::new(RwLock::new(yaml)),
339 }
340 }
341
342 pub fn contents(&self) -> Arc<RwLock<Vec<Yaml>>> {
343 self.yaml.clone()
344 }
345}
346
347impl Default for Frontmatter {
348 fn default() -> Self {
349 Frontmatter {
350 yaml: Arc::new(RwLock::new(vec![])),
351 }
352 }
353}
354
355#[derive(thiserror::Error, Debug, miette::Diagnostic)]
356pub enum RenderError {
357 #[error("WalkDir error at {}", path.display())]
358 #[diagnostic(code(crate::static_site::walker))]
359 WalkDirError { path: PathBuf, msg: String },
360}
361
362#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
363pub enum EventContext {
364 EmbedLink,
365 CodeBlock,
366 #[default]
367 Text,
368 Html,
369 Heading,
370 Reference,
371 RefDef,
372 Image,
373 Link,
374 Table,
375 Metadata,
376 Other,
377 None,
378}
379
380impl EventContext {
381 pub fn get_context<'a>(event: &Event<'a>, prev: Option<&Self>) -> Self {
382 match event {
383 Event::Start(tag) => match tag {
384 Tag::Paragraph(_) => Self::Text,
385 Tag::Heading { .. } => Self::Heading,
386 Tag::BlockQuote(_block_quote_kind) => Self::Text,
387 Tag::CodeBlock(_code_block_kind) => Self::CodeBlock,
388 Tag::HtmlBlock => Self::Text,
389 Tag::List(_) => Self::Other,
390 Tag::Item => Self::Other,
391 Tag::FootnoteDefinition(_cow_str) => Self::RefDef,
392 Tag::DefinitionList => Self::Other,
393 Tag::DefinitionListTitle => Self::Other,
394 Tag::DefinitionListDefinition => Self::Other,
395 Tag::Table(_alignments) => Self::Table,
396 Tag::TableHead => Self::Table,
397 Tag::TableRow => Self::Table,
398 Tag::TableCell => Self::Table,
399 Tag::Emphasis => Self::Text,
400 Tag::Strong => Self::Text,
401 Tag::Strikethrough => Self::Text,
402 Tag::Superscript => Self::Text,
403 Tag::Subscript => Self::Text,
404 Tag::Link { .. } => Self::Link,
405 Tag::Image { .. } => Self::Image,
406 Tag::Embed { .. } => Self::EmbedLink,
407 Tag::WeaverBlock(_weaver_block_kind, _weaver_attributes) => Self::Metadata,
408 Tag::MetadataBlock(_metadata_block_kind) => Self::Metadata,
409 },
410 Event::End(_tag_end) => Self::None,
411 Event::Text(_cow_str) => match prev {
412 Some(ctxt) => match ctxt {
413 EventContext::None => Self::Text,
414 _ => *ctxt,
415 },
416 None => Self::Text,
417 },
418 Event::Code(_cow_str) => Self::CodeBlock,
419 Event::InlineMath(_cow_str) => Self::Other,
420 Event::DisplayMath(_cow_str) => Self::Other,
421 Event::Html(_cow_str) => Self::Html,
422 Event::InlineHtml(_cow_str) => Self::Html,
423 Event::FootnoteReference(_cow_str) => Self::Reference,
424 Event::SoftBreak => Self::Other,
425 Event::HardBreak => Self::Other,
426 Event::Rule => Self::Other,
427 Event::TaskListMarker(_cow_str) => Self::Other,
428 Event::WeaverBlock(_cow_str) => Self::Other,
429 }
430 }
431
432 pub fn is_non_writing_block(&self) -> bool {
433 match self {
434 Self::Metadata => true,
435 _ => false,
436 }
437 }
438}
439
440pub fn default_md_options() -> markdown_weaver::Options {
441 markdown_weaver::Options::ENABLE_WIKILINKS
442 | markdown_weaver::Options::ENABLE_FOOTNOTES
443 | markdown_weaver::Options::ENABLE_TABLES
444 | markdown_weaver::Options::ENABLE_GFM
445 | markdown_weaver::Options::ENABLE_STRIKETHROUGH
446 | markdown_weaver::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
447 | markdown_weaver::Options::ENABLE_OBSIDIAN_EMBEDS
448 | markdown_weaver::Options::ENABLE_MATH
449 | markdown_weaver::Options::ENABLE_HEADING_ATTRIBUTES
450}