Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 8.4.5 - 2026-09-09

### Fixed

- Breadcrumb SHACL validation no longer requires `item` on every `ListItem`,
matching Google's documented rule that the final entry in a
`BreadcrumbList` may omit it. The shape now permits at most one entry
without `item` and still reports a violation when two or more are missing.

## 8.4.4 - 2026-09-08

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wordlift-sdk"
version = "8.4.4"
version = "8.4.5"
description = "Python toolkit for orchestrating WordLift imports and structured data workflows."
authors = ["David Riccitelli <david@wordlift.io>"]
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/search_gallery/baseline_conformance.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"total_with_context": 6
},
"breadcrumb": {
"conforming": 2,
"conforming": 4,
"total_with_context": 4
},
"carousel": {
Expand Down
6 changes: 0 additions & 6 deletions tests/fixtures/search_gallery/expectations.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@
"017-library-system-feed-01"
]
},
"breadcrumb": {
"known_nonconforming_sample_ids": [
"001-json-ld-bredacrumb-01",
"005-json-ld-multiple-breadcrumbs-01"
]
},
"carousel": {
"known_nonconforming_sample_ids": [
"001-summary-page-01",
Expand Down
96 changes: 96 additions & 0 deletions tests/test_breadcrumb_listitem_item_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from wordlift_sdk.validation.shacl import (
PreparedShaclValidator,
_load_graph_from_jsonld,
prepare_shapes,
)

import pytest


@pytest.fixture(scope="module")
def validator() -> PreparedShaclValidator:
return PreparedShaclValidator(prepare_shapes(["google-breadcrumb"]))


def _list_item(position: object, item: str | None = None) -> dict:
node: dict = {"@type": "ListItem", "position": position, "name": "X"}
if item is not None:
node["item"] = item
return node


def _breadcrumb(elements: list[dict]) -> dict:
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": elements,
}


def _conforms(validator: PreparedShaclValidator, elements: list[dict]) -> bool:
graph = _load_graph_from_jsonld(_breadcrumb(elements))
return validator.validate_graph(graph).conforms


@pytest.mark.parametrize(
"elements",
[
pytest.param(
[
_list_item(1, "https://example.com/a"),
_list_item(2, "https://example.com/b"),
],
id="every-entry-has-item",
),
pytest.param(
[_list_item(1, "https://example.com/a"), _list_item(2)],
id="final-entry-omits-item",
),
pytest.param(
[_list_item(1), _list_item(2, "https://example.com/b")],
id="first-entry-omits-item",
),
pytest.param([_list_item(1)], id="single-entry-omits-item"),
],
)
def test_one_entry_may_omit_item(
validator: PreparedShaclValidator, elements: list[dict]
) -> None:
assert _conforms(validator, elements)


@pytest.mark.parametrize(
"elements",
[
pytest.param(
[_list_item(1, "https://example.com/a"), _list_item(2), _list_item(3)],
id="two-entries-omit-item",
),
pytest.param(
[_list_item(1), _list_item(2), _list_item(3)],
id="no-entry-has-item",
),
pytest.param(
[_list_item(1.0), _list_item(2.0), _list_item(3.0)],
id="float-positions",
),
pytest.param(
[_list_item("1"), _list_item("2"), _list_item("3")],
id="string-positions",
),
pytest.param([_list_item(1), _list_item(1)], id="tied-positions"),
],
)
def test_two_or_more_entries_omitting_item_fail(
validator: PreparedShaclValidator, elements: list[dict]
) -> None:
assert not _conforms(validator, elements)


def test_missing_position_is_still_reported(
validator: PreparedShaclValidator,
) -> None:
graph = _load_graph_from_jsonld(_breadcrumb([{"@type": "ListItem", "name": "X"}]))
result = validator.validate_graph(graph)
assert not result.conforms
assert "position" in result.report_text
35 changes: 35 additions & 0 deletions tests/test_shacl_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,38 @@ def test_keeps_listitem_shape_without_itemlist(tmp_path: Path) -> None:
content = _read_output(tmp_path, feature)

assert "sh:targetClass schema:ListItem" in content


def test_breadcrumb_listitem_item_is_exempted_for_one_entry(tmp_path: Path) -> None:
feature = FeatureData(
url="https://example.com",
types={
"BreadcrumbList": {"required": {"itemListElement"}, "recommended": set()},
"ListItem": {
"required": {"position", "name", "item"},
"recommended": set(),
},
},
)

content = _read_output(tmp_path, feature)

assert "sh:qualifiedMaxCount 1 ;" in content
assert "sh:not [" in content
node_shape = content.split("sh:qualifiedValueShape")[0]
assert "sh:path schema:item ;\n sh:minCount 1 ;" not in node_shape


def test_itemlist_listitem_item_stays_unconditional(tmp_path: Path) -> None:
feature = FeatureData(
url="https://example.com",
types={
"ItemList": {"required": {"itemListElement"}, "recommended": set()},
"ListItem": {"required": {"position", "item"}, "recommended": set()},
},
)

content = _read_output(tmp_path, feature)

assert "sh:qualifiedMaxCount" not in content
assert "sh:path schema:item ;\n sh:minCount 1 ;" in content
46 changes: 46 additions & 0 deletions wordlift_sdk/validation/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ def _emit_property(
visited: set[str],
one_of_map: dict[str, list[set[str]]],
one_of_option_map: dict[str, list[list[set[str]]]],
parent_type: str | None = None,
) -> None:
sp = " " * indent
path = _prop_path(prop)
Expand Down Expand Up @@ -565,6 +566,7 @@ def _emit_property(
one_of_map,
one_of_option_map.get(child_type, []),
one_of_option_map,
parent_type,
)
lines.append(f"{node_sp}] ;")
elif len(valid_children) > 1:
Expand All @@ -585,6 +587,7 @@ def _emit_property(
one_of_map,
one_of_option_map.get(child_type, []),
one_of_option_map,
parent_type,
)
lines.append(f"{or_sp} ]")
lines.append(f"{or_sp}) ;")
Expand Down Expand Up @@ -637,6 +640,7 @@ def _emit_one_of_groups(
one_of_map,
one_of_option_map.get(child_type, []),
one_of_option_map,
parent_type,
)
lines.append(f"{node_sp}] ;")
elif len(valid_children) > 1:
Expand All @@ -657,6 +661,7 @@ def _emit_one_of_groups(
one_of_map,
one_of_option_map.get(child_type, []),
one_of_option_map,
parent_type,
)
lines.append(f"{or_sp} ]")
lines.append(f"{or_sp}) ;")
Expand Down Expand Up @@ -699,6 +704,7 @@ def _emit_one_of_option_groups(
visited=visited,
one_of_map=one_of_map,
one_of_option_map=one_of_option_map,
parent_type=parent_type,
)
lines.append(f"{sp} ]")
lines.append(f"{sp}) ;")
Expand Down Expand Up @@ -727,6 +733,26 @@ def _emit_listitem_name_or_item_name(lines: list[str], indent: int) -> None:
lines.append(f"{sp}) ;")


def _emit_breadcrumb_item_exemption(lines: list[str], indent: int) -> None:
sp = " " * indent
lines.append(f"{sp}sh:property [")
lines.append(f"{sp} sh:path schema:itemListElement ;")
lines.append(f"{sp} sh:qualifiedValueShape [")
lines.append(f"{sp} sh:not [")
lines.append(f"{sp} sh:property [")
lines.append(f"{sp} sh:path schema:item ;")
lines.append(f"{sp} sh:minCount 1 ;")
lines.append(f"{sp} ] ;")
lines.append(f"{sp} ] ;")
lines.append(f"{sp} ] ;")
lines.append(f"{sp} sh:qualifiedMaxCount 1 ;")
lines.append(
f'{sp} sh:message "Required by Google: item on every ListItem '
'except the final one." ;'
)
lines.append(f"{sp}] ;")


def _emit_node(
lines: list[str],
type_name: str,
Expand All @@ -738,13 +764,21 @@ def _emit_node(
one_of_map: dict[str, list[set[str]]],
one_of_option_groups: list[list[set[str]]] | None,
one_of_option_map: dict[str, list[list[set[str]]]],
parent_type: str | None = None,
) -> None:
sp = " " * indent
lines.append(f"{sp}a sh:NodeShape ;")
lines.append(f"{sp}sh:class schema:{type_name} ;")

child_rules = _SCOPED_CHILD_RULES.get(type_name, {})
for prop in sorted(bucket["required"]):
if (
parent_type == "BreadcrumbList"
and type_name == "ListItem"
and prop == "item"
):
# Google: the final breadcrumb entry may omit `item`.
continue
if type_name == "ListItem" and prop == "name":
# Google: ListItem.name is only required when `item` is a bare URL;
# if `item` is a Thing that itself carries a name, name may be omitted.
Expand All @@ -761,9 +795,14 @@ def _emit_node(
visited=visited,
one_of_map=one_of_map,
one_of_option_map=one_of_option_map,
parent_type=type_name,
)
if type_name == "ListItem" and "name" in bucket["required"]:
_emit_listitem_name_or_item_name(lines, indent)
if type_name == "BreadcrumbList" and "item" in buckets.get("ListItem", {}).get(
"required", set()
):
_emit_breadcrumb_item_exemption(lines, indent)

for prop in sorted(bucket["recommended"]):
child_types = child_rules.get(prop)
Expand All @@ -777,6 +816,7 @@ def _emit_node(
visited=visited,
one_of_map=one_of_map,
one_of_option_map=one_of_option_map,
parent_type=type_name,
)

_emit_one_of_groups(
Expand Down Expand Up @@ -858,9 +898,14 @@ def _write_feature(feature: FeatureData, output_path: Path, overwrite: bool) ->
visited={type_name},
one_of_map=feature.one_of,
one_of_option_map=feature.one_of_option_groups,
parent_type=type_name,
)
if type_name == "ListItem" and "name" in bucket["required"]:
_emit_listitem_name_or_item_name(lines, 2)
if type_name == "BreadcrumbList" and "item" in feature.types.get(
"ListItem", {}
).get("required", set()):
_emit_breadcrumb_item_exemption(lines, 2)

for prop in sorted(bucket["recommended"]):
child_types = _SCOPED_CHILD_RULES.get(type_name, {}).get(prop)
Expand All @@ -874,6 +919,7 @@ def _write_feature(feature: FeatureData, output_path: Path, overwrite: bool) ->
visited={type_name},
one_of_map=feature.one_of,
one_of_option_map=feature.one_of_option_groups,
parent_type=type_name,
)

_emit_one_of_groups(
Expand Down
19 changes: 14 additions & 5 deletions wordlift_sdk/validation/shacls/google-breadcrumb.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
@prefix schema: <http://schema.org/> .

# Source: https://developers.google.com/search/docs/appearance/structured-data/breadcrumb
# Generated: 2026-08-19T07:09:28Z
# Generated: 2026-09-09T08:57:24Z
# Notes: required properties => errors; recommended properties => warnings.

:google_BreadcrumbListShape
Expand All @@ -15,10 +15,6 @@
sh:node [
a sh:NodeShape ;
sh:class schema:ListItem ;
sh:property [
sh:path schema:item ;
sh:minCount 1 ;
] ;
sh:property [
sh:path schema:position ;
sh:minCount 1 ;
Expand All @@ -44,4 +40,17 @@
) ;
] ;
] ;
sh:property [
sh:path schema:itemListElement ;
sh:qualifiedValueShape [
sh:not [
sh:property [
sh:path schema:item ;
sh:minCount 1 ;
] ;
] ;
] ;
sh:qualifiedMaxCount 1 ;
sh:message "Required by Google: item on every ListItem except the final one." ;
] ;
.
Loading