From 98ba3de0b024884b75e7ea2bbd41df2a8d1fdfe2 Mon Sep 17 00:00:00 2001 From: Sanjay Rai Date: Wed, 5 Aug 2026 00:43:11 -0700 Subject: [PATCH 1/9] feat: emit Schema.org JSON-LD ItemList for card collections 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 --- .../Consonant/Container/Container.jsx | 22 +++ .../Helpers/__tests__/jsonLd.spec.js | 133 ++++++++++++++++++ .../components/Consonant/Helpers/constants.js | 1 + .../js/components/Consonant/Helpers/jsonLd.js | 129 +++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 react/src/js/components/Consonant/Helpers/__tests__/jsonLd.spec.js create mode 100644 react/src/js/components/Consonant/Helpers/jsonLd.js diff --git a/react/src/js/components/Consonant/Container/Container.jsx b/react/src/js/components/Consonant/Container/Container.jsx index d4ab7d049..eaf5b113a 100644 --- a/react/src/js/components/Consonant/Container/Container.jsx +++ b/react/src/js/components/Consonant/Container/Container.jsx @@ -30,6 +30,7 @@ import Bookmarks from '../Bookmarks/Bookmarks'; import Paginator from '../Pagination/Paginator'; import Grid from '../Grid/Grid'; import CardFilterer from '../Helpers/CardFilterer'; +import { injectCollectionJsonLd } from '../Helpers/jsonLd'; import FiltersPanelTop from '../Filters/Top/Panel'; import LeftFilterPanel from '../Filters/Left/Panel'; import JsonProcessor from '../Helpers/JsonProcessor'; @@ -113,6 +114,7 @@ const Container = (props) => { const paginationType = getConfig('pagination', 'type'); const paginationIsEnabled = getConfig('pagination', 'enabled'); const resultsPerPage = getConfig('collection', 'resultsPerPage'); + const showJsonLd = getConfig('collection', 'showJsonLd'); const onlyShowBookmarks = getConfig('bookmarks', 'leftFilterPanel.bookmarkOnlyCollection'); const authoredFilters = getConfig('filterPanel', 'filters'); const categoryMappings = getConfig('filterPanel', 'categoryMappings'); @@ -1534,6 +1536,26 @@ const Container = (props) => { gridCardLen = cardCount; } + /** + * Emits a Schema.org ItemList describing the rendered cards, so LLM + * crawlers and agents can classify collection content. Card tag ids + * (hashed or not) resolve to labels via the authored filter config, + * which Container has already hashed to match when isHashed is set. + * Additive script tag, replaced on re-render; no rendering impact. + * Opt-in via collection.showJsonLd; serializes at most 50 cards + * while numberOfItems reports the true filtered total. + */ + useEffect(() => { + if (!showJsonLd || !gridCards.length) return; + injectCollectionJsonLd({ + cards: gridCards, + filters: authoredFilters, + container: box.current, + collectionTitle: getConfig('collection', 'i18n.title'), + totalItems: filteredCards.length, + }); + }, [gridCards, showJsonLd]); + /** * Total pages (used by Paginator Component) * @type {Number} diff --git a/react/src/js/components/Consonant/Helpers/__tests__/jsonLd.spec.js b/react/src/js/components/Consonant/Helpers/__tests__/jsonLd.spec.js new file mode 100644 index 000000000..78913b8b0 --- /dev/null +++ b/react/src/js/components/Consonant/Helpers/__tests__/jsonLd.spec.js @@ -0,0 +1,133 @@ +import { + buildTagLabelMap, + buildCardEntry, + buildCollectionJsonLd, + injectCollectionJsonLd, +} from '../jsonLd'; + +const filters = [{ + id: 'caas:products', + group: 'Products', + items: [ + { id: 'caas:products/photoshop', label: 'Photoshop' }, + { + id: 'caas:products/video', + label: 'Video', + isCategory: true, + items: [{ id: 'caas:products/video/premiere', label: 'Premiere Pro' }], + }, + ], +}]; + +const hashedFilters = [{ + id: 'h4x2', + group: 'Products', + items: [{ id: '4x24/l1s1', label: 'Photoshop' }], +}]; + +const card = { + id: '1.0.0', + contentArea: { title: 'Getting started with Photoshop' }, + ctaLink: 'https://adobe.com/resources/photoshop-guide', + tags: [{ id: 'caas:products/photoshop' }], +}; + +const hashedCard = { + ...card, + tags: [{ id: '4x24/l1s1' }, { id: 'zz99/qq11' }], +}; + +describe('buildTagLabelMap', () => { + test('maps item ids to labels, including nested category items', () => { + const map = buildTagLabelMap(filters); + expect(map['caas:products/photoshop']).toBe('Photoshop'); + expect(map['caas:products/video/premiere']).toBe('Premiere Pro'); + }); + + test('works with hashed ids', () => { + expect(buildTagLabelMap(hashedFilters)['4x24/l1s1']).toBe('Photoshop'); + }); + + test('returns empty object for empty input', () => { + expect(buildTagLabelMap()).toEqual({}); + }); +}); + +describe('buildCardEntry', () => { + test('emits name, url and resolved keywords', () => { + const entry = buildCardEntry(card, buildTagLabelMap(filters)); + expect(entry).toEqual({ + '@type': 'CreativeWork', + name: 'Getting started with Photoshop', + url: 'https://adobe.com/resources/photoshop-guide', + keywords: 'Photoshop', + }); + }); + + test('resolves hashed tags via the filter map and skips unresolvable hashes', () => { + const entry = buildCardEntry(hashedCard, buildTagLabelMap(hashedFilters)); + expect(entry.keywords).toBe('Photoshop'); + }); + + test('omits url and keywords when absent', () => { + const entry = buildCardEntry({ contentArea: { title: 'X' } }, {}); + expect(entry).toEqual({ '@type': 'CreativeWork', name: 'X' }); + }); +}); + +describe('buildCollectionJsonLd', () => { + test('builds a valid ItemList with collection title', () => { + const jsonLd = buildCollectionJsonLd([card], filters, 'All resources'); + expect(jsonLd['@context']).toBe('https://schema.org'); + expect(jsonLd['@type']).toBe('ItemList'); + expect(jsonLd.name).toBe('All resources'); + expect(jsonLd.numberOfItems).toBe(1); + expect(jsonLd.itemListElement[0].position).toBe(1); + expect(jsonLd.itemListElement[0].item.name).toBe('Getting started with Photoshop'); + }); + + test('caps serialized entries at 50 while reporting the true total', () => { + const manyCards = Array.from({ length: 200 }, (_, i) => ({ ...card, id: `card-${i}` })); + const jsonLd = buildCollectionJsonLd(manyCards, [], '', 4000); + expect(jsonLd.itemListElement).toHaveLength(50); + expect(jsonLd.numberOfItems).toBe(4000); + }); + + test('true total never underreports the rendered count', () => { + expect(buildCollectionJsonLd([card, hashedCard], [], '', 0).numberOfItems).toBe(2); + }); + + test('round-trips through JSON serialization', () => { + const parsed = JSON.parse(JSON.stringify(buildCollectionJsonLd([card], filters))); + expect(parsed.itemListElement[0].item.keywords).toBe('Photoshop'); + }); +}); + +describe('injectCollectionJsonLd', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('injects one parseable script tag into the container', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + injectCollectionJsonLd({ cards: [card], filters, container }); + const script = container.querySelector('script[type="application/ld+json"]'); + expect(script).not.toBeNull(); + expect(JSON.parse(script.textContent)['@type']).toBe('ItemList'); + }); + + test('replaces the previous block on re-injection', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + injectCollectionJsonLd({ cards: [card], filters: [], container }); + injectCollectionJsonLd({ cards: [card, hashedCard], filters: [], container }); + const scripts = container.querySelectorAll('script[type="application/ld+json"]'); + expect(scripts).toHaveLength(1); + expect(JSON.parse(scripts[0].textContent).numberOfItems).toBe(2); + }); + + test('does nothing with no cards', () => { + expect(injectCollectionJsonLd({ cards: [] })).toBeNull(); + }); +}); diff --git a/react/src/js/components/Consonant/Helpers/constants.js b/react/src/js/components/Consonant/Helpers/constants.js index 2330b0676..3ffd587af 100644 --- a/react/src/js/components/Consonant/Helpers/constants.js +++ b/react/src/js/components/Consonant/Helpers/constants.js @@ -162,6 +162,7 @@ export const DEFAULT_CONFIG = { transparent: false, }, displayTotalResults: true, + showJsonLd: false, totalResultsText: '{} results', i18n: { prettyDateIntervalFormat: '{LLL} {dd} | {timeRange} {timeZone}', diff --git a/react/src/js/components/Consonant/Helpers/jsonLd.js b/react/src/js/components/Consonant/Helpers/jsonLd.js new file mode 100644 index 000000000..b410e217e --- /dev/null +++ b/react/src/js/components/Consonant/Helpers/jsonLd.js @@ -0,0 +1,129 @@ +import { getByPath } from './general'; + +/** + * JSON-LD emission for CaaS collections. + * + * Emits one