diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index b7503cc15..d841333d7 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::process::ExitCode; +use std::{collections::BTreeSet, fmt::Write, process::ExitCode}; use anyhow::{Context as _, bail}; use camino::Utf8PathBuf; @@ -83,7 +83,7 @@ impl Options { tokio::fs::read_dir(&out_dir).await.with_context(|| { format!("could not read {out_dir} to check it's empty") })?; - while let Some(x) = read_dir.next_entry().await? { + if read_dir.next_entry().await?.is_some() { bail!("Render directory {out_dir} is not empty, refusing to write."); } } else { @@ -92,19 +92,49 @@ impl Options { .with_context(|| format!("could not create {out_dir}"))?; } - for (template, template_renders) in &all_renders { - let template_filename_base = - template.trim_end_matches(".html").replace('/', "_"); - for (idx, render) in template_renders.iter().enumerate() { - let render_path = - out_dir.join(format!("{template_filename_base}-sample{idx}.html")); + let all_locales: BTreeSet<&str> = all_renders + .iter() + .filter_map(|((_, sample_identifier), _)| { + sample_identifier.locale.as_deref() + }) + .collect(); + for locale in all_locales { + let locale_dir = out_dir.join(locale); + tokio::fs::create_dir(&locale_dir) + .await + .with_context(|| format!("could not create {locale_dir}"))?; + } - tokio::fs::write(&render_path, render.as_bytes()) - .await - .with_context(|| { - format!("could not write render to {render_path}") - })?; - } + for ((template, sample_identifier), template_render) in &all_renders { + let (template_filename_base, template_ext) = + template.rsplit_once('.').unwrap_or((template, "txt")); + let template_filename_base = template_filename_base.replace('/', "_"); + + // Make a string like: + // - `-sample1` + // - `-session2-sample1` + let sample_suffix = { + let mut s = String::new(); + if let Some(session_index) = sample_identifier.session_index { + write!(s, "-session{session_index}")?; + } + write!(s, "-sample{}", sample_identifier.index)?; + s + }; + + let locale_dir = if let Some(locale) = &sample_identifier.locale { + out_dir.join(locale) + } else { + out_dir.clone() + }; + + let render_path = locale_dir.join(format!( + "{template_filename_base}{sample_suffix}.{template_ext}" + )); + + tokio::fs::write(&render_path, template_render.as_bytes()) + .await + .with_context(|| format!("could not write render to {render_path}"))?; } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 0a04677eb..18a86bd91 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -12,6 +12,7 @@ mod ext; mod features; use std::{ + collections::BTreeMap, fmt::Formatter, net::{IpAddr, Ipv4Addr}, }; @@ -105,21 +106,72 @@ pub trait TemplateContext: Serialize { /// /// This is then used to check for template validity in unit tests and in /// the CLI (`cargo run -- templates check`) - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized; } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SampleIdentifier { + /// A stable locale identifier. + pub locale: Option, + + /// A stable identifier for the session that was used in this sample. + pub session_index: Option, + + /// A stable positional index of the sample for this context. + pub index: usize, +} + +impl SampleIdentifier { + pub fn with_locale(&self, locale: String) -> Self { + SampleIdentifier { + locale: Some(locale), + session_index: self.session_index, + index: self.index, + } + } + + pub fn with_session_index(self, session_index: usize) -> Self { + SampleIdentifier { + locale: self.locale, + session_index: Some(session_index), + index: self.index, + } + } +} + +pub(crate) fn sample_list(samples: Vec) -> BTreeMap { + samples + .into_iter() + .enumerate() + .map(|(index, sample)| { + ( + SampleIdentifier { + locale: None, + session_index: None, + index, + }, + sample, + ) + }) + .collect() +} + impl TemplateContext for () { fn sample( _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - Vec::new() + BTreeMap::new() } } @@ -148,7 +200,11 @@ impl std::ops::Deref for WithLanguage { } impl TemplateContext for WithLanguage { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -157,9 +213,14 @@ impl TemplateContext for WithLanguage { .flat_map(|locale| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithLanguage { - lang: locale.to_string(), - inner, + .map(|(sample_id, sample)| { + ( + sample_id.with_locale(locale.to_string()), + WithLanguage { + lang: locale.to_string(), + inner: sample, + }, + ) }) }) .collect() @@ -176,15 +237,24 @@ pub struct WithCsrf { } impl TemplateContext for WithCsrf { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { T::sample(now, rng, locales) .into_iter() - .map(|inner| WithCsrf { - csrf_token: "fake_csrf_token".into(), - inner, + .map(|(k, inner)| { + ( + k, + WithCsrf { + csrf_token: "fake_csrf_token".into(), + inner, + }, + ) }) .collect() } @@ -200,18 +270,28 @@ pub struct WithSession { } impl TemplateContext for WithSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { BrowserSession::samples(now, rng) .into_iter() - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + k.with_session_index(session_index), + WithSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -228,7 +308,11 @@ pub struct WithOptionalSession { } impl TemplateContext for WithOptionalSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -236,12 +320,22 @@ impl TemplateContext for WithOptionalSession { .into_iter() .map(Some) // Wrap all samples in an Option .chain(std::iter::once(None)) // Add the "None" option - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithOptionalSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + if session.is_some() { + k.with_session_index(session_index) + } else { + k + }, + WithOptionalSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -269,11 +363,11 @@ impl TemplateContext for EmptyContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![EmptyContext] + sample_list(vec![EmptyContext]) } } @@ -297,15 +391,15 @@ impl TemplateContext for IndexContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { discovery_url: "https://example.com/.well-known/openid-configuration" .parse() .unwrap(), - }] + }]) } } @@ -343,12 +437,12 @@ impl TemplateContext for AppContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -376,12 +470,12 @@ impl TemplateContext for ApiDocContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -468,12 +562,12 @@ impl TemplateContext for LoginContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![ + sample_list(vec![ LoginContext { form: FormState::default(), next: None, @@ -503,7 +597,7 @@ impl TemplateContext for LoginContext { next: None, providers: Vec::new(), }, - ] + ]) } } @@ -576,14 +670,14 @@ impl TemplateContext for RegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![RegisterContext { + sample_list(vec![RegisterContext { providers: Vec::new(), next: None, - }] + }]) } } @@ -619,15 +713,15 @@ impl TemplateContext for PasswordRegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![PasswordRegisterContext { + sample_list(vec![PasswordRegisterContext { form: FormState::default(), next: None, - }] + }]) } } @@ -657,24 +751,30 @@ pub struct ConsentContext { } impl TemplateContext for ConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) - .into_iter() - .map(|client| { - let mut grant = AuthorizationGrant::sample(now, rng); - let action = PostAuthAction::continue_grant(grant.id); - // XXX - grant.client_id = client.id; - Self { - grant, - client, - action, - } - }) - .collect() + sample_list( + Client::samples(now, rng) + .into_iter() + .map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + let action = PostAuthAction::continue_grant(grant.id); + // XXX + grant.client_id = client.id; + Self { + grant, + client, + action, + } + }) + .collect(), + ) } } @@ -709,38 +809,44 @@ pub struct PolicyViolationContext { } impl TemplateContext for PolicyViolationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) - .into_iter() - .flat_map(|client| { - let mut grant = AuthorizationGrant::sample(now, rng); - // XXX - grant.client_id = client.id; + sample_list( + Client::samples(now, rng) + .into_iter() + .flat_map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + // XXX + grant.client_id = client.id; - let authorization_grant = - PolicyViolationContext::for_authorization_grant(grant, client.clone()); - let device_code_grant = PolicyViolationContext::for_device_code_grant( - DeviceCodeGrant { - id: Ulid::from_datetime_with_source(now.into(), rng), - state: mas_data_model::DeviceCodeGrantState::Pending, - client_id: client.id, - scope: [OPENID].into_iter().collect(), - user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), - device_code: Alphanumeric.sample_string(rng, 32), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - ip_address: None, - user_agent: None, - }, - client, - ); + let authorization_grant = + PolicyViolationContext::for_authorization_grant(grant, client.clone()); + let device_code_grant = PolicyViolationContext::for_device_code_grant( + DeviceCodeGrant { + id: Ulid::from_datetime_with_source(now.into(), rng), + state: mas_data_model::DeviceCodeGrantState::Pending, + client_id: client.id, + scope: [OPENID].into_iter().collect(), + user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + device_code: Alphanumeric.sample_string(rng, 32), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + ip_address: None, + user_agent: None, + }, + client, + ); - [authorization_grant, device_code_grant] - }) - .collect() + [authorization_grant, device_code_grant] + }) + .collect(), + ) } } @@ -778,18 +884,22 @@ pub struct CompatSsoContext { } impl TemplateContext for CompatSsoContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![CompatSsoContext::new(CompatSsoLogin { + sample_list(vec![CompatSsoContext::new(CompatSsoLogin { id, redirect_uri: Url::parse("https://app.element.io/").unwrap(), login_token: "abcdefghijklmnopqrstuvwxyz012345".into(), created_at: now, state: CompatSsoLoginState::Pending, - })] + })]) } } @@ -836,11 +946,15 @@ impl EmailRecoveryContext { } impl TemplateContext for EmailRecoveryContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng).into_iter().map(|user| { + sample_list(User::samples(now, rng).into_iter().map(|user| { let session = UserRecoverySession { id: Ulid::from_datetime_with_source(now.into(), rng), email: "hello@example.com".to_owned(), @@ -854,7 +968,7 @@ impl TemplateContext for EmailRecoveryContext { let link = "https://example.com/recovery/complete?ticket=abcdefghijklmnopqrstuvwxyz0123456789".parse().unwrap(); Self::new(user, session, link) - }).collect() + }).collect()) } } @@ -897,28 +1011,37 @@ impl EmailVerificationContext { } impl TemplateContext for EmailVerificationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - BrowserSession::samples(now, rng) - .into_iter() - .map(|browser_session| { - let authentication_code = UserEmailAuthenticationCode { - id: Ulid::from_datetime_with_source(now.into(), rng), - user_email_authentication_id: Ulid::from_datetime_with_source(now.into(), rng), - code: "123456".to_owned(), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - }; + sample_list( + BrowserSession::samples(now, rng) + .into_iter() + .map(|browser_session| { + let authentication_code = UserEmailAuthenticationCode { + id: Ulid::from_datetime_with_source(now.into(), rng), + user_email_authentication_id: Ulid::from_datetime_with_source( + now.into(), + rng, + ), + code: "123456".to_owned(), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + }; - Self { - browser_session: Some(browser_session), - user_registration: None, - authentication_code, - } - }) - .collect() + Self { + browser_session: Some(browser_session), + user_registration: None, + authentication_code, + } + }) + .collect(), + ) } } @@ -963,7 +1086,11 @@ impl RegisterStepsVerifyEmailContext { } impl TemplateContext for RegisterStepsVerifyEmailContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -976,10 +1103,10 @@ impl TemplateContext for RegisterStepsVerifyEmailContext { completed_at: None, }; - vec![Self { + sample_list(vec![Self { form: FormState::default(), authentication, - }] + }]) } } @@ -1003,13 +1130,13 @@ impl TemplateContext for RegisterStepsEmailInUseContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let email = "hello@example.com".to_owned(); let action = PostAuthAction::continue_grant(Ulid::nil()); - vec![Self::new(email, Some(action))] + sample_list(vec![Self::new(email, Some(action))]) } } @@ -1058,13 +1185,13 @@ impl TemplateContext for RegisterStepsDisplayNameContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1113,13 +1240,13 @@ impl TemplateContext for RegisterStepsRegistrationTokenContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1164,11 +1291,11 @@ impl TemplateContext for RecoveryStartContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() @@ -1178,7 +1305,7 @@ impl TemplateContext for RecoveryStartContext { FormState::default() .with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid), ), - ] + ]) } } @@ -1202,7 +1329,11 @@ impl RecoveryProgressContext { } impl TemplateContext for RecoveryProgressContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1216,7 +1347,7 @@ impl TemplateContext for RecoveryProgressContext { consumed_at: None, }; - vec![ + sample_list(vec![ Self { session: session.clone(), resend_failed_due_to_rate_limit: false, @@ -1225,7 +1356,7 @@ impl TemplateContext for RecoveryProgressContext { session, resend_failed_due_to_rate_limit: true, }, - ] + ]) } } @@ -1244,7 +1375,11 @@ impl RecoveryExpiredContext { } impl TemplateContext for RecoveryExpiredContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1258,10 +1393,9 @@ impl TemplateContext for RecoveryExpiredContext { consumed_at: None, }; - vec![Self { session }] + sample_list(vec![Self { session }]) } } - /// Fields of the account recovery finish form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -1305,30 +1439,36 @@ impl RecoveryFinishContext { } impl TemplateContext for RecoveryFinishContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .flat_map(|user| { - vec![ - Self::new(user.clone()), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPassword, - FieldError::Invalid, + sample_list( + User::samples(now, rng) + .into_iter() + .flat_map(|user| { + vec![ + Self::new(user.clone()), + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPassword, + FieldError::Invalid, + ), ), - ), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPasswordConfirm, - FieldError::Invalid, + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPasswordConfirm, + FieldError::Invalid, + ), ), - ), - ] - }) - .collect() + ] + }) + .collect(), + ) } } @@ -1348,14 +1488,20 @@ impl UpstreamExistingLinkContext { } impl TemplateContext for UpstreamExistingLinkContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|linked_user| Self { linked_user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|linked_user| Self { linked_user }) + .collect(), + ) } } @@ -1380,12 +1526,16 @@ impl UpstreamSuggestLink { } impl TemplateContext for UpstreamSuggestLink { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![Self::for_link_id(id)] + sample_list(vec![Self::for_link_id(id)]) } } @@ -1505,11 +1655,15 @@ impl UpstreamRegister { } impl TemplateContext for UpstreamRegister { - fn sample(now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![Self::new( + sample_list(vec![Self::new( UpstreamOAuthLink { id: Ulid::nil(), provider_id: Ulid::nil(), @@ -1545,7 +1699,7 @@ impl TemplateContext for UpstreamRegister { disabled_at: None, on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, - )] + )]) } } @@ -1591,17 +1745,17 @@ impl TemplateContext for DeviceLinkContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() .with_error_on_field(DeviceLinkFormField::Code, FieldError::Required), ), - ] + ]) } } @@ -1621,13 +1775,17 @@ impl DeviceConsentContext { } impl TemplateContext for DeviceConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() - .map(|client| { + .map(|client| { let grant = DeviceCodeGrant { id: Ulid::from_datetime_with_source(now.into(), rng), state: mas_data_model::DeviceCodeGrantState::Pending, @@ -1642,7 +1800,7 @@ impl TemplateContext for DeviceConsentContext { }; Self { grant, client } }) - .collect() + .collect()) } } @@ -1662,14 +1820,20 @@ impl AccountInactiveContext { } impl TemplateContext for AccountInactiveContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|user| AccountInactiveContext { user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|user| AccountInactiveContext { user }) + .collect(), + ) } } @@ -1692,17 +1856,21 @@ impl DeviceNameContext { } impl TemplateContext for DeviceNameContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() .map(|client| DeviceNameContext { client, raw_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned(), }) - .collect() + .collect()) } } @@ -1714,16 +1882,25 @@ pub struct FormPostContext { } impl TemplateContext for FormPostContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let sample_params = T::sample(now, rng, locales); sample_params .into_iter() - .map(|params| FormPostContext { - redirect_uri: "https://example.com/callback".parse().ok(), - params, + .map(|(k, params)| { + ( + k, + FormPostContext { + redirect_uri: "https://example.com/callback".parse().ok(), + params, + }, + ) }) .collect() } @@ -1791,18 +1968,18 @@ impl TemplateContext for ErrorContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new() .with_code("sample_error") .with_description("A fancy description".into()) .with_details("Something happened".into()), Self::new().with_code("another_error"), Self::new(), - ] + ]) } } @@ -1881,11 +2058,15 @@ impl NotFoundContext { } impl TemplateContext for NotFoundContext { - fn sample(_now: DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + _now: DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(&Method::GET, Version::HTTP_11, &"/".parse().unwrap()), Self::new(&Method::POST, Version::HTTP_2, &"/foo/bar".parse().unwrap()), Self::new( @@ -1893,6 +2074,6 @@ impl TemplateContext for NotFoundContext { Version::HTTP_10, &"/foo?bar=baz".parse().unwrap(), ), - ] + ]) } } diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index 442cea4f8..3daafb745 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::sync::Arc; +use std::{collections::BTreeMap, sync::Arc}; use mas_i18n::DataLocale; use minijinja::{ @@ -13,7 +13,7 @@ use minijinja::{ }; use serde::Serialize; -use crate::TemplateContext; +use crate::{TemplateContext, context::SampleIdentifier}; #[derive(Debug)] struct CaptchaConfig(mas_data_model::CaptchaConfig); @@ -62,14 +62,13 @@ impl TemplateContext for WithCaptcha { now: chrono::DateTime, rng: &mut impl rand::prelude::Rng, locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - let inner = T::sample(now, rng, locales); - inner + T::sample(now, rng, locales) .into_iter() - .map(|inner| Self::new(None, inner)) + .map(|(k, inner)| (k, Self::new(None, inner))) .collect() } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 72d704b0a..94706407b 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -53,6 +53,7 @@ pub use self::{ }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; +use crate::context::SampleIdentifier; /// Escape the given string for use in HTML /// @@ -457,7 +458,7 @@ impl Templates { &self, now: chrono::DateTime, rng: &mut impl Rng, - ) -> anyhow::Result>> { + ) -> anyhow::Result> { check::all(self, now, rng) } } diff --git a/crates/templates/src/macros.rs b/crates/templates/src/macros.rs index b93ed1931..95b57f0d9 100644 --- a/crates/templates/src/macros.rs +++ b/crates/templates/src/macros.rs @@ -78,13 +78,14 @@ macro_rules! register_templates { /// # Errors /// /// Returns an error if any template fails to render with any of the sample. - pub(crate) fn all(templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) -> anyhow::Result<::std::collections::BTreeMap<&'static str, Vec>> { + pub(crate) fn all(templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) -> anyhow::Result<::std::collections::BTreeMap<(&'static str, SampleIdentifier), String>> { let mut out = ::std::collections::BTreeMap::new(); // TODO shouldn't the Rng be independent for each render? $( - out.insert( - $template, + out.extend( $name $(::< $( $generic_default ),* >)? (templates, now, rng)? + .into_iter() + .map(|(sample_identifier, rendered)| (($template, sample_identifier), rendered)) ); )* @@ -102,18 +103,18 @@ macro_rules! register_templates { pub(crate) fn $name $(< $( $lt $( : $clt $(+ $dlt )* + TemplateContext )? ),+ >)? (templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) - -> anyhow::Result> { + -> anyhow::Result> { let locales = templates.translator().available_locales(); - let samples: Vec< $param > = TemplateContext::sample(now, rng, &locales); + let samples: BTreeMap = TemplateContext::sample(now, rng, &locales); let name = $template; - let mut out = Vec::new(); - for (idx, sample) in samples.into_iter().enumerate() { + let mut out = BTreeMap::new(); + for (sample_identifier, sample) in samples { let context = serde_json::to_value(&sample)?; ::tracing::info!(name, %context, "Rendering template"); let rendered = templates. $name (&sample) - .with_context(|| format!("Failed to render sample template {name:?}-{idx} with context {context}"))?; - out.push(rendered); + .with_context(|| format!("Failed to render sample template {name:?}-{sample_identifier:?} with context {context}"))?; + out.insert(sample_identifier, rendered); } Ok(out)