Skip to content
Draft
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
3 changes: 3 additions & 0 deletions libs/features/seotech/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment on lines 32 to 36
See [Structured Data for Milo](https://wiki.corp.adobe.com/x/YpPwwg) (Corp Only) for complete documentation.

Expand Down
118 changes: 107 additions & 11 deletions libs/features/seotech/seotech.js
Original file line number Diff line number Diff line change
@@ -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_-]+)/;
Expand All @@ -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}`);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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' };
Expand All @@ -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,
})));
}
Expand Down
6 changes: 5 additions & 1 deletion libs/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment on lines +2653 to +2654
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 },
));
Expand Down
126 changes: 114 additions & 12 deletions test/features/seotech/seotech.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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}`) };
});
Expand All @@ -91,44 +136,101 @@ 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 = {
'@context': 'http://schema.org',
'@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);
Expand Down
Loading