Skip to content

feat(mwpw-203439): emit Schema.org JSON-LD ItemList for card collections - #567

Open
sanrai wants to merge 12 commits into
mainfrom
jsonld-collection-emission
Open

feat(mwpw-203439): emit Schema.org JSON-LD ItemList for card collections#567
sanrai wants to merge 12 commits into
mainfrom
jsonld-collection-emission

Conversation

@sanrai

@sanrai sanrai commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What

Collections can now emit one <script type="application/ld+json"> block describing the rendered cards as a Schema.org ItemList. Each entry is minimal on purpose: url + keywords.

image

Why

Cards render as divs whose tag taxonomy is invisible to machine readers: tags arrive hashed from the backend. Titles and descriptions are already crawlable in the visible DOM, so this block adds only what is otherwise unreadable: the resolved tag classification (keywords) attached to a citable url.

How tag resolution works

We never reverse the hash. The authored filter config pairs tag ids with human labels, and Container already hashes those ids in place to match hashed card tags (existing behavior the filter panel depends on). So the config maps (hashed) tag id -> label for free. Card tags without a filter entry are skipped, never emitted raw.

Safety

  • Opt-in: collection.showJsonLd, default false.
  • Additive script tag injected in a useEffect after paint; scoped per collection container, so multiple collections on one page each manage their own block. Removed when the rendered card list becomes empty, so the metadata never describes cards no longer shown.
  • Serializes at most 50 cards; numberOfItems reports the true filtered total.

Testing

  • 15 unit tests: tag map (hashed + unhashed + nested), entry building, cap and true-total, JSON round-trip, injection, idempotent re-injection, multi-collection independence, stale cleanup.
  • e2e: block present and parseable with flag on, absent with flag off (run-e2e-tests green against the PR build).
  • Manually verified on localhost against the hashed fixture: hashed card tags resolve to correct labels end to end.

Known limitation

Client-rendered only: crawlers that do not execute JS will not see the block. Static-fetch coverage would require SSR, out of scope.

Rollout

Enable on one low-traffic collection surface first. Note: keyword coverage equals the page's filter panel coverage; tags outside the panel taxonomy are skipped by design. MWPW ticket to be linked.

Cards render as divs that machine readers cannot classify, and card
tags arrive hashed. This emits one application/ld+json script block
per collection describing rendered cards (name, url, keywords) as a
Schema.org ItemList. Tag ids resolve to labels via the authored
filter config, which Container already hashes to match card tags.

- Opt-in via collection.showJsonLd (default false)
- Serializes at most 50 cards; numberOfItems reports the true total
- Additive script tag injected after render; replaced on re-render
- Zero impact on rendering, payload, or SEO when disabled
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

AI Code Review

Last updated Aug 10, 2026 11:25 AM PDT · new commit pushed · commit ae6cadd · 8 files changed in PR.

0 open · 1 resolved

Open findings

No open findings.

✅ Resolved

  • 🟠 Stale JSON-LD script not removed when card list becomes empty — resolved in b3491d0
Review history (11 runs)
  • ae6cadd · Aug 10, 2026 11:25 AM PDT · new commit pushed · no changes (0 open) — Merge branch 'main' into jsonld-collection-emission
  • 858c0f4 · Aug 10, 2026 10:35 AM PDT · new commit pushed · no changes (0 open) — Merge branch 'main' into jsonld-collection-emission
  • d39daa9 · Aug 10, 2026 10:16 AM PDT · new commit pushed · no changes (0 open) — Merge branch 'main' into jsonld-collection-emission
  • 4961a30 · Aug 5, 2026 2:55 AM PDT · new commit pushed · no changes (0 open) — fix: e2e tests run against this PR's own build, not the shared deploy…
  • 7414336 · Aug 5, 2026 2:15 AM PDT · new commit pushed · no changes (0 open) — fix: give QA observation parent identity for structured data blocks
  • 4badc2b · Aug 5, 2026 2:02 AM PDT · new commit pushed · no changes (0 open) — chore: remove local verification page committed by mistake
  • 315c108 · Aug 5, 2026 1:49 AM PDT · new commit pushed · no changes (0 open) — refactor: trim JSON-LD entries to url and keywords only
  • b3491d0 · Aug 5, 2026 1:34 AM PDT · new commit pushed · 1 resolved (0 open) — fix: remove stale JSON-LD block when the card list becomes empty
  • 52758e5 · Aug 5, 2026 12:59 AM PDT · new commit pushed · no changes (1 open) — fix: scope JSON-LD block per collection container
  • 47e8d15 · Aug 5, 2026 12:53 AM PDT · new commit pushed · +1 new (1 open) — test: add JSON-LD emission e2e coverage
  • 98ba3de · Aug 5, 2026 12:43 AM PDT · PR opened · no changes (0 open) — feat: emit Schema.org JSON-LD ItemList for card collections

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Agent QA review — interactive + visual diff (advisory, non-blocking)

