Skip to main content

schemaorg_rs/profiles/google/
faqpage.rs

1//! Google Rich Results profile for `FAQPage`.
2//!
3//! Source: <https://developers.google.com/search/docs/appearance/structured-data/faqpage>
4//! Verified: 2026-04-01
5//!
6//! **Note:** Since 2024, `FAQPage` rich results are restricted to authoritative
7//! government and health-focused websites. This profile validates structure
8//! but emits an `EligibilityRestricted` info diagnostic.
9
10use crate::types::{SchemaNode, SchemaValue};
11use crate::validation::diagnostics::{DiagnosticCode, Severity, ValidationDiagnostic};
12use crate::validation::ValidationDiagnostic as VD;
13
14use super::common::{get_nested_nodes, has_non_empty_property, require_property};
15use crate::profiles::{NodeProfileResult, Profile, TypeEligibility};
16
17/// Google Rich Results profile for `FAQPage` structured data.
18pub struct GoogleFaqPageProfile;
19
20impl Profile for GoogleFaqPageProfile {
21    fn name(&self) -> &'static str {
22        "google"
23    }
24
25    fn version(&self) -> &'static str {
26        "2026-04-01"
27    }
28
29    fn source_url(&self) -> &'static str {
30        "https://developers.google.com/search/docs/appearance/structured-data/faqpage"
31    }
32
33    fn supported_types(&self) -> &[&str] {
34        &["FAQPage"]
35    }
36
37    fn evaluate_node(&self, node: &SchemaNode, _vocab_diagnostics: &[VD]) -> NodeProfileResult {
38        let path = "FAQPage";
39        let mut diagnostics = Vec::new();
40        let mut required_missing = Vec::new();
41
42        // Eligibility restriction notice
43        diagnostics.push(ValidationDiagnostic {
44            path: path.to_string(),
45            severity: Severity::Info,
46            code: DiagnosticCode::EligibilityRestricted,
47            message: "FAQPage rich results eligibility is restricted to authoritative sites \
48                      since 2024. Structural validation passed, but display depends on \
49                      Google's site authority assessment."
50                .to_string(),
51            source_location: None,
52        });
53
54        // Required: mainEntity (array of Questions)
55        if let Some(d) = require_property(node, "mainEntity", path) {
56            required_missing.push("mainEntity".to_string());
57            diagnostics.push(d);
58        }
59
60        // Validate each Question in mainEntity
61        let questions = get_nested_nodes(node, "mainEntity");
62        if questions.is_empty() && has_non_empty_property(node, "mainEntity") {
63            // mainEntity exists but contains no nested nodes (might be text/URL)
64            // Check if it's an array of SchemaValues that are nodes
65            if let Some(values) = node.properties.get("mainEntity") {
66                let has_any_node = values.iter().any(|v| matches!(v, SchemaValue::Node(_)));
67                if !has_any_node {
68                    diagnostics.push(ValidationDiagnostic {
69                        path: format!("{path}.mainEntity"),
70                        severity: Severity::Error,
71                        code: DiagnosticCode::InvalidFieldValue,
72                        message: "mainEntity must contain Question objects".to_string(),
73                        source_location: None,
74                    });
75                }
76            }
77        }
78
79        for (i, question) in questions.iter().enumerate() {
80            let q_path = if questions.len() > 1 {
81                format!("{path}.mainEntity[{i}]")
82            } else {
83                format!("{path}.mainEntity")
84            };
85
86            // Question: name required
87            if let Some(d) = require_property(question, "name", &q_path) {
88                diagnostics.push(d);
89            }
90
91            // Question: acceptedAnswer required
92            if let Some(d) = require_property(question, "acceptedAnswer", &q_path) {
93                diagnostics.push(d);
94            }
95
96            // Answer: text required
97            for answer in get_nested_nodes(question, "acceptedAnswer") {
98                let a_path = format!("{q_path}.acceptedAnswer");
99                if let Some(d) = require_property(answer, "text", &a_path) {
100                    diagnostics.push(d);
101                }
102            }
103        }
104
105        let eligible = required_missing.is_empty()
106            && !diagnostics.iter().any(|d| {
107                d.severity == Severity::Error && d.code != DiagnosticCode::EligibilityRestricted
108            });
109
110        NodeProfileResult {
111            type_eligibility: TypeEligibility {
112                schema_type: "FAQPage".to_string(),
113                eligible,
114                required_missing,
115                recommended_missing: Vec::new(),
116                field_diagnostics: diagnostics,
117            },
118        }
119    }
120}