Skip to main content

pathland_render_html/
document.rs

1//! Document-level metadata for the HTML renderer (SEO head) — canonical URL,
2//! Open Graph / Twitter cards, and the fallback title/description used by
3//! JSON-LD.
4//!
5//! This is **renderer input**, not protocol state: canonical/OG/Twitter are
6//! web-only artifacts and stay host-owned (spec/OPCODE.md §Window metadata).
7//! The application-owned presentation metadata (`TITLE`, `DESCRIPTION`,
8//! `LOCALE`) still arrives through `META::SET_WINDOW_METADATA`; a
9//! [`DocumentMeta`] title/description is only a fallback when that field is
10//! unset.
11
12use std::collections::BTreeMap;
13
14use pathland_core::{component_type, property_id, WindowMeta};
15
16use crate::{escape_html_attr, Node};
17
18/// The renderer's default document title when neither window metadata nor
19/// [`DocumentMeta`] supplies one.
20pub const DEFAULT_TITLE: &str = "Pathland";
21
22/// Optional document metadata supplied by the host for one render call.
23///
24/// The renderer remains a pure function of the opcode stream **plus this
25/// value**: nothing is retained across calls. Title/description set here are
26/// fallbacks; `META::SET_WINDOW_METADATA` values win.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct DocumentMeta {
29    /// Fallback title when the stream does not set `WINDOW_METADATA::TITLE`.
30    pub title: Option<String>,
31    /// Fallback description when the stream does not set
32    /// `WINDOW_METADATA::DESCRIPTION`.
33    pub description: Option<String>,
34    /// Absolute canonical URL for the current view.
35    pub canonical_url: Option<String>,
36    /// `og:site_name`.
37    pub site_name: Option<String>,
38    /// `og:image` / `twitter:image`; when absent the first `IMAGE` source in
39    /// the tree is used.
40    pub image_url: Option<String>,
41    /// `og:type` (default `website`).
42    pub og_type: Option<String>,
43    /// `twitter:card` (default `summary`, or `summary_large_image` when an
44    /// image is available).
45    pub twitter_card: Option<String>,
46    /// `twitter:site` (e.g. `@pathland`).
47    pub twitter_site: Option<String>,
48    /// Emit Open Graph tags.
49    pub open_graph: bool,
50    /// Emit Twitter card tags.
51    pub twitter: bool,
52    /// Emit JSON-LD structured data (WebPage + Article).
53    pub structured_data: bool,
54}
55
56impl DocumentMeta {
57    /// No extra metadata — the legacy document head.
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// No extra metadata — the legacy document head.
64    #[must_use]
65    pub fn none() -> Self {
66        Self::default()
67    }
68
69    /// Full SEO head for `canonical_url`: canonical + Open Graph + Twitter +
70    /// structured data. Refine with the other builders/fields.
71    #[must_use]
72    pub fn seo(canonical_url: impl Into<String>) -> Self {
73        Self {
74            canonical_url: Some(canonical_url.into()),
75            open_graph: true,
76            twitter: true,
77            structured_data: true,
78            ..Self::default()
79        }
80    }
81
82    /// Set a fallback title.
83    #[must_use]
84    pub fn with_title(mut self, title: impl Into<String>) -> Self {
85        self.title = Some(title.into());
86        self
87    }
88
89    /// Set a fallback description.
90    #[must_use]
91    pub fn with_description(mut self, description: impl Into<String>) -> Self {
92        self.description = Some(description.into());
93        self
94    }
95
96    /// Set the canonical URL.
97    #[must_use]
98    pub fn with_canonical_url(mut self, canonical_url: impl Into<String>) -> Self {
99        self.canonical_url = Some(canonical_url.into());
100        self
101    }
102
103    /// Set `og:site_name`.
104    #[must_use]
105    pub fn with_site_name(mut self, site_name: impl Into<String>) -> Self {
106        self.site_name = Some(site_name.into());
107        self
108    }
109
110    /// Set `og:image` / `twitter:image`.
111    #[must_use]
112    pub fn with_image_url(mut self, image_url: impl Into<String>) -> Self {
113        self.image_url = Some(image_url.into());
114        self
115    }
116
117    /// Set `og:type`.
118    #[must_use]
119    pub fn with_og_type(mut self, og_type: impl Into<String>) -> Self {
120        self.og_type = Some(og_type.into());
121        self
122    }
123
124    /// Set `twitter:card`.
125    #[must_use]
126    pub fn with_twitter_card(mut self, twitter_card: impl Into<String>) -> Self {
127        self.twitter_card = Some(twitter_card.into());
128        self
129    }
130
131    /// Set `twitter:site`.
132    #[must_use]
133    pub fn with_twitter_site(mut self, twitter_site: impl Into<String>) -> Self {
134        self.twitter_site = Some(twitter_site.into());
135        self
136    }
137
138    /// Enable/disable Open Graph tags.
139    #[must_use]
140    pub fn with_open_graph(mut self, on: bool) -> Self {
141        self.open_graph = on;
142        self
143    }
144
145    /// Enable/disable Twitter card tags.
146    #[must_use]
147    pub fn with_twitter(mut self, on: bool) -> Self {
148        self.twitter = on;
149        self
150    }
151
152    /// Enable/disable JSON-LD structured data.
153    #[must_use]
154    pub fn with_structured_data(mut self, on: bool) -> Self {
155        self.structured_data = on;
156        self
157    }
158}
159
160/// The effective title/description for a render: window metadata wins, then
161/// the host fallback, then the renderer default title.
162pub(crate) fn resolve(window: &WindowMeta, meta: &DocumentMeta) -> (String, Option<String>) {
163    let title = window
164        .title
165        .clone()
166        .filter(|value| !value.is_empty())
167        .or_else(|| meta.title.clone().filter(|value| !value.is_empty()))
168        .unwrap_or_else(|| DEFAULT_TITLE.to_string());
169    let description = window
170        .description
171        .clone()
172        .filter(|value| !value.is_empty())
173        .or_else(|| meta.description.clone().filter(|value| !value.is_empty()));
174    (title, description)
175}
176
177/// The image used by `og:image` / `twitter:image` / Article JSON-LD: the
178/// explicit host value, else the first `IMAGE` source in the rendered tree.
179pub(crate) fn image_url(
180    meta: &DocumentMeta,
181    nodes: &BTreeMap<u32, Node>,
182    root: u32,
183) -> Option<String> {
184    meta.image_url
185        .clone()
186        .filter(|value| !value.is_empty())
187        .or_else(|| first_image_source(nodes, root).map(str::to_string))
188}
189
190/// The canonical link (emitted independently of the Open Graph toggle).
191fn canonical_tag(meta: &DocumentMeta) -> String {
192    meta.canonical_url
193        .as_deref()
194        .filter(|url| !url.is_empty())
195        .map(|url| {
196            format!(
197                "<link rel=\"canonical\" href=\"{}\">\n",
198                escape_html_attr(url)
199            )
200        })
201        .unwrap_or_default()
202}
203
204/// Canonical + Open Graph + Twitter tags for the document head, in a fixed
205/// order shared with the Java renderer.
206pub(crate) fn head_tags(
207    meta: &DocumentMeta,
208    title: &str,
209    description: Option<&str>,
210    locale: Option<&str>,
211    image: Option<&str>,
212) -> String {
213    let mut out = canonical_tag(meta);
214    let canonical = meta.canonical_url.as_deref().filter(|url| !url.is_empty());
215
216    if meta.open_graph {
217        let og_type = meta
218            .og_type
219            .as_deref()
220            .filter(|value| !value.is_empty())
221            .unwrap_or("website");
222        out.push_str(&format!(
223            "<meta property=\"og:type\" content=\"{}\">\n",
224            escape_html_attr(og_type)
225        ));
226        out.push_str(&format!(
227            "<meta property=\"og:title\" content=\"{}\">\n",
228            escape_html_attr(title)
229        ));
230        if let Some(description) = description {
231            out.push_str(&format!(
232                "<meta property=\"og:description\" content=\"{}\">\n",
233                escape_html_attr(description)
234            ));
235        }
236        if let Some(canonical) = canonical {
237            out.push_str(&format!(
238                "<meta property=\"og:url\" content=\"{}\">\n",
239                escape_html_attr(canonical)
240            ));
241        }
242        if let Some(site_name) = meta.site_name.as_deref().filter(|v| !v.is_empty()) {
243            out.push_str(&format!(
244                "<meta property=\"og:site_name\" content=\"{}\">\n",
245                escape_html_attr(site_name)
246            ));
247        }
248        if let Some(image) = image.filter(|v| !v.is_empty()) {
249            out.push_str(&format!(
250                "<meta property=\"og:image\" content=\"{}\">\n",
251                escape_html_attr(image)
252            ));
253        }
254        if let Some(locale) = locale.filter(|v| !v.is_empty()) {
255            out.push_str(&format!(
256                "<meta property=\"og:locale\" content=\"{}\">\n",
257                escape_html_attr(locale)
258            ));
259        }
260    }
261
262    if meta.twitter {
263        let card = meta
264            .twitter_card
265            .as_deref()
266            .filter(|v| !v.is_empty())
267            .unwrap_or(if image.is_some() {
268                "summary_large_image"
269            } else {
270                "summary"
271            });
272        out.push_str(&format!(
273            "<meta name=\"twitter:card\" content=\"{}\">\n",
274            escape_html_attr(card)
275        ));
276        out.push_str(&format!(
277            "<meta name=\"twitter:title\" content=\"{}\">\n",
278            escape_html_attr(title)
279        ));
280        if let Some(description) = description {
281            out.push_str(&format!(
282                "<meta name=\"twitter:description\" content=\"{}\">\n",
283                escape_html_attr(description)
284            ));
285        }
286        if let Some(image) = image.filter(|v| !v.is_empty()) {
287            out.push_str(&format!(
288                "<meta name=\"twitter:image\" content=\"{}\">\n",
289                escape_html_attr(image)
290            ));
291        }
292        if let Some(site) = meta.twitter_site.as_deref().filter(|v| !v.is_empty()) {
293            out.push_str(&format!(
294                "<meta name=\"twitter:site\" content=\"{}\">\n",
295                escape_html_attr(site)
296            ));
297        }
298    }
299
300    out
301}
302
303/// Resolve a possibly root-relative asset URL against the canonical origin.
304pub(crate) fn resolve_url(canonical: Option<&str>, url: &str) -> String {
305    if url.is_empty() {
306        return String::new();
307    }
308    if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("//") {
309        return url.to_string();
310    }
311    if let (Some(canonical), true) = (canonical, url.starts_with('/')) {
312        if let Some(origin) = origin_of(canonical) {
313            return format!("{origin}{url}");
314        }
315    }
316    url.to_string()
317}
318
319/// The `scheme://authority` prefix of an absolute URL.
320fn origin_of(url: &str) -> Option<&str> {
321    let scheme = url.find("://")?;
322    let rest = &url[scheme + 3..];
323    let end = rest.find('/').map_or(url.len(), |i| scheme + 3 + i);
324    Some(&url[..end])
325}
326
327/// Escape a value for embedding inside a JSON string (no surrounding quotes).
328/// `<`, `>`, and `&` are unicode-escaped so application text can never close
329/// the surrounding `<script>` element.
330pub(crate) fn json_escape(value: &str) -> String {
331    let mut out = String::with_capacity(value.len());
332    for ch in value.chars() {
333        match ch {
334            '"' => out.push_str("\\\""),
335            '\\' => out.push_str("\\\\"),
336            '\u{08}' => out.push_str("\\b"),
337            '\u{0C}' => out.push_str("\\f"),
338            '\n' => out.push_str("\\n"),
339            '\r' => out.push_str("\\r"),
340            '\t' => out.push_str("\\t"),
341            '<' => out.push_str("\\u003C"),
342            '>' => out.push_str("\\u003E"),
343            '&' => out.push_str("\\u0026"),
344            '\u{2028}' => out.push_str("\\u2028"),
345            '\u{2029}' => out.push_str("\\u2029"),
346            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
347            c => out.push(c),
348        }
349    }
350    out
351}
352
353/// The first `IMAGE` component's `IMAGE_SOURCE` in document order (depth-first
354/// from `root`).
355pub(crate) fn first_image_source<'a>(nodes: &'a BTreeMap<u32, Node>, root: u32) -> Option<&'a str> {
356    let node = nodes.get(&root)?;
357    if node.component == component_type::IMAGE {
358        if let Some(src) = node.strings.get(&property_id::IMAGE_SOURCE) {
359            if !src.is_empty() {
360                return Some(src);
361            }
362        }
363    }
364    node.children
365        .iter()
366        .find_map(|child| first_image_source(nodes, *child))
367}