atproto blogging
1use crate::static_site::{StaticSiteOptions};
2use crate::theme::ResolvedTheme;
3use crate::{Frontmatter, NotebookContext,default_md_options};
4use dashmap::DashMap;
5use markdown_weaver::{CowStr, EmbedType, Tag, WeaverAttributes};
6use std::{
7 path::{Path, PathBuf},
8 sync::Arc,
9};
10use syntect::parsing::SyntaxSet;
11use weaver_common::{
12 aturi_to_http,
13 jacquard::{
14 client::{Agent, AgentSession, AgentSessionExt},
15 prelude::*,
16 types::blob::MimeType,
17 },
18};
19use yaml_rust2::Yaml;
20
21#[derive(Debug, Clone)]
22pub enum KaTeXSource {
23 Cdn,
24 Local(PathBuf),
25}
26
27pub struct StaticSiteContext<A: AgentSession> {
28 pub options: StaticSiteOptions,
29 pub md_options: markdown_weaver::Options,
30 pub bsky_appview: CowStr<'static>,
31 pub root: PathBuf,
32 pub destination: PathBuf,
33 pub start_at: PathBuf,
34 pub frontmatter: Arc<DashMap<PathBuf, Frontmatter>>,
35 pub dir_contents: Option<Arc<[PathBuf]>>,
36 reference_map: Arc<DashMap<CowStr<'static>, PathBuf>>,
37 pub titles: Arc<DashMap<PathBuf, CowStr<'static>>>,
38 pub position: usize,
39 pub client: Option<reqwest::Client>,
40 agent: Option<Arc<Agent<A>>>,
41
42 pub theme: Option<Arc<ResolvedTheme<'static>>>,
43 pub katex_source: Option<KaTeXSource>,
44 pub syntax_set: Arc<SyntaxSet>,
45 pub index_file: Option<PathBuf>,
46}
47
48impl<A: AgentSession> Clone for StaticSiteContext<A> {
49 fn clone(&self) -> Self {
50 Self {
51 options: self.options.clone(),
52 md_options: self.md_options.clone(),
53 bsky_appview: self.bsky_appview.clone(),
54 root: self.root.clone(),
55 destination: self.destination.clone(),
56 start_at: self.start_at.clone(),
57 frontmatter: self.frontmatter.clone(),
58 dir_contents: self.dir_contents.clone(),
59 reference_map: self.reference_map.clone(),
60 titles: self.titles.clone(),
61 position: self.position.clone(),
62 client: self.client.clone(),
63 agent: self.agent.clone(),
64 theme: self.theme.clone(),
65 katex_source: self.katex_source.clone(),
66 syntax_set: self.syntax_set.clone(),
67 index_file: self.index_file.clone(),
68 }
69 }
70}
71
72impl<A: AgentSession> StaticSiteContext<A> {
73 pub fn clone_with_dir_contents(&self, dir_contents: &[PathBuf]) -> Self {
74 Self {
75 start_at: self.start_at.clone(),
76 root: self.root.clone(),
77 bsky_appview: self.bsky_appview.clone(),
78 options: self.options.clone(),
79 md_options: self.md_options.clone(),
80 frontmatter: self.frontmatter.clone(),
81 dir_contents: Some(Arc::from(dir_contents)),
82 destination: self.destination.clone(),
83 reference_map: self.reference_map.clone(),
84 titles: self.titles.clone(),
85 position: self.position,
86 client: self.client.clone(),
87 agent: self.agent.clone(),
88 theme: self.theme.clone(),
89 katex_source: self.katex_source.clone(),
90 syntax_set: self.syntax_set.clone(),
91 index_file: self.index_file.clone(),
92 }
93 }
94
95 pub fn clone_with_path(&self, path: impl AsRef<Path>) -> Self {
96 let position = if let Some(dir_contents) = &self.dir_contents {
97 dir_contents
98 .iter()
99 .position(|p| p == path.as_ref())
100 .unwrap_or(0)
101 } else {
102 0
103 };
104 Self {
105 start_at: self.start_at.clone(),
106 root: self.root.clone(),
107 bsky_appview: self.bsky_appview.clone(),
108 options: self.options.clone(),
109 md_options: self.md_options.clone(),
110 frontmatter: self.frontmatter.clone(),
111 dir_contents: self.dir_contents.clone(),
112 destination: self.destination.clone(),
113 reference_map: self.reference_map.clone(),
114 titles: self.titles.clone(),
115 position,
116 client: Some(reqwest::Client::default()),
117 agent: self.agent.clone(),
118 theme: self.theme.clone(),
119 katex_source: self.katex_source.clone(),
120 syntax_set: self.syntax_set.clone(),
121 index_file: self.index_file.clone(),
122 }
123 }
124 pub fn new(root: PathBuf, destination: PathBuf, session: Option<A>) -> Self {
125 use crate::theme::default_resolved_theme;
126
127 Self {
128 start_at: root.clone(),
129 root,
130 bsky_appview: CowStr::Borrowed("deer.social"),
131 options: StaticSiteOptions::default(),
132 md_options: default_md_options(),
133 frontmatter: Arc::new(DashMap::new()),
134 dir_contents: None,
135 destination,
136 reference_map: Arc::new(DashMap::new()),
137 titles: Arc::new(DashMap::new()),
138 position: 0,
139 client: Some(reqwest::Client::default()),
140 agent: session.map(|session| Arc::new(Agent::new(session))),
141 theme: Some(Arc::new(default_resolved_theme())),
142 katex_source: None,
143 syntax_set: Arc::new(SyntaxSet::load_defaults_newlines()),
144 index_file: None,
145 }
146 }
147
148 pub fn with_theme(mut self, theme: ResolvedTheme<'static>) -> Self {
149 self.theme = Some(Arc::new(theme));
150 self
151 }
152
153 pub fn current_path(&self) -> &PathBuf {
154 if let Some(dir_contents) = &self.dir_contents {
155 &dir_contents[self.position]
156 } else {
157 &self.start_at
158 }
159 }
160
161 #[inline]
162 pub fn handle_link_aturi<'s>(&self, link: Tag<'s>) -> Tag<'s> {
163 let link = crate::utils::resolve_at_ident_or_uri(&link, &self.bsky_appview);
164 self.handle_link_normal(link)
165 }
166
167 pub async fn handle_embed_aturi<'s>(&self, embed: Tag<'s>) -> Tag<'s> {
168 match &embed {
169 Tag::Embed {
170 embed_type,
171 dest_url,
172 title,
173 id,
174 attrs,
175 } => {
176 if dest_url.starts_with("at://") {
177 let width = if let Some(attrs) = attrs {
178 let mut width = 600;
179 for attr in &attrs.attrs {
180 if attr.0 == CowStr::Borrowed("width".into()) {
181 width = attr.1.parse::<usize>().unwrap_or(600);
182 break;
183 }
184 }
185 width
186 } else {
187 600
188 };
189 let html = if let Some(client) = &self.client {
190 if let Ok(resp) = client
191 .get("https://embed.bsky.app/oembed")
192 .query(&[
193 ("url", dest_url.clone().into_string()),
194 ("maxwidth", width.to_string()),
195 ])
196 .send()
197 .await
198 {
199 resp.text().await.ok()
200 } else {
201 None
202 }
203 } else {
204 None
205 };
206 if let Some(html) = html {
207 let link = aturi_to_http(&dest_url, &self.bsky_appview)
208 .expect("assuming the at-uri is valid rn");
209 let mut attrs = if let Some(attrs) = attrs {
210 attrs.clone()
211 } else {
212 WeaverAttributes {
213 classes: vec![],
214 attrs: vec![],
215 }
216 };
217 attrs.attrs.push(("content".into(), html.into()));
218 Tag::Embed {
219 embed_type: EmbedType::Comments, // change this when i update markdown-weaver
220 dest_url: link.into_static(),
221 title: title.clone(),
222 id: id.clone(),
223 attrs: Some(attrs),
224 }
225 } else {
226 self.handle_embed_normal(embed).await
227 }
228 } else {
229 self.handle_embed_normal(embed).await
230 }
231 }
232 _ => embed,
233 }
234 }
235
236 pub async fn handle_embed_normal<'s>(&self, embed: Tag<'s>) -> Tag<'s> {
237 // This option will REALLY slow down iteration over events.
238 if self.options.contains(StaticSiteOptions::INLINE_EMBEDS) {
239 match &embed {
240 Tag::Embed {
241 embed_type: _,
242 dest_url,
243 title,
244 id,
245 attrs,
246 } => {
247 let mut attrs = if let Some(attrs) = attrs {
248 attrs.clone()
249 } else {
250 WeaverAttributes {
251 classes: vec![],
252 attrs: vec![],
253 }
254 };
255 let contents = if crate::utils::is_local_path(dest_url) {
256 let file_path = if crate::utils::is_relative_link(dest_url) {
257 let root_path = self.root.clone();
258 root_path.join(Path::new(&dest_url as &str))
259 } else {
260 PathBuf::from(&dest_url as &str)
261 };
262 crate::utils::inline_file(&file_path).await
263 } else if let Some(client) = &self.client {
264 if let Ok(resp) = client.get(dest_url.clone().into_string()).send().await {
265 resp.text().await.ok()
266 } else {
267 None
268 }
269 } else {
270 None
271 };
272 if let Some(contents) = contents {
273 attrs.attrs.push(("content".into(), contents.into()));
274 Tag::Embed {
275 embed_type: EmbedType::Markdown, // change this when i update markdown-weaver
276 dest_url: dest_url.clone(),
277 title: title.clone(),
278 id: id.clone(),
279 attrs: Some(attrs),
280 }
281 } else {
282 embed
283 }
284 }
285 _ => embed,
286 }
287 } else {
288 embed
289 }
290 }
291
292 /// This is a no-op for the static site renderer currently.
293 #[inline]
294 pub fn handle_link_normal<'s>(&self, link: Tag<'s>) -> Tag<'s> {
295 link
296 }
297
298 /// This is a no-op for the static site renderer currently.
299 #[inline]
300 pub fn handle_image_normal<'s>(&self, image: Tag<'s>) -> Tag<'s> {
301 image
302 }
303
304 pub fn set_options(&mut self, options: StaticSiteOptions) {
305 self.options = options;
306 }
307}
308
309impl<A: AgentSession + IdentityResolver> StaticSiteContext<A> {
310 /// TODO: rework this a bit, to not just do the same thing as whitewind
311 /// (also need to make a record to refer to them) that being said, doing
312 /// this with the static site renderer isn't *really* the standard workflow
313 pub async fn upload_image<'s>(&self, image: Tag<'s>) -> Tag<'s> {
314 if let Some(agent) = &self.agent {
315 match &image {
316 Tag::Image {
317 link_type,
318 dest_url,
319 title,
320 id,
321 attrs,
322 } => {
323 if crate::utils::is_local_path(&dest_url) {
324 let root_path = self.root.clone();
325 let file_path = root_path.join(Path::new(&dest_url as &str));
326 if let Ok(image_data) = std::fs::read(&file_path) {
327 if let Ok(blob) = agent
328 .upload_blob(image_data, MimeType::new_static("image/jpg"))
329 .await
330 {
331 let (did, _) = agent.info().await.unwrap();
332 let url = weaver_common::blob_url(
333 &did,
334 agent.endpoint().await.as_str(),
335 &blob.r#ref.0,
336 );
337 return Tag::Image {
338 link_type: *link_type,
339 dest_url: url.into(),
340 title: title.clone(),
341 id: id.clone(),
342 attrs: attrs.clone(),
343 };
344 }
345 }
346 }
347 }
348 _ => {}
349 }
350 }
351 image
352 }
353}
354
355impl<A: AgentSession + IdentityResolver> NotebookContext for StaticSiteContext<A> {
356 fn set_entry_title(&self, title: CowStr<'_>) {
357 let path = self.current_path();
358 self.titles
359 .insert(path.clone(), title.clone().into_static());
360 self.frontmatter.get_mut(path).map(|frontmatter| {
361 if let Ok(mut yaml) = frontmatter.yaml.write() {
362 if yaml.get(0).is_some_and(|y| y.is_hash()) {
363 let map = yaml.get_mut(0).unwrap().as_mut_hash().unwrap();
364 map.insert(
365 Yaml::String("title".into()),
366 Yaml::String(title.into_static().into()),
367 );
368 }
369 }
370 });
371 }
372 fn entry_title(&self) -> CowStr<'_> {
373 let path = self.current_path();
374 self.titles.get(path).unwrap().clone()
375 }
376
377 fn frontmatter(&self) -> Frontmatter {
378 let path = self.current_path();
379 self.frontmatter.get(path).unwrap().value().clone()
380 }
381
382 fn set_frontmatter(&self, frontmatter: Frontmatter) {
383 let path = self.current_path();
384 self.frontmatter.insert(path.clone(), frontmatter);
385 }
386
387 async fn handle_link<'s>(&self, link: Tag<'s>) -> Tag<'s> {
388 bitflags::bitflags_match!(self.options, {
389 // Split this somehow or just combine the options
390 StaticSiteOptions::RESOLVE_AT_URIS | StaticSiteOptions::RESOLVE_AT_IDENTIFIERS => {
391 self.handle_link_aturi(link)
392 }
393 _ => match &link {
394 Tag::Link { link_type, dest_url, title, id } => {
395 if self.options.contains(StaticSiteOptions::FLATTEN_STRUCTURE) {
396 let (parent, filename) = crate::utils::flatten_dir_to_just_one_parent(&dest_url);
397 let dest_url = if crate::utils::is_local_path(&dest_url) {
398 let filename = PathBuf::from(filename).with_extension("html");
399 if crate::utils::is_relative_link(&dest_url)
400 && self.options.contains(StaticSiteOptions::CREATE_CHAPTERS_BY_DIRECTORY) {
401 if !parent.is_empty() {
402 CowStr::Boxed(format!("./{}/{}", parent, filename.display()).into_boxed_str())
403 } else {
404 CowStr::Boxed(format!("./{}", filename.display()).into_boxed_str())
405 }
406 } else {
407 CowStr::Boxed(format!("./entry/{}", filename.display()).into_boxed_str())
408 }
409 } else {
410 dest_url.clone()
411 };
412 Tag::Link {
413 link_type: *link_type,
414 dest_url,
415 title: title.clone(),
416 id: id.clone(),
417 }
418 } else {
419 if crate::utils::is_local_path(&dest_url) {
420 let filename = PathBuf::from(dest_url.as_ref() as &str).with_extension("html");
421 Tag::Link {
422 link_type: *link_type,
423 dest_url: CowStr::Boxed(filename.to_string_lossy().into()),
424 title: title.clone(),
425 id: id.clone(),
426 }
427 } else {
428 link
429 }
430 }
431 },
432 _ => link,
433 }
434 })
435 }
436
437 async fn handle_image<'s>(&self, image: Tag<'s>) -> Tag<'s> {
438 if self.options.contains(StaticSiteOptions::UPLOAD_BLOBS) {
439 self.upload_image(image).await
440 } else {
441 self.handle_image_normal(image)
442 }
443 }
444
445 async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> {
446 if self.options.contains(StaticSiteOptions::RESOLVE_AT_URIS)
447 || self.options.contains(StaticSiteOptions::ADD_LINK_PREVIEWS)
448 {
449 self.handle_embed_aturi(embed).await
450 } else {
451 self.handle_embed_normal(embed).await
452 }
453 }
454
455 fn handle_reference(&self, reference: CowStr<'_>) -> CowStr<'_> {
456 let reference = reference.into_static();
457 if let Some(reference) = self.reference_map.get(&reference) {
458 let path = reference.value().clone();
459 CowStr::Boxed(path.to_string_lossy().into_owned().into_boxed_str())
460 } else {
461 reference
462 }
463 }
464
465 fn add_reference(&self, reference: CowStr<'_>) {
466 let path = self.current_path();
467 self.reference_map
468 .insert(reference.into_static(), path.clone());
469 }
470}