Skip to content

Commit 8f90297

Browse files
committed
[IMP] website, *: introduce JsonLd builder and structured data
*=website_blog Body: This commit introduces a reusable JsonLd builder for Schema.org payloads and integrates structured data generation in website and website_blog. - add a JsonLd helper with snake_case to camelCase normalization, nested schema support, datetime normalization, and safe rendering for single or multiple schemas - add website structured data foundations (organization schema default and breadcrumb helper) through a dedicated mixin - expose website-level structured data generation and inject structured_data in template rendering context - render JSON-LD payload in website layout head - add images_from_html utility to collect post images from blog content - generate blog schemas for listing and detail pages (Blog, CollectionPage, BlogPosting, BreadcrumbList) - pass structured_data from blog controllers for both list and detail routes - add dedicated tests validating JsonLd behavior and serialization rules This change enables consistent, extensible structured-data generation across website and blog pages. task-4655276
1 parent 63aa07b commit 8f90297

10 files changed

Lines changed: 572 additions & 3 deletions

File tree

addons/website/helpers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import jsonld_builder
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC
6+
from typing import Any
7+
8+
from odoo import fields
9+
from odoo.tools.json import scriptsafe
10+
11+
12+
class JsonLd:
13+
"""
14+
Fluent builder for creating Schema.org JSON-LD structured data.
15+
16+
Example:
17+
>>> product = JsonLd("Product", {
18+
... "name": "Laptop",
19+
... "sku": "LAP-001",
20+
... "price": 999.99,
21+
... })
22+
>>> offer = JsonLd("Offer", {
23+
... "price": 999.99,
24+
... "priceCurrency": "USD",
25+
... "availability": "https://schema.org/InStock"
26+
... })
27+
>>> product.add_nested({"offers": offer})
28+
29+
>>> JsonLd.render_structured_data([product])
30+
"""
31+
__slots__ = ("schema_type", "values")
32+
33+
def __init__(self, schema_type: str, /, values: dict[str, Any] | None = None):
34+
self.schema_type = schema_type
35+
self.values = {}
36+
if values:
37+
self.set(values)
38+
39+
@staticmethod
40+
def _has_invalid_types(values, expected_type: type) -> list[str]:
41+
"""Return class names of items that are not instances of *expected_type*,
42+
in the order they first appear."""
43+
return list(dict.fromkeys(
44+
v.__class__.__name__
45+
for v in values
46+
if v is not None and not isinstance(v, expected_type)
47+
))
48+
49+
@staticmethod
50+
def render_structured_data(builders: list[JsonLd]) -> str | bool:
51+
"""Render a list of JsonLd builders into a JSON-LD string.
52+
Falsy values (e.g., None) are ignored. If no valid builders remain,
53+
returns False.
54+
Args:
55+
builders: A list of JsonLd instances.
56+
Returns:
57+
A JSON string containing the rendered JSON-LD data, or False if
58+
the list is empty or contains no valid builders.
59+
Raises:
60+
TypeError: If any item in *builders* is not a JsonLd instance.
61+
Example:
62+
>>> org = JsonLd("Organization", {"name": "Example Corp"})
63+
>>> website = JsonLd("WebSite", {"name": "Example Site", "url": "https://example.com"})
64+
>>> JsonLd.render_structured_data([org, website])
65+
>>> Output: [
66+
{
67+
"@type": "Organization",
68+
"@context": "https://schema.org",
69+
"name": "Example Corp"
70+
},
71+
{
72+
"@type": "WebSite",
73+
"@context": "https://schema.org",
74+
"name": "Example Site",
75+
"url": "https://example.com"
76+
}
77+
]
78+
"""
79+
if not builders:
80+
return False
81+
invalid = JsonLd._has_invalid_types(builders, JsonLd)
82+
if invalid:
83+
raise TypeError(
84+
f"render_structured_data() expects JsonLd instances, "
85+
f"got: {', '.join(invalid)}",
86+
)
87+
rendered = [b._to_jsonld_dict() for b in builders if b]
88+
if not rendered:
89+
return False
90+
return scriptsafe.dumps(rendered, ensure_ascii=False)
91+
92+
@staticmethod
93+
def to_iso_datetime(dt) -> str | None:
94+
"""Convert datetime to ISO-8601 string with timezone information.
95+
As per https://schema.org/DateTime, the value must be in ISO-8601 format
96+
and include timezone information.
97+
Args:
98+
dt: Datetime object or compatible value
99+
Returns:
100+
ISO-8601 formatted string with timezone, or None if dt is falsy
101+
or cannot be converted.
102+
Example:
103+
>>> from datetime import datetime
104+
>>> dt = datetime(2025, 1, 15, 10, 30)
105+
>>> JsonLd.to_iso_datetime(dt)
106+
'2025-01-15T10:30:00+00:00'
107+
"""
108+
as_datetime = fields.Datetime.to_datetime(dt)
109+
if not as_datetime:
110+
return None
111+
if not as_datetime.tzinfo:
112+
as_datetime = as_datetime.replace(tzinfo=UTC)
113+
return as_datetime.isoformat()
114+
115+
def get(self, key: str, default=None):
116+
"""Retrieve a stored value by key.
117+
Args:
118+
key: Property name
119+
default: Value returned when the key is absent.
120+
Returns:
121+
The stored value, or *default*.
122+
"""
123+
return self.values.get(key, default)
124+
125+
def set(self, values: dict[str, Any]) -> JsonLd:
126+
"""Set properties on the schema (overwrites existing keys).
127+
Properties are automatically converted from snake_case to camelCase.
128+
Args:
129+
values: Property name-value pairs
130+
Returns:
131+
Self for method chaining
132+
Example:
133+
>>> product = JsonLd("Product")
134+
>>> product.set({"name": "Widget", "price": 29.99, "brand": "BrandName"})
135+
"""
136+
for key, value in values.items():
137+
if value is None:
138+
continue
139+
items = value if isinstance(value, list) else [value]
140+
if any(isinstance(v, JsonLd) for v in items):
141+
raise TypeError(f"Key '{key}' contains JsonLd value(s). Use add_nested() instead.")
142+
self.values[key] = value
143+
return self
144+
145+
def add_nested(self, values: dict[str, JsonLd | list[JsonLd] | None]) -> JsonLd:
146+
"""Add nested schema builder(s).
147+
A single nested value is stored as-is; values are converted to a list
148+
only when multiple nested values exist for the same key. None values
149+
are ignored.
150+
Args:
151+
values: Property name to JsonLd (or list of them) mapping
152+
Returns:
153+
Self for method chaining
154+
Example:
155+
>>> product = JsonLd("Product", {"name": "Widget"})
156+
>>> offer = JsonLd("Offer", {"price": 99.99, "priceCurrency": "USD"})
157+
>>> product.add_nested({"offers": offer})
158+
>>> isinstance(product.get("offers"), JsonLd)
159+
True
160+
>>>
161+
>>> # Adding another nested item appends to the existing value
162+
>>> offer2 = JsonLd("Offer", {"price": 79.99, "priceCurrency": "EUR"})
163+
>>> product.add_nested({"offers": offer2}) # offers is now [offer, offer2]
164+
>>>
165+
>>> # Multiple nested items at once
166+
>>> product.add_nested({"offers": [offer1, offer2, offer3]})
167+
"""
168+
for key, builder in values.items():
169+
if builder is None:
170+
continue
171+
items = builder if isinstance(builder, list) else [builder]
172+
if not items:
173+
continue
174+
invalid = self._has_invalid_types(items, JsonLd)
175+
if invalid:
176+
raise TypeError(
177+
f"add_nested() expects JsonLd instances for key '{key}', "
178+
f"got: {', '.join(invalid)}")
179+
existing = self.values.get(key)
180+
if existing is None:
181+
self.values[key] = items[0] if len(items) == 1 else items
182+
elif isinstance(existing, JsonLd):
183+
self.values[key] = [existing, *items]
184+
elif isinstance(existing, list):
185+
is_jsonld_list = isinstance(existing[0], JsonLd)
186+
if not is_jsonld_list:
187+
raise TypeError(f"Cannot append to '{key}', existing value is not a list of JsonLd")
188+
existing.extend(items)
189+
else:
190+
raise TypeError(f"Cannot append to '{key}', existing type: {existing.__class__.__name__}")
191+
return self
192+
193+
def _to_jsonld_dict(self, include_context: bool = True) -> dict[str, Any]:
194+
"""Convert this builder to a JSON-LD dictionary.
195+
Args:
196+
include_context: Whether to include @context
197+
Returns:
198+
JSON-LD dictionary
199+
"""
200+
normalized_values = {}
201+
for key, value in self.values.items():
202+
v = self._normalize_value(value)
203+
if v is not None:
204+
normalized_values[key] = v
205+
# Only @id -> reference object
206+
if len(normalized_values) == 1 and "@id" in normalized_values:
207+
return normalized_values
208+
data: dict[str, Any] = {}
209+
if include_context:
210+
data["@context"] = "https://schema.org"
211+
data["@type"] = self.schema_type
212+
data.update(normalized_values)
213+
return data
214+
215+
def _normalize_value(self, value):
216+
"""Normalize a value for JSON-LD rendering."""
217+
# False is also treated as value. None and empty lists are ignored.
218+
if value is None:
219+
return None
220+
if isinstance(value, JsonLd):
221+
return value._to_jsonld_dict(include_context=False)
222+
if isinstance(value, list):
223+
normalized = [
224+
self._normalize_value(v)
225+
for v in value
226+
if v is not None
227+
]
228+
if not normalized:
229+
return None
230+
# Single item arrays can be unwrapped as per Schema.org spec:
231+
# a property with one value doesn't need to be wrapped in an array.
232+
return normalized if len(normalized) > 1 else normalized[0]
233+
return value

