Skip to content

Commit e3bcb1d

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 [IMP] website_blog: WIP
1 parent e201b37 commit e3bcb1d

10 files changed

Lines changed: 600 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: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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 its snake_case key.
117+
The key is normalised exactly like :meth:`set` / :meth:`add_nested`
118+
so callers never have to know the internal camelCase representation.
119+
Args:
120+
key: Property name
121+
default: Value returned when the key is absent.
122+
Returns:
123+
The stored value, or *default*.
124+
"""
125+
return self.values.get(key, default)
126+
127+
def set(self, values: dict[str, Any]) -> JsonLd:
128+
"""Set properties on the schema (overwrites existing keys).
129+
Properties are automatically converted from snake_case to camelCase.
130+
Args:
131+
values: Property name-value pairs
132+
Returns:
133+
Self for method chaining
134+
Example:
135+
>>> product = JsonLd("Product")
136+
>>> product.set({"name": "Widget", "price": 29.99, "brand": "BrandName"})
137+
"""
138+
for key, value in values.items():
139+
if value is None:
140+
continue
141+
items = value if isinstance(value, list) else [value]
142+
if any(isinstance(v, JsonLd) for v in items):
143+
raise TypeError(f"Key '{key}' contains JsonLd value(s). Use add_nested() instead.")
144+
self.values[key] = value
145+
return self
146+
147+
def add_nested(self, values: dict[str, JsonLd | list[JsonLd] | None]) -> JsonLd:
148+
"""Add nested schema builder(s).
149+
A single nested value is stored as-is; values are converted to a list
150+
only when multiple nested values exist for the same key. None values
151+
are ignored.
152+
Args:
153+
values: Property name to JsonLd (or list of them) mapping
154+
Returns:
155+
Self for method chaining
156+
Example:
157+
>>> product = JsonLd("Product", {"name": "Widget"})
158+
>>> offer = JsonLd("Offer", {"price": 99.99, "priceCurrency": "USD"})
159+
>>> product.add_nested({"offers": offer})
160+
>>> isinstance(product.get("offers"), JsonLd)
161+
True
162+
>>>
163+
>>> # Adding another nested item appends to the existing value
164+
>>> offer2 = JsonLd("Offer", {"price": 79.99, "priceCurrency": "EUR"})
165+
>>> product.add_nested({"offers": offer2}) # offers is now [offer, offer2]
166+
>>>
167+
>>> # Multiple nested items at once
168+
>>> product.add_nested({"offers": [offer1, offer2, offer3]})
169+
"""
170+
for key, builder in values.items():
171+
if builder is None:
172+
continue
173+
items = builder if isinstance(builder, list) else [builder]
174+
if not items:
175+
continue
176+
invalid = self._has_invalid_types(items, JsonLd)
177+
if invalid:
178+
raise TypeError(
179+
f"add_nested() expects JsonLd instances for key '{key}', "
180+
f"got: {', '.join(invalid)}")
181+
existing = self.values.get(key)
182+
if existing is None:
183+
self.values[key] = items[0] if len(items) == 1 else items
184+
elif isinstance(existing, JsonLd):
185+
self.values[key] = [existing, *items]
186+
elif isinstance(existing, list):
187+
is_jsonld_list = isinstance(existing[0], JsonLd)
188+
if not is_jsonld_list:
189+
raise TypeError(f"Cannot append to '{key}', existing value is not a list of JsonLd")
190+
existing.extend(items)
191+
else:
192+
raise TypeError(f"Cannot append to '{key}', existing type: {existing.__class__.__name__}")
193+
return self
194+
195+
def _to_jsonld_dict(self, include_context: bool = True) -> dict[str, Any]:
196+
"""Convert this builder to a JSON-LD dictionary.
197+
Args:
198+
include_context: Whether to include @context
199+
Returns:
200+
JSON-LD dictionary
201+
"""
202+
data: dict[str, Any] = {"@type": self.schema_type}
203+
if include_context:
204+
data["@context"] = "https://schema.org"
205+
for key, value in self.values.items():
206+
normalized = self._normalize_value(value)
207+
if normalized is not None:
208+
data[key] = normalized
209+
return data
210+
211+
def _normalize_value(self, value):
212+
"""Normalize a value for JSON-LD rendering."""
213+
# False is also treated as value. None and empty lists are ignored.
214+
if value is None:
215+
return None
216+
if isinstance(value, JsonLd):
217+
return value._to_jsonld_dict(include_context=False)
218+
if isinstance(value, list):
219+
normalized = [
220+
self._normalize_value(v)
221+
for v in value
222+
if v is not None
223+
]
224+
if not normalized:
225+
return None
226+
# Single item arrays can be unwrapped as per Schema.org spec:
227+
# a property with one value doesn't need to be wrapped in an array.
228+
return normalized if len(normalized) > 1 else normalized[0]
229+
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_jsonLD(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._to_structured_data(is_detail_page=is_detail_page))
887+
888+
def _to_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: 17 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
@@ -2454,3 +2455,19 @@ def _is_tag_domains_watchlisted(self, tagName, atts):
24542455

24552456
def _is_tag_classes_watchlisted(self, tagName, atts):
24562457
return self._get_blocked_iframe_containers_classes().intersection((atts.get('class') or '').split(' '))
2458+
2459+
def get_jsonLD(self):
2460+
"""Generate structured data for the website."""
2461+
self.ensure_one()
2462+
return JsonLd.render_structured_data([self.organization_structured_data()])
2463+
2464+
def organization_structured_data(self):
2465+
"""Generate Organization structured data using only website context."""
2466+
self.ensure_one()
2467+
base_url = self.get_base_url()
2468+
logo_url = f"{base_url}/logo.png?company={self.company_id.id}"
2469+
return JsonLd("Organization",
2470+
{"name": self.name,
2471+
"url": base_url,
2472+
"id": f"{base_url}/#organization"},
2473+
).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
@@ -32,6 +32,7 @@
3232
from . import test_sitemap
3333
from . import test_skip_website_configurator
3434
from . import test_snippets
35+
from . import test_structure_data_defination
3536
from . import test_theme
3637
from . import test_ui
3738
from . import test_unsplash_beacon

0 commit comments

Comments
 (0)