Skip to main content

oxirs_ttl/
rdfa_parser.rs

1//! Basic RDFa 1.1 Lite parser.
2//!
3//! Extracts RDF triples from a simplified HTML/XML document representation
4//! by processing RDFa attributes:
5//!
6//! * `property`  → predicate IRI
7//! * `typeof`    → `rdf:type` triple
8//! * `resource`  → object IRI
9//! * `about`     → subject IRI
10//! * `prefix`    → prefix mappings
11//! * `content`   → literal value (overrides text content)
12//! * `datatype`  → literal datatype
13//! * `lang`      → language tag
14//! * `rel`/`rev` → link relation / reverse link
15//!
16//! The parser operates on a lightweight DOM-like `Element` tree (no external
17//! XML crate required) which callers can build from any source.
18
19use std::collections::HashMap;
20use std::fmt;
21
22// ---------------------------------------------------------------------------
23// Vocabulary constants
24// ---------------------------------------------------------------------------
25
26const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
27const RDF_PREFIX: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
28#[allow(dead_code)]
29const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
30
31/// Well-known prefix bindings pre-loaded by the parser.
32fn default_prefixes() -> HashMap<String, String> {
33    let mut m = HashMap::new();
34    m.insert("rdf".into(), RDF_PREFIX.into());
35    m.insert(
36        "rdfs".into(),
37        "http://www.w3.org/2000/01/rdf-schema#".into(),
38    );
39    m.insert("xsd".into(), "http://www.w3.org/2001/XMLSchema#".into());
40    m.insert("owl".into(), "http://www.w3.org/2002/07/owl#".into());
41    m.insert("dc".into(), "http://purl.org/dc/elements/1.1/".into());
42    m.insert("dcterms".into(), "http://purl.org/dc/terms/".into());
43    m.insert("foaf".into(), "http://xmlns.com/foaf/0.1/".into());
44    m.insert("schema".into(), "https://schema.org/".into());
45    m
46}
47
48// ---------------------------------------------------------------------------
49// Lightweight DOM types
50// ---------------------------------------------------------------------------
51
52/// A key-value attribute on an element.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Attribute {
55    /// The attribute name (e.g. `"property"`, `"typeof"`).
56    pub name: String,
57    /// The attribute value string.
58    pub value: String,
59}
60
61impl Attribute {
62    /// Create a new attribute with the given name and value.
63    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
64        Self {
65            name: name.into(),
66            value: value.into(),
67        }
68    }
69}
70
71/// A simplified DOM element used as input to the RDFa parser.
72#[derive(Debug, Clone)]
73pub struct Element {
74    /// Tag name (e.g. `"div"`, `"span"`, `"a"`).
75    pub tag: String,
76    /// Attributes on this element.
77    pub attributes: Vec<Attribute>,
78    /// Plain-text content (characters only, no child elements included).
79    pub text: String,
80    /// Child elements (ordered).
81    pub children: Vec<Element>,
82}
83
84impl Element {
85    /// Create a new element with the given tag name and no attributes, text, or children.
86    pub fn new(tag: impl Into<String>) -> Self {
87        Self {
88            tag: tag.into(),
89            attributes: Vec::new(),
90            text: String::new(),
91            children: Vec::new(),
92        }
93    }
94
95    /// Add an attribute and return `self` for builder chaining.
96    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
97        self.attributes.push(Attribute::new(name, value));
98        self
99    }
100
101    /// Set the text content.
102    pub fn text(mut self, text: impl Into<String>) -> Self {
103        self.text = text.into();
104        self
105    }
106
107    /// Append a child element.
108    pub fn child(mut self, child: Element) -> Self {
109        self.children.push(child);
110        self
111    }
112
113    /// Return the value of the named attribute, or `None`.
114    pub fn get_attr(&self, name: &str) -> Option<&str> {
115        self.attributes
116            .iter()
117            .find(|a| a.name == name)
118            .map(|a| a.value.as_str())
119    }
120}
121
122// ---------------------------------------------------------------------------
123// Output triple
124// ---------------------------------------------------------------------------
125
126/// An RDF triple produced by the RDFa parser.
127///
128/// Subject and predicate are always IRIs; the object is either an IRI or a
129/// plain/typed/language literal.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct RdfaTriple {
132    /// IRI of the triple's subject.
133    pub subject: String,
134    /// IRI of the triple's predicate.
135    pub predicate: String,
136    /// Object of the triple (IRI or literal).
137    pub object: RdfaObject,
138}
139
140/// The object of an RDFa triple.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum RdfaObject {
143    /// An IRI reference.
144    Iri(String),
145    /// A plain string literal (no datatype / language).
146    Literal(String),
147    /// A typed literal.
148    TypedLiteral {
149        /// The lexical value of the literal.
150        value: String,
151        /// The datatype IRI.
152        datatype: String,
153    },
154    /// A language-tagged string literal.
155    LangLiteral {
156        /// The lexical value of the literal.
157        value: String,
158        /// BCP-47 language tag (e.g. `"en"`, `"fr-CA"`).
159        lang: String,
160    },
161}
162
163impl fmt::Display for RdfaObject {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            RdfaObject::Iri(iri) => write!(f, "<{iri}>"),
167            RdfaObject::Literal(v) => write!(f, "\"{v}\""),
168            RdfaObject::TypedLiteral { value, datatype } => {
169                write!(f, "\"{value}\"^^<{datatype}>")
170            }
171            RdfaObject::LangLiteral { value, lang } => write!(f, "\"{value}\"@{lang}"),
172        }
173    }
174}
175
176// ---------------------------------------------------------------------------
177// Parser evaluation context
178// ---------------------------------------------------------------------------
179
180/// Inherited context passed down the element tree.
181#[derive(Debug, Clone)]
182struct EvalContext {
183    /// Current subject IRI (inherited from parent if not overridden).
184    subject: Option<String>,
185    /// Base IRI for resolving relative references.
186    base: String,
187    /// Active prefix mappings.
188    prefixes: HashMap<String, String>,
189    /// Default vocabulary IRI.
190    vocab: Option<String>,
191    /// Current language tag.
192    lang: Option<String>,
193}
194
195impl EvalContext {
196    fn new(base: &str) -> Self {
197        Self {
198            subject: None,
199            base: base.to_string(),
200            prefixes: default_prefixes(),
201            vocab: None,
202            lang: None,
203        }
204    }
205
206    /// Resolve a CURIE or IRI reference to an absolute IRI.
207    fn resolve(&self, term: &str) -> Option<String> {
208        let trimmed = term.trim();
209        if trimmed.is_empty() {
210            return None;
211        }
212        // Already absolute IRI
213        if trimmed.starts_with('<') && trimmed.ends_with('>') {
214            return Some(trimmed[1..trimmed.len() - 1].to_string());
215        }
216        if trimmed.contains("://") {
217            return Some(trimmed.to_string());
218        }
219        // CURIE: "prefix:local"
220        if let Some(colon) = trimmed.find(':') {
221            let prefix = &trimmed[..colon];
222            let local = &trimmed[colon + 1..];
223            if let Some(ns) = self.prefixes.get(prefix) {
224                return Some(format!("{ns}{local}"));
225            }
226        }
227        // Default vocab
228        if let Some(vocab) = &self.vocab {
229            return Some(format!("{vocab}{trimmed}"));
230        }
231        // Relative IRI against base
232        if !self.base.is_empty() {
233            return Some(format!("{}{}", self.base, trimmed));
234        }
235        None
236    }
237
238    /// Parse and register prefix declarations from a `prefix` attribute value.
239    ///
240    /// Format: `"ex: http://example.org/ dc: http://purl.org/dc/elements/1.1/ ..."`
241    fn register_prefix_attr(&mut self, prefix_attr: &str) {
242        let tokens: Vec<&str> = prefix_attr.split_whitespace().collect();
243        let mut i = 0;
244        while i + 1 < tokens.len() {
245            let pfx = tokens[i];
246            let ns = tokens[i + 1];
247            if let Some(prefix_label) = pfx.strip_suffix(':') {
248                self.prefixes
249                    .insert(prefix_label.to_string(), ns.to_string());
250            }
251            i += 2;
252        }
253    }
254}
255
256// ---------------------------------------------------------------------------
257// RDFa parser
258// ---------------------------------------------------------------------------
259
260/// Configuration for the RDFa parser.
261#[derive(Debug, Clone)]
262pub struct RdfaConfig {
263    /// Base IRI used for resolving relative references.
264    pub base: String,
265    /// Whether to generate `rdf:type` triples from `typeof` attributes.
266    pub process_typeof: bool,
267    /// Whether to process `rel` and `rev` attributes.
268    pub process_rel_rev: bool,
269}
270
271impl Default for RdfaConfig {
272    fn default() -> Self {
273        Self {
274            base: String::new(),
275            process_typeof: true,
276            process_rel_rev: true,
277        }
278    }
279}
280
281/// The RDFa 1.1 Lite parser.
282pub struct RdfaParser {
283    config: RdfaConfig,
284}
285
286impl RdfaParser {
287    /// Create a new parser with the given configuration.
288    pub fn new(config: RdfaConfig) -> Self {
289        Self { config }
290    }
291
292    /// Parse an element tree and return all extracted RDF triples.
293    pub fn parse(&self, root: &Element) -> Vec<RdfaTriple> {
294        let ctx = EvalContext::new(&self.config.base);
295        let mut triples = Vec::new();
296        self.process_element(root, &ctx, &mut triples);
297        triples
298    }
299
300    fn process_element(
301        &self,
302        element: &Element,
303        parent_ctx: &EvalContext,
304        triples: &mut Vec<RdfaTriple>,
305    ) {
306        let mut ctx = parent_ctx.clone();
307
308        // --- Update prefix mappings from `prefix` attribute ---
309        if let Some(pfx_attr) = element.get_attr("prefix") {
310            ctx.register_prefix_attr(pfx_attr);
311        }
312
313        // --- Update default vocabulary from `vocab` attribute ---
314        if let Some(vocab) = element.get_attr("vocab") {
315            ctx.vocab = Some(vocab.to_string());
316        }
317
318        // --- Update language from `lang` or `xml:lang` ---
319        if let Some(lang) = element
320            .get_attr("lang")
321            .or_else(|| element.get_attr("xml:lang"))
322        {
323            ctx.lang = if lang.is_empty() {
324                None
325            } else {
326                Some(lang.to_string())
327            };
328        }
329
330        // --- Determine the new subject ---
331        // Precedence: `about` > inherited subject > blank node
332        let new_subject: Option<String> = element
333            .get_attr("about")
334            .and_then(|v| ctx.resolve(v))
335            .or_else(|| ctx.subject.clone());
336
337        // `resource` attribute on a non-leaf element can also set the subject context
338        // for child elements (if no `about` is present and `property` is absent).
339        let resource_iri: Option<String> =
340            element.get_attr("resource").and_then(|v| ctx.resolve(v));
341
342        // Effective subject for triple emission
343        let effective_subject: Option<String> =
344            new_subject.clone().or_else(|| resource_iri.clone());
345
346        // --- `typeof` attribute → rdf:type triple(s) ---
347        if self.config.process_typeof {
348            if let (Some(subj), Some(typeof_attr)) =
349                (&effective_subject, element.get_attr("typeof"))
350            {
351                for type_term in typeof_attr.split_whitespace() {
352                    if let Some(type_iri) = ctx.resolve(type_term) {
353                        triples.push(RdfaTriple {
354                            subject: subj.clone(),
355                            predicate: RDF_TYPE.to_string(),
356                            object: RdfaObject::Iri(type_iri),
357                        });
358                    }
359                }
360            }
361        }
362
363        // --- `property` attribute → predicate with literal or resource object ---
364        if let (Some(subj), Some(property_attr)) =
365            (&effective_subject, element.get_attr("property"))
366        {
367            for prop_term in property_attr.split_whitespace() {
368                if let Some(predicate) = ctx.resolve(prop_term) {
369                    // Determine object
370                    let object = self.extract_object(element, &ctx, &resource_iri);
371                    if let Some(obj) = object {
372                        triples.push(RdfaTriple {
373                            subject: subj.clone(),
374                            predicate,
375                            object: obj,
376                        });
377                    }
378                }
379            }
380        }
381
382        // --- `rel` attribute → forward link relation ---
383        if self.config.process_rel_rev {
384            if let (Some(subj), Some(rel_attr), Some(obj_iri)) = (
385                &effective_subject,
386                element.get_attr("rel"),
387                resource_iri.as_ref().or(element
388                    .get_attr("href")
389                    .and_then(|h| ctx.resolve(h))
390                    .as_ref()),
391            ) {
392                for rel_term in rel_attr.split_whitespace() {
393                    if let Some(predicate) = ctx.resolve(rel_term) {
394                        triples.push(RdfaTriple {
395                            subject: subj.clone(),
396                            predicate,
397                            object: RdfaObject::Iri(obj_iri.clone()),
398                        });
399                    }
400                }
401            }
402
403            // --- `rev` attribute → reverse link relation ---
404            if let (Some(subj), Some(rev_attr), Some(obj_iri)) = (
405                &effective_subject,
406                element.get_attr("rev"),
407                resource_iri.as_ref().or(element
408                    .get_attr("href")
409                    .and_then(|h| ctx.resolve(h))
410                    .as_ref()),
411            ) {
412                for rev_term in rev_attr.split_whitespace() {
413                    if let Some(predicate) = ctx.resolve(rev_term) {
414                        // In `rev`, the roles of subject and object are swapped
415                        triples.push(RdfaTriple {
416                            subject: obj_iri.clone(),
417                            predicate,
418                            object: RdfaObject::Iri(subj.clone()),
419                        });
420                    }
421                }
422            }
423        }
424
425        // --- Recurse into children with updated context ---
426        // The child subject is:
427        //   * `resource` IRI (if present, and no `property`) — new subject for children
428        //   * Otherwise: effective_subject (inherited)
429        let child_subject = if element.get_attr("property").is_none() {
430            resource_iri.or(effective_subject)
431        } else {
432            effective_subject
433        };
434        ctx.subject = child_subject;
435
436        for child in &element.children {
437            self.process_element(child, &ctx, triples);
438        }
439    }
440
441    /// Determine the object for a `property` triple.
442    ///
443    /// Priority:
444    /// 1. `resource` attribute → IRI object
445    /// 2. `content` attribute → plain/typed/lang literal
446    /// 3. `datatype` with element text → typed literal
447    /// 4. Element text with language → lang literal
448    /// 5. Element text → plain literal (xsd:string)
449    fn extract_object(
450        &self,
451        element: &Element,
452        ctx: &EvalContext,
453        resource_iri: &Option<String>,
454    ) -> Option<RdfaObject> {
455        // resource overrides literal for property triples only when no content/datatype
456        if let Some(iri) = resource_iri {
457            if element.get_attr("content").is_none() && element.get_attr("datatype").is_none() {
458                return Some(RdfaObject::Iri(iri.clone()));
459            }
460        }
461
462        let raw_value: String = element
463            .get_attr("content")
464            .map(|s| s.to_string())
465            .unwrap_or_else(|| element.text.clone());
466
467        let datatype = element.get_attr("datatype").and_then(|dt| ctx.resolve(dt));
468        let lang = element
469            .get_attr("lang")
470            .or_else(|| element.get_attr("xml:lang"))
471            .and_then(|l| {
472                if l.is_empty() {
473                    None
474                } else {
475                    Some(l.to_string())
476                }
477            })
478            .or_else(|| {
479                // Only inherit parent lang if this element did not explicitly set lang="" to clear it
480                if element.get_attr("lang").is_some() || element.get_attr("xml:lang").is_some() {
481                    None
482                } else {
483                    ctx.lang.clone()
484                }
485            });
486
487        if let Some(dt) = datatype {
488            return Some(RdfaObject::TypedLiteral {
489                value: raw_value,
490                datatype: dt,
491            });
492        }
493        if let Some(l) = lang {
494            return Some(RdfaObject::LangLiteral {
495                value: raw_value,
496                lang: l,
497            });
498        }
499        // Default: plain string
500        if raw_value.is_empty() {
501            None
502        } else {
503            Some(RdfaObject::Literal(raw_value))
504        }
505    }
506}
507
508// ---------------------------------------------------------------------------
509// Convenience builder for HTML `<head>`-style prefix extraction
510// ---------------------------------------------------------------------------
511
512/// Extract prefix declarations from an HTML `<head>` element, scanning for
513/// `<link rel="prefix" ...>` or a `<meta prefix="..." ...>` element pattern.
514///
515/// Also handles a `prefix` attribute directly on the `<html>` element.
516pub fn extract_head_prefixes(head: &Element) -> HashMap<String, String> {
517    let mut prefixes = default_prefixes();
518    if let Some(pfx) = head.get_attr("prefix") {
519        let mut ctx = EvalContext::new("");
520        ctx.register_prefix_attr(pfx);
521        prefixes.extend(ctx.prefixes);
522    }
523    for child in &head.children {
524        if let Some(pfx) = child.get_attr("prefix") {
525            let mut ctx = EvalContext::new("");
526            ctx.register_prefix_attr(pfx);
527            for (k, v) in ctx.prefixes {
528                prefixes.insert(k, v);
529            }
530        }
531    }
532    prefixes
533}
534
535// ---------------------------------------------------------------------------
536// Tests
537// ---------------------------------------------------------------------------
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    fn parser() -> RdfaParser {
544        RdfaParser::new(RdfaConfig {
545            base: "http://example.org/".to_string(),
546            ..Default::default()
547        })
548    }
549
550    // -----------------------------------------------------------------------
551    // typeof → rdf:type
552    // -----------------------------------------------------------------------
553
554    #[test]
555    fn test_typeof_single() {
556        let el = Element::new("div")
557            .attr("about", "http://example.org/alice")
558            .attr("typeof", "foaf:Person");
559        let triples = parser().parse(&el);
560        assert_eq!(triples.len(), 1);
561        assert_eq!(triples[0].predicate, RDF_TYPE);
562        assert_eq!(
563            triples[0].object,
564            RdfaObject::Iri("http://xmlns.com/foaf/0.1/Person".to_string())
565        );
566    }
567
568    #[test]
569    fn test_typeof_multiple_types() {
570        let el = Element::new("div")
571            .attr("about", "http://example.org/alice")
572            .attr("typeof", "foaf:Person schema:Person");
573        let triples = parser().parse(&el);
574        assert_eq!(triples.len(), 2);
575    }
576
577    // -----------------------------------------------------------------------
578    // property → literal
579    // -----------------------------------------------------------------------
580
581    #[test]
582    fn test_property_plain_literal() {
583        let el = Element::new("span")
584            .attr("about", "http://example.org/alice")
585            .attr("property", "foaf:name")
586            .text("Alice");
587        let triples = parser().parse(&el);
588        assert_eq!(triples.len(), 1);
589        assert_eq!(triples[0].object, RdfaObject::Literal("Alice".to_string()));
590    }
591
592    #[test]
593    fn test_property_content_attribute() {
594        let el = Element::new("span")
595            .attr("about", "http://example.org/alice")
596            .attr("property", "foaf:name")
597            .attr("content", "Alice Smith")
598            .text("displayed text");
599        let triples = parser().parse(&el);
600        assert_eq!(
601            triples[0].object,
602            RdfaObject::Literal("Alice Smith".to_string())
603        );
604    }
605
606    #[test]
607    fn test_property_typed_literal() {
608        let el = Element::new("span")
609            .attr("about", "http://example.org/event")
610            .attr("property", "schema:startDate")
611            .attr("datatype", "xsd:date")
612            .text("2026-01-01");
613        let triples = parser().parse(&el);
614        assert_eq!(triples.len(), 1);
615        match &triples[0].object {
616            RdfaObject::TypedLiteral { value, datatype } => {
617                assert_eq!(value, "2026-01-01");
618                assert!(datatype.contains("date"));
619            }
620            other => panic!("Expected TypedLiteral, got {other:?}"),
621        }
622    }
623
624    #[test]
625    fn test_property_lang_literal() {
626        let el = Element::new("span")
627            .attr("about", "http://example.org/doc")
628            .attr("property", "dc:title")
629            .attr("lang", "en")
630            .text("Hello");
631        let triples = parser().parse(&el);
632        assert_eq!(
633            triples[0].object,
634            RdfaObject::LangLiteral {
635                value: "Hello".to_string(),
636                lang: "en".to_string()
637            }
638        );
639    }
640
641    // -----------------------------------------------------------------------
642    // about → subject IRI
643    // -----------------------------------------------------------------------
644
645    #[test]
646    fn test_about_absolute_iri() {
647        let el = Element::new("div")
648            .attr("about", "http://example.org/resource")
649            .attr("property", "rdfs:label")
650            .text("Test");
651        let triples = parser().parse(&el);
652        assert_eq!(triples[0].subject, "http://example.org/resource");
653    }
654
655    #[test]
656    fn test_about_curie() {
657        let el = Element::new("div")
658            .attr("prefix", "ex: http://example.org/")
659            .attr("about", "ex:alice")
660            .attr("property", "rdfs:label")
661            .text("Alice");
662        let triples = parser().parse(&el);
663        assert_eq!(triples[0].subject, "http://example.org/alice");
664    }
665
666    // -----------------------------------------------------------------------
667    // resource attribute
668    // -----------------------------------------------------------------------
669
670    #[test]
671    fn test_resource_as_object_iri() {
672        let el = Element::new("span")
673            .attr("about", "http://example.org/alice")
674            .attr("property", "foaf:knows")
675            .attr("resource", "http://example.org/bob");
676        let triples = parser().parse(&el);
677        assert_eq!(triples.len(), 1);
678        assert_eq!(
679            triples[0].object,
680            RdfaObject::Iri("http://example.org/bob".to_string())
681        );
682    }
683
684    // -----------------------------------------------------------------------
685    // prefix attribute
686    // -----------------------------------------------------------------------
687
688    #[test]
689    fn test_prefix_declaration() {
690        let el = Element::new("div")
691            .attr(
692                "prefix",
693                "ex: http://example.org/ dc: http://purl.org/dc/elements/1.1/",
694            )
695            .attr("about", "ex:book1")
696            .attr("property", "dc:title")
697            .text("The Book");
698        let triples = parser().parse(&el);
699        assert_eq!(triples[0].subject, "http://example.org/book1");
700        assert_eq!(
701            triples[0].predicate,
702            "http://purl.org/dc/elements/1.1/title"
703        );
704    }
705
706    // -----------------------------------------------------------------------
707    // rel / rev attributes
708    // -----------------------------------------------------------------------
709
710    #[test]
711    fn test_rel_attribute() {
712        let el = Element::new("a")
713            .attr("about", "http://example.org/alice")
714            .attr("rel", "foaf:knows")
715            .attr("resource", "http://example.org/bob");
716        let triples = parser().parse(&el);
717        assert!(triples
718            .iter()
719            .any(|t| t.predicate == "http://xmlns.com/foaf/0.1/knows"));
720    }
721
722    #[test]
723    fn test_rev_attribute() {
724        let el = Element::new("a")
725            .attr("about", "http://example.org/bob")
726            .attr("rev", "foaf:knows")
727            .attr("resource", "http://example.org/alice");
728        let triples = parser().parse(&el);
729        // In rev: subject becomes object and vice-versa
730        let rev_triple = triples
731            .iter()
732            .find(|t| t.predicate == "http://xmlns.com/foaf/0.1/knows");
733        assert!(rev_triple.is_some());
734        let rv = rev_triple.expect("rev triple should exist");
735        // The rev triple should have alice as subject (the resource) and bob as object (about)
736        assert_eq!(rv.subject, "http://example.org/alice");
737    }
738
739    // -----------------------------------------------------------------------
740    // Context inheritance
741    // -----------------------------------------------------------------------
742
743    #[test]
744    fn test_subject_inheritance_from_parent() {
745        let child = Element::new("span")
746            .attr("property", "foaf:name")
747            .text("Alice");
748        let parent = Element::new("div")
749            .attr("about", "http://example.org/alice")
750            .child(child);
751        let triples = parser().parse(&parent);
752        assert!(triples
753            .iter()
754            .any(|t| t.subject == "http://example.org/alice"));
755    }
756
757    #[test]
758    fn test_lang_inheritance_from_parent() {
759        let child = Element::new("span")
760            .attr("property", "rdfs:label")
761            .text("Bonjour");
762        let parent = Element::new("div")
763            .attr("about", "http://example.org/res")
764            .attr("lang", "fr")
765            .child(child);
766        let triples = parser().parse(&parent);
767        let label_triple = triples.iter().find(|t| t.predicate.contains("label"));
768        assert!(label_triple.is_some());
769        match &label_triple.expect("label triple should exist").object {
770            RdfaObject::LangLiteral { lang, .. } => assert_eq!(lang, "fr"),
771            other => panic!("Expected LangLiteral, got {other:?}"),
772        }
773    }
774
775    // -----------------------------------------------------------------------
776    // vocab attribute
777    // -----------------------------------------------------------------------
778
779    #[test]
780    fn test_vocab_attribute() {
781        let el = Element::new("div")
782            .attr("vocab", "https://schema.org/")
783            .attr("about", "http://example.org/person")
784            .attr("typeof", "Person");
785        let triples = parser().parse(&el);
786        assert!(triples.iter().any(|t| {
787            t.predicate == RDF_TYPE
788                && t.object == RdfaObject::Iri("https://schema.org/Person".to_string())
789        }));
790    }
791
792    // -----------------------------------------------------------------------
793    // extract_head_prefixes
794    // -----------------------------------------------------------------------
795
796    #[test]
797    fn test_extract_head_prefixes() {
798        let head =
799            Element::new("head").attr("prefix", "ex: http://example.org/ my: http://my.org/");
800        let prefixes = extract_head_prefixes(&head);
801        assert_eq!(
802            prefixes.get("ex").map(|s| s.as_str()),
803            Some("http://example.org/")
804        );
805        assert_eq!(
806            prefixes.get("my").map(|s| s.as_str()),
807            Some("http://my.org/")
808        );
809    }
810
811    // -----------------------------------------------------------------------
812    // RdfaObject display
813    // -----------------------------------------------------------------------
814
815    #[test]
816    fn test_rdfa_object_iri_display() {
817        let obj = RdfaObject::Iri("http://example.org/".to_string());
818        assert_eq!(obj.to_string(), "<http://example.org/>");
819    }
820
821    #[test]
822    fn test_rdfa_object_literal_display() {
823        let obj = RdfaObject::Literal("hello".to_string());
824        assert_eq!(obj.to_string(), "\"hello\"");
825    }
826
827    #[test]
828    fn test_rdfa_object_typed_literal_display() {
829        let obj = RdfaObject::TypedLiteral {
830            value: "42".to_string(),
831            datatype: XSD_STRING.to_string(),
832        };
833        assert!(obj.to_string().contains("42"));
834    }
835
836    #[test]
837    fn test_rdfa_object_lang_literal_display() {
838        let obj = RdfaObject::LangLiteral {
839            value: "hello".to_string(),
840            lang: "en".to_string(),
841        };
842        assert_eq!(obj.to_string(), "\"hello\"@en");
843    }
844
845    // -----------------------------------------------------------------------
846    // Multiple properties on same element
847    // -----------------------------------------------------------------------
848
849    #[test]
850    fn test_multiple_properties_space_separated() {
851        let el = Element::new("span")
852            .attr("about", "http://example.org/doc")
853            .attr("property", "dc:title rdfs:label")
854            .text("My Doc");
855        let triples = parser().parse(&el);
856        assert_eq!(triples.len(), 2);
857    }
858
859    // -----------------------------------------------------------------------
860    // Empty text content
861    // -----------------------------------------------------------------------
862
863    #[test]
864    fn test_empty_text_no_triple() {
865        let el = Element::new("span")
866            .attr("about", "http://example.org/doc")
867            .attr("property", "dc:description");
868        let triples = parser().parse(&el);
869        // No object → no triple
870        assert!(triples.is_empty());
871    }
872
873    // -----------------------------------------------------------------------
874    // No subject → no triple
875    // -----------------------------------------------------------------------
876
877    #[test]
878    fn test_no_subject_no_triple() {
879        // No `about`, no parent subject → effective_subject is None
880        let el = Element::new("span")
881            .attr("property", "foaf:name")
882            .text("Orphan");
883        let triples = parser().parse(&el);
884        assert!(triples.is_empty());
885    }
886
887    // -----------------------------------------------------------------------
888    // Additional coverage
889    // -----------------------------------------------------------------------
890
891    #[test]
892    fn test_element_builder_get_attr() {
893        let el = Element::new("div").attr("id", "main").attr("class", "hero");
894        assert_eq!(el.get_attr("id"), Some("main"));
895        assert_eq!(el.get_attr("class"), Some("hero"));
896        assert_eq!(el.get_attr("missing"), None);
897    }
898
899    #[test]
900    fn test_attribute_struct() {
901        let a = Attribute::new("rel", "stylesheet");
902        assert_eq!(a.name, "rel");
903        assert_eq!(a.value, "stylesheet");
904    }
905
906    #[test]
907    fn test_typeof_no_about() {
908        // typeof without about — no subject → no triple
909        let el = Element::new("div").attr("typeof", "foaf:Person");
910        let triples = parser().parse(&el);
911        assert!(triples.is_empty());
912    }
913
914    #[test]
915    fn test_property_iri_object_via_resource() {
916        let el = Element::new("a")
917            .attr("about", "http://example.org/s")
918            .attr("property", "foaf:homepage")
919            .attr("resource", "http://example.org/page");
920        let triples = parser().parse(&el);
921        assert!(triples
922            .iter()
923            .any(|t| matches!(&t.object, RdfaObject::Iri(iri) if iri.contains("page"))));
924    }
925
926    #[test]
927    fn test_default_prefix_rdf() {
928        // rdf: prefix should be predefined
929        let el = Element::new("span")
930            .attr("about", "http://example.org/r")
931            .attr("typeof", "rdf:Resource");
932        let triples = parser().parse(&el);
933        assert!(!triples.is_empty());
934        assert!(triples[0]
935            .object
936            .to_string()
937            .contains("rdf-syntax-ns#Resource"));
938    }
939
940    #[test]
941    fn test_xml_lang_attribute() {
942        let el = Element::new("span")
943            .attr("about", "http://example.org/r")
944            .attr("property", "dc:title")
945            .attr("xml:lang", "de")
946            .text("Hallo");
947        let triples = parser().parse(&el);
948        assert_eq!(
949            triples[0].object,
950            RdfaObject::LangLiteral {
951                value: "Hallo".to_string(),
952                lang: "de".to_string()
953            }
954        );
955    }
956
957    #[test]
958    fn test_multiple_children_multiple_triples() {
959        let child1 = Element::new("span")
960            .attr("property", "foaf:name")
961            .text("Alice");
962        let child2 = Element::new("span")
963            .attr("property", "foaf:mbox")
964            .attr("resource", "mailto:alice@example.org");
965        let parent = Element::new("div")
966            .attr("about", "http://example.org/alice")
967            .child(child1)
968            .child(child2);
969        let triples = parser().parse(&parent);
970        assert!(triples.len() >= 2);
971    }
972
973    #[test]
974    fn test_nested_resource_sets_child_subject() {
975        let child = Element::new("span")
976            .attr("property", "foaf:name")
977            .text("Bob");
978        let parent = Element::new("div")
979            .attr("about", "http://example.org/alice")
980            .attr("resource", "http://example.org/bob")
981            .child(child);
982        let triples = parser().parse(&parent);
983        // Child should have bob as subject
984        let name_triple = triples.iter().find(|t| t.predicate.contains("name"));
985        assert!(name_triple.is_some());
986        assert_eq!(
987            name_triple.expect("name triple should exist").subject,
988            "http://example.org/bob"
989        );
990    }
991
992    #[test]
993    fn test_extract_head_prefixes_with_child() {
994        let meta = Element::new("meta").attr("prefix", "schema: https://schema.org/");
995        let head = Element::new("head").child(meta);
996        let prefixes = extract_head_prefixes(&head);
997        assert_eq!(
998            prefixes.get("schema").map(|s| s.as_str()),
999            Some("https://schema.org/")
1000        );
1001    }
1002
1003    #[test]
1004    fn test_rdfa_config_default() {
1005        let cfg = RdfaConfig::default();
1006        assert!(cfg.process_typeof);
1007        assert!(cfg.process_rel_rev);
1008        assert!(cfg.base.is_empty());
1009    }
1010
1011    #[test]
1012    fn test_parser_no_process_typeof() {
1013        let cfg = RdfaConfig {
1014            process_typeof: false,
1015            base: "http://example.org/".to_string(),
1016            ..Default::default()
1017        };
1018        let el = Element::new("div")
1019            .attr("about", "http://example.org/alice")
1020            .attr("typeof", "foaf:Person");
1021        let triples = RdfaParser::new(cfg).parse(&el);
1022        assert!(triples.is_empty());
1023    }
1024
1025    #[test]
1026    fn test_parser_no_process_rel_rev() {
1027        let cfg = RdfaConfig {
1028            process_rel_rev: false,
1029            base: "http://example.org/".to_string(),
1030            ..Default::default()
1031        };
1032        let el = Element::new("a")
1033            .attr("about", "http://example.org/s")
1034            .attr("rel", "foaf:knows")
1035            .attr("resource", "http://example.org/o");
1036        let triples = RdfaParser::new(cfg).parse(&el);
1037        assert!(triples.is_empty());
1038    }
1039
1040    #[test]
1041    fn test_triple_subject_from_parent_and_child_typeof() {
1042        let child = Element::new("div")
1043            .attr("typeof", "schema:Book")
1044            .attr("about", "http://example.org/book1");
1045        let parent = Element::new("div")
1046            .attr("about", "http://example.org/collection")
1047            .child(child);
1048        let triples = parser().parse(&parent);
1049        assert!(triples
1050            .iter()
1051            .any(|t| t.subject == "http://example.org/book1"));
1052    }
1053
1054    #[test]
1055    fn test_about_relative_iri_resolved_with_base() {
1056        let el = Element::new("span")
1057            .attr("about", "alice")
1058            .attr("property", "foaf:name")
1059            .text("Alice");
1060        // Base is http://example.org/ — alice should resolve to http://example.org/alice
1061        let triples = parser().parse(&el);
1062        assert_eq!(triples[0].subject, "http://example.org/alice");
1063    }
1064
1065    #[test]
1066    fn test_property_with_datatype_overrides_resource() {
1067        // When `datatype` is present, a typed literal is preferred over resource IRI
1068        let el = Element::new("span")
1069            .attr("about", "http://example.org/e")
1070            .attr("property", "schema:startDate")
1071            .attr("datatype", "xsd:date")
1072            .attr("resource", "http://example.org/ignored")
1073            .text("2026-03-04");
1074        let triples = parser().parse(&el);
1075        assert!(matches!(
1076            &triples[0].object,
1077            RdfaObject::TypedLiteral { .. }
1078        ));
1079    }
1080
1081    #[test]
1082    fn test_default_prefixes_rdfs() {
1083        let el = Element::new("div")
1084            .attr("about", "http://example.org/r")
1085            .attr("property", "rdfs:comment")
1086            .text("A thing");
1087        let triples = parser().parse(&el);
1088        assert!(triples[0].predicate.contains("comment"));
1089    }
1090
1091    #[test]
1092    fn test_default_prefixes_owl() {
1093        let el = Element::new("div")
1094            .attr("about", "http://example.org/r")
1095            .attr("typeof", "owl:Class");
1096        let triples = parser().parse(&el);
1097        assert!(triples[0].object.to_string().contains("owl"));
1098    }
1099
1100    #[test]
1101    fn test_element_children_count() {
1102        let el = Element::new("div")
1103            .child(Element::new("span"))
1104            .child(Element::new("span"));
1105        assert_eq!(el.children.len(), 2);
1106    }
1107
1108    #[test]
1109    fn test_element_text_method() {
1110        let el = Element::new("span").text("hello");
1111        assert_eq!(el.text, "hello");
1112    }
1113
1114    #[test]
1115    fn test_rdfa_triple_fields() {
1116        let triple = RdfaTriple {
1117            subject: "http://example.org/s".to_string(),
1118            predicate: "http://example.org/p".to_string(),
1119            object: RdfaObject::Literal("value".to_string()),
1120        };
1121        assert_eq!(triple.subject, "http://example.org/s");
1122        assert_eq!(triple.predicate, "http://example.org/p");
1123    }
1124
1125    #[test]
1126    fn test_lang_empty_string_clears_lang() {
1127        let child = Element::new("span")
1128            .attr("property", "dc:title")
1129            .attr("lang", "")
1130            .text("Title");
1131        let parent = Element::new("div")
1132            .attr("about", "http://example.org/doc")
1133            .attr("lang", "fr")
1134            .child(child);
1135        let triples = parser().parse(&parent);
1136        let title = triples.iter().find(|t| t.predicate.contains("title"));
1137        // Empty lang clears language → plain literal
1138        if let Some(t) = title {
1139            assert!(matches!(&t.object, RdfaObject::Literal(_)));
1140        }
1141    }
1142
1143    #[test]
1144    fn test_curie_unknown_prefix_not_resolved() {
1145        let el = Element::new("span")
1146            .attr("about", "http://example.org/r")
1147            .attr("property", "unknown:prop")
1148            .text("value");
1149        // unknown prefix with no base vocab → property not resolvable if strict
1150        // (with base, it would fall through to relative resolution)
1151        let _triples = parser().parse(&el);
1152        // Should not panic — just may produce a triple or not
1153    }
1154}