Last updated Aug 10, 2026, 11:25 AM PDT · new commit pushed · commit ae6cadd · 8 files changed.

0 open · 0 resolved · visual diff 0.00% · verdict PASS

Open findings

  • Smoke test passed ✅
What the agent checked

Tested the PR (feat(mwpw-203439): emit Schema.org JSON-LD ItemList for card collections) against the live business.adobe.com/resources/main.html collection.

What I checked:

  1. Visual diff: loaded the provided diff.png — consistent with the stated 0.00% pixel change, no magenta/highlighted regions were visible, confirming this PR's change is non-visual (a hidden <script type="application/ld+json"> block gated by an opt-in flag).
  2. Verified the opt-in flag: inspected the live collection's rendered wrapper (.consonant-Wrapper) via DOM query — it contains 0 script[type="application/ld+json"] elements inside it. The page's config does not set collection.showJsonLd, so per the PR's stated default (false), no new JSON-LD block is emitted here. This is expected/correct behavior, not a bug — the feature is additive and opt-in, and this collection instance hasn't opted in.
  3. Confirmed pre-existing global JSON-LD (WebSite, BreadcrumbList schema blocks in , unrelated to this PR/collection) still render normally and were untouched.
  4. Functional regression check on the collection itself (since the PR touches Container.jsx which renders the collection):
    • Filter panel opens/closes correctly (expanded "Products" category, saw full checkbox list render properly, no layout issues).
    • Applying a filter ("Acrobat") worked correctly: result count updated from 3778 → 181, active filter pill "Acrobat x" appeared, filter count badge "1" appeared next to "Products", cards grid re-rendered with matching Acrobat-related content, no visual glitches, overlap, or truncation observed.
    • Search input and clear-search control render fine in the panel.
  5. get_console_errors(): no console errors observed on initial load or after interacting with filters.
  6. Did not get to run run_axe or test pagination/search-execution/card click-through due to turn budget, and did not find a way on this page to force-enable showJsonLd to visually confirm the new <script> tag's actual output (default is off and no query-param override was evident) — this remains unverified in a live opt-in state, though the code diff shows it's scoped, additive, and removed when the card list is empty, and the change ships behind a default-false flag so it carries no risk to this page as configured.

Overall: no regressions found in visible/interactive behavior of the card collection (filters, results count, card rendering). The core new feature (JSON-LD ItemList emission) is correctly not present because it is opt-in and disabled by default on this collection instance, which matches the PR's documented safety design. No console errors, no layout/visual defects observed.

PR / stable / diff screenshots + console + axe artifacts in the workflow run.

Review history (9 runs)
  • ae6cadd · Aug 10, 2026, 11:25 AM PDT · new commit pushed · passing, no regressions — Merge branch 'main' into jsonld-collection-emission
  • 4961a30 · Aug 5, 2026, 3:00 AM PDT · new commit pushed · passing, no regressions — fix: e2e tests run against this PR's own build, not the shar
  • 7414336 · Aug 5, 2026, 2:22 AM PDT · new commit pushed · passing, no regressions — fix: give QA observation parent identity for structured data
  • 4badc2b · Aug 5, 2026, 2:04 AM PDT · new commit pushed · passing, no regressions — chore: remove local verification page committed by mistake
  • 315c108 · Aug 5, 2026, 1:49 AM PDT · new commit pushed · passing, no regressions — refactor: trim JSON-LD entries to url and keywords only
  • b3491d0 · Aug 5, 2026, 1:31 AM PDT · new commit pushed · passing, no regressions — fix: remove stale JSON-LD block when the card list becomes e
  • 52758e5 · Aug 5, 2026, 12:59 AM PDT · new commit pushed · passing, no regressions — fix: scope JSON-LD block per collection container
  • 47e8d15 · Aug 5, 2026, 12:53 AM PDT · new commit pushed · passing, no regressions — test: add JSON-LD emission e2e coverage
  • 98ba3de · Aug 5, 2026, 12:47 AM PDT · PR opened · passing, no regressions — feat: emit Schema.org JSON-LD ItemList for card collections