addons/website/models/mixins.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from odoo import api, fields, models, _
77
from odoo.fields import Domain
88
from odoo.addons.website.tools import text_from_html
9+
from odoo.addons.website.helpers.jsonld_builder import JsonLd
910
from odoo.http import request
1011
from odoo.exceptions import AccessError, UserError
1112
from odoo.models import Query
@@ -870,3 +871,48 @@ def _search_highlight_tags(self, field_meta, value, term):
870871
return False, value, 'html'
871872

872873
return True, value, 'tags'
874+
875+
876+
class WebsiteStructuredDataMixin(models.AbstractModel):
877+
_name = 'website.structured_data.mixin'
878+
_description = 'Website Structured Data Mixin'
879+
880+
def get_json_ld(self, is_detail_page=False):
881+
"""Return the JSON-LD structured data for this record.
882+
:param is_detail_page: whether the structured data is for a detail page
883+
:return: string containing the JSON-LD structured data
884+
:rtype: str (JSON-LD)
885+
"""
886+
return JsonLd.render_structured_data(self._build_structured_data(is_detail_page=is_detail_page))
887+
888+
def _build_structured_data(self, is_detail_page=False):
889+
"""Return a list of JsonLd builders for this record.
890+
891+
Default implementation returns the Organization schema of the
892+
current website. Override in sub-models to append page-specific
893+
schemas (BlogPosting, Product, BreadcrumbList, ...).
894+
895+
:param is_detail_page: whether the structured data is for a detail page
896+
:return: list of JsonLd builders to be rendered in the page
897+
:rtype: list[JsonLd]
898+
"""
899+
website = self.env['website'].get_current_website()
900+
return [website.organization_structured_data()]
901+
902+
def _build_breadcrumb_schema(self, items):
903+
"""Generic breadcrumb builder.
904+
905+
:param items: List of ``(name, url)`` tuples.
906+
:return: BreadcrumbList schema, or ``None`` when no valid items.
907+
:rtype: JsonLd | None
908+
"""
909+
valid_items = [item for item in items if item is not None]
910+
if not valid_items:
911+
return None
912+
list_items = []
913+
for position, (name, url) in enumerate(valid_items, start=1):
914+
item = JsonLd("ListItem", {"position": position, "name": name, "item": url})
915+
list_items.append(item)
916+
breadcrumbs = JsonLd("BreadcrumbList")
917+
breadcrumbs.add_nested({"itemListElement": list_items})
918+
return breadcrumbs

