diff --git a/libs/features/seotech/README.md b/libs/features/seotech/README.md index aaf6d709e35..43729b23275 100644 --- a/libs/features/seotech/README.md +++ b/libs/features/seotech/README.md @@ -26,10 +26,13 @@ See [video-metadata](../../blocks/video-metadata/) if you need to define a speci ### Structured Data This feature queries the SEOTECH service for adhoc structured data that should be added to the page. +Repo resolution now comes from the public origin map (`aem-origin-map/public.json`) and the +structured-data URL template in that document. Metadata Properties: - `seotech-structured-data`: `on` to enable SEOTECH lookup +- `?seotech-structured-data=on`: test-only query param override to force SEOTECH lookup on a page See [Structured Data for Milo](https://wiki.corp.adobe.com/x/YpPwwg) (Corp Only) for complete documentation. diff --git a/libs/features/seotech/seotech.js b/libs/features/seotech/seotech.js index 1b175c21522..8c6c237f204 100644 --- a/libs/features/seotech/seotech.js +++ b/libs/features/seotech/seotech.js @@ -1,4 +1,7 @@ export const PROD_BASE_URL = 'https://www.adobe.com/seotech/api'; +// TODO: move to a CSP-allowed endpoint once firefly.azureedge.net is proxied +// through www.adobe.com/seotech/public (MWPW-201818) +export const STRUCTURED_DATA_ORIGIN_MAP_URL = 'https://firefly.azureedge.net/c4dbffdc97a2c4f65073a222e967ea7c-public/public/aem-origin-map/public.json'; export const REGEX_ADOBETV = /(?:https?:\/\/)?(?:stage-)?video.tv.adobe.com\/v\/([\d]+)/; export const REGEX_YOUTUBE = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?([a-zA-Z0-9_-]+)/; @@ -15,10 +18,18 @@ export function logError(msg, context = {}) { additionalInfo.push(`bucket:${context.bucket}`); } + if (context.repo) { + additionalInfo.push(`repo:${context.repo}`); + } + if (context.id) { additionalInfo.push(`id:${context.id}`); } + if (context.hostname) { + additionalInfo.push(`hostname:${context.hostname}`); + } + if (context.videoUrl) { additionalInfo.push(`videoUrl:${context.videoUrl}`); } @@ -87,12 +98,93 @@ export async function sha256(message) { return hashHex; } -export async function getStructuredData(bucket, id, { baseUrl = PROD_BASE_URL } = {}) { - if (!bucket || !id) { - throw new Error(`bucket and id are required. Received: bucket=${bucket}, id=${id}`); +export function canonicalizePathname(pathname = '') { + let path = pathname || '/'; + path = path.replace(/\.html$/, ''); + path = path.replace(/\/+$/, ''); + if (!path || path === '/') return '/index'; + return path; +} + +export function getOriginMapHostKey(hostname = '') { + const [subdomain] = hostname.toLowerCase().split('.'); + return subdomain; +} + +export function matchesOriginPrefix(canonicalPathname, prefix) { + if (!prefix || !canonicalPathname) return false; + + if (prefix.includes('*')) { + const escaped = prefix.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`).test(canonicalPathname); } - const url = `${baseUrl}/structured-data/${bucket}/${id}`; + return canonicalPathname === prefix + || canonicalPathname.startsWith(`${prefix}/`) + || canonicalPathname.startsWith(`${prefix}.`); +} + +function findRepoFromOrigins(origins, canonicalPathname) { + for (const origin of origins) { + const matched = (origin.prefixes || []) + .some((prefix) => matchesOriginPrefix(canonicalPathname, prefix)); + if (matched) return origin.repo; + } + return null; +} + +export function resolveRepoFromOriginMap(originMap, hostname, pathname) { + const hostKey = getOriginMapHostKey(hostname); + const properties = originMap?.properties || {}; + const hostOrigins = properties[hostKey]?.origins || []; + const canonicalPathname = canonicalizePathname(pathname); + + // Try host-specific prefix match first + const repo = findRepoFromOrigins(hostOrigins, canonicalPathname); + if (repo) return repo; + + if (hostOrigins.length) { + // Homepage and default fallbacks for a known host + if (canonicalPathname === '/index') { + const homepageOrigin = hostOrigins.find((origin) => origin.homepage); + if (homepageOrigin?.repo) return homepageOrigin.repo; + } + return hostOrigins.find((origin) => origin.default)?.repo || null; + } + + // Unknown host (e.g. AEM preview/live URL): search all subdomains by path. + // Path prefixes are unique across the whole origin map so this is unambiguous. + for (const prop of Object.values(properties)) { + const fallback = findRepoFromOrigins(prop.origins || [], canonicalPathname); + if (fallback) return fallback; + } + + return null; +} + +export async function getStructuredData(pathname, hostname, options = {}) { + const { originMapUrl = STRUCTURED_DATA_ORIGIN_MAP_URL } = options; + const originMapResp = await fetch(originMapUrl); + if (!originMapResp?.ok) { + throw new Error(`Failed to fetch origin map: ${originMapResp?.status} ${originMapResp?.statusText}`); + } + const originMap = await originMapResp.json(); + + const repo = resolveRepoFromOriginMap(originMap, hostname, pathname); + if (!repo) { + throw new Error(`Unable to resolve repo for ${hostname}${pathname}`); + } + + const template = originMap?.structuredDataUrlTemplate; + if (!template) { + throw new Error('Missing structuredDataUrlTemplate in origin map'); + } + + const canonicalPath = canonicalizePathname(pathname); + const url = template + .replace('{repo}', repo) + .replace('{path}', canonicalPath); + const resp = await fetch(url); if (!resp) { @@ -107,10 +199,17 @@ export async function getStructuredData(bucket, id, { baseUrl = PROD_BASE_URL } return body; } -export async function appendScriptTag({ locationUrl, getMetadata, createTag, getConfig }) { +export function isStructuredDataEnabled(locationUrl, getMetadata) { + const url = new URL(locationUrl); + return getMetadata('seotech-structured-data') === 'on' + || url.searchParams.get('seotech-structured-data') === 'on'; +} + +export async function appendScriptTag({ locationUrl, getMetadata, createTag }) { const url = new URL(locationUrl); const params = new URLSearchParams(url.search); const baseUrl = params.get('seotech-api-base-url') || undefined; + const originMapUrl = params.get('seotech-origin-map-url') || undefined; const append = (obj, className) => { if (!obj) return; const attributes = { type: 'application/ld+json' }; @@ -120,14 +219,11 @@ export async function appendScriptTag({ locationUrl, getMetadata, createTag, get }; const promises = []; - if (getMetadata('seotech-structured-data') === 'on') { - const bucket = getRepoByImsClientId(getConfig()?.imsClientId); - const id = await sha256(url.pathname?.replace('.html', '')); - promises.push(getStructuredData(bucket, id, { baseUrl }) + if (isStructuredDataEnabled(locationUrl, getMetadata)) { + promises.push(getStructuredData(url.pathname, url.hostname, { originMapUrl }) .then((obj) => append(obj, 'seotech-structured-data')) .catch(() => logError('Structured data operation failed', { - bucket, - id, + hostname: url.hostname, pathname: url.pathname, }))); } diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 76772eb7025..43e7c70ecdb 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -2650,7 +2650,11 @@ async function documentPostSectionLoading(config) { import('../scripts/accessibility.js').then((accessibility) => { accessibility.default(); }); - if (getMetadata('seotech-structured-data') === 'on' || getMetadata('seotech-video-url')) { + const seotechStructuredDataParam = new URL(window.location.href).searchParams + .get('seotech-structured-data') === 'on'; + if (getMetadata('seotech-structured-data') === 'on' + || seotechStructuredDataParam + || getMetadata('seotech-video-url')) { import('../features/seotech/seotech.js').then((module) => module.default( { locationUrl: window.location.href, getMetadata, createTag, getConfig }, )); diff --git a/test/features/seotech/seotech.test.js b/test/features/seotech/seotech.test.js index 1f8b8a49c62..0de53d691c7 100644 --- a/test/features/seotech/seotech.test.js +++ b/test/features/seotech/seotech.test.js @@ -8,6 +8,9 @@ import { sha256, REGEX_ADOBETV, REGEX_YOUTUBE, + canonicalizePathname, + isStructuredDataEnabled, + STRUCTURED_DATA_ORIGIN_MAP_URL, } from '../../../libs/features/seotech/seotech.js'; describe('REGEX_ADOBETV', () => { @@ -80,8 +83,50 @@ describe('sha256', () => { }); }); +describe('canonicalizePathname', () => { + const testCases = [ + { pathname: '/foo', expected: '/foo' }, + { pathname: '/foo/', expected: '/foo' }, + { pathname: '/foo.html', expected: '/foo' }, + { pathname: '/foo/bar.html', expected: '/foo/bar' }, + { pathname: '/', expected: '/index' }, + ]; + + testCases.forEach(({ pathname, expected }) => { + it(`should canonicalize ${pathname} to ${expected}`, () => { + expect(canonicalizePathname(pathname)).to.equal(expected); + }); + }); +}); + +describe('isStructuredDataEnabled', () => { + it('should enable structured data via metadata', () => { + const getMetadata = stub().returns(null); + getMetadata.withArgs('seotech-structured-data').returns('on'); + expect(isStructuredDataEnabled('https://www.adobe.com/foo', getMetadata)).to.be.true; + }); + + it('should enable structured data via query param', () => { + const getMetadata = stub().returns(null); + expect(isStructuredDataEnabled('https://www.adobe.com/foo?seotech-structured-data=on', getMetadata)) + .to.be.true; + }); +}); + describe('seotech', () => { describe('appendScriptTag + seotech-structured-data', () => { + const originMap = { + properties: { + www: { + origins: [{ + repo: 'da-cc', + prefixes: ['/in/creativecloud'], + }], + }, + }, + structuredDataUrlTemplate: 'https://edge.example.net/public/structured-data/{repo}{path}.json', + }; + beforeEach(async () => { window.lana = { log: (s) => console.log(`LANA NOT STUBBED! ${s}`) }; }); @@ -91,28 +136,31 @@ describe('seotech', () => { }); it('should not append JSON-LD', async () => { - const locationUrl = 'https://main--cc--adobecom.aem.page/in/creativecloud/example2?foo=bar&seotech-env=stage'; + const locationUrl = 'https://www.adobe.com/in/creativecloud/example2?foo=bar'; stub(window.lana, 'log'); const getMetadata = stub().returns(null); getMetadata.withArgs('seotech-structured-data').returns('on'); - const getConfigStub = stub().returns({ imsClientId: 'adobedotcom-cc' }); const fetchStub = stub(window, 'fetch'); - fetchStub.returns(Promise.resolve(Response.json( + fetchStub.onFirstCall().returns(Promise.resolve(Response.json( + { ...originMap }, + { status: 200 }, + ))); + fetchStub.onSecondCall().returns(Promise.resolve(Response.json( { error: 'ERROR!' }, { status: 400 }, ))); await appendScriptTag( - { locationUrl, getMetadata, getConfig: getConfigStub, createTag }, + { locationUrl, getMetadata, createTag }, ); - const expectedApiCall = 'https://www.adobe.com/seotech/api/structured-data/cc/3e2d1ce8ccf0e45d42d33e0f190fc306ab1ee0f2890c8ff5da27414f8014ceb2'; - expect(fetchStub.getCall(0)?.firstArg).to.equal(expectedApiCall); + const expectedApiCall = 'https://edge.example.net/public/structured-data/da-cc/in/creativecloud/example2.json'; + expect(fetchStub.getCall(0)?.firstArg).to.equal(STRUCTURED_DATA_ORIGIN_MAP_URL); + expect(fetchStub.getCall(1)?.firstArg).to.equal(expectedApiCall); }); it('should append JSON-LD', async () => { - const locationUrl = 'https://main--cc--adobecom.aem.page/in/creativecloud/example?foo=bar'; + const locationUrl = 'https://www.adobe.com/in/creativecloud/example?foo=bar'; const lanaStub = stub(window.lana, 'log'); const fetchStub = stub(window, 'fetch'); - const getConfigStub = stub().returns({ imsClientId: 'adobedotcom-cc' }); const getMetadata = stub().returns(null); getMetadata.withArgs('seotech-structured-data').returns('on'); const expectedObject = { @@ -120,15 +168,69 @@ describe('seotech', () => { '@type': 'VideoObject', name: 'fake', }; - fetchStub.returns(Promise.resolve(Response.json( + fetchStub.onFirstCall().returns(Promise.resolve(Response.json( + { ...originMap }, + { status: 200 }, + ))); + fetchStub.onSecondCall().returns(Promise.resolve(Response.json( + { ...expectedObject }, + { status: 200 }, + ))); + await appendScriptTag( + { locationUrl, getMetadata, createTag }, + ); + const expectedApiCall = 'https://edge.example.net/public/structured-data/da-cc/in/creativecloud/example.json'; + expect(fetchStub.getCall(0)?.firstArg).to.equal(STRUCTURED_DATA_ORIGIN_MAP_URL); + expect(fetchStub.getCall(1)?.firstArg).to.equal(expectedApiCall); + const el = await waitForElement('script[type="application/ld+json"]'); + const obj = JSON.parse(el.text); + expect(obj).to.deep.equal(expectedObject); + expect(lanaStub.called).to.be.false; + }); + + it('should resolve repo via path fallback for AEM preview URLs', async () => { + const locationUrl = 'https://main--cc--adobecom.aem.live/in/creativecloud/example' + + '?seotech-structured-data=on'; + const lanaStub = stub(window.lana, 'log'); + const fetchStub = stub(window, 'fetch'); + const getMetadata = stub().returns(null); + const expectedObject = { '@context': 'http://schema.org', '@type': 'WebPage', name: 'fake' }; + fetchStub.onFirstCall().returns( + Promise.resolve(Response.json({ ...originMap }, { status: 200 })), + ); + fetchStub.onSecondCall().returns( + Promise.resolve(Response.json({ ...expectedObject }, { status: 200 })), + ); + await appendScriptTag({ locationUrl, getMetadata, createTag }); + const expectedApiCall = 'https://edge.example.net/public/structured-data/da-cc/in/creativecloud/example.json'; + expect(fetchStub.getCall(1)?.firstArg).to.equal(expectedApiCall); + expect(lanaStub.called).to.be.false; + }); + + it('should append JSON-LD when enabled by query param', async () => { + const locationUrl = 'https://www.adobe.com/in/creativecloud/example?seotech-structured-data=on'; + const lanaStub = stub(window.lana, 'log'); + const fetchStub = stub(window, 'fetch'); + const getMetadata = stub().returns(null); + const expectedObject = { + '@context': 'http://schema.org', + '@type': 'VideoObject', + name: 'fake', + }; + fetchStub.onFirstCall().returns(Promise.resolve(Response.json( + { ...originMap }, + { status: 200 }, + ))); + fetchStub.onSecondCall().returns(Promise.resolve(Response.json( { ...expectedObject }, { status: 200 }, ))); await appendScriptTag( - { locationUrl, getMetadata, getConfig: getConfigStub, createTag }, + { locationUrl, getMetadata, createTag }, ); - const expectedApiCall = 'https://www.adobe.com/seotech/api/structured-data/cc/f0f5cec5d8b70cf798b602c3586da39e93b9638d9b8001b3a4298605dc5f6ebe'; - expect(fetchStub.getCall(0)?.firstArg).to.equal(expectedApiCall); + const expectedApiCall = 'https://edge.example.net/public/structured-data/da-cc/in/creativecloud/example.json'; + expect(fetchStub.getCall(0)?.firstArg).to.equal(STRUCTURED_DATA_ORIGIN_MAP_URL); + expect(fetchStub.getCall(1)?.firstArg).to.equal(expectedApiCall); const el = await waitForElement('script[type="application/ld+json"]'); const obj = JSON.parse(el.text); expect(obj).to.deep.equal(expectedObject);