seo: align public identity and social metadata
This commit is contained in:
@@ -33,9 +33,6 @@ const exactlyOne = (source, expression, label) => {
|
||||
};
|
||||
|
||||
const metadataMarkup = (metadata) => {
|
||||
const image = metadata.openGraph.image === undefined
|
||||
? ''
|
||||
: `\n <meta property="og:image" content="${escapeHtml(metadata.openGraph.image)}" />`;
|
||||
return [
|
||||
` <meta name="description" content="${escapeHtml(metadata.description)}" />`,
|
||||
` <meta name="robots" content="${escapeHtml(metadata.robots)}" />`,
|
||||
@@ -43,7 +40,16 @@ const metadataMarkup = (metadata) => {
|
||||
` <meta property="og:type" content="${metadata.openGraph.type}" />`,
|
||||
` <meta property="og:title" content="${escapeHtml(metadata.openGraph.title)}" />`,
|
||||
` <meta property="og:description" content="${escapeHtml(metadata.openGraph.description)}" />`,
|
||||
` <meta property="og:url" content="${escapeHtml(metadata.openGraph.url)}" />${image}`,
|
||||
` <meta property="og:url" content="${escapeHtml(metadata.openGraph.url)}" />`,
|
||||
...(metadata.openGraph.image === undefined
|
||||
? []
|
||||
: [` <meta property="og:image" content="${escapeHtml(metadata.openGraph.image)}" />`]),
|
||||
` <meta name="twitter:card" content="${metadata.twitter.card}" />`,
|
||||
` <meta name="twitter:title" content="${escapeHtml(metadata.twitter.title)}" />`,
|
||||
` <meta name="twitter:description" content="${escapeHtml(metadata.twitter.description)}" />`,
|
||||
...(metadata.twitter.image === undefined
|
||||
? []
|
||||
: [` <meta name="twitter:image" content="${escapeHtml(metadata.twitter.image)}" />`]),
|
||||
` <script type="application/ld+json">${metadata.serializedJsonLd}</script>`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -26,11 +26,6 @@ const prohibitedSurfaces = [
|
||||
];
|
||||
|
||||
const escapeRegularExpression = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||
const escapeHtml = (value) => value
|
||||
.replace(/&/gu, '&')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>')
|
||||
.replace(/"/gu, '"');
|
||||
|
||||
const readRequiredText = async (root, name) => {
|
||||
try {
|
||||
@@ -272,22 +267,79 @@ const assertSemanticHtml = (html, inputs, contract) => {
|
||||
}
|
||||
};
|
||||
|
||||
const assertMetadata = (html, inputs) => {
|
||||
const expectedTitle = escapeHtml(inputs.content.metadata.title);
|
||||
const title = exactlyOneMatch(html, /<title>([\s\S]*?)<\/title>/gu, 'page title');
|
||||
if (title[1] !== expectedTitle) throw new Error('Page title does not match public content.');
|
||||
const canonical = exactlyOneMatch(html, /<link rel="canonical" href="([^"]+)" \/>/gu, 'canonical link');
|
||||
if (canonical[1] !== inputs.site.canonicalUrl) throw new Error('Canonical URL does not match site config.');
|
||||
const description = exactlyOneMatch(html, /<meta name="description" content="([^"]+)" \/>/gu, 'meta description');
|
||||
if (description[1] !== escapeHtml(inputs.content.metadata.description)) {
|
||||
throw new Error('Meta description does not match public content.');
|
||||
}
|
||||
for (const property of ['og:type', 'og:title', 'og:description', 'og:url']) {
|
||||
exactlyOneMatch(html, new RegExp(`<meta property="${property}" content="[^"]+" \\/>`, 'gu'), `${property} metadata`);
|
||||
}
|
||||
const ogImages = [...html.matchAll(/<meta property="og:image"/gu)];
|
||||
if (inputs.content.metadata.socialPreviewAssetId === undefined && ogImages.length !== 0) {
|
||||
throw new Error('Open Graph image must be omitted without an approved manifest asset.');
|
||||
const exactlyOneElement = (document, selector, label) => {
|
||||
const elements = [...document.querySelectorAll(selector)];
|
||||
if (elements.length !== 1) throw new Error(`Expected exactly one ${label}.`);
|
||||
return elements[0];
|
||||
};
|
||||
|
||||
const assertMetadata = (html, expected) => {
|
||||
const window = new Window();
|
||||
window.document.write(html);
|
||||
const document = window.document;
|
||||
try {
|
||||
const title = exactlyOneElement(document, 'title', 'page title');
|
||||
if (title.textContent !== expected.title) throw new Error('Page title does not match public metadata.');
|
||||
const canonical = exactlyOneElement(document, 'link[rel~="canonical"]', 'canonical link');
|
||||
if (canonical.getAttribute('href') !== expected.canonicalUrl) {
|
||||
throw new Error('Canonical URL does not match public metadata.');
|
||||
}
|
||||
const description = exactlyOneElement(document, 'meta[name="description"]', 'meta description');
|
||||
if (description.getAttribute('content') !== expected.description) {
|
||||
throw new Error('Meta description does not match public metadata.');
|
||||
}
|
||||
const robots = exactlyOneElement(document, 'meta[name="robots"]', 'robots metadata');
|
||||
if (robots.getAttribute('content') !== expected.robots) {
|
||||
throw new Error('Robots metadata does not match the configured environment.');
|
||||
}
|
||||
if (document.querySelectorAll('meta[name="keywords"]').length !== 0) {
|
||||
throw new Error('Meta keywords are not part of the public metadata contract.');
|
||||
}
|
||||
|
||||
for (const [property, value] of [
|
||||
['og:type', expected.openGraph.type],
|
||||
['og:title', expected.openGraph.title],
|
||||
['og:description', expected.openGraph.description],
|
||||
['og:url', expected.openGraph.url],
|
||||
]) {
|
||||
const node = exactlyOneElement(document, `meta[property="${property}"]`, `${property} metadata`);
|
||||
if (node.getAttribute('content') !== value) {
|
||||
throw new Error(`${property} metadata does not match public metadata or the canonical URL.`);
|
||||
}
|
||||
}
|
||||
const ogImages = [...document.querySelectorAll('meta[property="og:image"]')];
|
||||
if (expected.openGraph.image === undefined) {
|
||||
if (ogImages.length !== 0) throw new Error('og:image must be omitted without an approved manifest asset.');
|
||||
} else {
|
||||
const image = exactlyOneElement(document, 'meta[property="og:image"]', 'og:image metadata');
|
||||
if (image.getAttribute('content') !== expected.openGraph.image) {
|
||||
throw new Error('og:image metadata does not match the approved manifest image.');
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of [
|
||||
['twitter:card', expected.twitter.card],
|
||||
['twitter:title', expected.twitter.title],
|
||||
['twitter:description', expected.twitter.description],
|
||||
]) {
|
||||
const node = exactlyOneElement(document, `meta[name="${name}"]`, `${name} metadata`);
|
||||
if (node.getAttribute('content') !== value) {
|
||||
throw new Error(`${name} metadata does not match public metadata.`);
|
||||
}
|
||||
}
|
||||
const twitterImages = [...document.querySelectorAll('meta[name="twitter:image"]')];
|
||||
if (expected.twitter.image === undefined) {
|
||||
if (twitterImages.length !== 0) {
|
||||
throw new Error('twitter:image must be omitted without an approved manifest image.');
|
||||
}
|
||||
} else {
|
||||
const image = exactlyOneElement(document, 'meta[name="twitter:image"]', 'twitter:image metadata');
|
||||
if (image.getAttribute('content') !== expected.twitter.image) {
|
||||
throw new Error('twitter:image metadata does not match the approved manifest image.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
window.close();
|
||||
}
|
||||
|
||||
const jsonLdMatch = exactlyOneMatch(
|
||||
@@ -301,21 +353,17 @@ const assertMetadata = (html, inputs) => {
|
||||
} catch {
|
||||
throw new Error('JSON-LD is malformed.');
|
||||
}
|
||||
if (/"(?:streetAddress|postalCode|telephone|image|interactionStatistic)"\s*:/iu.test(jsonLdMatch[1])) {
|
||||
throw new Error('JSON-LD contains unsafe private, portrait, or interaction metadata.');
|
||||
}
|
||||
if (jsonLd['@type'] !== 'ProfilePage' || jsonLd.mainEntity?.['@type'] !== 'Person') {
|
||||
throw new Error('JSON-LD must contain one ProfilePage with one nested Person.');
|
||||
}
|
||||
const person = jsonLd.mainEntity;
|
||||
if (
|
||||
person.name !== inputs.content.identity.name ||
|
||||
person.jobTitle !== inputs.content.identity.role ||
|
||||
person.url !== inputs.site.canonicalUrl ||
|
||||
jsonLd.url !== inputs.site.canonicalUrl
|
||||
) {
|
||||
throw new Error('JSON-LD identity or canonical URL is inconsistent.');
|
||||
if (JSON.stringify(jsonLd.mainEntity.homeLocation) !== JSON.stringify(expected.jsonLd.mainEntity.homeLocation)) {
|
||||
throw new Error('JSON-LD location must contain only the approved public city and country code.');
|
||||
}
|
||||
const expectedSameAs = inputs.content.contact.social.map((link) => link.url);
|
||||
if (JSON.stringify(person.sameAs ?? []) !== JSON.stringify(expectedSameAs)) {
|
||||
throw new Error('JSON-LD sameAs contains an unverified or missing profile.');
|
||||
if (jsonLdMatch[1] !== expected.serializedJsonLd) {
|
||||
throw new Error('JSON-LD does not exactly match the derived public metadata.');
|
||||
}
|
||||
if (jsonLdMatch[1].toLowerCase().includes('</script')) throw new Error('JSON-LD contains a script-closing hazard.');
|
||||
return jsonLdMatch[1];
|
||||
@@ -379,11 +427,8 @@ const assertCrawlArtifacts = async (root, inputs, headers) => {
|
||||
}
|
||||
};
|
||||
|
||||
const assertExactHostArtifacts = async (root, inputs) => {
|
||||
const expected = createStaticHostArtifacts(
|
||||
createPageMetadata(inputs.content, inputs.manifest, inputs.site),
|
||||
inputs.site,
|
||||
);
|
||||
const assertExactHostArtifacts = async (root, inputs, metadata) => {
|
||||
const expected = createStaticHostArtifacts(metadata, inputs.site);
|
||||
const actualHeaders = await readRequiredText(root, '_headers.example');
|
||||
if (actualHeaders !== expected.headers) throw new Error('Headers must exactly equal the generated expected headers.');
|
||||
const actualRobots = await readRequiredText(root, 'robots.txt');
|
||||
@@ -397,6 +442,7 @@ const assertExactHostArtifacts = async (root, inputs) => {
|
||||
};
|
||||
|
||||
export const verifyArtifactDirectory = async (root, inputs, contractInput) => {
|
||||
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
|
||||
const contract = parseArtifactContract(contractInput);
|
||||
const html = await readRequiredText(root, 'index.html');
|
||||
const files = await listArtifacts(root);
|
||||
@@ -405,8 +451,8 @@ export const verifyArtifactDirectory = async (root, inputs, contractInput) => {
|
||||
await assertNoProhibitedSurfaces(root, files);
|
||||
assertTrustedHtmlAssets(html, contract);
|
||||
assertSemanticHtml(html, inputs, contract);
|
||||
const serializedJsonLd = assertMetadata(html, inputs);
|
||||
await assertExactHostArtifacts(root, inputs);
|
||||
const serializedJsonLd = assertMetadata(html, metadata);
|
||||
await assertExactHostArtifacts(root, inputs, metadata);
|
||||
const headers = await readRequiredText(root, '_headers.example');
|
||||
assertSecurityHeaders(headers, inputs, serializedJsonLd);
|
||||
await assertCrawlArtifacts(root, inputs, headers);
|
||||
|
||||
Reference in New Issue
Block a user