Replace the fixed element id with a data-caas-jsonld attribute and
scope lookup/replacement to each collection's own container. Pages
with multiple card collections now emit one valid block per
collection with no id duplication and no cross-collection clobbering.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🧪 Feature QA review — injected feature test (advisory, non-blocking)

Last updated Aug 10, 2026, 11:54 AM PT · commit 9d637b2 · pull_request.

Overall: PASS

Injected the PR build with a searched, source-grounded config + collection fixture.

Source test: injectCollectionJsonLd > injects one parseable script tag into the container
Code searches: 8
Mapping evidence: react/src/js/components/Consonant/Container/Container.jsx:117, react/src/js/components/Consonant/Container/Container.jsx:1550, react/src/js/components/Consonant/Container/Container.jsx:1791, react/src/js/components/Consonant/Helpers/constants.js:165, react/src/js/components/Consonant/Helpers/__tests__/jsonLd.spec.js:113, react/src/js/components/Consonant/Container/Container.jsx:1533
Fixture cards: 3
Expected: A script[data-caas-jsonld][type="application/ld+json"] element is injected into the collection's section container, and its parsed JSON.textContent has ['@type'] === 'ItemList' (script is not null, JSON parses successfully).
Rendered (first collection):

    1. A Customer Blog Post
    1. Express Yourself With Adobe Express
    1. Getting Started With Acrobat
      Structured data blocks: 4 (first: parent head, 153 chars)

Verdict: The first collection's section container has a script[data-caas-jsonld][type='application/ld+json'] (block n=3) whose parsed JSON has '@type' === 'ItemList', matching the expected assertion.

Screenshot in the workflow run.

Review history (9 runs)
  • Aug 10, 2026, 11:54 AM PT · PASS · 9d637b2 · pull_request
  • Aug 10, 2026, 10:40 AM PT · PASS · 2c7d8c2 · pull_request
  • Aug 10, 2026, 10:22 AM PT · PASS · a40735c · pull_request
  • Aug 5, 2026, 3:03 AM PT · PASS · 37a5d40 · pull_request
  • Aug 5, 2026, 2:19 AM PT · PASS · cf8aa40 · pull_request
  • Aug 5, 2026, 2:09 AM PT · FAIL · c9fa74f · pull_request
  • Aug 5, 2026, 1:52 AM PT · FAIL · dabc0f4 · pull_request
  • Aug 5, 2026, 1:33 AM PT · PASS · ea361d8 · pull_request
  • Aug 5, 2026, 1:04 AM PT · FAIL · 9cbd35a · pull_request

The effect previously bailed out on an empty card list, leaving a
previously injected block describing cards no longer shown. An empty
list now removes the existing block, so the metadata always matches
the rendered state.
Titles and descriptions are already crawlable in the visible DOM.
The block's unique contribution is the resolved tag taxonomy and the
link, so entries now carry only url and keywords.
Sanjay Rai added 2 commits August 5, 2026 01:59
The observation step extracted only card-level data (title, text,
links), so any assertion about non-card DOM, such as an injected
script[type=application/ld+json] block, was structurally unverifiable
and produced an automatic FAIL even when the tag was present (verified
present via e2e and manual DOM inspection on the same build).

The observation now also captures up to four ld+json blocks with their
parent element, attributes, and content, passes them to the validation
judge, and reports the block count in the PR comment.
The class-based parent label is ambiguous: two different collection
sections share the same class, so one correct block per collection was
judged as a duplicate injection into one container. Blocks now carry a
parentNode identity index (equal values mean same container element)
and a collectionIndex (which .consonant-Wrapper, in document order, or
-1 for head/page level), and the judge is instructed to evaluate
per-container assertions per container.
…ment

