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(locale) = citum_schema::embedded::get_locale(id) else {
32 continue;
33 };
34 locales.insert(id.to_ascii_lowercase(), locale);
35 }
36 locales
37 })
38}
39
40pub(crate) fn lookup_embedded_locale(locale_id: &str) -> Option<&'static Locale> {
47 let locales = embedded_render_locales();
48 let key = locale_id.to_ascii_lowercase();
49 locales.get(&key).or_else(|| {
50 let primary = key.split(['-', '_']).next()?;
51 locales.iter().find_map(|(candidate, locale)| {
52 candidate
53 .split(['-', '_'])
54 .next()
55 .is_some_and(|candidate_primary| candidate_primary == primary)
56 .then_some(locale)
57 })
58 })
59}
60
61pub struct Renderer<'a> {
66 pub style: &'a citum_schema::Style,
68 pub bibliography: &'a Bibliography,
70 pub locale: &'a Locale,
72 pub config: Arc<Config>,
74 pub bibliography_config: Option<Arc<BibliographyConfig>>,
76 pub hints: &'a HashMap<String, ProcHints>,
78 pub citation_numbers: &'a RwLock<HashMap<String, usize>>,
84 pub compound_set_by_ref: &'a HashMap<String, String>,
86 pub compound_member_index: &'a HashMap<String, usize>,
88 pub compound_sets: &'a IndexMap<String, Vec<String>>,
90 pub show_semantics: bool,
92 pub inject_ast_indices: bool,
94 pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
96 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
98 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
100}
101
102pub struct CompoundRenderData<'a> {
104 pub set_by_ref: &'a HashMap<String, String>,
106 pub member_index: &'a HashMap<String, usize>,
108 pub sets: &'a IndexMap<String, Vec<String>>,
110}
111
112mod collapse;
113mod grouped;
114mod grouped_fallback;
115mod helpers;
116
117#[cfg(test)]
118#[allow(
119 clippy::unwrap_used,
120 clippy::expect_used,
121 clippy::panic,
122 clippy::indexing_slicing,
123 clippy::todo,
124 clippy::unimplemented,
125 clippy::unreachable,
126 clippy::get_unwrap,
127 reason = "Panicking is acceptable and often desired in tests."
128)]
129mod tests;
130
131pub use grouped_fallback::GroupRenderParams;
132pub use grouped_fallback::TemplateRenderParams;
133pub(super) use helpers::{
134 find_grouping_component, has_contributor_component, leading_group_affix,
135 remove_first_contributor_with_role, strip_author_component, strip_leading_group_affixes,
136};
137
138pub struct TemplateRenderRequest<'a> {
140 pub template: &'a [TemplateComponent],
142 pub context: RenderContext,
144 pub mode: citum_schema::citation::CitationMode,
146 pub suppress_author: bool,
148 pub locator_raw: Option<&'a CitationLocator>,
150 pub citation_number: usize,
152 pub position: Option<citum_schema::citation::Position>,
154 pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
156 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
158 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
160 pub first_reference_note_number: Option<u32>,
162}
163
164struct UngroupedItemRenderState<'a> {
166 reference: &'a Reference,
167 template: Cow<'a, [TemplateComponent]>,
168 delimiter: &'a str,
169}
170
171#[derive(Clone, Copy)]
173struct UngroupedItemRenderParams<'a> {
174 mode: &'a citum_schema::citation::CitationMode,
175 suppress_author: bool,
176 position: Option<&'a citum_schema::citation::Position>,
177 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
178}
179
180#[derive(Clone, Default)]
181struct TemplateComponentTracker {
182 rendered_vars: HashSet<String>,
183 substituted_bases: HashSet<String>,
184}
185
186impl TemplateComponentTracker {
187 fn should_skip(&self, var_key: Option<&str>) -> bool {
188 let Some(var_key) = var_key else {
189 return false;
190 };
191 let base = key_base(var_key);
192 self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
193 }
194
195 fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
196 if let Some(var_key) = var_key {
197 self.rendered_vars.insert(var_key);
198 }
199 if let Some(substituted_key) = substituted_key {
200 self.rendered_vars.insert(substituted_key.to_string());
201 self.substituted_bases
202 .insert(key_base(substituted_key).into_owned());
203 }
204 }
205
206 fn merge_from(&mut self, other: Self) {
207 self.rendered_vars.extend(other.rendered_vars);
208 self.substituted_bases.extend(other.substituted_bases);
209 }
210}
211
212pub struct RendererResources<'a> {
217 pub style: &'a citum_schema::Style,
219 pub bibliography: &'a Bibliography,
221 pub locale: &'a Locale,
223 pub config: Arc<Config>,
225 pub bibliography_config: Option<Arc<BibliographyConfig>>,
227 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
229}
230
231impl<'a> Renderer<'a> {
232 pub fn new(
234 resources: RendererResources<'a>,
235 hints: &'a HashMap<String, ProcHints>,
236 citation_numbers: &'a RwLock<HashMap<String, usize>>,
237 compound: CompoundRenderData<'a>,
238 show_semantics: bool,
239 inject_ast_indices: bool,
240 abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
241 ) -> Self {
242 Self {
243 style: resources.style,
244 bibliography: resources.bibliography,
245 locale: resources.locale,
246 config: resources.config,
247 bibliography_config: resources.bibliography_config,
248 hints,
249 citation_numbers,
250 compound_set_by_ref: compound.set_by_ref,
251 compound_member_index: compound.member_index,
252 compound_sets: compound.sets,
253 show_semantics,
254 inject_ast_indices,
255 filtered_to_original_index: RefCell::new(None),
256 abbreviation_map,
257 first_note_by_id: resources.first_note_by_id,
258 }
259 }
260
261 fn locale_for_reference(
274 &self,
275 reference: &Reference,
276 context: RenderContext,
277 ) -> Cow<'a, Locale> {
278 let language = crate::values::effective_item_language(reference);
279 let selected = match context {
280 RenderContext::Citation => self
281 .style
282 .citation
283 .as_ref()
284 .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
285 RenderContext::Bibliography => self
286 .style
287 .bibliography
288 .as_ref()
289 .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
290 };
291
292 if let Some(locale_id) = selected.and_then(|resolved| resolved.locale) {
293 return Cow::Borrowed(lookup_embedded_locale(&locale_id).unwrap_or(self.locale));
294 }
295
296 let term_locale_is_item = self
297 .config
298 .multilingual
299 .as_ref()
300 .is_some_and(|ml| ml.term_locale == citum_schema::options::TermLocale::Item);
301
302 if term_locale_is_item
303 && let Some(item_locale) = language.as_deref().and_then(lookup_embedded_locale)
304 {
305 return Cow::Owned(self.locale.with_term_surfaces_from(item_locale));
306 }
307
308 Cow::Borrowed(self.locale)
309 }
310
311 fn resolve_contributor_names(
313 &self,
314 contributor: &citum_schema::reference::contributor::Contributor,
315 ) -> Vec<crate::reference::FlatName> {
316 let ml = self.config.multilingual.as_ref();
317 crate::values::resolve_multilingual_name(
318 contributor,
319 ml.and_then(|m| m.name_mode.as_ref()),
320 ml.and_then(|m| m.preferred_transliteration.as_deref()),
321 ml.and_then(|m| m.preferred_script.as_ref()),
322 &self.locale.locale,
323 )
324 }
325
326 fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
329 let compound = self
330 .bibliography_config
331 .as_ref()
332 .and_then(|b| b.compound_numeric.as_ref())?;
333 let set_id = self.compound_set_by_ref.get(ref_id)?;
334 let members = self.compound_sets.get(set_id)?;
335 if members.len() <= 1 {
336 return None;
337 }
338 if !compound.subentry {
339 return None;
340 }
341 let idx = *self.compound_member_index.get(ref_id)?;
342 match compound.sub_label {
343 citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
344 crate::values::int_to_letter((idx + 1) as u32)
345 }
346 citum_schema::options::bibliography::SubLabelStyle::Numeric => {
347 Some((idx + 1).to_string())
348 }
349 }
350 }
351
352 fn should_render_author_number_for_numeric_integral(
358 &self,
359 mode: &citum_schema::citation::CitationMode,
360 ) -> bool {
361 matches!(mode, citum_schema::citation::CitationMode::Integral)
362 && self.config.processing.as_ref().is_some_and(|processing| {
363 matches!(processing, citum_schema::options::Processing::Numeric)
364 })
365 && !self.has_explicit_integral_template()
366 }
367
368 fn has_explicit_integral_template(&self) -> bool {
370 self.style.citation.as_ref().is_some_and(|c| {
371 c.integral.as_ref().is_some_and(|i| {
372 i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
373 })
374 })
375 }
376
377 fn should_collapse_compound_subentries(
379 &self,
380 mode: &citum_schema::citation::CitationMode,
381 ) -> bool {
382 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
383 return false;
384 }
385
386 self.bibliography_config
387 .as_ref()
388 .and_then(|b| b.compound_numeric.as_ref())
389 .is_some_and(|c| c.subentry && c.collapse_subentries)
390 }
391
392 fn should_collapse_citation_numbers(
394 &self,
395 spec: &citum_schema::CitationSpec,
396 mode: &citum_schema::citation::CitationMode,
397 ) -> bool {
398 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
399 return false;
400 }
401
402 let is_numeric = self
403 .config
404 .processing
405 .as_ref()
406 .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
407
408 is_numeric
409 && matches!(
410 spec.collapse,
411 Some(citum_schema::CitationCollapse::CitationNumber)
412 )
413 }
414
415 fn normalize_prefix_spacing(prefix: &str) -> String {
417 if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
418 format!("{prefix} ")
419 } else {
420 prefix.to_string()
421 }
422 }
423
424 fn ensure_suffix_spacing(suffix: &str) -> String {
427 if suffix.is_empty() {
428 String::new()
429 } else if suffix.starts_with(char::is_whitespace)
430 || suffix.starts_with(',')
431 || suffix.starts_with(';')
432 || suffix.starts_with('.')
433 {
434 suffix.to_string()
436 } else {
437 format!(" {suffix}")
439 }
440 }
441
442 fn wants_latin_punctuation_for_id(&self, ref_id: &str) -> bool {
451 let configured = self.config.multilingual.as_ref().is_some_and(|ml| {
452 ml.scripts.get("latin").is_some_and(|script| {
453 script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
454 })
455 });
456
457 configured
458 && self.bibliography.get(ref_id).is_some_and(|reference| {
459 crate::values::is_latin_script_language(
460 crate::values::effective_item_language(reference).as_deref(),
461 )
462 })
463 }
464
465 fn affix_content<F>(
472 &self,
473 fmt: &F,
474 content: String,
475 prefix: Option<&str>,
476 suffix: Option<&str>,
477 ref_id: Option<&str>,
478 ) -> String
479 where
480 F: crate::render::format::OutputFormat<Output = String>,
481 {
482 let prefix = prefix.unwrap_or("");
483 let suffix = suffix.unwrap_or("");
484 let affixed = if prefix.is_empty() && suffix.is_empty() {
485 content
486 } else {
487 fmt.affix(
488 &Self::normalize_prefix_spacing(prefix),
489 content,
490 &Self::ensure_suffix_spacing(suffix),
491 )
492 };
493
494 if ref_id.is_some_and(|id| self.wants_latin_punctuation_for_id(id)) {
495 crate::render::component::remap_to_latin_punctuation(affixed)
496 } else {
497 affixed
498 }
499 }
500
501 fn build_citation_chunk<F>(
503 &self,
504 fmt: &F,
505 ids: Vec<String>,
506 content: String,
507 prefix: Option<&str>,
508 suffix: Option<&str>,
509 ) -> Option<(Vec<String>, String)>
510 where
511 F: crate::render::format::OutputFormat<Output = String>,
512 {
513 if content.is_empty() {
514 None
515 } else {
516 let affixed = self.affix_content(
517 fmt,
518 content,
519 prefix,
520 suffix,
521 ids.first().map(String::as_str),
522 );
523 Some((ids, affixed))
524 }
525 }
526
527 fn build_item_chunk<F>(
529 &self,
530 fmt: &F,
531 item: &crate::reference::CitationItem,
532 content: String,
533 ) -> Option<(Vec<String>, String)>
534 where
535 F: crate::render::format::OutputFormat<Output = String>,
536 {
537 self.build_citation_chunk(
538 fmt,
539 vec![item.id.clone()],
540 content,
541 item.prefix.as_deref(),
542 item.suffix.as_deref(),
543 )
544 }
545
546 fn citation_render_request<'b>(
548 &self,
549 item: &'b crate::reference::CitationItem,
550 template: &'b [TemplateComponent],
551 mode: &citum_schema::citation::CitationMode,
552 suppress_author: bool,
553 position: Option<&citum_schema::citation::Position>,
554 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
555 ) -> TemplateRenderRequest<'b> {
556 TemplateRenderRequest {
557 template,
558 context: RenderContext::Citation,
559 mode: mode.clone(),
560 suppress_author,
561 locator_raw: item.locator.as_ref(),
562 citation_number: self.get_or_assign_citation_number(&item.id),
563 position: position.cloned(),
564 note_start_text_case,
565 integral_name_state: item.integral_name_state,
566 org_abbreviation_state: item.org_abbreviation_state,
567 first_reference_note_number: self.first_note_by_id.as_ref().and_then(|m| {
568 m.read()
569 .unwrap_or_else(std::sync::PoisonError::into_inner)
570 .get(&item.id)
571 .copied()
572 }),
573 }
574 }
575
576 fn render_item_from_template_with_format<F>(
578 &self,
579 reference: &Reference,
580 request: TemplateRenderRequest<'_>,
581 delimiter: &str,
582 ) -> Option<String>
583 where
584 F: crate::render::format::OutputFormat<Output = String>,
585 {
586 self.process_template_request_with_format::<F>(reference, request)
587 .map(|proc| {
588 crate::render::citation::citation_to_string_with_format::<F>(
589 &proc,
590 None,
591 None,
592 None,
593 Some(delimiter),
594 )
595 })
596 }
597
598 fn resolve_ungrouped_item_render_state<'b>(
601 &'b self,
602 item: &'b crate::reference::CitationItem,
603 spec: &'b citum_schema::CitationSpec,
604 intra_delimiter: &'b str,
605 ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
606 let reference = self
607 .bibliography
608 .get(&item.id)
609 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
610 let ref_type = reference.ref_type();
611 let item_language = crate::values::effective_item_language(reference);
612 let localized = spec.resolve_localized_template(item_language.as_deref());
613 let template = localized
614 .as_ref()
615 .filter(|resolved| resolved.type_variants.is_some())
616 .cloned()
617 .map(|resolved| {
618 Cow::Owned(resolve_localized_type_variant(
619 resolved,
620 spec.type_variants.as_ref(),
621 &ref_type,
622 ))
623 })
624 .or_else(|| {
625 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
626 })
627 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))
628 .unwrap_or(Cow::Borrowed(&[] as &[TemplateComponent]));
629
630 Ok(UngroupedItemRenderState {
631 reference,
632 template,
633 delimiter: intra_delimiter,
634 })
635 }
636
637 fn citation_render_options<'b>(
642 &'b self,
643 locale: &'b Locale,
644 mode: citum_schema::citation::CitationMode,
645 suppress_author: bool,
646 locator_raw: Option<&'b CitationLocator>,
647 ref_type: Option<String>,
648 ) -> RenderOptions<'b> {
649 RenderOptions {
650 config: self.config.clone(),
651 bibliography_config: self.bibliography_config.clone(),
652 locale,
653 context: RenderContext::Citation,
654 mode,
655 suppress_author,
656 locator_raw,
657 ref_type,
658 show_semantics: self.show_semantics,
659 current_template_index: None,
660 abbreviation_map: self.abbreviation_map,
661 }
662 }
663
664 fn render_author_number_for_numeric_integral_with_format<F>(
668 &self,
669 reference: &Reference,
670 item: &crate::reference::CitationItem,
671 citation_number: usize,
672 ) -> String
673 where
674 F: crate::render::format::OutputFormat<Output = String>,
675 {
676 let fmt = F::default();
677 let locale = self.locale_for_reference(reference, RenderContext::Citation);
678 let options = self.citation_render_options(
679 locale.as_ref(),
680 citum_schema::citation::CitationMode::Integral,
681 false,
682 item.locator.as_ref(),
683 Some(reference.ref_type()),
684 );
685
686 let author_part = if let Some(authors) = reference.author() {
688 let names_vec = self.resolve_contributor_names(&authors);
689 fmt.text(&crate::values::format_contributors_short(
690 &names_vec, &options,
691 ))
692 } else {
693 String::new()
694 };
695
696 let ref_id = reference.id().unwrap_or_default().to_string();
698 let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
699
700 if author_part.is_empty() {
702 format!("[{citation_number}{sub_label}]")
704 } else {
705 format!("{author_part} [{citation_number}{sub_label}]")
706 }
707 }
708
709 fn render_numeric_integral_item_chunk_with_format<F>(
711 &self,
712 fmt: &F,
713 item: &crate::reference::CitationItem,
714 ) -> Result<Option<(Vec<String>, String)>, ProcessorError>
715 where
716 F: crate::render::format::OutputFormat<Output = String>,
717 {
718 let reference = self
719 .bibliography
720 .get(&item.id)
721 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
722 let citation_number = self.get_or_assign_citation_number(&item.id);
723 let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
724 reference,
725 item,
726 citation_number,
727 );
728 Ok(self.build_item_chunk(fmt, item, item_str))
729 }
730
731 fn render_template_item_chunk_with_format<F>(
733 &self,
734 fmt: &F,
735 item: &crate::reference::CitationItem,
736 state: UngroupedItemRenderState<'_>,
737 params: UngroupedItemRenderParams<'_>,
738 ) -> Option<(Vec<String>, String)>
739 where
740 F: crate::render::format::OutputFormat<Output = String>,
741 {
742 let request = self.citation_render_request(
743 item,
744 &state.template,
745 params.mode,
746 params.suppress_author,
747 params.position,
748 params.note_start_text_case,
749 );
750 self.render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
751 .and_then(|item_str| self.build_item_chunk(fmt, item, item_str))
752 }
753
754 pub fn render_ungrouped_citation(
761 &self,
762 items: &[crate::reference::CitationItem],
763 spec: &citum_schema::CitationSpec,
764 mode: &citum_schema::citation::CitationMode,
765 intra_delimiter: &str,
766 suppress_author: bool,
767 position: Option<&citum_schema::citation::Position>,
768 ) -> Result<Vec<String>, ProcessorError> {
769 self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
770 items,
771 spec,
772 mode,
773 intra_delimiter,
774 suppress_author,
775 position,
776 spec.note_start_text_case,
777 )
778 }
779
780 #[allow(
790 clippy::too_many_arguments,
791 reason = "Ungrouped citation rendering now needs explicit note-start context."
792 )]
793 pub fn render_ungrouped_citation_with_format<F>(
794 &self,
795 items: &[crate::reference::CitationItem],
796 spec: &citum_schema::CitationSpec,
797 mode: &citum_schema::citation::CitationMode,
798 intra_delimiter: &str,
799 suppress_author: bool,
800 position: Option<&citum_schema::citation::Position>,
801 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
802 ) -> Result<Vec<String>, ProcessorError>
803 where
804 F: crate::render::format::OutputFormat<Output = String>,
805 {
806 let fmt = F::default();
807 let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
808
809 let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
811 let params = UngroupedItemRenderParams {
812 mode,
813 suppress_author,
814 position,
815 note_start_text_case,
816 };
817
818 for item in items {
819 let chunk = if use_author_number {
820 self.render_numeric_integral_item_chunk_with_format::<F>(&fmt, item)?
821 } else {
822 let state =
823 self.resolve_ungrouped_item_render_state(item, spec, intra_delimiter)?;
824 self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
825 };
826
827 if let Some(chunk) = chunk {
828 chunks.push(chunk);
829 }
830 }
831
832 if self.should_collapse_compound_subentries(mode) {
833 chunks = self.collapse_compound_citation_chunks(chunks);
834 }
835 if self.should_collapse_citation_numbers(spec, mode) {
836 chunks = self.collapse_numeric_citation_chunks(chunks);
837 }
838
839 Ok(chunks
840 .into_iter()
841 .map(|(ids, content)| fmt.citation(ids, content))
842 .collect())
843 }
844}
845
846fn key_base(key: &str) -> Cow<'_, str> {
847 let mut parts = key.splitn(3, ':');
848 match (parts.next(), parts.next()) {
849 (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
850 _ => Cow::Borrowed(key),
851 }
852}
853
854#[must_use]
861pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
862 use citum_schema::template::Rendering;
863 use std::fmt::Write;
864
865 fn push_context_suffix(key: &mut String, rendering: &Rendering) {
866 match (&rendering.prefix, &rendering.suffix) {
867 (Some(prefix), Some(suffix)) => {
868 key.push(':');
869 key.push_str(prefix);
870 key.push('_');
871 key.push_str(suffix);
872 }
873 (Some(prefix), None) => {
874 key.push(':');
875 key.push_str(prefix);
876 }
877 (None, Some(suffix)) => {
878 key.push(':');
879 key.push_str(suffix);
880 }
881 (None, None) => {}
882 }
883 }
884
885 fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
886 let mut key = String::new();
887 write!(&mut key, "{kind}:{value:?}").ok()?;
888 push_context_suffix(&mut key, rendering);
889 Some(key)
890 }
891
892 match component {
893 TemplateComponent::Contributor(c) => c.contributor.as_single().map_or_else(
894 || make_key("contributor", &c.contributor, &c.rendering),
895 |role| make_key("contributor", role, &c.rendering),
896 ),
897 TemplateComponent::Date(_) => None,
903 TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
904 TemplateComponent::Title(t) => {
905 let mut key = format!("title:{:?}", t.title);
906 if let Some(form) = &t.form {
907 write!(&mut key, ":{form:?}").ok()?;
908 }
909 push_context_suffix(&mut key, &t.rendering);
910 Some(key)
911 }
912 TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
913 TemplateComponent::Identifier(i) => make_key("identifier", &i.identifier, &i.rendering),
914 TemplateComponent::Group(_) => None,
915 _ => None,
916 }
917}