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 (
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
161pub 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
205pub 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}