addons/website/models/website.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from urllib.parse import urlparse, urlsplit
1919
from werkzeug import urls
2020

21+
from odoo.addons.website.helpers.jsonld_builder import JsonLd
2122
from odoo import api, fields, models, tools, release
2223
from odoo.addons.website.models.ir_http import sitemap_qs2dom
2324
from odoo.addons.website.tools import similarity_score, text_from_html, get_base_domain
@@ -2491,3 +2492,21 @@ def _is_tag_domains_watchlisted(self, tagName, atts):
24912492

24922493
def _is_tag_classes_watchlisted(self, tagName, atts):
24932494
return self._get_blocked_iframe_containers_classes().intersection((atts.get('class') or '').split(' '))
2495+
2496+
def get_json_ld(self):
2497+
"""Generate structured data for the website."""
2498+
self.ensure_one()
2499+
return JsonLd.render_structured_data([self.organization_structured_data()])
2500+
2501+
def organization_structured_data(self):
2502+
"""Generate Organization structured data using only website context."""
2503+
self.ensure_one()
2504+
base_url = self.get_base_url()
2505+
logo_url = f"{base_url}/logo.png?company={self.company_id.id}"
2506+
return JsonLd("Organization",
2507+
{
2508+
"name": self.name,
2509+
"url": base_url,
2510+
"@id": f"{base_url}/#organization",
2511+
},
2512+
).add_nested({"logo": JsonLd("ImageObject", {"url": logo_url})})

addons/website/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from . import test_iap
2020
from . import test_import_files
2121
from . import test_ir_asset
22+
from . import test_jsonld_builder
2223
from . import test_lang_url
2324
from . import test_menu
2425
from . import test_multi_website

0 commit comments

Comments
 (0)