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        // Rendered body HTML isn't available yet at corpus-build time (pass 1);
69        // the host fills it in after the document pass. See [`set_note_bodies`].
70        body: String::new(),
71    }
72}
73
74/// After the document render pass, give each corpus note its rendered body HTML
75/// (keyed by slug), so `.base` *pages* can surface `note.body` — e.g. release
76/// notes shown in full on a Releases page. Notes with no matching rendered doc
77/// keep their empty body. Embedded ```base blocks render during the document
78/// pass, before this runs, so `note.body` is empty within them by construction.
79pub fn set_note_bodies(corpus: &mut Corpus, body_html_by_slug: &dyn Fn(&str) -> Option<String>) {
80    for note in &mut corpus.notes {
81        if let Some(html) = body_html_by_slug(&note.slug) {
82            note.body = html;
83        }
84    }
85}
86
87/// Build a corpus from all prepared (page) docs. `facts` supplies per-doc file
88/// metadata by docs-relative path.
89pub fn build_corpus(prepared: &[PreparedDoc], facts: &dyn Fn(&str) -> FileFacts) -> Corpus {
90    Corpus::new(
91        prepared
92            .iter()
93            .map(|p| note_from_doc(p, facts(&p.rel_path)))
94            .collect(),
95    )
96}
97
98fn ms_to_date(ms: i64) -> docgen_bases::BaseDate {
99    docgen_bases::eval::date_from_epoch_millis(ms, true)
100}
101
102/// Collect tags from a note: frontmatter `tags:`/`tag:` (string or list) plus
103/// inline `#tags` in the body. Leading `#` is stripped; duplicates removed,
104/// first-seen order preserved.
105fn collect_tags(fm: &serde_yml::Value, body: &str) -> Vec<String> {
106    let mut out: Vec<String> = Vec::new();
107    let mut push = |t: String| {
108        let t = t.trim_start_matches('#').to_string();
109        if !t.is_empty() && !out.contains(&t) {
110            out.push(t);
111        }
112    };
113    for key in ["tags", "tag"] {
114        if let Some(v) = fm.get(key) {
115            match value_from_yaml(v) {
116                Value::Str(s) => s.split([',', ' ']).for_each(|t| push(t.to_string())),
117                Value::List(items) => {
118                    for it in items {
119                        push(it.display());
120                    }
121                }
122                _ => {}
123            }
124        }
125    }
126    for tag in scan_inline_tags(body) {
127        push(tag);
128    }
129    out
130}
131
132/// Scan `#tag` occurrences in body text. A tag starts at a `#` that is at the
133/// start of the string or preceded by whitespace, followed by a tag char run
134/// (alphanumeric, `/`, `-`, `_`); a purely numeric run (`#123`) is not a tag.
135fn scan_inline_tags(body: &str) -> Vec<String> {
136    let mut out = Vec::new();
137    let chars: Vec<char> = body.chars().collect();
138    let mut i = 0;
139    while i < chars.len() {
140        let boundary = i == 0 || chars[i - 1].is_whitespace();
141        if chars[i] == '#' && boundary {
142            let start = i + 1;
143            let mut j = start;
144            while j < chars.len()
145                && (chars[j].is_alphanumeric() || matches!(chars[j], '/' | '-' | '_'))
146            {
147                j += 1;
148            }
149            let tag: String = chars[start..j].iter().collect();
150            // Require at least one non-digit char (so `#123` is not a tag).
151            if !tag.is_empty() && tag.chars().any(|c| !c.is_ascii_digit()) {
152                out.push(tag);
153            }
154            i = j.max(start);
155        } else {
156            i += 1;
157        }
158    }
159    out
160}
161
162/// Collect outbound links: frontmatter values that are links, plus `[[wikilinks]]`
163/// in the body.
164fn collect_links(fm: &serde_yml::Value, body: &str) -> Vec<BaseLink> {
165    let mut out: Vec<BaseLink> = Vec::new();
166    let mut push = |l: BaseLink| {
167        if !out.iter().any(|e| e.path == l.path) {
168            out.push(l);
169        }
170    };
171    collect_links_from_value(&value_from_yaml(fm), &mut push);
172    for link in scan_body_wikilinks(body) {
173        push(link);
174    }
175    out
176}
177
178fn collect_links_from_value(v: &Value, push: &mut impl FnMut(BaseLink)) {
179    match v {
180        Value::Link(l) => push(l.clone()),
181        Value::List(items) => items
182            .iter()
183            .for_each(|it| collect_links_from_value(it, push)),
184        Value::Object(map) => map
185            .values()
186            .for_each(|it| collect_links_from_value(it, push)),
187        _ => {}
188    }
189}
190
191/// Scan `[[target|label]]` wikilinks out of body markdown.
192fn scan_body_wikilinks(body: &str) -> Vec<BaseLink> {
193    let mut out = Vec::new();
194    let bytes = body.as_bytes();
195    let mut i = 0;
196    while i + 1 < bytes.len() {
197        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
198            if let Some(end) = body[i + 2..].find("]]") {
199                let inner = &body[i + 2..i + 2 + end];
200                let (target, label) = crate::wikilink::parse_wikilink(inner);
201                let target = target.split(['#', '^']).next().unwrap_or(&target).trim();
202                let target = target.strip_suffix(".md").unwrap_or(target);
203                if !target.is_empty() {
204                    out.push(match label {
205                        Some(d) => BaseLink::with_display(target, d),
206                        None => BaseLink::new(target),
207                    });
208                }
209                i = i + 2 + end + 2;
210                continue;
211            }
212        }
213        i += 1;
214    }
215    out
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::model::RawDoc;
222    use crate::pipeline::prepare;
223
224    fn prep(path: &str, raw: &str) -> PreparedDoc {
225        prepare(RawDoc {
226            rel_path: path.into(),
227            raw: raw.into(),
228        })
229    }
230
231    #[test]
232    fn builds_note_metadata() {
233        let p = prep(
234            "guide/Intro.md",
235            "---\ntitle: Introduction\nstatus: done\ntags: [book, guide]\n---\n# Introduction\nSee [[guide/Advanced]] and #project/active.\n",
236        );
237        let note = note_from_doc(
238            &p,
239            FileFacts {
240                size: 42,
241                ..Default::default()
242            },
243        );
244        assert_eq!(note.name, "Intro.md");
245        assert_eq!(note.basename, "Intro");
246        assert_eq!(note.folder, "guide");
247        assert_eq!(note.ext, "md");
248        assert_eq!(note.size, 42);
249        assert_eq!(note.slug, "guide/Intro");
250        // Frontmatter tags + inline tag.
251        assert!(note.tags.contains(&"book".to_string()));
252        assert!(note.tags.contains(&"guide".to_string()));
253        assert!(note.tags.contains(&"project/active".to_string()));
254        // Body wikilink.
255        assert!(note.links.iter().any(|l| l.path == "guide/Advanced"));
256        // Property access.
257        assert!(matches!(note.note_property("status"), Value::Str(s) if s == "done"));
258        assert!(matches!(note.note_property("title"), Value::Str(s) if s == "Introduction"));
259    }
260
261    #[test]
262    fn inline_tag_scan_ignores_numbers_and_midword() {
263        let tags = scan_inline_tags("a #real tag, issue #123, email a#b, and #nested/tag.");
264        assert!(tags.contains(&"real".to_string()));
265        assert!(tags.contains(&"nested/tag".to_string()));
266        assert!(!tags.iter().any(|t| t == "123"));
267        assert!(!tags.iter().any(|t| t == "b")); // a#b is not a tag (no boundary)
268    }
269
270    #[test]
271    fn corpus_from_multiple_docs() {
272        let docs = vec![
273            prep("a.md", "---\ntype: note\n---\n# A\n"),
274            prep("b.md", "# B\n"),
275        ];
276        let corpus = build_corpus(&docs, &|_| FileFacts::default());
277        assert_eq!(corpus.notes.len(), 2);
278        assert_eq!(corpus.notes[0].basename, "a");
279    }
280
281    #[test]
282    fn frontmatter_link_property_collected() {
283        let p = prep(
284            "x.md",
285            "---\nauthor: \"[[People/Herbert|Herbert]]\"\n---\n# X\n",
286        );
287        let note = note_from_doc(&p, FileFacts::default());
288        assert!(note.links.iter().any(|l| l.path == "People/Herbert"));
289    }
290}