Skip to main content

docgen_core/
bases.rs

1//! Bridge from docgen's document model to the `docgen-bases` engine: builds a
2//! queryable [`Corpus`] of notes (frontmatter properties + file metadata + tags +
3//! links) from the prepared docs, and re-exports the pieces the build needs.
4//!
5//! This module owns the docgen-specific glue (reading `PreparedDoc`s, scanning
6//! `#tags`/`[[wikilinks]]` out of bodies); `docgen-bases` itself stays free of any
7//! docgen types.
8
9use docgen_bases::{properties_from_yaml, value_from_yaml, BaseLink, Corpus, Note, Value};
10
11use crate::pipeline::PreparedDoc;
12
13/// Filesystem facts a caller supplies per doc (docgen-core does no I/O). Times are
14/// epoch-milliseconds; `None` when unavailable (git checkouts don't preserve
15/// mtime, so this is best-effort).
16#[derive(Debug, Clone, Copy, Default)]
17pub struct FileFacts {
18    pub size: u64,
19    pub ctime_ms: Option<i64>,
20    pub mtime_ms: Option<i64>,
21}
22
23/// Build a [`Note`] from a prepared doc plus its file facts.
24pub fn note_from_doc(p: &PreparedDoc, facts: FileFacts) -> Note {
25    let name = p
26        .rel_path
27        .rsplit('/')
28        .next()
29        .unwrap_or(&p.rel_path)
30        .to_string();
31    let basename = name
32        .rsplit_once('.')
33        .map(|(b, _)| b.to_string())
34        .unwrap_or_else(|| name.clone());
35    let ext = name
36        .rsplit_once('.')
37        .map(|(_, e)| e.to_string())
38        .unwrap_or_default();
39    let folder = p
40        .rel_path
41        .rsplit_once('/')
42        .map(|(d, _)| d.to_string())
43        .unwrap_or_default();
44
45    let mut properties = properties_from_yaml(&p.frontmatter);
46    // Obsidian exposes the note title (from an `title:`/first-H1) too; docgen has
47    // already resolved it, so surface it as a synthetic property when absent.
48    properties
49        .entry("title".to_string())
50        .or_insert_with(|| Value::Str(p.title.clone()));
51
52    let tags = collect_tags(&p.frontmatter, &p.body_md);
53    let links = collect_links(&p.frontmatter, &p.body_md);
54
55    Note {
56        properties,
57        name,
58        basename,
59        path: p.rel_path.clone(),
60        folder,
61        ext,
62        size: facts.size,
63        ctime: facts.ctime_ms.map(ms_to_date),
64        mtime: facts.mtime_ms.map(ms_to_date),
65        tags,
66        links,
67        slug: p.slug.clone(),
68    }
69}
70
71/// Build a corpus from all prepared (page) docs. `facts` supplies per-doc file
72/// metadata by docs-relative path.
73pub fn build_corpus(prepared: &[PreparedDoc], facts: &dyn Fn(&str) -> FileFacts) -> Corpus {
74    Corpus::new(
75        prepared
76            .iter()
77            .map(|p| note_from_doc(p, facts(&p.rel_path)))
78            .collect(),
79    )
80}
81
82fn ms_to_date(ms: i64) -> docgen_bases::BaseDate {
83    docgen_bases::eval::date_from_epoch_millis(ms, true)
84}
85
86/// Collect tags from a note: frontmatter `tags:`/`tag:` (string or list) plus
87/// inline `#tags` in the body. Leading `#` is stripped; duplicates removed,
88/// first-seen order preserved.
89fn collect_tags(fm: &serde_yml::Value, body: &str) -> Vec<String> {
90    let mut out: Vec<String> = Vec::new();
91    let mut push = |t: String| {
92        let t = t.trim_start_matches('#').to_string();
93        if !t.is_empty() && !out.contains(&t) {
94            out.push(t);
95        }
96    };
97    for key in ["tags", "tag"] {
98        if let Some(v) = fm.get(key) {
99            match value_from_yaml(v) {
100                Value::Str(s) => s.split([',', ' ']).for_each(|t| push(t.to_string())),
101                Value::List(items) => {
102                    for it in items {
103                        push(it.display());
104                    }
105                }
106                _ => {}
107            }
108        }
109    }
110    for tag in scan_inline_tags(body) {
111        push(tag);
112    }
113    out
114}
115
116/// Scan `#tag` occurrences in body text. A tag starts at a `#` that is at the
117/// start of the string or preceded by whitespace, followed by a tag char run
118/// (alphanumeric, `/`, `-`, `_`); a purely numeric run (`#123`) is not a tag.
119fn scan_inline_tags(body: &str) -> Vec<String> {
120    let mut out = Vec::new();
121    let chars: Vec<char> = body.chars().collect();
122    let mut i = 0;
123    while i < chars.len() {
124        let boundary = i == 0 || chars[i - 1].is_whitespace();
125        if chars[i] == '#' && boundary {
126            let start = i + 1;
127            let mut j = start;
128            while j < chars.len()
129                && (chars[j].is_alphanumeric() || matches!(chars[j], '/' | '-' | '_'))
130            {
131                j += 1;
132            }
133            let tag: String = chars[start..j].iter().collect();
134            // Require at least one non-digit char (so `#123` is not a tag).
135            if !tag.is_empty() && tag.chars().any(|c| !c.is_ascii_digit()) {
136                out.push(tag);
137            }
138            i = j.max(start);
139        } else {
140            i += 1;
141        }
142    }
143    out
144}
145
146/// Collect outbound links: frontmatter values that are links, plus `[[wikilinks]]`
147/// in the body.
148fn collect_links(fm: &serde_yml::Value, body: &str) -> Vec<BaseLink> {
149    let mut out: Vec<BaseLink> = Vec::new();
150    let mut push = |l: BaseLink| {
151        if !out.iter().any(|e| e.path == l.path) {
152            out.push(l);
153        }
154    };
155    collect_links_from_value(&value_from_yaml(fm), &mut push);
156    for link in scan_body_wikilinks(body) {
157        push(link);
158    }
159    out
160}
161
162fn collect_links_from_value(v: &Value, push: &mut impl FnMut(BaseLink)) {
163    match v {
164        Value::Link(l) => push(l.clone()),
165        Value::List(items) => items
166            .iter()
167            .for_each(|it| collect_links_from_value(it, push)),
168        Value::Object(map) => map
169            .values()
170            .for_each(|it| collect_links_from_value(it, push)),
171        _ => {}
172    }
173}
174
175/// Scan `[[target|label]]` wikilinks out of body markdown.
176fn scan_body_wikilinks(body: &str) -> Vec<BaseLink> {
177    let mut out = Vec::new();
178    let bytes = body.as_bytes();
179    let mut i = 0;
180    while i + 1 < bytes.len() {
181        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
182            if let Some(end) = body[i + 2..].find("]]") {
183                let inner = &body[i + 2..i + 2 + end];
184                let (target, label) = crate::wikilink::parse_wikilink(inner);
185                let target = target.split(['#', '^']).next().unwrap_or(&target).trim();
186                let target = target.strip_suffix(".md").unwrap_or(target);
187                if !target.is_empty() {
188                    out.push(match label {
189                        Some(d) => BaseLink::with_display(target, d),
190                        None => BaseLink::new(target),
191                    });
192                }
193                i = i + 2 + end + 2;
194                continue;
195            }
196        }
197        i += 1;
198    }
199    out
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::model::RawDoc;
206    use crate::pipeline::prepare;
207
208    fn prep(path: &str, raw: &str) -> PreparedDoc {
209        prepare(RawDoc {
210            rel_path: path.into(),
211            raw: raw.into(),
212        })
213    }
214
215    #[test]
216    fn builds_note_metadata() {
217        let p = prep(
218            "guide/Intro.md",
219            "---\ntitle: Introduction\nstatus: done\ntags: [book, guide]\n---\n# Introduction\nSee [[guide/Advanced]] and #project/active.\n",
220        );
221        let note = note_from_doc(
222            &p,
223            FileFacts {
224                size: 42,
225                ..Default::default()
226            },
227        );
228        assert_eq!(note.name, "Intro.md");
229        assert_eq!(note.basename, "Intro");
230        assert_eq!(note.folder, "guide");
231        assert_eq!(note.ext, "md");
232        assert_eq!(note.size, 42);
233        assert_eq!(note.slug, "guide/Intro");
234        // Frontmatter tags + inline tag.
235        assert!(note.tags.contains(&"book".to_string()));
236        assert!(note.tags.contains(&"guide".to_string()));
237        assert!(note.tags.contains(&"project/active".to_string()));
238        // Body wikilink.
239        assert!(note.links.iter().any(|l| l.path == "guide/Advanced"));
240        // Property access.
241        assert!(matches!(note.note_property("status"), Value::Str(s) if s == "done"));
242        assert!(matches!(note.note_property("title"), Value::Str(s) if s == "Introduction"));
243    }
244
245    #[test]
246    fn inline_tag_scan_ignores_numbers_and_midword() {
247        let tags = scan_inline_tags("a #real tag, issue #123, email a#b, and #nested/tag.");
248        assert!(tags.contains(&"real".to_string()));
249        assert!(tags.contains(&"nested/tag".to_string()));
250        assert!(!tags.iter().any(|t| t == "123"));
251        assert!(!tags.iter().any(|t| t == "b")); // a#b is not a tag (no boundary)
252    }
253
254    #[test]
255    fn corpus_from_multiple_docs() {
256        let docs = vec![
257            prep("a.md", "---\ntype: note\n---\n# A\n"),
258            prep("b.md", "# B\n"),
259        ];
260        let corpus = build_corpus(&docs, &|_| FileFacts::default());
261        assert_eq!(corpus.notes.len(), 2);
262        assert_eq!(corpus.notes[0].basename, "a");
263    }
264
265    #[test]
266    fn frontmatter_link_property_collected() {
267        let p = prep(
268            "x.md",
269            "---\nauthor: \"[[People/Herbert|Herbert]]\"\n---\n# X\n",
270        );
271        let note = note_from_doc(&p, FileFacts::default());
272        assert!(note.links.iter().any(|l| l.path == "People/Herbert"));
273    }
274}