Skip to main content

wowlab_docgen_cli/infra/
template.rs

1// #t(file: rust_alloc_in_loop) template rendering uses format! and push_str in loops by design
2
3use minijinja::{
4    Environment,
5    value::{Kwargs, Rest},
6};
7use wowlab_fs::path::{Path, PathBuf};
8
9use crate::{
10    RenderCtx, context,
11    infra::{badge, dep_graph, index_of, mermaid, toc, tree},
12};
13
14const MAX_CONSECUTIVE_BLANKS: u32 = 2;
15
16/// Failure to register, load, or render a documentation template.
17#[derive(Debug, thiserror::Error)]
18#[error("{kind}")]
19pub struct TemplateError {
20    #[source]
21    kind: TemplateErrorKind,
22}
23
24#[derive(Debug, thiserror::Error)]
25enum TemplateErrorKind {
26    #[error("base template {name}: {source}")]
27    BaseTemplate {
28        name: &'static str,
29        #[source]
30        source: minijinja::Error,
31    },
32    #[error("template parse error: {0}")]
33    Parse(#[source] minijinja::Error),
34    #[error("template load error: {0}")]
35    Load(#[source] minijinja::Error),
36    #[error("render error: {0}")]
37    Render(#[source] minijinja::Error),
38    #[error("{}: not indexed as text", path.display())]
39    NotIndexed { path: PathBuf },
40}
41
42impl TemplateError {
43    const fn new(kind: TemplateErrorKind) -> Self {
44        Self { kind }
45    }
46}
47
48const BASE_TEMPLATES: &[(&str, &str)] = &[
49    (
50        "claude-crate.md.j2",
51        include_str!("../../templates/claude-crate.md.j2"),
52    ),
53    (
54        "claude-directory.md.j2",
55        include_str!("../../templates/claude-directory.md.j2"),
56    ),
57    (
58        "claude-package.md.j2",
59        include_str!("../../templates/claude-package.md.j2"),
60    ),
61    (
62        "claude-root.md.j2",
63        include_str!("../../templates/claude-root.md.j2"),
64    ),
65    ("crate.md.j2", include_str!("../../templates/crate.md.j2")),
66    (
67        "directory.md.j2",
68        include_str!("../../templates/directory.md.j2"),
69    ),
70    (
71        "package.md.j2",
72        include_str!("../../templates/package.md.j2"),
73    ),
74    ("root.md.j2", include_str!("../../templates/root.md.j2")),
75];
76
77fn create_env(
78    workspace: crate::WorkspaceIndex,
79    dir: &Path,
80) -> Result<Environment<'static>, TemplateError> {
81    let mut env = Environment::new();
82
83    env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
84
85    env.add_function("table_head", |headers: Rest<String>| -> String {
86        wowlab_common::markdown::Table::new()
87            .headers(headers.iter())
88            .build_markdown()
89    });
90
91    env.add_function("table_row", |cells: Rest<String>| -> String {
92        wowlab_common::markdown::render_table_row(&cells)
93    });
94
95    for (name, source) in BASE_TEMPLATES {
96        env.add_template(name, source).map_err(|source| {
97            TemplateError::new(TemplateErrorKind::BaseTemplate { name, source })
98        })?;
99    }
100
101    let index_workspace = workspace.clone();
102    let index_dir = dir.to_path_buf();
103
104    env.add_function(
105        "index_of",
106        move |file: String, patterns: Rest<String>, kwargs: Kwargs| -> String {
107            let show_kind: bool = kwargs.get("kind").ok().flatten().unwrap_or(false);
108            let _ = kwargs.assert_all_used();
109
110            index_of::build(&index_workspace, &index_dir, &file, &patterns, show_kind)
111        },
112    );
113
114    let dir_buf = dir.to_path_buf();
115    let tree_workspace = workspace.clone();
116
117    env.add_function(
118        "tree",
119        move |paths: Rest<String>, kwargs: Kwargs| -> String {
120            let depth: Option<u32> = kwargs.get("depth").ok().flatten();
121            let _ = kwargs.assert_all_used();
122
123            tree::build(&tree_workspace, &dir_buf, &paths, depth)
124        },
125    );
126
127    let badge_workspace = workspace.clone();
128
129    env.add_function("badge", move |names: Rest<String>| -> String {
130        badge::build(&badge_workspace, &names)
131    });
132
133    let dir_for_mermaid = dir.to_path_buf();
134    let mermaid_workspace = workspace.clone();
135
136    env.add_function("mermaid", move |name: String| -> String {
137        mermaid::build(&mermaid_workspace, &dir_for_mermaid, &name)
138    });
139
140    let graph_workspace = workspace.clone();
141
142    env.add_function("dep_graph", move || -> String {
143        dep_graph::build(&graph_workspace)
144    });
145
146    let dir_for_include = dir.to_path_buf();
147    let include_workspace = workspace;
148
149    env.add_function("include", move |path: String| -> String {
150        let target = dir_for_include.join(&path);
151
152        include_workspace
153            .contents(&target)
154            .unwrap_or_default()
155            .to_string()
156    });
157
158    Ok(env)
159}
160
161/// Render a `.md.in` template with full context.
162///
163/// # Errors
164/// Returns an error if the base or requested template cannot be registered, parsed, loaded, or rendered.
165pub fn render(source: &str, ctx: &RenderCtx<'_>) -> Result<String, TemplateError> {
166    let mut env = create_env(ctx.workspace.clone(), ctx.dir)?;
167    let data = context::build(ctx);
168
169    let effective_source = if has_extends_directive(source) {
170        source.to_string()
171    } else {
172        let base = context::default_base(ctx);
173
174        format!("{{% extends \"{base}\" %}}\n{{% block content %}}\n{source}\n{{% endblock %}}")
175    };
176
177    env.add_template("_current", &effective_source)
178        .map_err(|source| TemplateError::new(TemplateErrorKind::Parse(source)))?;
179
180    let tmpl = env
181        .get_template("_current")
182        .map_err(|source| TemplateError::new(TemplateErrorKind::Load(source)))?;
183
184    let rendered = tmpl
185        .render(&data)
186        .map_err(|source| TemplateError::new(TemplateErrorKind::Render(source)))?;
187
188    let rendered = toc::inject(&rendered);
189    let rendered = inject_badges(&rendered, &ctx.workspace);
190    let mut result = collapse_blank_lines(&rendered);
191    let trimmed = result.trim_end().len();
192
193    result.truncate(trimmed);
194    result.push('\n');
195
196    Ok(result)
197}
198
199#[derive(Debug)]
200pub struct RenderedDocument {
201    pub output_path: PathBuf,
202    pub contents: String,
203}
204
205/// Prepare an unformatted rendered document.
206///
207/// # Errors
208/// Returns an error if the template is not indexed as text or cannot be rendered.
209pub fn prepare(
210    template_path: &Path,
211    ctx: &RenderCtx<'_>,
212) -> Result<RenderedDocument, TemplateError> {
213    let source = ctx.workspace.contents(template_path).ok_or_else(|| {
214        TemplateError::new(TemplateErrorKind::NotIndexed {
215            path: template_path.to_path_buf(),
216        })
217    })?;
218
219    let rendered = render(source, ctx)?;
220    let output_path = crate::infra::walk::output_path(template_path);
221
222    Ok(RenderedDocument {
223        output_path,
224        contents: rendered,
225    })
226}
227
228fn inject_badges(rendered: &str, workspace: &crate::WorkspaceIndex) -> String {
229    if !rendered.contains("<!-- block:badges -->") {
230        return rendered.to_string();
231    }
232
233    let badges = badge::build(
234        workspace,
235        &[
236            "ci".to_string(),
237            "discord".to_string(),
238            "license".to_string(),
239            "website".to_string(),
240        ],
241    );
242
243    rendered.replace("<!-- block:badges -->", &badges)
244}
245
246fn has_extends_directive(source: &str) -> bool {
247    source.lines().any(is_extends_directive)
248}
249
250fn is_extends_directive(line: &str) -> bool {
251    let Some(body) = line.trim().strip_prefix("{%") else {
252        return false;
253    };
254    let Some(body) = body.strip_suffix("%}") else {
255        return false;
256    };
257    let body = body.strip_prefix('-').unwrap_or(body);
258    let body = body.strip_suffix('-').unwrap_or(body);
259    let Some(rest) = body.trim_start().strip_prefix("extends") else {
260        return false;
261    };
262
263    rest.chars().next().is_some_and(char::is_whitespace)
264}
265
266fn collapse_blank_lines(s: &str) -> String {
267    let mut result = String::with_capacity(s.len());
268    let mut blank_count = 0u32;
269
270    for line in s.lines() {
271        if line.trim().is_empty() {
272            blank_count += 1;
273
274            if blank_count <= MAX_CONSECUTIVE_BLANKS {
275                result.push('\n');
276            }
277        } else {
278            blank_count = 0;
279
280            if !result.is_empty() {
281                result.push('\n');
282            }
283
284            result.push_str(line);
285        }
286    }
287
288    result
289}
290
291#[cfg(test)]
292mod tests {
293    use googletest::prelude::*;
294
295    use super::*;
296
297    #[gtest]
298    fn collapse_blanks() -> Result<()> {
299        verify_eq!(collapse_blank_lines("a\n\n\n\n\nb\n\nc"), "a\n\n\nb\n\nc")
300    }
301
302    #[gtest]
303    fn detects_extends() -> Result<()> {
304        verify_true!(has_extends_directive(
305            r#"{% extends "bases/crate.md.j2" %}"#
306        ))?;
307        verify_true!(has_extends_directive(
308            "{%\textends\t\"bases/crate.md.j2\"\t%}"
309        ))?;
310        verify_true!(has_extends_directive(
311            r#"{%- extends "bases/crate.md.j2" -%}"#
312        ))?;
313        verify_false!(has_extends_directive("# Title\nSome text"))?;
314        verify_false!(has_extends_directive(
315            r#"{% extension = "bases/crate.md.j2" %}"#
316        ))?;
317
318        verify_false!(has_extends_directive(
319            r#"{% set description = "this extends the docs" %}"#
320        ))
321    }
322}