All PRs deploy to the same github.io site, so run-e2e-tests raced:
whichever PR deployed last was the build every concurrent PR's e2e
actually tested. This PR's e2e failed because a later branch's
deployment overwrote ours before the tests ran.

The e2e job now builds this PR's dist and serves it locally, and
generateUrl prefers an explicit E2E_BASE_URL over the shared site.
Every PR's e2e is now hermetic to its own code.
@sanrai sanrai changed the title feat: emit Schema.org JSON-LD ItemList for card collections feat(mwpw-203439): emit Schema.org JSON-LD ItemList for card collections Aug 6, 2026
@sanrai
sanrai marked this pull request as ready for review August 6, 2026 19:23
@cmiqueo

cmiqueo commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

@sanrai

Take a look at these findings and let me know if these are known issues so that I can approve it.

Code review(review · 7 findings)
react/src/js/components/Consonant/Container/Container.jsx
● 1550 [correctness] JSON-LD is built from the full filtered/timed card list (gridCards), not the subset actually rendered on the current page.
● 1548 [correctness] JSON-LD is injected client-side only (useEffect after mount), which defeats the feature's stated purpose for non-JS crawlers.
● 1549 [correctness] Toggling collection.showJsonLd from true to false at runtime leaves the stale JSON-LD block in the DOM.
● 1557 [efficiency] The useEffect dependency array includes gridCards, a new array reference on every render, so it re-runs (remove+stringify+append) on every Container re-render regardless of whether cards changed.
.github/workflows/pull-request.yaml
● 160 [reliability] Fixed sleep 3 before health-checking the locally served build is a race condition that can fail CI on slow runners.
● 158 [efficiency] The new e2e job rebuilds the project from scratch a third time, duplicating work already done by check-build and deployment jobs in the same workflow run.
react/src/js/components/Consonant/Helpers/jsonLd.js
● 35 [reuse] buildTagLabelMap re-implements category/nested-item tree-walking that already exists elsewhere in the codebase instead of reusing a shared traversal helper.

One more thing worth flagging: unit tests pass with coverage thresholds met, but Container.jsx:1550 (the injectCollectionJsonLd call site) isn't exercised by any existing Container test — not a build breaker, but worth mentioning to the PR author.

✻ Cooked for 36s

※ recap: You're reviewing PR #567 (JSON-LD for card collections); the background code review just finished with 7 findings, mainly stale/misaligned JSON-LD injection timing and content. Next: decide whether to fix these issues or comment them on the PR. (disable recaps in /config)

@sheridansunier

Copy link
Copy Markdown
Collaborator

A few things from review, roughly in order of importance:

  1. numberOfItems undercounts on partial/paginated loads. The JSON-LD effect passes totalItems: filteredCards.length, but gridCardLen is the value the code already computes as the true total during server-side partial loads (isPartialLoad). As written, a collection with e.g. 500 total cards but 20 fetched so far would emit numberOfItems: 20 — contradicting the comment above it that says this reports "the true filtered total."

  2. JSON-LD url can diverge from the card's actual rendered link. buildCardEntry in jsonLd.js reads footer[0].right[0].href unconditionally as the URL fallback, but Card.jsx's own getCtaLink only uses that path when there's exactly one footer with exactly one right-side item — otherwise it renders no CTA at all. A multi-footer card would get a JSON-LD url pointing to a link that isn't actually on the page.

  3. Stale <script data-caas-jsonld> can be left in the DOM. The effect returns early when showJsonLd is false, so injectCollectionJsonLd (the only place that removes the existing script) never runs, and there's no cleanup function either. If showJsonLd flips truefalse at runtime without a full remount, the old structured-data script stays behind.

  4. CI: the new e2e job is still coupled to the shared deployment. run-e2e-tests keeps needs: deployment even though it now builds and serves its own dist locally — so it's still transitively blocked by the shared/racy github-pages Pages environment that this change was meant to decouple from.

  5. CI: E2E_BASE_URL scope is wider than intended. It's set for the whole e2e job, not just the new JSON-LD spec — so all existing specs now resolve against the locally-served dist instead of the deployed adobecom.github.io site. Worth confirming that's intentional, since local-serve vs. deployed can differ (caching, MIME types, base path).

  6. CI: flaky health check. sleep 3 before curling localhost has no retry/poll loop, so a slow CI runner could fail the whole gate for reasons unrelated to the PR. Also, the health check only pings a static html/e2e/index.html, not the actual built JS/CSS bundle, so a truncated/empty webpack output wouldn't be caught here.

  7. Minor/perf: filteredCards/gridCards aren't memoized, so the JSON-LD effect rebuilds and re-injects the script on every re-render that recomputes them (e.g. keystrokes in search), not just when the visible card set actually changes.

