1use crate::error::ProcessorError;
13use crate::reference::{Bibliography, Reference};
14use crate::values::{ProcHints, RenderContext, RenderOptions};
15use citum_schema::citation::CitationLocator;
16use citum_schema::locale::Locale;
17use citum_schema::options::{Config, bibliography::BibliographyConfig};
18use citum_schema::template::TemplateComponent;
19use grouped::component_predicates::{resolve_localized_type_variant, resolve_type_variant};
20use indexmap::IndexMap;
21use std::borrow::Cow;
22use std::cell::RefCell;
23use std::collections::{HashMap, HashSet};
24use std::sync::{Arc, OnceLock, RwLock};
25
26fn embedded_render_locales() -> &'static HashMap<String, Locale> {
27 static LOCALES: OnceLock<HashMap<String, Locale>> = OnceLock::new();
28 LOCALES.get_or_init(|| {
29 let mut locales = HashMap::new();
30 for id in citum_schema::embedded::EMBEDDED_LOCALE_IDS {
31 let Some(bytes) = citum_schema::embedded::get_locale_bytes(id) else {
32 continue;
33 };
34 let Ok(yaml) = std::str::from_utf8(bytes) else {
35 continue;
36 };
37 let Ok(locale) = Locale::from_yaml_str(yaml) else {
38 continue;
39 };
40 locales.insert(id.to_ascii_lowercase(), locale);
41 }
42 locales
43 })
44}
45
46pub struct Renderer<'a> {
51 pub style: &'a citum_schema::Style,
53 pub bibliography: &'a Bibliography,
55 pub locale: &'a Locale,
57 pub config: Arc<Config>,
59 pub bibliography_config: Option<Arc<BibliographyConfig>>,
61 pub hints: &'a HashMap<String, ProcHints>,
63 pub citation_numbers: &'a RwLock<HashMap<String, usize>>,
69 pub compound_set_by_ref: &'a HashMap<String, String>,
71 pub compound_member_index: &'a HashMap<String, usize>,
73 pub compound_sets: &'a IndexMap<String, Vec<String>>,
75 pub show_semantics: bool,
77 pub inject_ast_indices: bool,
79 pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
81 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
83 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
85}
86
87pub struct CompoundRenderData<'a> {
89 pub set_by_ref: &'a HashMap<String, String>,
91 pub member_index: &'a HashMap<String, usize>,
93 pub sets: &'a IndexMap<String, Vec<String>>,
95}
96
97mod collapse;
98mod grouped;
99mod grouped_fallback;
100mod helpers;
101
102#[cfg(test)]
103#[allow(
104 clippy::unwrap_used,
105 clippy::expect_used,
106 clippy::panic,
107 clippy::indexing_slicing,
108 clippy::todo,
109 clippy::unimplemented,
110 clippy::unreachable,
111 clippy::get_unwrap,
112 reason = "Panicking is acceptable and often desired in tests."
113)]
114mod tests;
115
116pub use grouped_fallback::GroupRenderParams;
117pub use grouped_fallback::TemplateRenderParams;
118pub(super) use helpers::{
119 find_grouping_component, has_contributor_component, leading_group_affix,
120 remove_first_contributor_with_role, strip_author_component, strip_leading_group_affixes,
121};
122
123pub struct TemplateRenderRequest<'a> {
125 pub template: &'a [TemplateComponent],
127 pub context: RenderContext,
129 pub mode: citum_schema::citation::CitationMode,
131 pub suppress_author: bool,
133 pub locator_raw: Option<&'a CitationLocator>,
135 pub citation_number: usize,
137 pub position: Option<citum_schema::citation::Position>,
139 pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
141 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
143 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
145 pub first_reference_note_number: Option<u32>,
147}
148
149struct UngroupedItemRenderState<'a> {
151 reference: &'a Reference,
152 template: Cow<'a, [TemplateComponent]>,
153 delimiter: &'a str,
154}
155
156#[derive(Clone, Copy)]
158struct UngroupedItemRenderParams<'a> {
159 mode: &'a citum_schema::citation::CitationMode,
160 suppress_author: bool,
161 position: Option<&'a citum_schema::citation::Position>,
162 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
163}
164
165#[derive(Clone, Default)]
166struct TemplateComponentTracker {
167 rendered_vars: HashSet<String>,
168 substituted_bases: HashSet<String>,
169}
170
171impl TemplateComponentTracker {
172 fn should_skip(&self, var_key: Option<&str>) -> bool {
173 let Some(var_key) = var_key else {
174 return false;
175 };
176 let base = key_base(var_key);
177 self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
178 }
179
180 fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
181 if let Some(var_key) = var_key {
182 self.rendered_vars.insert(var_key);
183 }
184 if let Some(substituted_key) = substituted_key {
185 self.rendered_vars.insert(substituted_key.to_string());
186 self.substituted_bases
187 .insert(key_base(substituted_key).into_owned());
188 }
189 }
190
191 fn merge_from(&mut self, other: Self) {
192 self.rendered_vars.extend(other.rendered_vars);
193 self.substituted_bases.extend(other.substituted_bases);
194 }
195}
196
197pub struct RendererResources<'a> {
202 pub style: &'a citum_schema::Style,
204 pub bibliography: &'a Bibliography,
206 pub locale: &'a Locale,
208 pub config: Arc<Config>,
210 pub bibliography_config: Option<Arc<BibliographyConfig>>,
212 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
214}
215
216impl<'a> Renderer<'a> {
217 pub fn new(
219 resources: RendererResources<'a>,
220 hints: &'a HashMap<String, ProcHints>,
221 citation_numbers: &'a RwLock<HashMap<String, usize>>,
222 compound: CompoundRenderData<'a>,
223 show_semantics: bool,
224 inject_ast_indices: bool,
225 abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
226 ) -> Self {
227 Self {
228 style: resources.style,
229 bibliography: resources.bibliography,
230 locale: resources.locale,
231 config: resources.config,
232 bibliography_config: resources.bibliography_config,
233 hints,
234 citation_numbers,
235 compound_set_by_ref: compound.set_by_ref,
236 compound_member_index: compound.member_index,
237 compound_sets: compound.sets,
238 show_semantics,
239 inject_ast_indices,
240 filtered_to_original_index: RefCell::new(None),
241 abbreviation_map,
242 first_note_by_id: resources.first_note_by_id,
243 }
244 }
245
246 fn locale_for_reference(&self, reference: &Reference, context: RenderContext) -> &Locale {
248 let language = crate::values::effective_item_language(reference);
249 let selected = match context {
250 RenderContext::Citation => self
251 .style
252 .citation
253 .as_ref()
254 .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
255 RenderContext::Bibliography => self
256 .style
257 .bibliography
258 .as_ref()
259 .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
260 };
261 let Some(locale_id) = selected.and_then(|resolved| resolved.locale) else {
262 return self.locale;
263 };
264
265 let locales = embedded_render_locales();
266 let key = locale_id.to_ascii_lowercase();
267 locales
268 .get(&key)
269 .or_else(|| {
270 let primary = key.split(['-', '_']).next()?;
271 locales.iter().find_map(|(candidate, locale)| {
272 candidate
273 .split(['-', '_'])
274 .next()
275 .is_some_and(|candidate_primary| candidate_primary == primary)
276 .then_some(locale)
277 })
278 })
279 .unwrap_or(self.locale)
280 }
281
282 fn resolve_contributor_names(
284 &self,
285 contributor: &citum_schema::reference::contributor::Contributor,
286 ) -> Vec<crate::reference::FlatName> {
287 let ml = self.config.multilingual.as_ref();
288 crate::values::resolve_multilingual_name(
289 contributor,
290 ml.and_then(|m| m.name_mode.as_ref()),
291 ml.and_then(|m| m.preferred_transliteration.as_deref()),
292 ml.and_then(|m| m.preferred_script.as_ref()),
293 &self.locale.locale,
294 )
295 }
296
297 fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
300 let compound = self
301 .bibliography_config
302 .as_ref()
303 .and_then(|b| b.compound_numeric.as_ref())?;
304 let set_id = self.compound_set_by_ref.get(ref_id)?;
305 let members = self.compound_sets.get(set_id)?;
306 if members.len() <= 1 {
307 return None;
308 }
309 if !compound.subentry {
310 return None;
311 }
312 let idx = *self.compound_member_index.get(ref_id)?;
313 match compound.sub_label {
314 citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
315 crate::values::int_to_letter((idx + 1) as u32)
316 }
317 citum_schema::options::bibliography::SubLabelStyle::Numeric => {
318 Some((idx + 1).to_string())
319 }
320 }
321 }
322
323 fn should_render_author_number_for_numeric_integral(
329 &self,
330 mode: &citum_schema::citation::CitationMode,
331 ) -> bool {
332 matches!(mode, citum_schema::citation::CitationMode::Integral)
333 && self.config.processing.as_ref().is_some_and(|processing| {
334 matches!(processing, citum_schema::options::Processing::Numeric)
335 })
336 && !self.has_explicit_integral_template()
337 }
338
339 fn has_explicit_integral_template(&self) -> bool {
341 self.style.citation.as_ref().is_some_and(|c| {
342 c.integral.as_ref().is_some_and(|i| {
343 i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
344 })
345 })
346 }
347
348 fn should_collapse_compound_subentries(
350 &self,
351 mode: &citum_schema::citation::CitationMode,
352 ) -> bool {
353 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
354 return false;
355 }
356
357 self.bibliography_config
358 .as_ref()
359 .and_then(|b| b.compound_numeric.as_ref())
360 .is_some_and(|c| c.subentry && c.collapse_subentries)
361 }
362
363 fn should_collapse_citation_numbers(
365 &self,
366 spec: &citum_schema::CitationSpec,
367 mode: &citum_schema::citation::CitationMode,
368 ) -> bool {
369 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
370 return false;
371 }
372
373 let is_numeric = self
374 .config
375 .processing
376 .as_ref()
377 .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
378
379 is_numeric
380 && matches!(
381 spec.collapse,
382 Some(citum_schema::CitationCollapse::CitationNumber)
383 )
384 }
385
386 fn normalize_prefix_spacing(prefix: &str) -> String {
388 if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
389 format!("{prefix} ")
390 } else {
391 prefix.to_string()
392 }
393 }
394
395 fn ensure_suffix_spacing(suffix: &str) -> String {
398 if suffix.is_empty() {
399 String::new()
400 } else if suffix.starts_with(char::is_whitespace)
401 || suffix.starts_with(',')
402 || suffix.starts_with(';')
403 || suffix.starts_with('.')
404 {
405 suffix.to_string()
407 } else {
408 format!(" {suffix}")
410 }
411 }
412
413 fn wants_latin_punctuation_for_id(&self, ref_id: &str) -> bool {
422 let configured = self.config.multilingual.as_ref().is_some_and(|ml| {
423 ml.scripts.get("latin").is_some_and(|script| {
424 script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
425 })
426 });
427
428 configured
429 && self.bibliography.get(ref_id).is_some_and(|reference| {
430 crate::values::is_latin_script_language(
431 crate::values::effective_item_language(reference).as_deref(),
432 )
433 })
434 }
435
436 fn affix_content<F>(
443 &self,
444 fmt: &F,
445 content: String,
446 prefix: Option<&str>,
447 suffix: Option<&str>,
448 ref_id: Option<&str>,
449 ) -> String
450 where
451 F: crate::render::format::OutputFormat<Output = String>,
452 {
453 let prefix = prefix.unwrap_or("");
454 let suffix = suffix.unwrap_or("");
455 let affixed = if prefix.is_empty() && suffix.is_empty() {
456 content
457 } else {
458 fmt.affix(
459 &Self::normalize_prefix_spacing(prefix),
460 content,
461 &Self::ensure_suffix_spacing(suffix),
462 )
463 };
464
465 if ref_id.is_some_and(|id| self.wants_latin_punctuation_for_id(id)) {
466 crate::render::component::remap_to_latin_punctuation(affixed)
467 } else {
468 affixed
469 }
470 }
471
472 fn build_citation_chunk<F>(
474 &self,
475 fmt: &F,
476 ids: Vec<String>,
477 content: String,
478 prefix: Option<&str>,
479 suffix: Option<&str>,
480 ) -> Option<(Vec<String>, String)>
481 where
482 F: crate::render::format::OutputFormat<Output = String>,
483 {
484 if content.is_empty() {
485 None
486 } else {
487 let affixed = self.affix_content(
488 fmt,
489 content,
490 prefix,
491 suffix,
492 ids.first().map(String::as_str),
493 );
494 Some((ids, affixed))
495 }
496 }
497
498 fn build_item_chunk<F>(
500 &self,
501 fmt: &F,
502 item: &crate::reference::CitationItem,
503 content: String,
504 ) -> Option<(Vec<String>, String)>
505 where
506 F: crate::render::format::OutputFormat<Output = String>,
507 {
508 self.build_citation_chunk(
509 fmt,
510 vec![item.id.clone()],
511 content,
512 item.prefix.as_deref(),
513 item.suffix.as_deref(),
514 )
515 }
516
517 fn citation_render_request<'b>(
519 &self,
520 item: &'b crate::reference::CitationItem,
521 template: &'b [TemplateComponent],
522 mode: &citum_schema::citation::CitationMode,
523 suppress_author: bool,
524 position: Option<&citum_schema::citation::Position>,
525 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
526 ) -> TemplateRenderRequest<'b> {
527 TemplateRenderRequest {
528 template,
529 context: RenderContext::Citation,
530 mode: mode.clone(),
531 suppress_author,
532 locator_raw: item.locator.as_ref(),
533 citation_number: self.get_or_assign_citation_number(&item.id),
534 position: position.cloned(),
535 note_start_text_case,
536 integral_name_state: item.integral_name_state,
537 org_abbreviation_state: item.org_abbreviation_state,
538 first_reference_note_number: self.first_note_by_id.as_ref().and_then(|m| {
539 m.read()
540 .unwrap_or_else(std::sync::PoisonError::into_inner)
541 .get(&item.id)
542 .copied()
543 }),
544 }
545 }
546
547 fn render_item_from_template_with_format<F>(
549 &self,
550 reference: &Reference,
551 request: TemplateRenderRequest<'_>,
552 delimiter: &str,
553 ) -> Option<String>
554 where
555 F: crate::render::format::OutputFormat<Output = String>,
556 {
557 self.process_template_request_with_format::<F>(reference, request)
558 .map(|proc| {
559 crate::render::citation::citation_to_string_with_format::<F>(
560 &proc,
561 None,
562 None,
563 None,
564 Some(delimiter),
565 )
566 })
567 }
568
569 fn resolve_ungrouped_item_render_state<'b>(
572 &'b self,
573 item: &'b crate::reference::CitationItem,
574 spec: &'b citum_schema::CitationSpec,
575 intra_delimiter: &'b str,
576 ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
577 let reference = self
578 .bibliography
579 .get(&item.id)
580 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
581 let ref_type = reference.ref_type();
582 let item_language = crate::values::effective_item_language(reference);
583 let localized = spec.resolve_localized_template(item_language.as_deref());
584 let template = localized
585 .as_ref()
586 .filter(|resolved| resolved.type_variants.is_some())
587 .cloned()
588 .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
589 .or_else(|| {
590 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
591 })
592 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))
593 .unwrap_or(Cow::Borrowed(&[] as &[TemplateComponent]));
594
595 Ok(UngroupedItemRenderState {
596 reference,
597 template,
598 delimiter: intra_delimiter,
599 })
600 }
601
602 fn citation_render_options<'b>(
604 &'b self,
605 reference: &Reference,
606 mode: citum_schema::citation::CitationMode,
607 suppress_author: bool,
608 locator_raw: Option<&'b CitationLocator>,
609 ref_type: Option<String>,
610 ) -> RenderOptions<'b> {
611 RenderOptions {
612 config: self.config.clone(),
613 bibliography_config: self.bibliography_config.clone(),
614 locale: self.locale_for_reference(reference, RenderContext::Citation),
615 context: RenderContext::Citation,
616 mode,
617 suppress_author,
618 locator_raw,
619 ref_type,
620 show_semantics: self.show_semantics,
621 current_template_index: None,
622 abbreviation_map: self.abbreviation_map,
623 }
624 }
625
626 fn render_author_number_for_numeric_integral_with_format<F>(
630 &self,
631 reference: &Reference,
632 item: &crate::reference::CitationItem,
633 citation_number: usize,
634 ) -> String
635 where
636 F: crate::render::format::OutputFormat<Output = String>,
637 {
638 let fmt = F::default();
639 let options = self.citation_render_options(
640 reference,
641 citum_schema::citation::CitationMode::Integral,
642 false,
643 item.locator.as_ref(),
644 Some(reference.ref_type()),
645 );
646
647 let author_part = if let Some(authors) = reference.author() {
649 let names_vec = self.resolve_contributor_names(&authors);
650 fmt.text(&crate::values::format_contributors_short(
651 &names_vec, &options,
652 ))
653 } else {
654 String::new()
655 };
656
657 let ref_id = reference.id().unwrap_or_default().to_string();
659 let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
660
661 if author_part.is_empty() {
663 format!("[{citation_number}{sub_label}]")
665 } else {
666 format!("{author_part} [{citation_number}{sub_label}]")
667 }
668 }
669
670 fn render_numeric_integral_item_chunk_with_format<F>(
672 &self,
673 fmt: &F,
674 item: &crate::reference::CitationItem,
675 ) -> Result<Option<(Vec<String>, String)>, ProcessorError>
676 where
677 F: crate::render::format::OutputFormat<Output = String>,
678 {
679 let reference = self
680 .bibliography
681 .get(&item.id)
682 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
683 let citation_number = self.get_or_assign_citation_number(&item.id);
684 let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
685 reference,
686 item,
687 citation_number,
688 );
689 Ok(self.build_item_chunk(fmt, item, item_str))
690 }
691
692 fn render_template_item_chunk_with_format<F>(
694 &self,
695 fmt: &F,
696 item: &crate::reference::CitationItem,
697 state: UngroupedItemRenderState<'_>,
698 params: UngroupedItemRenderParams<'_>,
699 ) -> Option<(Vec<String>, String)>
700 where
701 F: crate::render::format::OutputFormat<Output = String>,
702 {
703 let request = self.citation_render_request(
704 item,
705 &state.template,
706 params.mode,
707 params.suppress_author,
708 params.position,
709 params.note_start_text_case,
710 );
711 self.render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
712 .and_then(|item_str| self.build_item_chunk(fmt, item, item_str))
713 }
714
715 pub fn render_ungrouped_citation(
722 &self,
723 items: &[crate::reference::CitationItem],
724 spec: &citum_schema::CitationSpec,
725 mode: &citum_schema::citation::CitationMode,
726 intra_delimiter: &str,
727 suppress_author: bool,
728 position: Option<&citum_schema::citation::Position>,
729 ) -> Result<Vec<String>, ProcessorError> {
730 self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
731 items,
732 spec,
733 mode,
734 intra_delimiter,
735 suppress_author,
736 position,
737 spec.note_start_text_case,
738 )
739 }
740
741 #[allow(
751 clippy::too_many_arguments,
752 reason = "Ungrouped citation rendering now needs explicit note-start context."
753 )]
754 pub fn render_ungrouped_citation_with_format<F>(
755 &self,
756 items: &[crate::reference::CitationItem],
757 spec: &citum_schema::CitationSpec,
758 mode: &citum_schema::citation::CitationMode,
759 intra_delimiter: &str,
760 suppress_author: bool,
761 position: Option<&citum_schema::citation::Position>,
762 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
763 ) -> Result<Vec<String>, ProcessorError>
764 where
765 F: crate::render::format::OutputFormat<Output = String>,
766 {
767 let fmt = F::default();
768 let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
769
770 let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
772 let params = UngroupedItemRenderParams {
773 mode,
774 suppress_author,
775 position,
776 note_start_text_case,
777 };
778
779 for item in items {
780 let chunk = if use_author_number {
781 self.render_numeric_integral_item_chunk_with_format::<F>(&fmt, item)?
782 } else {
783 let state =
784 self.resolve_ungrouped_item_render_state(item, spec, intra_delimiter)?;
785 self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
786 };
787
788 if let Some(chunk) = chunk {
789 chunks.push(chunk);
790 }
791 }
792
793 if self.should_collapse_compound_subentries(mode) {
794 chunks = self.collapse_compound_citation_chunks(chunks);
795 }
796 if self.should_collapse_citation_numbers(spec, mode) {
797 chunks = self.collapse_numeric_citation_chunks(chunks);
798 }
799
800 Ok(chunks
801 .into_iter()
802 .map(|(ids, content)| fmt.citation(ids, content))
803 .collect())
804 }
805}
806
807fn key_base(key: &str) -> Cow<'_, str> {
808 let mut parts = key.splitn(3, ':');
809 match (parts.next(), parts.next()) {
810 (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
811 _ => Cow::Borrowed(key),
812 }
813}
814
815#[must_use]
821pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
822 use citum_schema::template::Rendering;
823 use std::fmt::Write;
824
825 fn push_context_suffix(key: &mut String, rendering: &Rendering) {
826 match (&rendering.prefix, &rendering.suffix) {
827 (Some(prefix), Some(suffix)) => {
828 key.push(':');
829 key.push_str(prefix);
830 key.push('_');
831 key.push_str(suffix);
832 }
833 (Some(prefix), None) => {
834 key.push(':');
835 key.push_str(prefix);
836 }
837 (None, Some(suffix)) => {
838 key.push(':');
839 key.push_str(suffix);
840 }
841 (None, None) => {}
842 }
843 }
844
845 fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
846 let mut key = String::new();
847 write!(&mut key, "{kind}:{value:?}").ok()?;
848 push_context_suffix(&mut key, rendering);
849 Some(key)
850 }
851
852 match component {
853 TemplateComponent::Contributor(c) => c.contributor.as_single().map_or_else(
854 || make_key("contributor", &c.contributor, &c.rendering),
855 |role| make_key("contributor", role, &c.rendering),
856 ),
857 TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
858 TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
859 TemplateComponent::Title(t) => {
860 let mut key = format!("title:{:?}", t.title);
861 if let Some(form) = &t.form {
862 write!(&mut key, ":{form:?}").ok()?;
863 }
864 push_context_suffix(&mut key, &t.rendering);
865 Some(key)
866 }
867 TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
868 TemplateComponent::Identifier(i) => make_key("identifier", &i.identifier, &i.rendering),
869 TemplateComponent::Group(_) => None,
870 _ => None,
871 }
872}