1use 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#[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
162pub 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
206pub 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}