atproto blogging
1#[cfg(feature = "syntax-css")]
2use crate::css::{generate_base_css, generate_syntax_css};
3use crate::static_site::context::{KaTeXSource, StaticSiteContext};
4use crate::theme::default_resolved_theme;
5use miette::IntoDiagnostic;
6use weaver_common::jacquard::client::AgentSession;
7
8#[derive(Debug, Clone, Copy)]
9pub enum CssMode {
10 Linked,
11 Inline,
12}
13
14pub async fn write_document_head<A: AgentSession>(
15 context: &StaticSiteContext<A>,
16 writer: &mut (impl tokio::io::AsyncWrite + Unpin),
17 css_mode: CssMode,
18 output_path: &std::path::Path,
19) -> miette::Result<()> {
20 use tokio::io::AsyncWriteExt;
21
22 // Get title from frontmatter or current path
23 let title = if let Some(path) = context
24 .dir_contents
25 .as_ref()
26 .and_then(|contents| contents.get(context.position))
27 {
28 context
29 .titles
30 .get(path)
31 .map(|t| t.value().to_string())
32 .unwrap_or_else(|| {
33 path.file_stem()
34 .and_then(|s| s.to_str())
35 .unwrap_or("Untitled")
36 .to_string()
37 })
38 } else {
39 "Untitled".to_string()
40 };
41
42 // Calculate relative path to root based on output file depth
43 let relative_to_root = if let Ok(rel) = output_path.strip_prefix(&context.destination) {
44 let depth = rel.components().count() - 1; // -1 because the file itself doesn't count
45 if depth <= 0 {
46 "./".to_string()
47 } else {
48 "../".repeat(depth)
49 }
50 } else {
51 "./".to_string()
52 };
53
54 writer
55 .write_all(b"<!DOCTYPE html>\n")
56 .await
57 .into_diagnostic()?;
58 writer
59 .write_all(b"<html lang=\"en\">\n")
60 .await
61 .into_diagnostic()?;
62 writer.write_all(b"<head>\n").await.into_diagnostic()?;
63 writer
64 .write_all(b" <meta charset=\"utf-8\">\n")
65 .await
66 .into_diagnostic()?;
67 writer
68 .write_all(b" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
69 .await
70 .into_diagnostic()?;
71
72 // Title
73 writer.write_all(b" <title>").await.into_diagnostic()?;
74 writer.write_all(title.as_bytes()).await.into_diagnostic()?;
75 writer.write_all(b"</title>\n").await.into_diagnostic()?;
76
77 // CSS
78 match css_mode {
79 CssMode::Linked => {
80 writer
81 .write_all(
82 format!(
83 " <link rel=\"stylesheet\" href=\"{}css/base.css\">\n",
84 relative_to_root
85 )
86 .as_bytes(),
87 )
88 .await
89 .into_diagnostic()?;
90 writer
91 .write_all(
92 format!(
93 " <link rel=\"stylesheet\" href=\"{}css/syntax.css\">\n",
94 relative_to_root
95 )
96 .as_bytes(),
97 )
98 .await
99 .into_diagnostic()?;
100 }
101 #[cfg(feature = "syntax-css")]
102 CssMode::Inline => {
103 let default_theme = default_resolved_theme();
104 let theme = context.theme.as_deref().unwrap_or(&default_theme);
105
106 writer.write_all(b" <style>\n").await.into_diagnostic()?;
107 writer
108 .write_all(generate_base_css(theme).as_bytes())
109 .await
110 .into_diagnostic()?;
111 writer.write_all(b" </style>\n").await.into_diagnostic()?;
112
113 writer.write_all(b" <style>\n").await.into_diagnostic()?;
114 let syntax_css = generate_syntax_css(theme).await?;
115 writer
116 .write_all(syntax_css.as_bytes())
117 .await
118 .into_diagnostic()?;
119 writer.write_all(b" </style>\n").await.into_diagnostic()?;
120 }
121 #[cfg(not(feature = "syntax-css"))]
122 CssMode::Inline => {
123 // CSS generation not available without syntax-css feature
124 return Err(miette::miette!(
125 "Inline CSS mode requires the 'syntax-css' feature"
126 ));
127 }
128 }
129
130 // KaTeX if enabled
131 if let Some(ref katex) = context.katex_source {
132 match katex {
133 KaTeXSource::Cdn => {
134 writer.write_all(b" <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css\">\n").await.into_diagnostic()?;
135 writer.write_all(b" <script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js\"></script>\n").await.into_diagnostic()?;
136 writer.write_all(b" <script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js\" onload=\"renderMathInElement(document.body);\"></script>\n").await.into_diagnostic()?;
137 }
138 KaTeXSource::Local(path) => {
139 let path_str = path.to_string_lossy();
140 writer
141 .write_all(
142 format!(
143 " <link rel=\"stylesheet\" href=\"{}/katex.min.css\">\n",
144 path_str
145 )
146 .as_bytes(),
147 )
148 .await
149 .into_diagnostic()?;
150 writer
151 .write_all(
152 format!(
153 " <script defer src=\"{}/katex.min.js\"></script>\n",
154 path_str
155 )
156 .as_bytes(),
157 )
158 .await
159 .into_diagnostic()?;
160 writer.write_all(format!(" <script defer src=\"{}/contrib/auto-render.min.js\" onload=\"renderMathInElement(document.body);\"></script>\n", path_str).as_bytes()).await.into_diagnostic()?;
161 }
162 }
163 }
164
165 writer.write_all(b"</head>\n").await.into_diagnostic()?;
166 writer
167 .write_all(b"<body style=\"background: var(--color-base); min-height: 100vh;\">\n")
168 .await
169 .into_diagnostic()?;
170 writer
171 .write_all(b"<div class=\"notebook-content\">\n")
172 .await
173 .into_diagnostic()?;
174
175 Ok(())
176}
177
178pub async fn write_document_footer(
179 writer: &mut (impl tokio::io::AsyncWrite + Unpin),
180) -> miette::Result<()> {
181 use tokio::io::AsyncWriteExt;
182
183 writer.write_all(b"</div>\n").await.into_diagnostic()?;
184 writer.write_all(b"</body>\n").await.into_diagnostic()?;
185 writer.write_all(b"</html>\n").await.into_diagnostic()?;
186
187 Ok(())
188}