Skip to content

Commit bccd3b7

Browse files
authored
Merge pull request #3111 from itowlson/templates-partials
Support Liquid partials in templates
2 parents bc70b97 + c5b37aa commit bccd3b7

File tree

8 files changed

+117
-12
lines changed

8 files changed

+117
-12
lines changed

crates/templates/src/manager.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,32 @@ mod tests {
11961196
assert!(!http_empty.supports_variant(&add_component));
11971197
}
11981198

1199+
#[tokio::test]
1200+
async fn can_render_partials() {
1201+
let manager = TempManager::new();
1202+
manager.install_test_data_templates().await;
1203+
1204+
let partials_template = manager.get("partials").unwrap().unwrap();
1205+
1206+
let dest_temp_dir = tempdir().unwrap();
1207+
let output_dir = dest_temp_dir.path().join("myproj");
1208+
let options = run_options("my project", &output_dir, |opts| {
1209+
opts.values = [("example-value".to_owned(), "myvalue".to_owned())]
1210+
.into_iter()
1211+
.collect();
1212+
});
1213+
1214+
partials_template.run(options).silent().await.unwrap();
1215+
1216+
let generated_file = output_dir.join("test.txt");
1217+
let generated_text = std::fs::read_to_string(&generated_file).unwrap();
1218+
let generated_lines = generated_text.lines().collect::<Vec<_>>();
1219+
1220+
assert_eq!("Value: myvalue", generated_lines[0]);
1221+
assert_eq!("Partial 1: Hello from P1", generated_lines[1]);
1222+
assert_eq!("Partial 2: Value is myvalue", generated_lines[2]);
1223+
}
1224+
11991225
#[tokio::test]
12001226
async fn fails_on_unknown_filter() {
12011227
let manager = TempManager::new();

crates/templates/src/run.rs

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ use crate::{
1919
template::Template,
2020
};
2121

22+
/// A set of partials to be included in a Liquid template.
23+
type PartialsBuilder = liquid::partials::EagerCompiler<liquid::partials::InMemorySource>;
24+
2225
/// Executes a template to the point where it is ready to generate
2326
/// artefacts.
2427
pub struct Run {
@@ -104,6 +107,9 @@ impl Run {
104107
};
105108
}
106109

110+
let partials = self.partials()?;
111+
let parser = Self::template_parser(partials)?;
112+
107113
self.validate_provided_values()?;
108114

109115
let files = match self.template.content_dir() {
@@ -112,15 +118,15 @@ impl Run {
112118
let from = path
113119
.absolutize()
114120
.context("Failed to get absolute path of template directory")?;
115-
self.included_files(&from, &to)?
121+
self.included_files(&from, &to, &parser)?
116122
}
117123
};
118124

119125
let snippets = self
120126
.template
121127
.snippets(&self.options.variant)
122128
.iter()
123-
.map(|(id, path)| self.snippet_operation(id, path))
129+
.map(|(id, path)| self.snippet_operation(id, path, &parser))
124130
.collect::<anyhow::Result<Vec<_>>>()?;
125131

126132
let extras = self
@@ -151,7 +157,12 @@ impl Run {
151157
}
152158
}
153159

