atproto blogging
1//! Static renderer
2//!
3//! This mode of the renderer creates a static html and css website from a notebook in a local directory.
4//! It does not upload it to the PDS by default (though it can ). This is good for testing and for self-hosting.
5//! URLs in the notebook are mostly unaltered. It is compatible with GitHub or Cloudflare Pages
6//! and other similar static hosting services.
7
8pub mod context;
9pub mod document;
10pub mod writer;
11
12use crate::utils::VaultBrokenLinkCallback;
13use crate::{
14 ContextIterator, NotebookProcessor,
15 static_site::{
16 context::StaticSiteContext,
17 document::{CssMode, write_document_footer, write_document_head},
18 writer::StaticPageWriter,
19 },
20 theme::default_resolved_theme,
21 utils::flatten_dir_to_just_one_parent,
22 walker::{WalkOptions, vault_contents},
23};
24use bitflags::bitflags;
25use markdown_weaver::{BrokenLink, CowStr, Parser};
26use markdown_weaver_escape::FmtWriter;
27use miette::IntoDiagnostic;
28#[cfg(all(target_family = "wasm", target_os = "unknown"))]
29use n0_future::io::AsyncWriteExt;
30use std::{
31 path::{Path, PathBuf},
32 sync::Arc,
33};
34#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
35use tokio::io::AsyncWriteExt;
36use unicode_normalization::UnicodeNormalization;
37use weaver_common::jacquard::{client::AgentSession, prelude::*};
38
39bitflags! {
40 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
41 pub struct StaticSiteOptions:u32 {
42 const FLATTEN_STRUCTURE = 1 << 1;
43 const UPLOAD_BLOBS = 1 << 2;
44 const INLINE_EMBEDS = 1 << 3;
45 const ADD_LINK_PREVIEWS = 1 << 4;
46 const RESOLVE_AT_IDENTIFIERS = 1 << 5;
47 const RESOLVE_AT_URIS = 1 << 6;
48 const ADD_BSKY_COMMENTS_EMBED = 1 << 7;
49 const CREATE_INDEX = 1 << 8;
50 const CREATE_CHAPTERS_BY_DIRECTORY = 1 << 9;
51 const CREATE_PAGES_BY_TITLE = 1 << 10;
52 const NORMALIZE_DIR_NAMES = 1 << 11;
53 const ADD_TOC_TO_PAGES = 1 << 12;
54 }
55}
56
57impl Default for StaticSiteOptions {
58 fn default() -> Self {
59 Self::FLATTEN_STRUCTURE
60 //| Self::UPLOAD_BLOBS
61 | Self::RESOLVE_AT_IDENTIFIERS
62 | Self::RESOLVE_AT_URIS
63 | Self::CREATE_INDEX
64 | Self::CREATE_CHAPTERS_BY_DIRECTORY
65 | Self::CREATE_PAGES_BY_TITLE
66 | Self::NORMALIZE_DIR_NAMES
67 }
68}
69
70pub struct StaticSiteWriter<A>
71where
72 A: AgentSession,
73{
74 context: StaticSiteContext<A>,
75}
76
77impl<A> StaticSiteWriter<A>
78where
79 A: AgentSession,
80{
81 pub fn new(root: PathBuf, destination: PathBuf, session: Option<A>) -> Self {
82 let context = StaticSiteContext::new(root, destination, session);
83 Self { context }
84 }
85}
86
87impl<A> StaticSiteWriter<A>
88where
89 A: AgentSession + IdentityResolver + 'static,
90{
91 pub async fn run(mut self) -> Result<(), miette::Report> {
92 if !self.context.root.exists() {
93 return Err(miette::miette!(
94 "The path specified ({}) does not exist",
95 self.context.root.display()
96 ));
97 }
98 let contents = vault_contents(&self.context.root, WalkOptions::new())?;
99
100 self.context.dir_contents = Some(contents.into());
101
102 if self.context.root.is_file() || self.context.start_at.is_file() {
103 let source_filename = self
104 .context
105 .start_at
106 .file_name()
107 .expect("wtf how!?")
108 .to_string_lossy();
109
110 let dest = if self.context.destination.is_dir() {
111 self.context.destination.join(String::from(source_filename))
112 } else {
113 let parent = self
114 .context
115 .destination
116 .parent()
117 .unwrap_or(&self.context.destination);
118 // Avoid recursively creating self.destination through the call to
119 // export_note when the parent directory doesn't exist.
120 if !parent.exists() {
121 return Err(miette::miette!(
122 "Destination parent path ({}) does not exist, SOMEHOW",
123 parent.display()
124 ));
125 }
126 self.context.destination.clone()
127 };
128 // Use standalone writer for single file (inline CSS)
129 return write_page_standalone(self.context.clone(), &self.context.start_at, dest).await;
130 }
131
132 if !self.context.destination.exists() {
133 return Err(miette::miette!(
134 "The destination path specified ({}) does not exist",
135 self.context.destination.display()
136 ));
137 }
138
139 // Generate CSS files for multi-file mode
140 self.generate_css_files().await?;
141
142 for file in self
143 .context
144 .dir_contents
145 .as_ref()
146 .unwrap()
147 .clone()
148 .into_iter()
149 .filter(|file| file.starts_with(&self.context.start_at))
150 {
151 let context = self.context.clone();
152 let relative_path = file
153 .strip_prefix(context.start_at.clone())
154 .expect("file should always be nested under root")
155 .to_path_buf();
156
157 // Check if this is a markdown file
158 let is_markdown = file
159 .extension()
160 .and_then(|ext| ext.to_str())
161 .map(|ext| ext == "md" || ext == "markdown")
162 .unwrap_or(false);
163
164 if !is_markdown {
165 // Copy non-markdown files directly
166 let output_path = if context
167 .options
168 .contains(StaticSiteOptions::FLATTEN_STRUCTURE)
169 {
170 let path_str = relative_path.to_string_lossy();
171 let (parent, fname) = flatten_dir_to_just_one_parent(&path_str);
172 let parent = if parent.is_empty() { "entry" } else { parent };
173 context
174 .destination
175 .join(String::from(parent))
176 .join(String::from(fname))
177 } else {
178 context.destination.join(relative_path.clone())
179 };
180
181 // Create parent directory if needed
182 if let Some(parent) = output_path.parent() {
183 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
184 }
185
186 tokio::fs::copy(&file, &output_path)
187 .await
188 .into_diagnostic()?;
189 return Ok(());
190 }
191
192 // Process markdown files
193 // Check if this is the designated index file
194 if let Some(index) = &context.index_file {
195 if &relative_path == index {
196 let output_path = context.destination.join("index.html");
197 return write_page(context.clone(), file, output_path).await;
198 }
199 }
200
201 if context
202 .options
203 .contains(StaticSiteOptions::FLATTEN_STRUCTURE)
204 {
205 let path_str = relative_path.to_string_lossy();
206 let (parent, fname) = flatten_dir_to_just_one_parent(&path_str);
207 let parent = if parent.is_empty() { "entry" } else { parent };
208 let output_path = context
209 .destination
210 .join(String::from(parent))
211 .join(String::from(fname));
212
213 write_page(context.clone(), file.clone(), output_path).await?;
214 } else {
215 let output_path = context.destination.join(relative_path.clone());
216
217 write_page(context.clone(), file.clone(), output_path).await?;
218 }
219 }
220
221 // Generate default index if requested and no custom index specified
222 if self
223 .context
224 .options
225 .contains(StaticSiteOptions::CREATE_INDEX)
226 && self.context.index_file.is_none()
227 {
228 self.generate_default_index().await?;
229 }
230
231 Ok(())
232 }
233
234 #[cfg(feature = "syntax-css")]
235 async fn generate_css_files(&self) -> Result<(), miette::Report> {
236 use crate::css::{generate_base_css, generate_syntax_css};
237
238 let css_dir = self.context.destination.join("css");
239 tokio::fs::create_dir_all(&css_dir)
240 .await
241 .into_diagnostic()?;
242
243 let default_theme = default_resolved_theme();
244 let theme = self.context.theme.as_deref().unwrap_or(&default_theme);
245
246 // Write base.css
247 let base_css = generate_base_css(theme);
248 tokio::fs::write(css_dir.join("base.css"), base_css)
249 .await
250 .into_diagnostic()?;
251
252 // Write syntax.css
253 let syntax_css = generate_syntax_css(theme).await?;
254 tokio::fs::write(css_dir.join("syntax.css"), syntax_css)
255 .await
256 .into_diagnostic()?;
257
258 Ok(())
259 }
260
261 #[cfg(not(feature = "syntax-css"))]
262 async fn generate_css_files(&self) -> Result<(), miette::Report> {
263 Err(miette::miette!(
264 "CSS generation requires the 'syntax-css' feature"
265 ))
266 }
267
268 async fn generate_default_index(&self) -> Result<(), miette::Report> {
269 let index_path = self.context.destination.join("index.html");
270 let mut index_file = crate::utils::create_file(&index_path).await?;
271
272 // Write head
273 write_document_head(&self.context, &mut index_file, CssMode::Linked, &index_path).await?;
274
275 // Write title and list
276 index_file
277 .write_all(b"<h1>Index</h1>\n<ul>\n")
278 .await
279 .into_diagnostic()?;
280
281 // List all files
282 if let Some(contents) = &self.context.dir_contents {
283 for file in contents.iter() {
284 if let Ok(relative) = file.strip_prefix(&self.context.start_at) {
285 let display_name = relative.to_string_lossy();
286 let link = if self
287 .context
288 .options
289 .contains(StaticSiteOptions::FLATTEN_STRUCTURE)
290 {
291 let (parent, fname) = flatten_dir_to_just_one_parent(&display_name);
292 // Change extension to .html
293 let fname_html =
294 PathBuf::from(fname.as_ref() as &str).with_extension("html");
295 let fname_html_str = fname_html.to_string_lossy();
296 if !parent.is_empty() {
297 format!("./{}/{}", parent, fname_html_str)
298 } else {
299 format!("./entry/{}", fname_html_str)
300 }
301 } else {
302 // Change extension to .html
303 let html_path =
304 PathBuf::from(display_name.as_ref() as &str).with_extension("html");
305 format!("./{}", html_path.to_string_lossy())
306 };
307
308 index_file
309 .write_all(
310 format!(" <li><a href=\"{}\">{}</a></li>\n", link, display_name)
311 .as_bytes(),
312 )
313 .await
314 .into_diagnostic()?;
315 }
316 }
317 }
318
319 index_file.write_all(b"</ul>\n").await.into_diagnostic()?;
320
321 // Write footer
322 write_document_footer(&mut index_file).await?;
323
324 Ok(())
325 }
326}
327
328pub async fn export_page<'input, A>(
329 contents: &'input str,
330 context: StaticSiteContext<A>,
331) -> Result<String, miette::Report>
332where
333 A: AgentSession + IdentityResolver,
334{
335 let callback = if let Some(dir_contents) = context.dir_contents.clone() {
336 Some(VaultBrokenLinkCallback {
337 vault_contents: dir_contents,
338 })
339 } else {
340 None
341 };
342 let parser = Parser::new_with_broken_link_callback(&contents, context.md_options, callback)
343 .into_offset_iter();
344 let iterator = ContextIterator::default(parser);
345 let mut output = String::new();
346 let writer = StaticPageWriter::new(
347 NotebookProcessor::new(context, iterator),
348 FmtWriter(&mut output),
349 contents,
350 );
351 writer.run().await.into_diagnostic()?;
352 Ok(output)
353}
354
355pub async fn write_page<A>(
356 context: StaticSiteContext<A>,
357 input_path: impl AsRef<Path>,
358 output_path: impl AsRef<Path>,
359) -> Result<(), miette::Report>
360where
361 A: AgentSession + IdentityResolver,
362{
363 let contents = tokio::fs::read_to_string(&input_path)
364 .await
365 .into_diagnostic()?;
366
367 // Change extension to .html
368 let output_path = output_path.as_ref().with_extension("html");
369 let mut output_file = crate::utils::create_file(&output_path).await?;
370 let context = context.clone_with_path(input_path);
371
372 // Write document head
373 write_document_head(&context, &mut output_file, CssMode::Linked, &output_path).await?;
374
375 // Write body content
376 let output = export_page(&contents, context).await?;
377 output_file
378 .write_all(output.as_bytes())
379 .await
380 .into_diagnostic()?;
381
382 // Write document footer
383 write_document_footer(&mut output_file).await?;
384
385 Ok(())
386}
387
388pub async fn write_page_standalone<A>(
389 context: StaticSiteContext<A>,
390 input_path: impl AsRef<Path>,
391 output_path: impl AsRef<Path>,
392) -> Result<(), miette::Report>
393where
394 A: AgentSession + IdentityResolver,
395{
396 let contents = tokio::fs::read_to_string(&input_path)
397 .await
398 .into_diagnostic()?;
399
400 // Change extension to .html
401 let output_path = output_path.as_ref().with_extension("html");
402 let mut output_file = crate::utils::create_file(&output_path).await?;
403 let context = context.clone_with_path(input_path);
404
405 // Write document head with inline CSS
406 write_document_head(&context, &mut output_file, CssMode::Inline, &output_path).await?;
407
408 // Write body content
409 let output = export_page(&contents, context).await?;
410 output_file
411 .write_all(output.as_bytes())
412 .await
413 .into_diagnostic()?;
414
415 // Write document footer
416 write_document_footer(&mut output_file).await?;
417
418 Ok(())
419}
420
421#[cfg(test)]
422mod tests;