#1 and #2 are the ones I'd want addressed before merge — the rest are good to track as follow-ups.

@sheridansunier sheridansunier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed via automated pass (line-by-line, removed-behavior, cross-file, reuse, simplification, efficiency, altitude, conventions angles + direct source verification). Feature is opt-in/low-blast-radius and well tested overall; flagging two correctness issues that undercut the PR's own stated goals, one staleness edge case, and two CI nits. Posting as comments for discussion, not a formal block.

* Opt-in via collection.showJsonLd; serializes at most 50 cards
* while numberOfItems reports the true filtered total.
*/
useEffect(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No cleanup on unmount / toggle-off — this useEffect never returns a cleanup function, and injectCollectionJsonLd's removal step only runs when it's actually invoked. Since the effect body early-returns on if (!showJsonLd) return;, that removal path is skipped whenever showJsonLd goes true→false, and there's no unmount cleanup at all.

Concretely: unmounting the Container (route change, re-render without remount) or flipping showJsonLd off in the same mounted instance leaves the injected <script data-caas-jsonld> block in the DOM permanently, describing cards that are no longer shown — which directly contradicts the PR description's safety claim ("Removed when the rendered card list becomes empty").

Suggest: useEffect(() => { if (!showJsonLd) return; const script = injectCollectionJsonLd({...}); return () => script?.remove(); }, [...]);

collectionTitle: getConfig('collection', 'i18n.title'),
totalItems: filteredCards.length,
});
}, [gridCards, showJsonLd]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency array omits variables the effect reads — deps are [gridCards, showJsonLd], but the body also reads authoredFilters, getConfig('collection', 'i18n.title'), and filteredCards.length.

gridCards is timedCollection.length ? timedCollection : filteredCards (line 1533) — so whenever a timed-event sort is active, gridCards tracks timedCollection, fully decoupled from filteredCards. In that case filteredCards.length (the totalItems passed in) can change on a re-render without gridCards changing, so the effect won't re-run and the injected numberOfItems goes stale relative to the real current filtered count.


const url = getByPath(card, 'ctaLink', '')
|| getByPath(card, 'overlayLink', '')
|| getByPath(card, 'footer.0.right.0.href', '');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL fallback misses real card link slots — this only ever reads the right CTA slot (footer.0.right.0.href). Cards/Card.jsx's actual link resolution (getCtaLink, lines ~296-339) also serves the card's rendered link from center and alt footer slots depending on ctaButtonStyle.

Any card whose real, rendered link lives in a center or alt slot (a real, supported layout) will get no url in its JSON-LD entry at all — silently dropping the one piece of data this PR describes as its "unique contribution" for that subset of cards. Worth reusing getCtaLink's resolution (or an equivalent covering all three slots) instead of a partial ad-hoc path lookup.

run: npm ci
- name: Build and serve this PR's own dist
run: |
npm run build

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate build — this job needs: deployment (which already runs npm run build and uploads the result as a Pages artifact a few jobs up). This step rebuilds from source instead of reusing that artifact (e.g. via actions/download-artifact), so every PR run now builds the same commit twice with no reuse. Not blocking, but worth a follow-up to save CI time.

run: |
npm run build
npx serve -l 5000 &
sleep 3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile readiness checksleep 3 before curling the backgrounded serve process is a fixed-timing guess rather than a retry loop. On a slower/loaded runner this could intermittently fail the E2E step for reasons unrelated to this feature. Suggest a short retry loop instead, e.g. for i in $(seq 1 30); do curl -sf ... && break; sleep 1; done.

@sheridansunier sheridansunier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on the two correctness issues flagged inline (missing cleanup in the useEffect leaves a stale JSON-LD script in the DOM, and the URL fallback silently drops the url for cards using center/alt CTA slots) — see review comments above for details. The dependency-array staleness and CI items are non-blocking suggestions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants