diff --git a/CHANGELOG.md b/CHANGELOG.md index 2258647..875b9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-02-16 + +### Changed + +- **Breaking:** `Citation.reference` (String) renamed to `Citation.refs` (Vec\) to support multi-citation clusters (e.g., `[smith2023; jones2024]`) +- **Breaking:** `ExtensionMark::citation()` and `citation_with_page()` now emit `"refs"` (array) instead of `"ref"` (string) +- Deserialization accepts both old `"ref"` (string) and new `"refs"` (array) for backward compatibility + +### Added + +- `Citation::multi()` constructor for multi-reference citations +- `Citation::first_ref()` and `Citation::refs()` accessors +- `ExtensionMark::multi_citation()` convenience constructor +- `ExtensionMark::get_string_array_attribute()` for array-typed attributes +- `ExtensionMark::get_citation_refs()` helper supporting both `"refs"` and legacy `"ref"` keys +- `ExtensionMark::normalize_citation_attrs()` to migrate `"ref"` → `"refs"` in-place +- `ExtensionBlock::get_string_array_attribute()` for parity with `ExtensionMark` +- Backward-compatibility conformance tests for singular `"ref"` deserialization +- Multi-reference citation roundtrip tests + ## [0.5.0] - 2026-02-16 ### Changed @@ -276,7 +296,8 @@ Initial release implementing Codex Document Format Specification v0.1. - `sign_document` - Sign a document with ES256 - `extract_content` - Extract text content from blocks -[Unreleased]: https://github.com/Entrolution/cdx-core/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/Entrolution/cdx-core/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/Entrolution/cdx-core/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/Entrolution/cdx-core/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/Entrolution/cdx-core/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/Entrolution/cdx-core/compare/v0.2.0...v0.3.0 diff --git a/Cargo.toml b/Cargo.toml index 9e0dfc8..f16eefe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,4 +17,4 @@ thiserror = "2.0" chrono = { version = "0.4", features = ["serde", "now"], default-features = false } # Internal crates -cdx-core = { path = "cdx-core", version = "0.5.0" } +cdx-core = { path = "cdx-core", version = "0.6.0" } diff --git a/SECURITY.md b/SECURITY.md index 47cb64b..47a6a8e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | |---------|-----------| -| 0.5.x | Yes | -| < 0.5 | No | +| 0.6.x | Yes | +| < 0.6 | No | Only the latest minor release receives security updates. Earlier versions are not supported. diff --git a/cdx-cli/Cargo.toml b/cdx-cli/Cargo.toml index c030887..272129e 100644 --- a/cdx-cli/Cargo.toml +++ b/cdx-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cdx-cli" -version = "0.5.0" +version = "0.6.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/cdx-core/Cargo.toml b/cdx-core/Cargo.toml index 0d7cff8..d3d8342 100644 --- a/cdx-core/Cargo.toml +++ b/cdx-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cdx-core" -version = "0.5.0" +version = "0.6.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/cdx-core/src/content/text.rs b/cdx-core/src/content/text.rs index 190c9e1..fd092d3 100644 --- a/cdx-core/src/content/text.rs +++ b/cdx-core/src/content/text.rs @@ -255,13 +255,57 @@ impl ExtensionMark { self.attributes.get(key).and_then(serde_json::Value::as_str) } + /// Get an array-of-strings attribute. + #[must_use] + pub fn get_string_array_attribute(&self, key: &str) -> Option> { + self.attributes.get(key).and_then(|v| { + v.as_array() + .map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect()) + }) + } + + /// Get citation refs, supporting both `"refs"` (array) and legacy `"ref"` (string). + /// + /// Returns `None` if neither key is present. + #[must_use] + pub fn get_citation_refs(&self) -> Option> { + // Try new format first + if let Some(refs) = self.get_string_array_attribute("refs") { + return Some(refs); + } + // Fall back to legacy singular "ref" + self.get_string_attribute("ref").map(|r| vec![r]) + } + + /// Rewrite legacy `"ref"` (string) → `"refs"` (array) in the attributes map. + /// + /// No-op if `"refs"` already exists or `"ref"` is absent. + pub fn normalize_citation_attrs(&mut self) { + if let Some(obj) = self.attributes.as_object_mut() { + if obj.contains_key("refs") { + return; + } + if let Some(single) = obj.remove("ref") { + if let Some(s) = single.as_str() { + obj.insert( + "refs".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]), + ); + } else { + // Put it back if it wasn't a string + obj.insert("ref".to_string(), single); + } + } + } + } + // ===== Convenience constructors for common extension marks ===== /// Create a citation mark (semantic extension). #[must_use] pub fn citation(reference: impl Into) -> Self { Self::new("semantic", "citation").with_attributes(serde_json::json!({ - "ref": reference.into() + "refs": [reference.into()] })) } @@ -269,12 +313,20 @@ impl ExtensionMark { #[must_use] pub fn citation_with_page(reference: impl Into, page: impl Into) -> Self { Self::new("semantic", "citation").with_attributes(serde_json::json!({ - "ref": reference.into(), + "refs": [reference.into()], "locator": page.into(), "locatorType": "page" })) } + /// Create a multi-reference citation mark (e.g., `[smith2023; jones2024]`). + #[must_use] + pub fn multi_citation(refs: &[String]) -> Self { + Self::new("semantic", "citation").with_attributes(serde_json::json!({ + "refs": refs + })) + } + /// Create an entity link mark (semantic extension). #[must_use] pub fn entity(uri: impl Into, entity_type: impl Into) -> Self { @@ -936,11 +988,14 @@ mod tests { #[test] fn test_extension_mark_with_attributes() { let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({ - "ref": "smith2023", + "refs": ["smith2023"], "page": "42" })); - assert_eq!(ext.get_string_attribute("ref"), Some("smith2023")); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); assert_eq!(ext.get_string_attribute("page"), Some("42")); } @@ -969,14 +1024,14 @@ mod tests { #[test] fn test_extension_mark_serialization() { let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({ - "ref": "smith2023" + "refs": ["smith2023"] })); let mark = Mark::Extension(ext); let json = serde_json::to_string(&mark).unwrap(); // New format: type is "namespace:markType", attributes flattened assert!(json.contains("\"type\":\"semantic:citation\"")); - assert!(json.contains("\"ref\":\"smith2023\"")); + assert!(json.contains("\"refs\":[\"smith2023\"]")); // Should NOT contain old wrapper fields assert!(!json.contains("\"namespace\"")); assert!(!json.contains("\"markType\"")); @@ -1046,18 +1101,66 @@ mod tests { fn test_citation_convenience() { let ext = ExtensionMark::citation("smith2023"); assert!(ext.is_type("semantic", "citation")); - assert_eq!(ext.get_string_attribute("ref"), Some("smith2023")); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); + assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"])); } #[test] fn test_citation_with_page_convenience() { let ext = ExtensionMark::citation_with_page("smith2023", "42-45"); assert!(ext.is_type("semantic", "citation")); - assert_eq!(ext.get_string_attribute("ref"), Some("smith2023")); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); assert_eq!(ext.get_string_attribute("locator"), Some("42-45")); assert_eq!(ext.get_string_attribute("locatorType"), Some("page")); } + #[test] + fn test_multi_citation_convenience() { + let refs = vec!["smith2023".into(), "jones2024".into()]; + let ext = ExtensionMark::multi_citation(&refs); + assert!(ext.is_type("semantic", "citation")); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023", "jones2024"]) + ); + } + + #[test] + fn test_get_citation_refs_legacy() { + // Legacy "ref" key should still be readable via get_citation_refs + let ext = ExtensionMark::new("semantic", "citation") + .with_attributes(serde_json::json!({"ref": "smith2023"})); + assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"])); + } + + #[test] + fn test_normalize_citation_attrs() { + let mut ext = ExtensionMark::new("semantic", "citation") + .with_attributes(serde_json::json!({"ref": "smith2023"})); + ext.normalize_citation_attrs(); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); + assert!(ext.get_string_attribute("ref").is_none()); + } + + #[test] + fn test_normalize_citation_attrs_noop_when_refs_exists() { + let mut ext = ExtensionMark::citation("smith2023"); + ext.normalize_citation_attrs(); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); + } + #[test] fn test_entity_convenience() { let ext = ExtensionMark::entity("https://www.wikidata.org/wiki/Q937", "person"); diff --git a/cdx-core/src/document/tests.rs b/cdx-core/src/document/tests.rs index 2270bf7..c20cea1 100644 --- a/cdx-core/src/document/tests.rs +++ b/cdx-core/src/document/tests.rs @@ -136,7 +136,7 @@ mod tests { let ext_block = Block::Extension( ExtensionBlock::new("semantic", "citation") - .with_attributes(serde_json::json!({"ref": "smith2023"})), + .with_attributes(serde_json::json!({"refs": ["smith2023"]})), ); let content = Content::new(vec![ diff --git a/cdx-core/src/extensions/mod.rs b/cdx-core/src/extensions/mod.rs index 922fc9e..7bd18e0 100644 --- a/cdx-core/src/extensions/mod.rs +++ b/cdx-core/src/extensions/mod.rs @@ -179,6 +179,15 @@ impl ExtensionBlock { self.attributes.get(key).and_then(Value::as_str) } + /// Get an array-of-strings attribute. + #[must_use] + pub fn get_string_array_attribute(&self, key: &str) -> Option> { + self.attributes.get(key).and_then(|v| { + v.as_array() + .map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect()) + }) + } + /// Get a boolean attribute. #[must_use] pub fn get_bool_attribute(&self, key: &str) -> Option { @@ -303,7 +312,7 @@ mod tests { "blockType": "citation", "id": "cite-1", "attributes": { - "ref": "smith2023", + "refs": ["smith2023"], "page": 42 } }"#; @@ -312,7 +321,10 @@ mod tests { assert_eq!(ext.namespace, "semantic"); assert_eq!(ext.block_type, "citation"); assert_eq!(ext.id, Some("cite-1".to_string())); - assert_eq!(ext.get_string_attribute("ref"), Some("smith2023")); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); assert_eq!(ext.get_i64_attribute("page"), Some(42)); } } diff --git a/cdx-core/src/extensions/semantic/citation.rs b/cdx-core/src/extensions/semantic/citation.rs index 3728254..35142d7 100644 --- a/cdx-core/src/extensions/semantic/citation.rs +++ b/cdx-core/src/extensions/semantic/citation.rs @@ -1,44 +1,46 @@ //! Citations and footnotes for academic documents. +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; use crate::content::Block; /// An inline citation reference. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +/// +/// Supports both single and multi-reference citations (e.g., `[smith2023; jones2024]`). +/// +/// # Backward Compatibility +/// +/// Deserializes from both the old singular `"ref"` (string) format and the +/// new `"refs"` (array) format. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Citation { - /// Reference to bibliography entry ID. - #[serde(rename = "ref")] - pub reference: String, + /// References to bibliography entry IDs. + pub refs: Vec, /// Page or location within the reference. - #[serde(default, skip_serializing_if = "Option::is_none")] pub locator: Option, /// Locator type (page, chapter, section, etc.). - #[serde(default, skip_serializing_if = "Option::is_none")] pub locator_type: Option, /// Text before the citation (e.g., "see"). - #[serde(default, skip_serializing_if = "Option::is_none")] pub prefix: Option, /// Text after the citation (e.g., "for details"). - #[serde(default, skip_serializing_if = "Option::is_none")] pub suffix: Option, /// Suppress author name in citation. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub suppress_author: bool, } impl Citation { - /// Create a new citation. + /// Create a new citation with a single reference. #[must_use] pub fn new(reference: impl Into) -> Self { Self { - reference: reference.into(), + refs: vec![reference.into()], locator: None, locator_type: None, prefix: None, @@ -47,6 +49,31 @@ impl Citation { } } + /// Create a citation with multiple references (e.g., `[smith2023; jones2024]`). + #[must_use] + pub fn multi(refs: Vec) -> Self { + Self { + refs, + locator: None, + locator_type: None, + prefix: None, + suffix: None, + suppress_author: false, + } + } + + /// Get the first reference, if any. + #[must_use] + pub fn first_ref(&self) -> Option<&str> { + self.refs.first().map(String::as_str) + } + + /// Get all references. + #[must_use] + pub fn refs(&self) -> &[String] { + &self.refs + } + /// Set page locator. #[must_use] pub fn with_page(mut self, page: impl Into) -> Self { @@ -85,6 +112,107 @@ impl Citation { } } +impl Serialize for Citation { + fn serialize(&self, serializer: S) -> Result { + let mut count = 1; // refs is always present + if self.locator.is_some() { + count += 1; + } + if self.locator_type.is_some() { + count += 1; + } + if self.prefix.is_some() { + count += 1; + } + if self.suffix.is_some() { + count += 1; + } + if self.suppress_author { + count += 1; + } + + let mut map = serializer.serialize_map(Some(count))?; + map.serialize_entry("refs", &self.refs)?; + if let Some(ref locator) = self.locator { + map.serialize_entry("locator", locator)?; + } + if let Some(ref locator_type) = self.locator_type { + map.serialize_entry("locatorType", locator_type)?; + } + if let Some(ref prefix) = self.prefix { + map.serialize_entry("prefix", prefix)?; + } + if let Some(ref suffix) = self.suffix { + map.serialize_entry("suffix", suffix)?; + } + if self.suppress_author { + map.serialize_entry("suppressAuthor", &true)?; + } + map.end() + } +} + +impl<'de> Deserialize<'de> for Citation { + fn deserialize>(deserializer: D) -> Result { + struct CitationVisitor; + + impl<'de> Visitor<'de> for CitationVisitor { + type Value = Citation; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a Citation object with 'refs' (array) or 'ref' (string)") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut refs: Option> = None; + let mut locator: Option = None; + let mut locator_type: Option = None; + let mut prefix: Option = None; + let mut suffix: Option = None; + let mut suppress_author = false; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + "refs" => { + // Accept array of strings + refs = Some(map.next_value::>()?); + } + "ref" => { + // Backward compat: singular string → wrap in vec + let single: String = map.next_value()?; + refs = Some(vec![single]); + } + "locator" => locator = Some(map.next_value()?), + "locatorType" | "locator_type" => locator_type = Some(map.next_value()?), + "prefix" => prefix = Some(map.next_value()?), + "suffix" => suffix = Some(map.next_value()?), + "suppressAuthor" | "suppress_author" => { + suppress_author = map.next_value()?; + } + _ => { + // Skip unknown fields for forward compatibility + let _: serde_json::Value = map.next_value()?; + } + } + } + + let refs = refs.ok_or_else(|| de::Error::missing_field("refs"))?; + + Ok(Citation { + refs, + locator, + locator_type, + prefix, + suffix, + suppress_author, + }) + } + } + + deserializer.deserialize_map(CitationVisitor) + } +} + /// Type of locator within a reference. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] diff --git a/cdx-core/src/extensions/semantic/mod.rs b/cdx-core/src/extensions/semantic/mod.rs index da36145..4f2593b 100644 --- a/cdx-core/src/extensions/semantic/mod.rs +++ b/cdx-core/src/extensions/semantic/mod.rs @@ -15,7 +15,7 @@ //! ```json //! { //! "type": "semantic:citation", -//! "ref": "smith2023", +//! "refs": ["smith2023"], //! "page": "42-45", //! "prefix": "see", //! "suffix": "for details" @@ -103,7 +103,8 @@ mod tests { #[test] fn test_citation_new() { let cite = Citation::new("smith2023"); - assert_eq!(cite.reference, "smith2023"); + assert_eq!(cite.refs, vec!["smith2023"]); + assert_eq!(cite.first_ref(), Some("smith2023")); assert!(!cite.suppress_author); } @@ -246,10 +247,34 @@ mod tests { fn test_citation_serialization() { let cite = Citation::new("smith2023").with_page("42"); let json = serde_json::to_string(&cite).unwrap(); - assert!(json.contains("\"ref\":\"smith2023\"")); + assert!(json.contains("\"refs\":[\"smith2023\"]")); assert!(json.contains("\"locator\":\"42\"")); } + #[test] + fn test_citation_multi() { + let cite = Citation::multi(vec!["smith2023".into(), "jones2024".into()]); + assert_eq!(cite.refs(), &["smith2023", "jones2024"]); + assert_eq!(cite.first_ref(), Some("smith2023")); + } + + #[test] + fn test_citation_backward_compat_singular_ref() { + let json = r#"{"ref":"smith2023","locator":"42","locatorType":"page"}"#; + let cite: Citation = serde_json::from_str(json).unwrap(); + assert_eq!(cite.refs, vec!["smith2023"]); + assert_eq!(cite.locator, Some("42".to_string())); + } + + #[test] + fn test_citation_multi_refs_roundtrip() { + let cite = Citation::multi(vec!["smith2023".into(), "jones2024".into()]).with_page("42"); + let json = serde_json::to_string(&cite).unwrap(); + let parsed: Citation = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.refs, vec!["smith2023", "jones2024"]); + assert_eq!(parsed.locator, Some("42".to_string())); + } + // Footnote tests #[test] fn test_footnote_new() { diff --git a/cdx-core/tests/conformance.rs b/cdx-core/tests/conformance.rs index fdf2696..32dd6b9 100644 --- a/cdx-core/tests/conformance.rs +++ b/cdx-core/tests/conformance.rs @@ -101,22 +101,27 @@ fn extension_mark_serializes_without_wrapper() { // Type should be "semantic:citation", not "extension" assert_eq!(val["type"], "semantic:citation"); - assert_eq!(val["ref"], "smith2023"); + assert_eq!(val["refs"], serde_json::json!(["smith2023"])); // Should NOT have wrapper fields assert!(val.get("namespace").is_none()); assert!(val.get("markType").is_none()); + // Should NOT have old singular "ref" + assert!(val.get("ref").is_none()); } #[test] fn extension_mark_deserializes_new_format() { - let json = r#"{"type":"semantic:citation","ref":"smith2023"}"#; + let json = r#"{"type":"semantic:citation","refs":["smith2023"]}"#; let mark: Mark = serde_json::from_str(json).unwrap(); if let Mark::Extension(ext) = &mark { assert_eq!(ext.namespace, "semantic"); assert_eq!(ext.mark_type, "citation"); - assert_eq!(ext.get_string_attribute("ref"), Some("smith2023")); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); } else { panic!("Expected Extension mark, got {mark:?}"); } @@ -131,12 +136,64 @@ fn extension_mark_deserializes_old_format() { if let Mark::Extension(ext) = &mark { assert_eq!(ext.namespace, "semantic"); assert_eq!(ext.mark_type, "citation"); - assert_eq!(ext.get_string_attribute("ref"), Some("smith2023")); + // Old format preserves "ref" as-is in attributes; use get_citation_refs for compat + assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"])); } else { panic!("Expected Extension mark, got {mark:?}"); } } +#[test] +fn citation_mark_backward_compat_singular_ref() { + // Old format with singular "ref" string + let json = r#"{"type":"semantic:citation","ref":"smith2023"}"#; + let mark: Mark = serde_json::from_str(json).unwrap(); + + if let Mark::Extension(ext) = &mark { + assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"])); + } else { + panic!("Expected Extension mark, got {mark:?}"); + } +} + +#[test] +fn citation_mark_multi_refs_roundtrip() { + use cdx_core::content::ExtensionMark; + + let refs = vec!["smith2023".into(), "jones2024".into()]; + let mark = Mark::Extension(ExtensionMark::multi_citation(&refs)); + + let json = serde_json::to_string(&mark).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["refs"], serde_json::json!(["smith2023", "jones2024"])); + + // Round-trip + let parsed: Mark = serde_json::from_str(&json).unwrap(); + if let Mark::Extension(ext) = &parsed { + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023", "jones2024"]) + ); + } else { + panic!("Expected Extension mark"); + } +} + +#[test] +fn citation_mark_normalize_attrs() { + use cdx_core::content::ExtensionMark; + + let mut ext = ExtensionMark::new("semantic", "citation") + .with_attributes(serde_json::json!({"ref": "smith2023"})); + ext.normalize_citation_attrs(); + + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); + assert!(ext.get_string_attribute("ref").is_none()); +} + #[test] fn math_mark_uses_source_field() { let mark = Mark::Math { @@ -272,8 +329,7 @@ fn spec_example_text_with_bold_string_mark() { #[test] fn spec_example_text_with_citation_mark() { // Spec: extension marks use "namespace:markType" as type, attributes flattened - let spec_json = - r#"{"value":"important claim","marks":[{"type":"semantic:citation","ref":"smith2023"}]}"#; + let spec_json = r#"{"value":"important claim","marks":[{"type":"semantic:citation","refs":["smith2023"]}]}"#; let text: Text = serde_json::from_str(spec_json).unwrap(); assert_eq!(text.value, "important claim"); @@ -282,7 +338,10 @@ fn spec_example_text_with_citation_mark() { if let Mark::Extension(ext) = &text.marks[0] { assert_eq!(ext.namespace, "semantic"); assert_eq!(ext.mark_type, "citation"); - assert_eq!(ext.get_string_attribute("ref"), Some("smith2023")); + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); } else { panic!("Expected Extension mark"); } diff --git a/docs/extension-guide.md b/docs/extension-guide.md index b60b9ee..3f2db09 100644 --- a/docs/extension-guide.md +++ b/docs/extension-guide.md @@ -69,8 +69,9 @@ ext.is_type("forms", "textInput") // true Inline extension marks on text spans use the same namespace convention via `ExtensionMark`: ```rust -ExtensionMark::citation("ref-smith2024") -ExtensionMark::citation_with_page("ref-smith2024", "42") +ExtensionMark::citation("ref-smith2024") // emits {"refs": ["ref-smith2024"]} +ExtensionMark::citation_with_page("ref-smith2024", "42") // emits {"refs": ["ref-smith2024"], "locator": "42", ...} +ExtensionMark::multi_citation(&["smith2023".into(), "jones2024".into()]) ExtensionMark::entity("https://www.wikidata.org/wiki/Q42", EntityType::Person) ExtensionMark::glossary("term-algorithm") ExtensionMark::index("Machine learning")