diff --git a/crates/templates/src/reader.rs b/crates/templates/src/reader.rs index 9006c8a065..5e64739442 100644 --- a/crates/templates/src/reader.rs +++ b/crates/templates/src/reader.rs @@ -23,6 +23,7 @@ pub(crate) struct RawTemplateManifestV1 { pub add_component: Option, pub parameters: Option>, pub custom_filters: Option, // kept for error messaging + pub outputs: Option>, } #[derive(Debug, Deserialize)] @@ -45,6 +46,18 @@ pub(crate) struct RawParameter { pub pattern: Option, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "action")] +pub(crate) enum RawExtraOutput { + CreateDir(RawCreateDir), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub(crate) struct RawCreateDir { + pub path: String, +} + pub(crate) fn parse_manifest_toml(text: impl AsRef) -> anyhow::Result { toml::from_str(text.as_ref()).context("Failed to parse template manifest TOML") } diff --git a/crates/templates/src/renderer.rs b/crates/templates/src/renderer.rs index 3aca99e408..faf0233ca3 100644 --- a/crates/templates/src/renderer.rs +++ b/crates/templates/src/renderer.rs @@ -20,6 +20,7 @@ pub(crate) enum RenderOperation { AppendToml(PathBuf, TemplateContent), MergeToml(PathBuf, MergeTarget, TemplateContent), // file to merge into, table to merge into, content to merge WriteFile(PathBuf, TemplateContent), + CreateDirectory(PathBuf, std::sync::Arc), } pub(crate) enum MergeTarget { @@ -75,6 +76,11 @@ impl RenderOperation { let MergeTarget::Application(target_table) = target; Ok(TemplateOutput::MergeToml(path, target_table, rendered_text)) } + Self::CreateDirectory(path, template) => { + let rendered = template.render(globals)?; + let path = path.join(rendered); // TODO: should we validate that `rendered` was relative?` + Ok(TemplateOutput::CreateDirectory(path)) + } } } } diff --git a/crates/templates/src/run.rs b/crates/templates/src/run.rs index 928de175c2..cbde4dda6d 100644 --- a/crates/templates/src/run.rs +++ b/crates/templates/src/run.rs @@ -12,7 +12,7 @@ use crate::{ cancellable::Cancellable, interaction::{InteractionStrategy, Interactive, Silent}, renderer::MergeTarget, - template::TemplateVariantInfo, + template::{ExtraOutputAction, TemplateVariantInfo}, }; use crate::{ renderer::{RenderOperation, TemplateContent, TemplateRenderer}, @@ -118,7 +118,14 @@ impl Run { .map(|(id, path)| self.snippet_operation(id, path)) .collect::>>()?; - let render_operations = files.into_iter().chain(snippets).collect(); + let extras = self + .template + .extra_outputs() + .iter() + .map(|extra| self.extra_operation(extra)) + .collect::>>()?; + + let render_operations = files.into_iter().chain(snippets).chain(extras).collect(); match interaction.populate_parameters(self) { Cancellable::Ok(parameter_values) => { @@ -292,6 +299,17 @@ impl Run { } } + fn extra_operation(&self, extra: &ExtraOutputAction) -> anyhow::Result { + match extra { + ExtraOutputAction::CreateDirectory(_, template) => { + Ok(RenderOperation::CreateDirectory( + self.options.output_path.clone(), + template.clone(), + )) + } + } + } + fn list_content_files(from: &Path) -> anyhow::Result> { let walker = WalkDir::new(from); let files = walker diff --git a/crates/templates/src/template.rs b/crates/templates/src/template.rs index 6b6f227fbe..0de9c5ab2f 100644 --- a/crates/templates/src/template.rs +++ b/crates/templates/src/template.rs @@ -9,7 +9,10 @@ use regex::Regex; use crate::{ constraints::StringConstraints, - reader::{RawParameter, RawTemplateManifest, RawTemplateManifestV1, RawTemplateVariant}, + reader::{ + RawExtraOutput, RawParameter, RawTemplateManifest, RawTemplateManifestV1, + RawTemplateVariant, + }, run::{Run, RunOptions}, store::TemplateLayout, }; @@ -24,6 +27,7 @@ pub struct Template { trigger: TemplateTriggerCompatibility, variants: HashMap, parameters: Vec, + extra_outputs: Vec, snippets_dir: Option, content_dir: Option, // TODO: maybe always need a spin.toml file in there? } @@ -113,6 +117,18 @@ pub(crate) struct TemplateParameter { default_value: Option, } +pub(crate) enum ExtraOutputAction { + CreateDirectory(String, std::sync::Arc), +} + +impl std::fmt::Debug for ExtraOutputAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CreateDirectory(orig, _) => f.debug_tuple("CreateDirectory").field(orig).finish(), + } + } +} + impl Template { pub(crate) fn load_from(layout: &TemplateLayout) -> anyhow::Result { let manifest_path = layout.manifest_path(); @@ -155,6 +171,7 @@ impl Template { trigger: Self::parse_trigger_type(raw.trigger_type, layout), variants: Self::parse_template_variants(raw.new_application, raw.add_component), parameters: Self::parse_parameters(&raw.parameters)?, + extra_outputs: Self::parse_extra_outputs(&raw.outputs)?, snippets_dir, content_dir, }, @@ -237,6 +254,10 @@ impl Template { self.parameters.iter().find(|p| p.id == name.as_ref()) } + pub(crate) fn extra_outputs(&self) -> &[ExtraOutputAction] { + &self.extra_outputs + } + pub(crate) fn content_dir(&self) -> &Option { &self.content_dir } @@ -343,6 +364,18 @@ impl Template { } } + fn parse_extra_outputs( + raw: &Option>, + ) -> anyhow::Result> { + match raw { + None => Ok(vec![]), + Some(parameters) => parameters + .iter() + .map(|(k, v)| ExtraOutputAction::from_raw(k, v)) + .collect(), + } + } + pub(crate) fn included_files( &self, base: &std::path::Path, @@ -460,6 +493,20 @@ impl TemplateParameterDataType { } } +impl ExtraOutputAction { + fn from_raw(id: &str, raw: &RawExtraOutput) -> anyhow::Result { + Ok(match raw { + RawExtraOutput::CreateDir(create) => { + let path_template = + liquid::Parser::new().parse(&create.path).with_context(|| { + format!("Template error: output {id} is not a valid template") + })?; + Self::CreateDirectory(create.path.clone(), std::sync::Arc::new(path_template)) + } + }) + } +} + impl TemplateVariant { pub(crate) fn skip_file(&self, base: &std::path::Path, path: &std::path::Path) -> bool { self.skip_files diff --git a/crates/templates/src/test_built_ins/mod.rs b/crates/templates/src/test_built_ins/mod.rs index ef32188fdb..aa018a984b 100644 --- a/crates/templates/src/test_built_ins/mod.rs +++ b/crates/templates/src/test_built_ins/mod.rs @@ -15,7 +15,53 @@ impl ProgressReporter for DiscardingReporter { } #[tokio::test] -async fn add_fileserver_does_not_create_dir() -> anyhow::Result<()> { +async fn new_fileserver_creates_assets_dir() -> anyhow::Result<()> { + let built_ins_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let built_ins_src = TemplateSource::File(built_ins_dir); + + let store_dir = tempfile::tempdir()?; + let store = store::TemplateStore::new(store_dir.path()); + let manager = TemplateManager::new(store); + + manager + .install( + &built_ins_src, + &InstallOptions::default(), + &DiscardingReporter, + ) + .await?; + + let app_dir = tempfile::tempdir()?; + + // Create an app to add the fileserver into + let new_fs_options = RunOptions { + variant: TemplateVariantInfo::NewApplication, + name: "fs".to_owned(), + output_path: app_dir.path().join("fs"), + values: HashMap::new(), + accept_defaults: true, + no_vcs: false, + }; + manager + .get("static-fileserver")? + .expect("static-fileserver template should exist") + .run(new_fs_options) + .silent() + .await?; + + assert!( + app_dir.path().join("fs").exists(), + "fs dir should have been created" + ); + assert!( + app_dir.path().join("fs").join("assets").exists(), + "fs/assets dir should have been created" + ); + Ok(()) +} + +#[tokio::test] +async fn add_fileserver_creates_assets_dir() -> anyhow::Result<()> { let built_ins_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let built_ins_src = TemplateSource::File(built_ins_dir); @@ -49,13 +95,15 @@ async fn add_fileserver_does_not_create_dir() -> anyhow::Result<()> { .silent() .await?; + let fs_settings = HashMap::from_iter([("files-path".to_owned(), "moarassets".to_owned())]); + // Add the fileserver to that app let manifest_path = app_dir.path().join("spin.toml"); let add_fs_options = RunOptions { variant: TemplateVariantInfo::AddComponent { manifest_path }, name: "fs".to_owned(), output_path: app_dir.path().join("fs"), - values: HashMap::new(), + values: fs_settings, accept_defaults: true, no_vcs: false, }; @@ -68,8 +116,12 @@ async fn add_fileserver_does_not_create_dir() -> anyhow::Result<()> { // Finally! assert!( - !app_dir.path().join("fs").exists(), - "/fs should not have been created" + app_dir.path().join("fs").exists(), + "/fs should have been created" + ); + assert!( + app_dir.path().join("fs").join("moarassets").exists(), + "/fs/moarassets should have been created" ); Ok(()) } diff --git a/crates/templates/src/writer.rs b/crates/templates/src/writer.rs index d896fcfc70..2b35c45373 100644 --- a/crates/templates/src/writer.rs +++ b/crates/templates/src/writer.rs @@ -10,6 +10,7 @@ pub(crate) enum TemplateOutput { WriteFile(PathBuf, Vec), AppendToml(PathBuf, String), MergeToml(PathBuf, &'static str, String), // only have to worry about merging into root table for now + CreateDirectory(PathBuf), } impl TemplateOutputs { @@ -57,6 +58,11 @@ impl TemplateOutput { .await .with_context(|| format!("Can't save changes to {}", path.display()))?; } + TemplateOutput::CreateDirectory(dir) => { + tokio::fs::create_dir_all(dir) + .await + .with_context(|| format!("Failed to create directory {}", dir.display()))?; + } } Ok(()) } diff --git a/templates/static-fileserver/metadata/spin-template.toml b/templates/static-fileserver/metadata/spin-template.toml index 00ae333541..a700ad135f 100644 --- a/templates/static-fileserver/metadata/spin-template.toml +++ b/templates/static-fileserver/metadata/spin-template.toml @@ -14,3 +14,6 @@ component = "component.txt" project-description = { type = "string", prompt = "Description", default = "" } http-path = { type = "string", prompt = "HTTP path", default = "/static/...", pattern = "^/\\S*$" } files-path = { type = "string", prompt = "Directory containing the files to serve", default = "assets", pattern = "^\\S+$" } + +[outputs] +create_asset_directory = { action = "create_dir", path = "{{ files-path }}" }