atproto blogging
1use markdown_weaver::CowStr;
2use miette::IntoDiagnostic;
3use n0_future::TryFutureExt;
4use std::{path::Path, sync::OnceLock};
5
6#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
7use regex::Regex;
8#[cfg(all(target_family = "wasm", target_os = "unknown"))]
9use regex_lite::Regex;
10
11use markdown_weaver::BrokenLink;
12use std::path::PathBuf;
13use std::sync::Arc;
14use unicode_bidi::{get_base_direction, Direction};
15use unicode_normalization::UnicodeNormalization;
16
17#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
18pub async fn inline_file(path: impl AsRef<Path>) -> Option<String> {
19 tokio::fs::read_to_string(path).await.ok()
20}
21#[cfg(all(target_family = "wasm", target_os = "unknown"))]
22pub async fn inline_file(path: impl AsRef<Path>) -> Option<String> {
23 todo!()
24}
25
26pub const AVOID_URL_CHARS: &[char] = &[
27 '!', '#', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '@', '%', '[', ']', '?', '/',
28 '~', '|', '{', '}', '^', '`',
29];
30
31pub fn resolve_at_ident_or_uri<'s>(
32 link: &markdown_weaver::Tag<'s>,
33 appview: &str,
34) -> markdown_weaver::Tag<'s> {
35 use markdown_weaver::Tag;
36 match link {
37 Tag::Link {
38 link_type,
39 dest_url,
40 title,
41 id,
42 } => {
43 if dest_url.starts_with("at://") {
44 // Make the appview string swappable
45 let at_uri = weaver_common::aturi_to_http(dest_url.as_ref(), appview);
46 if let Some(at_uri) = at_uri {
47 Tag::Link {
48 link_type: *link_type,
49 dest_url: at_uri.into_static(),
50 title: title.clone(),
51 id: id.clone(),
52 }
53 } else {
54 link.clone()
55 }
56 } else if dest_url.starts_with("@") {
57 let maybe_identifier = dest_url.strip_prefix("@").unwrap();
58 if let Some(identifier) = weaver_common::match_identifier(maybe_identifier) {
59 let link = CowStr::Boxed(
60 format!("https://{}/profile/{}", appview, identifier).into_boxed_str(),
61 );
62 Tag::Link {
63 link_type: *link_type,
64 dest_url: link,
65 title: title.clone(),
66 id: id.clone(),
67 }
68 } else {
69 link.clone()
70 }
71 } else {
72 link.clone()
73 }
74 }
75 _ => link.clone(),
76 }
77}
78
79/// Rough and ready check if a path is a local path.
80/// Basically checks if the path is absolute and if so, is it accessible.
81/// Relative paths are assumed to be local, but URL schemes are not
82pub fn is_local_path(path: &str) -> bool {
83 // Check for URL schemes (http, https, at, etc.)
84 if path.contains("://") {
85 return false;
86 }
87 let path = Path::new(path);
88 path.is_relative() || path.try_exists().unwrap_or(false)
89}
90
91/// Is this link relative to somewhere?
92/// Rust has built-in checks for file paths, so this just wraps that.
93pub fn is_relative_link(link: &str) -> bool {
94 let path = Path::new(link);
95 path.is_relative()
96}
97
98/// Flatten a directory path to just the parent and filename, if present.
99/// Maybe worth to swap to using the Path tools, but this works.
100pub fn flatten_dir_to_just_one_parent(path: &str) -> (&str, &str) {
101 static RE_PARENT_DIR: OnceLock<Regex> = OnceLock::new();
102 let caps = RE_PARENT_DIR
103 .get_or_init(|| {
104 Regex::new(r".*[/\\](?P<parent>[^/\\]+)[/\\](?P<filename>[^/\\]+)$").unwrap()
105 })
106 .captures(path);
107 if let Some(caps) = caps {
108 if let Some(parent) = caps.name("parent") {
109 if let Some(filename) = caps.name("filename") {
110 return (parent.as_str(), filename.as_str());
111 }
112 return (parent.as_str(), "");
113 }
114 if let Some(filename) = caps.name("filename") {
115 return ("", filename.as_str());
116 }
117 }
118 ("", path)
119}
120
121#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
122use tokio::fs::{self, File};
123
124#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
125pub async fn create_file(dest: &Path) -> miette::Result<File> {
126 let file = File::create(dest)
127 .or_else(async |err| {
128 {
129 if err.kind() == std::io::ErrorKind::NotFound {
130 let parent = dest.parent().expect("file should have a parent directory");
131 fs::create_dir_all(parent).await?
132 }
133 File::create(dest)
134 }
135 .await
136 })
137 .await
138 .into_diagnostic()?;
139 Ok(file)
140}
141
142/// Path lookup in an Obsidian vault
143///
144/// Credit to https://github.com/zoni
145///
146/// Taken from https://github.com/zoni/obsidian-export/blob/main/src/lib.rs.rs on 2025-05-21
147///
148pub fn lookup_filename_in_vault<'a>(
149 filename: &str,
150 vault_contents: &'a [PathBuf],
151) -> Option<&'a PathBuf> {
152 let filename = PathBuf::from(filename);
153 let filename_normalized: String = filename.to_string_lossy().nfc().collect();
154
155 vault_contents.iter().find(|path| {
156 let path_normalized_str: String = path.to_string_lossy().nfc().collect();
157 let path_normalized = PathBuf::from(&path_normalized_str);
158 let path_normalized_lowered = PathBuf::from(&path_normalized_str.to_lowercase());
159
160 // It would be convenient if we could just do `filename.set_extension("md")` at the start
161 // of this funtion so we don't need multiple separate + ".md" match cases here, however
162 // that would break with a reference of `[[Note.1]]` linking to `[[Note.1.md]]`.
163
164 path_normalized.ends_with(&filename_normalized)
165 || path_normalized.ends_with(filename_normalized.clone() + ".md")
166 || path_normalized_lowered.ends_with(filename_normalized.to_lowercase())
167 || path_normalized_lowered.ends_with(filename_normalized.to_lowercase() + ".md")
168 })
169}
170
171pub struct VaultBrokenLinkCallback {
172 pub vault_contents: Arc<[PathBuf]>,
173}
174
175impl<'input> markdown_weaver::BrokenLinkCallback<'input> for VaultBrokenLinkCallback {
176 fn handle_broken_link(
177 &mut self,
178 link: BrokenLink<'input>,
179 ) -> Option<(CowStr<'input>, CowStr<'input>)> {
180 let text = link.reference;
181 let captures = crate::OBSIDIAN_NOTE_LINK_RE
182 .captures(&text)
183 .expect("note link regex didn't match - bad input?");
184 let file = captures.name("file").map(|v| v.as_str().trim());
185 let label = captures.name("label").map(|v| v.as_str());
186 let section = captures.name("section").map(|v| v.as_str().trim());
187
188 if let Some(file) = file {
189 if let Some(path) = lookup_filename_in_vault(file, self.vault_contents.as_ref()) {
190 let mut link_text = String::from(path.to_string_lossy());
191 if let Some(section) = section {
192 link_text.push('#');
193 link_text.push_str(section);
194 if let Some(label) = label {
195 let label = label.to_string();
196 Some((CowStr::from(link_text), CowStr::from(label)))
197 } else {
198 Some((link_text.into(), format!("{} > {}", file, section).into()))
199 }
200 } else {
201 Some((link_text.into(), format!("{}", file).into()))
202 }
203 } else {
204 None
205 }
206 } else {
207 None
208 }
209 }
210}
211
212/// Detect text direction from first strong directional character.
213/// Returns Some("rtl") for Hebrew/Arabic/etc, Some("ltr") for Latin, None if no strong char found.
214pub fn detect_text_direction(text: &str) -> Option<&'static str> {
215 match get_base_direction(text) {
216 Direction::Ltr => Some("ltr"),
217 Direction::Rtl => Some("rtl"),
218 Direction::Mixed => None, // neutral/unknown - let browser decide
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn test_detect_text_direction_ltr() {
228 assert_eq!(detect_text_direction("Hello World"), Some("ltr"));
229 assert_eq!(detect_text_direction("Привет мир"), Some("ltr"));
230 assert_eq!(detect_text_direction("你好世界"), Some("ltr"));
231 assert_eq!(detect_text_direction("Γειά σου κόσμε"), Some("ltr"));
232 }
233
234 #[test]
235 fn test_detect_text_direction_rtl() {
236 // Hebrew
237 assert_eq!(detect_text_direction("שלום עולם"), Some("rtl"));
238 // Arabic
239 assert_eq!(detect_text_direction("مرحبا بالعالم"), Some("rtl"));
240 // Mixed with leading whitespace and punctuation
241 assert_eq!(detect_text_direction(" 123... שלום"), Some("rtl"));
242 assert_eq!(detect_text_direction(" 456!!! مرحبا"), Some("rtl"));
243 }
244
245 #[test]
246 fn test_detect_text_direction_neutral_only() {
247 assert_eq!(detect_text_direction(" "), None);
248 assert_eq!(detect_text_direction("123456"), None);
249 assert_eq!(detect_text_direction("!!!..."), None);
250 assert_eq!(detect_text_direction(""), None);
251 }
252
253 #[test]
254 fn test_detect_text_direction_leading_neutrals() {
255 assert_eq!(detect_text_direction(" 123... Hello"), Some("ltr"));
256 assert_eq!(detect_text_direction("!!!456 שלום"), Some("rtl"));
257 }
258}