Skip to content

Commit bd269fd

Browse files
committed
Scope carousel list items and bump version
1 parent b9116cf commit bd269fd

8 files changed

Lines changed: 145 additions & 27 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# AGENTS
2+
3+
## Project Notes
4+
5+
- Generated Google SHACLs scope `ListItem` requirements under `ItemList` when both types
6+
appear in a feature definition, preventing carousel constraints from firing on
7+
`BreadcrumbList`.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ Override the web page import callback by placing `web_page_import_protocol.py` w
9090

9191
Add `.ttl.liquid` files under `data/templates`. Templates render with `account` fields available (e.g., `{{ account.dataset_uri }}`) and are uploaded before URL handling begins.
9292

93+
## Validation
94+
95+
SHACL validation utilities and generated Google Search Gallery shapes are included. Carousel `ListItem` requirements are scoped under `ItemList` when both types are present to avoid enforcing carousel constraints on non-carousel lists (for example, `BreadcrumbList`).
96+
9397
## Testing
9498

9599
```bash
@@ -100,4 +104,3 @@ poetry run pytest
100104
## Documentation
101105

102106
- [Google Sheets Lookup](docs/google_sheets_lookup.md): Utility for O(1) lookups from Google Sheets.
103-

TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# TODO
2+
3+
- [x] 2026-02-04 Scope carousel `ListItem` constraints under `ItemList` in the SHACL generator.
4+
- [x] 2026-02-04 Replace deprecated `datetime.utcnow()` with timezone-aware UTC timestamps.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "wordlift-sdk"
3-
version = "2.10.3"
3+
version = "2.11.0"
44
description = ""
55
authors = ["David Riccitelli <david@wordlift.io>"]
66
readme = "README.md"

specs/validation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Validation Specs
2+
3+
## Google Search Gallery SHACLs
4+
5+
When a Google Search Gallery feature includes both `ItemList` and `ListItem` requirements,
6+
`ListItem` constraints are scoped under `ItemList` via `itemListElement` instead of
7+
targeting `ListItem` globally. This prevents carousel rules from applying to unrelated
8+
lists such as `BreadcrumbList`.

tests/test_shacl_generator.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from pathlib import Path
2+
3+
from wordlift_sdk.validation.generator import FeatureData, _write_feature
4+
5+
6+
def _read_output(tmp_path: Path, feature: FeatureData) -> str:
7+
output_path = tmp_path / "google-carousel.ttl"
8+
assert _write_feature(feature, output_path, overwrite=True)
9+
return output_path.read_text(encoding="utf-8")
10+
11+
12+
def test_scopes_listitem_under_itemlist(tmp_path: Path) -> None:
13+
feature = FeatureData(
14+
url="https://example.com",
15+
types={
16+
"ItemList": {"required": {"itemListElement"}, "recommended": set()},
17+
"ListItem": {
18+
"required": {"position", "url", "name", "item"},
19+
"recommended": set(),
20+
},
21+
},
22+
)
23+
24+
content = _read_output(tmp_path, feature)
25+
26+
assert "sh:targetClass schema:ItemList" in content
27+
assert "sh:targetClass schema:ListItem" not in content
28+
assert "sh:path schema:itemListElement" in content
29+
assert "sh:node [" in content
30+
assert "sh:class schema:ListItem" in content
31+
assert "sh:path schema:url" in content
32+
33+
34+
def test_keeps_listitem_shape_without_itemlist(tmp_path: Path) -> None:
35+
feature = FeatureData(
36+
url="https://example.com",
37+
types={
38+
"ListItem": {
39+
"required": {"position", "url"},
40+
"recommended": set(),
41+
}
42+
},
43+
)
44+
45+
content = _read_output(tmp_path, feature)
46+
47+
assert "sh:targetClass schema:ListItem" in content

wordlift_sdk/validation/generator.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import html as html_lib
77
import re
88
from dataclasses import dataclass
9-
from datetime import datetime
9+
from datetime import datetime, timezone
1010
from pathlib import Path
1111
from typing import Iterable
1212

@@ -173,6 +173,10 @@ def _write_feature(feature: FeatureData, output_path: Path, overwrite: bool) ->
173173
if output_path.exists() and not overwrite:
174174
return False
175175

176+
scoped_list_item = None
177+
if "ItemList" in feature.types and "ListItem" in feature.types:
178+
scoped_list_item = feature.types["ListItem"]
179+
176180
lines: list[str] = []
177181
slug = output_path.stem
178182
prefix_base = f"https://wordlift.io/shacl/google/{slug}/"
@@ -181,27 +185,67 @@ def _write_feature(feature: FeatureData, output_path: Path, overwrite: bool) ->
181185
lines.append("@prefix schema: <http://schema.org/> .")
182186
lines.append("")
183187
lines.append(f"# Source: {feature.url}")
184-
lines.append(f"# Generated: {datetime.utcnow().isoformat(timespec='seconds')}Z")
188+
lines.append(
189+
f"# Generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}Z"
190+
)
185191
lines.append(
186192
"# Notes: required properties => errors; recommended properties => warnings."
187193
)
188194
lines.append("")
189195

190196
for type_name in sorted(feature.types.keys()):
197+
if scoped_list_item and type_name == "ListItem":
198+
continue
191199
bucket = feature.types[type_name]
192200
shape_name = f":google_{type_name}Shape"
193201
lines.append(shape_name)
194202
lines.append(" a sh:NodeShape ;")
195203
lines.append(f" sh:targetClass schema:{type_name} ;")
196204

197205
for prop in sorted(bucket["required"]):
206+
if (
207+
scoped_list_item
208+
and type_name == "ItemList"
209+
and prop == "itemListElement"
210+
):
211+
lines.append(" sh:property [")
212+
lines.append(" sh:path schema:itemListElement ;")
213+
lines.append(" sh:minCount 1 ;")
214+
lines.append(" sh:node [")
215+
lines.append(" a sh:NodeShape ;")
216+
lines.append(" sh:class schema:ListItem ;")
217+
for item_prop in sorted(scoped_list_item["required"]):
218+
item_path = _prop_path(item_prop)
219+
lines.append(" sh:property [")
220+
lines.append(f" sh:path {item_path} ;")
221+
lines.append(" sh:minCount 1 ;")
222+
lines.append(" ] ;")
223+
for item_prop in sorted(scoped_list_item["recommended"]):
224+
item_path = _prop_path(item_prop)
225+
lines.append(" sh:property [")
226+
lines.append(f" sh:path {item_path} ;")
227+
lines.append(" sh:minCount 1 ;")
228+
lines.append(" sh:severity sh:Warning ;")
229+
lines.append(
230+
f' sh:message "Recommended by Google: {item_prop}." ;'
231+
)
232+
lines.append(" ] ;")
233+
lines.append(" ] ;")
234+
lines.append(" ] ;")
235+
continue
198236
path = _prop_path(prop)
199237
lines.append(" sh:property [")
200238
lines.append(f" sh:path {path} ;")
201239
lines.append(" sh:minCount 1 ;")
202240
lines.append(" ] ;")
203241

204242
for prop in sorted(bucket["recommended"]):
243+
if (
244+
scoped_list_item
245+
and type_name == "ItemList"
246+
and prop == "itemListElement"
247+
):
248+
continue
205249
path = _prop_path(prop)
206250
lines.append(" sh:property [")
207251
lines.append(f" sh:path {path} ;")
@@ -381,7 +425,9 @@ def generate_schema_shacls(output_file: Path, overwrite: bool) -> int:
381425
lines.append(f"@prefix schema: <{SCHEMA_DATA}> .")
382426
lines.append("")
383427
lines.append(f"# Source: {SCHEMA_JSONLD_URL}")
384-
lines.append(f"# Generated: {datetime.utcnow().isoformat(timespec='seconds')}Z")
428+
lines.append(
429+
f"# Generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}Z"
430+
)
385431
lines.append(
386432
"# Notes: schema.org grammar checks only; all constraints are warnings."
387433
)

wordlift_sdk/validation/shacls/google-carousel.ttl

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
@prefix schema: <http://schema.org/> .
44

55
# Source: https://developers.google.com/search/docs/appearance/structured-data/carousel
6-
# Generated: 2026-01-13T11:23:16Z
6+
# Generated: 2026-02-04T18:12:24+00:00Z
77
# Notes: required properties => errors; recommended properties => warnings.
88

99
:google_ItemListShape
@@ -12,26 +12,29 @@
1212
sh:property [
1313
sh:path schema:itemListElement ;
1414
sh:minCount 1 ;
15-
] ;
16-
.
17-
18-
:google_ListItemShape
19-
a sh:NodeShape ;
20-
sh:targetClass schema:ListItem ;
21-
sh:property [
22-
sh:path schema:item ;
23-
sh:minCount 1 ;
24-
] ;
25-
sh:property [
26-
sh:path schema:name ;
27-
sh:minCount 1 ;
28-
] ;
29-
sh:property [
30-
sh:path schema:position ;
31-
sh:minCount 1 ;
32-
] ;
33-
sh:property [
34-
sh:path schema:url ;
35-
sh:minCount 1 ;
15+
sh:node [
16+
a sh:NodeShape ;
17+
sh:class schema:ListItem ;
18+
sh:property [
19+
sh:path schema:item ;
20+
sh:minCount 1 ;
21+
] ;
22+
sh:property [
23+
sh:path ( schema:item schema:name ) ;
24+
sh:minCount 1 ;
25+
] ;
26+
sh:property [
27+
sh:path ( schema:item schema:url ) ;
28+
sh:minCount 1 ;
29+
] ;
30+
sh:property [
31+
sh:path schema:position ;
32+
sh:minCount 1 ;
33+
] ;
34+
sh:property [
35+
sh:path schema:url ;
36+
sh:minCount 1 ;
37+
] ;
38+
] ;
3639
] ;
3740
.

0 commit comments

Comments
 (0)