154-
fn included_files(&self, from: &Path, to: &Path) -> anyhow::Result<Vec<RenderOperation>> {
160+
fn included_files(
161+
&self,
162+
from: &Path,
163+
to: &Path,
164+
parser: &liquid::Parser,
165+
) -> anyhow::Result<Vec<RenderOperation>> {
155166
let gitignore = ".gitignore";
156167
let mut all_content_files = Self::list_content_files(from)?;
157168
// If user asked for no_vcs
@@ -164,7 +175,7 @@ impl Run {
164175
let included_files =
165176
self.template
166177
.included_files(from, all_content_files, &self.options.variant);
167-
let template_contents = self.read_all(included_files)?;
178+
let template_contents = self.read_all(included_files, parser)?;
168179
let outputs = Self::to_output_paths(from, to, template_contents);
169180
let file_ops = outputs
170181
.into_iter()
@@ -262,7 +273,12 @@ impl Run {
262273
}
263274
}
264275

265-
fn snippet_operation(&self, id: &str, snippet_file: &str) -> anyhow::Result<RenderOperation> {
276+
fn snippet_operation(
277+
&self,
278+
id: &str,
279+
snippet_file: &str,
280+
parser: &liquid::Parser,
281+
) -> anyhow::Result<RenderOperation> {
266282
let snippets_dir = self
267283
.template
268284
.snippets_dir()
@@ -271,7 +287,7 @@ impl Run {
271287
let abs_snippet_file = snippets_dir.join(snippet_file);
272288
let file_content = std::fs::read(abs_snippet_file)
273289
.with_context(|| format!("Error reading snippet file {}", snippet_file))?;
274-
let content = TemplateContent::infer_from_bytes(file_content, &Self::template_parser())
290+
let content = TemplateContent::infer_from_bytes(file_content, parser)
275291
.with_context(|| format!("Error parsing snippet file {}", snippet_file))?;
276292

277293
match id {
@@ -356,11 +372,14 @@ impl Run {
356372
}
357373

358374
// TODO: async when we know where things sit
359-
fn read_all(&self, paths: Vec<PathBuf>) -> anyhow::Result<Vec<(PathBuf, TemplateContent)>> {
360-
let template_parser = Self::template_parser();
375+
fn read_all(
376+
&self,
377+
paths: Vec<PathBuf>,
378+
template_parser: &liquid::Parser,
379+
) -> anyhow::Result<Vec<(PathBuf, TemplateContent)>> {
361380
let contents = paths
362381
.iter()
363-
.map(|path| TemplateContent::infer_from_bytes(std::fs::read(path)?, &template_parser))
382+
.map(|path| TemplateContent::infer_from_bytes(std::fs::read(path)?, template_parser))
364383
.collect::<Result<Vec<_>, _>>()?;
365384
// Strip optional .tmpl extension
366385
// Templates can use this if they don't want to store files with their final extensions
@@ -394,16 +413,45 @@ impl Run {
394413
pathdiff::diff_paths(source, src_dir).map(|rel| (dest_dir.join(rel), cont))
395414
}
396415

397-
fn template_parser() -> liquid::Parser {
416+
fn template_parser(
417+
partials: impl liquid::partials::PartialCompiler,
418+
) -> anyhow::Result<liquid::Parser> {
398419
let builder = liquid::ParserBuilder::with_stdlib()
420+
.partials(partials)
399421
.filter(crate::filters::KebabCaseFilterParser)
400422
.filter(crate::filters::PascalCaseFilterParser)
401423
.filter(crate::filters::DottedPascalCaseFilterParser)
402424
.filter(crate::filters::SnakeCaseFilterParser)
403425
.filter(crate::filters::HttpWildcardFilterParser);
404426
builder
405427
.build()
406-
.expect("can't fail due to no partials support")
428+
.context("Template error: unable to build parser")
429+
}
430+
431+
fn partials(&self) -> anyhow::Result<impl liquid::partials::PartialCompiler> {
432+
let mut partials = PartialsBuilder::empty();
433+
434+
if let Some(partials_dir) = self.template.partials_dir() {
435+
let partials_dir = std::fs::read_dir(partials_dir)
436+
.context("Error opening template partials directory")?;
437+
for partial_file in partials_dir {
438+
let partial_file =
439+
partial_file.context("Error scanning template partials directory")?;
440+
if !partial_file.file_type().is_ok_and(|t| t.is_file()) {
441+
anyhow::bail!("Non-file in partials directory: {partial_file:?}");
442+
}
443+
let partial_name = partial_file
444+
.file_name()
445+
.into_string()
446+
.map_err(|f| anyhow!("Unusable partial name {f:?}"))?;
447+
let partial_file = partial_file.path();
448+
let content = std::fs::read_to_string(&partial_file)
449+
.with_context(|| format!("Invalid partial template {partial_file:?}"))?;
450+
partials.add(partial_name, content);
451+
}
452+
}
453+
454+
Ok(partials)
407455
}
408456
}
409457

@@ -418,7 +466,8 @@ mod test {
418466
"kebabby": "originally-kebabby",
419467
"dotted": "originally.semi-dotted"
420468
});
421-
let parser = Run::template_parser();
469+
let no_partials = super::PartialsBuilder::empty();
470+
let parser = Run::template_parser(no_partials).unwrap();
422471

423472
let eval = |s: &str| parser.parse(s).unwrap().render(&data).unwrap();
424473

crates/templates/src/store.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub(crate) struct TemplateLayout {
6868
const METADATA_DIR_NAME: &str = "metadata";
6969
const CONTENT_DIR_NAME: &str = "content";
7070
const SNIPPETS_DIR_NAME: &str = "snippets";
71+
const PARTIALS_DIR_NAME: &str = "partials";
7172

7273
const MANIFEST_FILE_NAME: &str = "spin-template.toml";
7374

@@ -96,6 +97,10 @@ impl TemplateLayout {
9697
self.metadata_dir().join(SNIPPETS_DIR_NAME)
9798
}
9899

100+
pub fn partials_dir(&self) -> PathBuf {
101+
self.metadata_dir().join(PARTIALS_DIR_NAME)
102+
}
103+
99104
pub fn installation_record_file(&self) -> PathBuf {
100105
self.template_dir.join(INSTALLATION_RECORD_FILE_NAME)
101106
}

crates/templates/src/template.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub struct Template {
3030
parameters: Vec<TemplateParameter>,
3131
extra_outputs: Vec<ExtraOutputAction>,
3232
snippets_dir: Option<PathBuf>,
33+
partials_dir: Option<PathBuf>,
3334
content_dir: Option<PathBuf>, // TODO: maybe always need a spin.toml file in there?
3435
}
3536

@@ -184,6 +185,12 @@ impl Template {
184185
None
185186
};
186187

188+
let partials_dir = if layout.partials_dir().exists() {
189+
Some(layout.partials_dir())
190+
} else {
191+
None
192+
};
193+
187194
let installed_from = read_install_record(layout);
188195

189196
let template = match raw {
@@ -197,6 +204,7 @@ impl Template {
197204
parameters: Self::parse_parameters(&raw.parameters)?,
198205
extra_outputs: Self::parse_extra_outputs(&raw.outputs)?,
199206
snippets_dir,
207+
partials_dir,
200208
content_dir,
201209
},
202210
};
@@ -294,6 +302,10 @@ impl Template {
294302
&self.snippets_dir
295303
}
296304

305+
pub(crate) fn partials_dir(&self) -> &Option<PathBuf> {
306+
&self.partials_dir
307+
}
308+
297309
/// Checks if the template supports the specified variant mode.
298310
pub fn supports_variant(&self, variant: &TemplateVariantInfo) -> bool {
299311
self.variants.contains_key(&variant.kind())
@@ -753,6 +765,7 @@ mod test {
753765
parameters: vec![],
754766
extra_outputs: vec![],
755767
snippets_dir: None,
768+
partials_dir: None,
756769
content_dir: None,
757770
};
758771

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Value: {{ example-value }}
2+
Partial 1: {% render 'p1' %}
3+
Partial 2: {% render 'p2', value: example-value %}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello from P1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Value is {{ value }}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
manifest_version = "1"
2+
id = "partials"
3+
description = "Tests Liquid partials"
4+
trigger_type = "http"
5+
6+
[parameters]
7+
example-value = { type = "string", prompt = "Example value" }

0 commit comments

Comments
 (0)