From aef212b2544e7e3de0c9f0cd1fdd56eb5cc58ed3 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Wed, 24 Dec 2025 18:06:44 -0400 Subject: [PATCH 01/11] Implement custom deserialization for EntryType enum to support case-insensitive matching of entry types. --- src/types/mod.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/types/mod.rs b/src/types/mod.rs index c552bb4c..106c4d5d 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -137,7 +137,7 @@ use deserialize_from_str; use serialize_display; /// Describes which kind of work a database entry refers to. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[derive(Copy, Clone, Debug, Serialize, PartialEq, Eq, Hash)] #[non_exhaustive] #[serde(rename_all = "kebab-case")] pub enum EntryType { @@ -250,6 +250,70 @@ pub enum EntryType { Original, } +impl<'de> Deserialize<'de> for EntryType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, Visitor}; + use std::fmt; + + struct EntryTypeVisitor; + + impl<'de> Visitor<'de> for EntryTypeVisitor { + type Value = EntryType; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an entry type (case-insensitive)") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + // Convert to lowercase for case-insensitive matching + let lower = value.to_lowercase(); + // Match against kebab-case names + match lower.as_str() { + "article" => Ok(EntryType::Article), + "chapter" => Ok(EntryType::Chapter), + "entry" => Ok(EntryType::Entry), + "anthos" => Ok(EntryType::Anthos), + "report" => Ok(EntryType::Report), + "thesis" => Ok(EntryType::Thesis), + "web" => Ok(EntryType::Web), + "scene" => Ok(EntryType::Scene), + "artwork" => Ok(EntryType::Artwork), + "patent" => Ok(EntryType::Patent), + "case" => Ok(EntryType::Case), + "newspaper" => Ok(EntryType::Newspaper), + "legislation" => Ok(EntryType::Legislation), + "manuscript" => Ok(EntryType::Manuscript), + "post" => Ok(EntryType::Post), + "misc" => Ok(EntryType::Misc), + "performance" => Ok(EntryType::Performance), + "periodical" => Ok(EntryType::Periodical), + "proceedings" => Ok(EntryType::Proceedings), + "book" => Ok(EntryType::Book), + "blog" => Ok(EntryType::Blog), + "reference" => Ok(EntryType::Reference), + "conference" => Ok(EntryType::Conference), + "anthology" => Ok(EntryType::Anthology), + "repository" => Ok(EntryType::Repository), + "thread" => Ok(EntryType::Thread), + "video" => Ok(EntryType::Video), + "audio" => Ok(EntryType::Audio), + "exhibition" => Ok(EntryType::Exhibition), + "original" => Ok(EntryType::Original), + _ => Err(E::custom(format!("unknown entry type: `{}`", value))), + } + } + } + + deserializer.deserialize_any(EntryTypeVisitor) + } +} + impl EntryType { /// Entry parents have implicit defaults. This function returns the default /// parent for this entry type. From 5af34195dd13fba5e1bca1c0306baefe916d7ce4 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Wed, 24 Dec 2025 18:10:41 -0400 Subject: [PATCH 02/11] Implement custom deserialization for PersonRole enum to support case-insensitive matching of roles. --- src/types/persons.rs | 56 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/types/persons.rs b/src/types/persons.rs index a3cfa266..5740e9de 100644 --- a/src/types/persons.rs +++ b/src/types/persons.rs @@ -35,7 +35,7 @@ impl PersonsWithRoles { /// Specifies the role a group of persons had in the creation to the /// cited item. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, Serialize, PartialEq, Eq, Hash)] #[non_exhaustive] #[serde(rename_all = "kebab-case")] pub enum PersonRole { @@ -85,6 +85,60 @@ pub enum PersonRole { Unknown(String), } +impl<'de> Deserialize<'de> for PersonRole { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, Visitor}; + use std::fmt; + + struct PersonRoleVisitor; + + impl<'de> Visitor<'de> for PersonRoleVisitor { + type Value = PersonRole; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a person role (case-insensitive)") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + // Convert to lowercase for case-insensitive matching + let lower = value.to_lowercase(); + // Match against kebab-case names + match lower.as_str() { + "translator" => Ok(PersonRole::Translator), + "afterword" => Ok(PersonRole::Afterword), + "foreword" => Ok(PersonRole::Foreword), + "introduction" => Ok(PersonRole::Introduction), + "annotator" => Ok(PersonRole::Annotator), + "commentator" => Ok(PersonRole::Commentator), + "holder" => Ok(PersonRole::Holder), + "compiler" => Ok(PersonRole::Compiler), + "founder" => Ok(PersonRole::Founder), + "collaborator" => Ok(PersonRole::Collaborator), + "organizer" => Ok(PersonRole::Organizer), + "cast-member" => Ok(PersonRole::CastMember), + "composer" => Ok(PersonRole::Composer), + "producer" => Ok(PersonRole::Producer), + "executive-producer" => Ok(PersonRole::ExecutiveProducer), + "writer" => Ok(PersonRole::Writer), + "cinematography" => Ok(PersonRole::Cinematography), + "director" => Ok(PersonRole::Director), + "illustrator" => Ok(PersonRole::Illustrator), + "narrator" => Ok(PersonRole::Narrator), + _ => Err(E::custom(format!("unknown role: `{}`", value))), + } + } + } + + deserializer.deserialize_any(PersonRoleVisitor) + } +} + derive_or_from_str! { /// Holds the name of a person. #[derive(Clone, Debug, PartialEq, Eq, Hash)] From 8a9e13d68e1b4cf56c491f105e7bf2713ff51e50 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Wed, 24 Dec 2025 18:15:50 -0400 Subject: [PATCH 03/11] fix affiliated allowing objects --- src/lib.rs | 8 +++--- src/util.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 47f6e9ae..b66f14bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -168,8 +168,8 @@ use serde::{Deserialize, Serialize, de::Visitor}; use types::*; use unic_langid::LanguageIdentifier; use util::{ - OneOrMany, deserialize_one_or_many_opt, serialize_one_or_many, - serialize_one_or_many_opt, + OneOrMany, deserialize_list_only_opt, deserialize_one_or_many_opt, serialize_list_only_opt, + serialize_one_or_many, serialize_one_or_many_opt, }; /// A collection of bibliographic entries. @@ -502,8 +502,8 @@ entry! { #[serde(deserialize_with = "deserialize_one_or_many_opt")] "editor" => editors: Vec | [Person], /// Persons involved in the production of the item that are not authors or editors. - #[serde(serialize_with = "serialize_one_or_many_opt")] - #[serde(deserialize_with = "deserialize_one_or_many_opt")] + #[serde(serialize_with = "serialize_list_only_opt")] + #[serde(deserialize_with = "deserialize_list_only_opt")] "affiliated" => affiliated: Vec | [PersonsWithRoles], /// Publisher of the item, which may have a name and a location. "publisher" => publisher: Publisher, diff --git a/src/util.rs b/src/util.rs index 772fdfbf..b8f3bbf0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -79,6 +79,23 @@ where } } +/// Function that always serializes as a list, even for single items. +/// This is used for fields like `affiliated` that should only accept lists. +pub fn serialize_list_only_opt( + value: &Option>, + serializer: S, +) -> Result +where + S: serde::Serializer, + T: Serialize, +{ + if let Some(value) = value { + value.serialize(serializer) + } else { + serializer.serialize_none() + } +} + /// This is a wrapper for [`OneOrMany`] that assumes that the single /// representation isn't a sequence. This allows better error messages. #[derive(Clone, Debug, PartialEq, Eq)] @@ -185,3 +202,67 @@ where { >>::deserialize(deserializer).map(|v| v.map(|v| v.into())) } + +/// Wrapper that only accepts sequences (lists), rejecting maps (single objects). +#[derive(Clone, Debug, PartialEq, Eq)] +struct ListOnly(Vec); + +impl<'de, T> Deserialize<'de> for ListOnly +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ListOnlyVisitor(std::marker::PhantomData); + + impl<'de, T> Visitor<'de> for ListOnlyVisitor + where + T: Deserialize<'de>, + { + type Value = ListOnly; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a list") + } + + fn visit_seq(self, seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + Vec::deserialize(serde::de::value::SeqAccessDeserializer::new(seq)) + .map(ListOnly) + } + + fn visit_map(self, _map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + Err(serde::de::Error::custom( + "expected a list, found a single object. `affiliated` must be a list of objects, not a single object.", + )) + } + } + + deserializer.deserialize_any(ListOnlyVisitor(std::marker::PhantomData)) + } +} + +impl From> for Vec { + fn from(list_only: ListOnly) -> Self { + list_only.0 + } +} + +/// Function that only accepts a list (sequence) for deserialization, rejecting single objects. +/// This is used for fields like `affiliated` that should only accept a list of objects. +pub fn deserialize_list_only_opt<'de, T, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + >>::deserialize(deserializer).map(|opt| opt.map(Into::into)) +} From 5350fd5a5a75e1d612d31aee336f91ca3e79da0d Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Wed, 24 Dec 2025 18:24:58 -0400 Subject: [PATCH 04/11] timestamp enforcement fix --- src/lib.rs | 4 +-- src/types/mod.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++- src/types/time.rs | 49 ++++++++++++++++++++++++++++++++--- 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b66f14bc..cf83bfd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -532,9 +532,9 @@ entry! { /// The total number of pages the item has. "page-total" => page_total: Numeric, /// The time range within the parent this item starts and ends at. - "time-range" => time_range: MaybeTyped, + "time-range" => time_range: DurationRange, /// The total runtime of the item. - "runtime" => runtime: MaybeTyped, + "runtime" => runtime: Duration, /// Canonical public URL of the item, can have access date. "url" => url: QualifiedUrl, /// Any serial number or version describing the item that is not appropriate diff --git a/src/types/mod.rs b/src/types/mod.rs index 106c4d5d..31d60824 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -369,7 +369,7 @@ pub enum DeserializationError { } /// A type that may be a string or a strictly typed value. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Serialize, Eq, Hash)] #[serde(untagged)] pub enum MaybeTyped { /// The typed variant. @@ -446,6 +446,69 @@ impl From for MaybeTyped { } } +// Custom deserializer for MaybeTyped that allows fallback to String +impl<'de, T> Deserialize<'de> for MaybeTyped +where + T: Deserialize<'de> + FromStr, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, Visitor}; + use std::fmt; + + struct MaybeTypedVisitor(std::marker::PhantomData); + + impl<'de, T> Visitor<'de> for MaybeTypedVisitor + where + T: Deserialize<'de> + FromStr, + { + type Value = MaybeTyped; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a typed value or a string") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match T::from_str(value) { + Ok(t) => Ok(MaybeTyped::Typed(t)), + Err(_) => Ok(MaybeTyped::String(value.to_owned())), + } + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + .map(MaybeTyped::Typed) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + T::deserialize(serde::de::value::I64Deserializer::new(value)) + .map(MaybeTyped::Typed) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + T::deserialize(serde::de::value::U64Deserializer::new(value)) + .map(MaybeTyped::Typed) + } + } + + deserializer.deserialize_any(MaybeTypedVisitor(std::marker::PhantomData)) + } +} + derive_or_from_str! { /// An URL, possibly with a last visited date. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/src/types/time.rs b/src/types/time.rs index e1a6dce2..07ee20cb 100644 --- a/src/types/time.rs +++ b/src/types/time.rs @@ -433,6 +433,42 @@ impl Duration { return Err(DurationError::Malformed); } + // Validate that lower-order denominations are within bounds when higher-order + // denominations are specified. According to the spec: "The left-most time + // denomination only allows values that could overflow into the next-largest + // denomination if that is not specified." + // This means: + // - DD:HH:MM:SS: hours < 24, minutes < 60, seconds < 60 + // - HH:MM:SS: minutes < 60, seconds < 60 + // - MM:SS: seconds < 60 (minutes can be any value) + match start { + 0 => { + // DD:HH:MM:SS format - hours, minutes, and seconds must be in bounds + if hours >= 24 || minutes >= 60 || seconds >= 60 { + return Err(DurationError::TooLarge); + } + } + 1 => { + // HH:MM:SS format - minutes and seconds must be in bounds + if minutes >= 60 || seconds >= 60 { + return Err(DurationError::TooLarge); + } + } + 2 => { + // MM:SS format - only seconds must be in bounds (minutes can overflow) + if seconds >= 60 { + return Err(DurationError::TooLarge); + } + } + _ => unreachable!(), + } + + // Milliseconds must always be in bounds + if milliseconds >= 1000 { + return Err(DurationError::TooLarge); + } + + // Now normalize overflow values for i in (0..=start).rev() { match i { 0 => {} @@ -448,10 +484,6 @@ impl Duration { } } - if hours >= 24 || minutes >= 60 || seconds >= 60 || milliseconds >= 1000 { - return Err(DurationError::TooLarge); - } - Ok(Duration { days, hours, minutes, seconds, milliseconds }) } @@ -749,6 +781,15 @@ mod tests { ); assert!(Duration::from_str("01:00,").is_err()); assert!(Duration::from_str("010:00,").is_err()); + + // Test validation: 01:78:00 should be invalid (HH:MM:SS format, minutes >= 60) + assert!(Duration::from_str("01:78:00").is_err()); + + // Test validation: 138:00 should be valid (MM:SS format, minutes can overflow) + assert!(Duration::from_str("138:00").is_ok()); + + // Test validation: 00:78:00 should be invalid (HH:MM:SS format, minutes >= 60) + assert!(Duration::from_str("00:78:00").is_err()); } #[test] From 10616a72e0dd100d5162610e4d2a6237f6709768 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Wed, 24 Dec 2025 18:37:31 -0400 Subject: [PATCH 05/11] docs and error messages --- docs/file-format.md | 6 ++-- src/lib.rs | 8 ++--- src/util.rs | 81 --------------------------------------------- 3 files changed, 7 insertions(+), 88 deletions(-) diff --git a/docs/file-format.md b/docs/file-format.md index 1b70d129..ac3ef0b6 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -208,9 +208,9 @@ This section lists all possible fields and data types for them. | | | |------------------|-----------------------------------------------------------| -| **Data type:** | list of persons with role / list of lists of persons with role | -| **Description:** | persons involved with the item that do not fit `author` or `editor` | -| **Example:** |
affiliated:
- role: Director
names: Cameron, James
- role: CastMember
names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]
| +| **Data type:** | person with role / list of persons with role | +| **Description:** | persons involved with the item that do not fit `author` or `editor`. Can be specified as a single object or a list of objects. | +| **Example:** |
affiliated:
- role: Director
names: Cameron, James
- role: CastMember
names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]
or
affiliated:
role: Director
names: Cameron, James
| #### `call-number` diff --git a/src/lib.rs b/src/lib.rs index cf83bfd1..ef06cb26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -168,8 +168,8 @@ use serde::{Deserialize, Serialize, de::Visitor}; use types::*; use unic_langid::LanguageIdentifier; use util::{ - OneOrMany, deserialize_list_only_opt, deserialize_one_or_many_opt, serialize_list_only_opt, - serialize_one_or_many, serialize_one_or_many_opt, + OneOrMany, deserialize_one_or_many_opt, serialize_one_or_many, + serialize_one_or_many_opt, }; /// A collection of bibliographic entries. @@ -502,8 +502,8 @@ entry! { #[serde(deserialize_with = "deserialize_one_or_many_opt")] "editor" => editors: Vec | [Person], /// Persons involved in the production of the item that are not authors or editors. - #[serde(serialize_with = "serialize_list_only_opt")] - #[serde(deserialize_with = "deserialize_list_only_opt")] + #[serde(serialize_with = "serialize_one_or_many_opt")] + #[serde(deserialize_with = "deserialize_one_or_many_opt")] "affiliated" => affiliated: Vec | [PersonsWithRoles], /// Publisher of the item, which may have a name and a location. "publisher" => publisher: Publisher, diff --git a/src/util.rs b/src/util.rs index b8f3bbf0..772fdfbf 100644 --- a/src/util.rs +++ b/src/util.rs @@ -79,23 +79,6 @@ where } } -/// Function that always serializes as a list, even for single items. -/// This is used for fields like `affiliated` that should only accept lists. -pub fn serialize_list_only_opt( - value: &Option>, - serializer: S, -) -> Result -where - S: serde::Serializer, - T: Serialize, -{ - if let Some(value) = value { - value.serialize(serializer) - } else { - serializer.serialize_none() - } -} - /// This is a wrapper for [`OneOrMany`] that assumes that the single /// representation isn't a sequence. This allows better error messages. #[derive(Clone, Debug, PartialEq, Eq)] @@ -202,67 +185,3 @@ where { >>::deserialize(deserializer).map(|v| v.map(|v| v.into())) } - -/// Wrapper that only accepts sequences (lists), rejecting maps (single objects). -#[derive(Clone, Debug, PartialEq, Eq)] -struct ListOnly(Vec); - -impl<'de, T> Deserialize<'de> for ListOnly -where - T: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct ListOnlyVisitor(std::marker::PhantomData); - - impl<'de, T> Visitor<'de> for ListOnlyVisitor - where - T: Deserialize<'de>, - { - type Value = ListOnly; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a list") - } - - fn visit_seq
(self, seq: A) -> Result - where - A: serde::de::SeqAccess<'de>, - { - Vec::deserialize(serde::de::value::SeqAccessDeserializer::new(seq)) - .map(ListOnly) - } - - fn visit_map(self, _map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - Err(serde::de::Error::custom( - "expected a list, found a single object. `affiliated` must be a list of objects, not a single object.", - )) - } - } - - deserializer.deserialize_any(ListOnlyVisitor(std::marker::PhantomData)) - } -} - -impl From> for Vec { - fn from(list_only: ListOnly) -> Self { - list_only.0 - } -} - -/// Function that only accepts a list (sequence) for deserialization, rejecting single objects. -/// This is used for fields like `affiliated` that should only accept a list of objects. -pub fn deserialize_list_only_opt<'de, T, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: Deserializer<'de>, - T: Deserialize<'de>, -{ - >>::deserialize(deserializer).map(|opt| opt.map(Into::into)) -} From ecd72d4c100e3978f03b1c4171708099ea69009b Mon Sep 17 00:00:00 2001 From: peter <66807853+peterc-s@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:30:59 +0000 Subject: [PATCH 06/11] Fix DOI, PMID, and PMCID's not forming proper links with some styles. (#432) Also added local tests, with a new test mode for links. --------- Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> --- src/csl/rendering/mod.rs | 69 ++++++++++++++++++++---------- tests/citeproc.rs | 53 ++++++++++++++++++----- tests/local/affix_DoiUrlPrefix.txt | 63 +++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 tests/local/affix_DoiUrlPrefix.txt diff --git a/src/csl/rendering/mod.rs b/src/csl/rendering/mod.rs index 0f1eabc7..63fe4e64 100644 --- a/src/csl/rendering/mod.rs +++ b/src/csl/rendering/mod.rs @@ -52,7 +52,31 @@ impl RenderCsl for citationberg::Text { _ => true, }; - let affix_loc = print_affixes.then(|| ctx.apply_prefix(&self.affixes)); + /// Gets the url prefix for a given StandardVariable if one exists. + /// + /// These are those prefixes in the Appendix IV of the CSL 1.0.2 spec. + fn get_url_prefix(var: StandardVariable) -> Option<&'static str> { + match var { + StandardVariable::DOI => Some("https://doi.org/"), + StandardVariable::PMID => Some("https://www.ncbi.nlm.nih.gov/pubmed/"), + StandardVariable::PMCID => { + Some("https://www.ncbi.nlm.nih.gov/pmc/articles/") + } + _ => None, + } + } + + // Check if a URL prefix exists for the target and if so, if the CSL specifies + // the URL prefix as the affix. If this is the case, we want to make sure the + // URL prefix is a part of the link (and therefore not printed here, but later). + let affix_is_url_prefix = match &target { + ResolvedTextTarget::StandardVariable(var, _) => get_url_prefix(*var) + .is_some_and(|prefix| self.affixes.prefix.as_deref() == Some(prefix)), + _ => false, + }; + + let affix_loc = (print_affixes && !affix_is_url_prefix) + .then(|| ctx.apply_prefix(&self.affixes)); if self.quotes { ctx.push_quotes(); @@ -62,29 +86,28 @@ impl RenderCsl for citationberg::Text { let cidx = ctx.push_case(self.text_case); match target { - ResolvedTextTarget::StandardVariable(var, val) => match var { - StandardVariable::URL => { - let str = val.to_string(); - ctx.push_link(&val, str); - } - StandardVariable::DOI => { - let url = format!("https://doi.org/{}", val.to_str()); - ctx.push_link(&val, url); - } - StandardVariable::PMID => { - let url = - format!("https://www.ncbi.nlm.nih.gov/pubmed/{}", val.to_str()); - ctx.push_link(&val, url); - } - StandardVariable::PMCID => { - let url = format!( - "https://www.ncbi.nlm.nih.gov/pmc/articles/{}", - val.to_str() - ); - ctx.push_link(&val, url); + ResolvedTextTarget::StandardVariable(var, val) => { + if let Some(url_prefix) = get_url_prefix(var) { + // For link variables, create the full URL for the destination of the link. + let full_url = format!("{}{}", url_prefix, val); + + let (display, destination) = if affix_is_url_prefix { + // If the affix in the CSL was the URL prefix, then use + // the full URL as both the link displayed and its destination. + (full_url.clone(), full_url) + } else { + // Otherwise, display the value (e.g. the DOI) with the full URL + // as its destination. + (val.to_string(), full_url) + }; + + ctx.push_link(&display.into(), destination); + } else if var == StandardVariable::URL { + ctx.push_link(&val, val.to_string()); + } else { + ctx.push_chunked(&val); } - _ => ctx.push_chunked(&val), - }, + } ResolvedTextTarget::NumberVariable(_, n) => match n { NumberVariableResult::Regular(MaybeTyped::Typed(num)) if num.will_transform() => diff --git a/tests/citeproc.rs b/tests/citeproc.rs index 20146a92..2c171a76 100644 --- a/tests/citeproc.rs +++ b/tests/citeproc.rs @@ -87,6 +87,16 @@ impl fmt::Display for SectionTag { enum TestMode { Citation, Bibliography, + + /// Same as bibliography but made for hayagriva's local tests. + /// Includes links, which are normally not printed by citeproc tests. + BibliographyFull, +} + +impl TestMode { + fn is_bibliography(self) -> bool { + matches!(self, Self::Bibliography | Self::BibliographyFull) + } } impl FromStr for TestMode { @@ -96,6 +106,7 @@ impl FromStr for TestMode { match s { "citation" => Ok(TestMode::Citation), "bibliography" => Ok(TestMode::Bibliography), + "bibliography-full" => Ok(TestMode::BibliographyFull), _ => Err(()), } } @@ -495,7 +506,7 @@ where eprintln!("Skipping test {}\t(cause: unsupported test feature)", display()); } false - } else if case.mode != TestMode::Bibliography && case.result.contains('<') { + } else if !case.mode.is_bibliography() && case.result.contains('<') { if print { eprintln!( "Skipping test {}\t(cause: HTML suspected in citation result)", @@ -588,13 +599,18 @@ where case.result.trim() } - TestMode::Bibliography => { + TestMode::Bibliography | TestMode::BibliographyFull => { static INDENT_REGEX: OnceLock = OnceLock::new(); let bib = rendered .bibliography .expect("Bibliography mode test but no bibliography was rendered"); - citeproc_bib::render(&bib, &mut output).unwrap(); + citeproc_bib::render( + &bib, + &mut output, + case.mode == TestMode::BibliographyFull, + ) + .unwrap(); output.push('\n'); // Remove indentation from original result to match our own output, @@ -631,10 +647,11 @@ mod citeproc_bib { pub(super) fn render( bib: &hayagriva::RenderedBibliography, output: &mut String, + is_full: bool, ) -> Result<(), fmt::Error> { output.push_str(r#"
"#); for item in &bib.items { - render_item(item, output)?; + render_item(item, output, is_full)?; } output.push_str("
"); Ok(()) @@ -643,6 +660,7 @@ mod citeproc_bib { fn render_item( item: &hayagriva::BibliographyItem, output: &mut String, + is_full: bool, ) -> Result<(), fmt::Error> { let mut second_field_align_suffix = ""; output.push_str(r#"
"#); @@ -650,22 +668,33 @@ mod citeproc_bib { // Uses 'second-field-align', so add implicit alignment // (cf. test bugreports_AsmJournals.txt) output.push_str("
"); - render_child(field, output)?; + render_child(field, output, is_full)?; output.push_str("
"); second_field_align_suffix = "
"; } for child in &item.content.0 { - render_child(child, output)?; + render_child(child, output, is_full)?; } output.push_str(second_field_align_suffix); output.push_str("
"); Ok(()) } - fn render_child(child: &ElemChild, output: &mut String) -> Result<(), fmt::Error> { + fn render_child( + child: &ElemChild, + output: &mut String, + is_full: bool, + ) -> Result<(), fmt::Error> { match child { ElemChild::Text(formatted) => render_formatted_text(formatted, output), - ElemChild::Elem(e) => render_elem(e, output), + ElemChild::Elem(e) => render_elem(e, output, is_full), + + ElemChild::Link { text, url } if is_full => { + output.push_str(&format!("
")); + render_formatted_text(text, output)?; + output.push_str(""); + Ok(()) + } // Citeproc bib tests do not output for links ElemChild::Link { text, url: _ } => render_formatted_text(text, output), @@ -757,7 +786,11 @@ mod citeproc_bib { Ok(()) } - fn render_elem(elem: &Elem, output: &mut String) -> Result<(), fmt::Error> { + fn render_elem( + elem: &Elem, + output: &mut String, + is_full: bool, + ) -> Result<(), fmt::Error> { let mut div_suffix = ""; if let Some(display) = elem.display { div_suffix = ""; @@ -772,7 +805,7 @@ mod citeproc_bib { } for child in &elem.children.0 { - render_child(child, output)?; + render_child(child, output, is_full)?; } if !div_suffix.is_empty() { diff --git a/tests/local/affix_DoiUrlPrefix.txt b/tests/local/affix_DoiUrlPrefix.txt new file mode 100644 index 00000000..9f67608e --- /dev/null +++ b/tests/local/affix_DoiUrlPrefix.txt @@ -0,0 +1,63 @@ + +>>===== MODE =====>> +bibliography-full +<<===== MODE =====<< + + + +>>===== RESULT =====>> +
+
+ DOI 12345; https://typst.app/12345; https://doi.org/12345 +
+
+<<===== RESULT =====<< + + +>>===== CSL =====>> + + +<<===== CSL =====<< + + +>>===== INPUT =====>> +[ + { + "author": [ + { + "family": "Doe Co.", + "isInstitution": true + } + ], + "id": "ITEM-1", + "title": "His Collectively Anonymous Life", + "DOI": "12345", + "type": "book" + } +] +<<===== INPUT =====<< + + +>>===== VERSION =====>> +1.0 +<<===== VERSION =====<< From 0231240c94cde2884288a4d44b245902c0503838 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Sun, 28 Dec 2025 16:04:14 -0400 Subject: [PATCH 07/11] no sentence case --- tests/data/basic.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/data/basic.yml b/tests/data/basic.yml index 0697ad00..4083ea9a 100644 --- a/tests/data/basic.yml +++ b/tests/data/basic.yml @@ -39,7 +39,6 @@ omarova-libra: author: ["Omarova, Saule", "Steele, Graham"] title: value: There’s a Lot We Still Don’t Know About Libra - sentence-case: There’s a lot we still don’t know about Libra date: 2019-11-04 url: https://www.nytimes.com/2019/11/04/opinion/facebook-libra-cryptocurrency.html parent: @@ -100,7 +99,7 @@ science-e-issue: given-name: "Laurenz" alias: "laurmaedje" date: 2020-07-18 - parent: + parent: type: Repository title: Typst url: https://github.com/typst/typst @@ -110,7 +109,7 @@ terminator-2: title: "Terminator 2: Judgment Day" publisher: Carolco Pictures; Pacific Western Productions; Lightstorm Entertainment; Le Studio Canal+ S.A. affiliated: - - role: director + - role: Director names: Cameron, James - role: cast-member names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"] From 6e16c912f3ea08be14ef23501c844aa75636ede3 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Sun, 28 Dec 2025 16:37:09 -0400 Subject: [PATCH 08/11] persons --- docs/file-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format.md b/docs/file-format.md index ac3ef0b6..9b6226aa 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -208,7 +208,7 @@ This section lists all possible fields and data types for them. | | | |------------------|-----------------------------------------------------------| -| **Data type:** | person with role / list of persons with role | +| **Data type:** | persons with role / list of persons with role | | **Description:** | persons involved with the item that do not fit `author` or `editor`. Can be specified as a single object or a list of objects. | | **Example:** |
affiliated:
- role: Director
names: Cameron, James
- role: CastMember
names: ["Schwarzenegger, Arnold", "Hamilton, Linda", "Patrick, Robert"]
or
affiliated:
role: Director
names: Cameron, James
| From 8d1d5ba498864a3d7afe202738e92cda75581ca7 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Sun, 28 Dec 2025 17:18:22 -0400 Subject: [PATCH 09/11] no err --- src/types/persons.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/persons.rs b/src/types/persons.rs index 5740e9de..b8669fe3 100644 --- a/src/types/persons.rs +++ b/src/types/persons.rs @@ -130,7 +130,7 @@ impl<'de> Deserialize<'de> for PersonRole { "director" => Ok(PersonRole::Director), "illustrator" => Ok(PersonRole::Illustrator), "narrator" => Ok(PersonRole::Narrator), - _ => Err(E::custom(format!("unknown role: `{}`", value))), + _ => Ok(PersonRole::Unknown(value.to_owned())), } } } From 7a0177aab2a7f61dd0ae6fb35569034d2e119fcf Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Sun, 28 Dec 2025 17:39:54 -0400 Subject: [PATCH 10/11] clarification on issue and volume --- docs/file-format.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/file-format.md b/docs/file-format.md index 9b6226aa..6312abc9 100644 --- a/docs/file-format.md +++ b/docs/file-format.md @@ -83,7 +83,7 @@ kinetics: publisher: American Physical Society ``` -This means that the article was published in issue 16, volume 102 of the journal "Physical Review B". Notice that the `title` field is in use for both the article and its parent - every field is available for both top-level use and all parents. +This means that the article was published in issue 16, volume 102 of the journal "Physical Review B". Notice that the `title` field is in use for both the article and its parent - every field is available for both top-level use and all parents. The `volume` and `issue` fields can be placed on either the entry or its parent container; when placed on the parent, they identify the specific volume and issue of the periodical in which the article appears. To specify parent information, write the `parent` field name and a colon and then put all fields for that parent on indented lines below. @@ -249,7 +249,7 @@ This section lists all possible fields and data types for them. | | | |------------------|-----------------------------------------------------------| | **Data type:** | numeric or string | -| **Description:** | For an item whose parent has multiple issues, indicates the position in the issue sequence. Also used to indicate the episode number for TV. | +| **Description:** | For an item whose parent has multiple issues, indicates the position in the issue sequence. Also used to indicate the episode number for TV. This field can be placed on either the entry or its parent container; when placed on the parent (e.g., a Periodical), it identifies the specific issue in which the entry appears. | | **Example:** | `issue: 5` | #### `volume` @@ -257,7 +257,7 @@ This section lists all possible fields and data types for them. | | | |------------------|-----------------------------------------------------------| | **Data type:** | numeric or string | -| **Description:** | For an item whose parent has multiple volumes/parts/seasons ... of which this item is one | +| **Description:** | For an item whose parent has multiple volumes/parts/seasons ... of which this item is one. This field can be placed on either the entry or its parent container; when placed on the parent (e.g., a Periodical), it identifies the specific volume in which the entry appears. | | **Example:** | `volume: 2-3` | #### `volume-total` From b1745087856c618a5589ac762c241685a7c11367 Mon Sep 17 00:00:00 2001 From: Jassiel Ovando Date: Sun, 28 Dec 2025 17:44:54 -0400 Subject: [PATCH 11/11] no comments --- src/types/mod.rs | 2 -- src/types/persons.rs | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/types/mod.rs b/src/types/mod.rs index 31d60824..7ac8b0a4 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -271,9 +271,7 @@ impl<'de> Deserialize<'de> for EntryType { where E: de::Error, { - // Convert to lowercase for case-insensitive matching let lower = value.to_lowercase(); - // Match against kebab-case names match lower.as_str() { "article" => Ok(EntryType::Article), "chapter" => Ok(EntryType::Chapter), diff --git a/src/types/persons.rs b/src/types/persons.rs index b8669fe3..6af77a42 100644 --- a/src/types/persons.rs +++ b/src/types/persons.rs @@ -106,9 +106,7 @@ impl<'de> Deserialize<'de> for PersonRole { where E: de::Error, { - // Convert to lowercase for case-insensitive matching let lower = value.to_lowercase(); - // Match against kebab-case names match lower.as_str() { "translator" => Ok(PersonRole::Translator), "afterword" => Ok(PersonRole::Afterword),