-
Notifications
You must be signed in to change notification settings - Fork 13
feat(mwpw-203439): emit Schema.org JSON-LD ItemList for card collections #567
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
98ba3de
47e8d15
52758e5
b3491d0
315c108
1856c7f
4badc2b
7414336
4961a30
d39daa9
858c0f4
ae6cadd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,9 +153,16 @@ jobs: | |
| node-version: 16.13.1 | ||
| - name: Install | ||
| run: npm ci | ||
| - name: Build and serve this PR's own dist | ||
| run: | | ||
| npm run build | ||
| npx serve -l 5000 & | ||
| sleep 3 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fragile readiness check — |
||
| curl -sf http://localhost:5000/html/e2e/index.html > /dev/null | ||
| - name: E2E | ||
| run: npm run test:e2e-prod | ||
| env: | ||
| E2E_BASE_URL: http://localhost:5000 | ||
| # Pin the Chrome binary path to the version installed above. | ||
| # The ubuntu-latest runner ships a newer system Chrome at | ||
| # /opt/google/chrome/chrome that chromedriver@146 cannot drive. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| // e2e-tests/specs/jsonld.e2e.js | ||
| const generateUrl = require('../helpers/generateUrl'); | ||
|
|
||
| describe('JSON-LD Collection Emission', () => { | ||
| it('emits a parseable Schema.org ItemList when showJsonLd is enabled', async () => { | ||
| const url = generateUrl({ collection: { showJsonLd: true } }); | ||
| await browser.url(url); | ||
|
|
||
| await browser.waitUntil( | ||
| async () => $('script[data-caas-jsonld]').isExisting(), | ||
| { timeout: 15000, timeoutMsg: 'JSON-LD script tag was not injected' }, | ||
| ); | ||
|
|
||
| /* eslint-disable-next-line */ | ||
| const jsonText = await browser.execute(() => document.querySelector('script[data-caas-jsonld]').textContent); | ||
| const jsonLd = JSON.parse(jsonText); | ||
|
|
||
| expect(jsonLd['@context']).toEqual('https://schema.org'); | ||
| expect(jsonLd['@type']).toEqual('ItemList'); | ||
| expect(jsonLd.numberOfItems).toBeGreaterThan(0); | ||
| expect(jsonLd.itemListElement.length).toBeGreaterThan(0); | ||
| expect(jsonLd.itemListElement.length).toBeLessThanOrEqual(50); | ||
| expect(jsonLd.itemListElement[0].item['@type']).toEqual('CreativeWork'); | ||
| }); | ||
|
|
||
| it('does not emit the block when showJsonLd is disabled', async () => { | ||
| const url = generateUrl({}); | ||
| await browser.url(url); | ||
|
|
||
| await browser.waitUntil( | ||
| async () => $('.consonant-Card').isExisting(), | ||
| { timeout: 15000, timeoutMsg: 'Cards did not render' }, | ||
| ); | ||
|
|
||
| const exists = await $('script[data-caas-jsonld]').isExisting(); | ||
| expect(exists).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(() => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No cleanup on unmount / toggle-off — this Concretely: unmounting the Container (route change, re-render without remount) or flipping Suggest: |
||
| if (!showJsonLd) return; | ||
| injectCollectionJsonLd({ | ||
| cards: gridCards, | ||
| filters: authoredFilters, | ||
| container: box.current, | ||
| collectionTitle: getConfig('collection', 'i18n.title'), | ||
| totalItems: filteredCards.length, | ||
| }); | ||
| }, [gridCards, showJsonLd]); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dependency array omits variables the effect reads — deps are
|
||
|
|
||
| /** | ||
| * Total pages (used by Paginator Component) | ||
| * @type {Number} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| 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 url and resolved keywords only', () => { | ||
| const entry = buildCardEntry(card, buildTagLabelMap(filters)); | ||
| expect(entry).toEqual({ | ||
| '@type': 'CreativeWork', | ||
| 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' }); | ||
| }); | ||
| }); | ||
|
|
||
| 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.url).toBe('https://adobe.com/resources/photoshop-guide'); | ||
| }); | ||
|
|
||
| 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('returns null with no cards', () => { | ||
| expect(injectCollectionJsonLd({ cards: [] })).toBeNull(); | ||
| }); | ||
|
|
||
| test('removes the stale block when the card list becomes empty', () => { | ||
| const container = document.createElement('div'); | ||
| document.body.appendChild(container); | ||
| injectCollectionJsonLd({ cards: [card], filters: [], container }); | ||
| expect(container.querySelector('script[data-caas-jsonld]')).not.toBeNull(); | ||
| injectCollectionJsonLd({ cards: [], filters: [], container }); | ||
| expect(container.querySelector('script[data-caas-jsonld]')).toBeNull(); | ||
| }); | ||
|
|
||
| test('supports multiple collections on one page independently', () => { | ||
| const containerA = document.createElement('div'); | ||
| const containerB = document.createElement('div'); | ||
| document.body.appendChild(containerA); | ||
| document.body.appendChild(containerB); | ||
| injectCollectionJsonLd({ cards: [card], filters: [], container: containerA }); | ||
| injectCollectionJsonLd({ cards: [card, hashedCard], filters: [], container: containerB }); | ||
| injectCollectionJsonLd({ cards: [card], filters: [], container: containerA }); | ||
| expect(document.querySelectorAll('script[data-caas-jsonld]')).toHaveLength(2); | ||
| expect(JSON.parse(containerA.querySelector('script').textContent).numberOfItems).toBe(1); | ||
| expect(JSON.parse(containerB.querySelector('script').textContent).numberOfItems).toBe(2); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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 runsnpm run buildand uploads the result as a Pages artifact a few jobs up). This step rebuilds from source instead of reusing that artifact (e.g. viaactions/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.