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