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 metadataMarkup = (metadata) => {
|
||||||
const image = metadata.openGraph.image === undefined
|
|
||||||
? ''
|
|
||||||
: `\n <meta property="og:image" content="${escapeHtml(metadata.openGraph.image)}" />`;
|
|
||||||
return [
|
return [
|
||||||
` <meta name="description" content="${escapeHtml(metadata.description)}" />`,
|
` <meta name="description" content="${escapeHtml(metadata.description)}" />`,
|
||||||
` <meta name="robots" content="${escapeHtml(metadata.robots)}" />`,
|
` <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:type" content="${metadata.openGraph.type}" />`,
|
||||||
` <meta property="og:title" content="${escapeHtml(metadata.openGraph.title)}" />`,
|
` <meta property="og:title" content="${escapeHtml(metadata.openGraph.title)}" />`,
|
||||||
` <meta property="og:description" content="${escapeHtml(metadata.openGraph.description)}" />`,
|
` <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>`,
|
` <script type="application/ld+json">${metadata.serializedJsonLd}</script>`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,11 +26,6 @@ const prohibitedSurfaces = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const escapeRegularExpression = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
const escapeRegularExpression = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||||
const escapeHtml = (value) => value
|
|
||||||
.replace(/&/gu, '&')
|
|
||||||
.replace(/</gu, '<')
|
|
||||||
.replace(/>/gu, '>')
|
|
||||||
.replace(/"/gu, '"');
|
|
||||||
|
|
||||||
const readRequiredText = async (root, name) => {
|
const readRequiredText = async (root, name) => {
|
||||||
try {
|
try {
|
||||||
@@ -272,22 +267,79 @@ const assertSemanticHtml = (html, inputs, contract) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertMetadata = (html, inputs) => {
|
const exactlyOneElement = (document, selector, label) => {
|
||||||
const expectedTitle = escapeHtml(inputs.content.metadata.title);
|
const elements = [...document.querySelectorAll(selector)];
|
||||||
const title = exactlyOneMatch(html, /<title>([\s\S]*?)<\/title>/gu, 'page title');
|
if (elements.length !== 1) throw new Error(`Expected exactly one ${label}.`);
|
||||||
if (title[1] !== expectedTitle) throw new Error('Page title does not match public content.');
|
return elements[0];
|
||||||
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');
|
const assertMetadata = (html, expected) => {
|
||||||
if (description[1] !== escapeHtml(inputs.content.metadata.description)) {
|
const window = new Window();
|
||||||
throw new Error('Meta description does not match public content.');
|
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.');
|
||||||
}
|
}
|
||||||
for (const property of ['og:type', 'og:title', 'og:description', 'og:url']) {
|
const description = exactlyOneElement(document, 'meta[name="description"]', 'meta description');
|
||||||
exactlyOneMatch(html, new RegExp(`<meta property="${property}" content="[^"]+" \\/>`, 'gu'), `${property} metadata`);
|
if (description.getAttribute('content') !== expected.description) {
|
||||||
|
throw new Error('Meta description does not match public metadata.');
|
||||||
}
|
}
|
||||||
const ogImages = [...html.matchAll(/<meta property="og:image"/gu)];
|
const robots = exactlyOneElement(document, 'meta[name="robots"]', 'robots metadata');
|
||||||
if (inputs.content.metadata.socialPreviewAssetId === undefined && ogImages.length !== 0) {
|
if (robots.getAttribute('content') !== expected.robots) {
|
||||||
throw new Error('Open Graph image must be omitted without an approved manifest asset.');
|
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(
|
const jsonLdMatch = exactlyOneMatch(
|
||||||
@@ -301,21 +353,17 @@ const assertMetadata = (html, inputs) => {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new Error('JSON-LD is malformed.');
|
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') {
|
if (jsonLd['@type'] !== 'ProfilePage' || jsonLd.mainEntity?.['@type'] !== 'Person') {
|
||||||
throw new Error('JSON-LD must contain one ProfilePage with one nested Person.');
|
throw new Error('JSON-LD must contain one ProfilePage with one nested Person.');
|
||||||
}
|
}
|
||||||
const person = jsonLd.mainEntity;
|
if (JSON.stringify(jsonLd.mainEntity.homeLocation) !== JSON.stringify(expected.jsonLd.mainEntity.homeLocation)) {
|
||||||
if (
|
throw new Error('JSON-LD location must contain only the approved public city and country code.');
|
||||||
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.');
|
|
||||||
}
|
}
|
||||||
const expectedSameAs = inputs.content.contact.social.map((link) => link.url);
|
if (jsonLdMatch[1] !== expected.serializedJsonLd) {
|
||||||
if (JSON.stringify(person.sameAs ?? []) !== JSON.stringify(expectedSameAs)) {
|
throw new Error('JSON-LD does not exactly match the derived public metadata.');
|
||||||
throw new Error('JSON-LD sameAs contains an unverified or missing profile.');
|
|
||||||
}
|
}
|
||||||
if (jsonLdMatch[1].toLowerCase().includes('</script')) throw new Error('JSON-LD contains a script-closing hazard.');
|
if (jsonLdMatch[1].toLowerCase().includes('</script')) throw new Error('JSON-LD contains a script-closing hazard.');
|
||||||
return jsonLdMatch[1];
|
return jsonLdMatch[1];
|
||||||
@@ -379,11 +427,8 @@ const assertCrawlArtifacts = async (root, inputs, headers) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertExactHostArtifacts = async (root, inputs) => {
|
const assertExactHostArtifacts = async (root, inputs, metadata) => {
|
||||||
const expected = createStaticHostArtifacts(
|
const expected = createStaticHostArtifacts(metadata, inputs.site);
|
||||||
createPageMetadata(inputs.content, inputs.manifest, inputs.site),
|
|
||||||
inputs.site,
|
|
||||||
);
|
|
||||||
const actualHeaders = await readRequiredText(root, '_headers.example');
|
const actualHeaders = await readRequiredText(root, '_headers.example');
|
||||||
if (actualHeaders !== expected.headers) throw new Error('Headers must exactly equal the generated expected headers.');
|
if (actualHeaders !== expected.headers) throw new Error('Headers must exactly equal the generated expected headers.');
|
||||||
const actualRobots = await readRequiredText(root, 'robots.txt');
|
const actualRobots = await readRequiredText(root, 'robots.txt');
|
||||||
@@ -397,6 +442,7 @@ const assertExactHostArtifacts = async (root, inputs) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const verifyArtifactDirectory = async (root, inputs, contractInput) => {
|
export const verifyArtifactDirectory = async (root, inputs, contractInput) => {
|
||||||
|
const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site);
|
||||||
const contract = parseArtifactContract(contractInput);
|
const contract = parseArtifactContract(contractInput);
|
||||||
const html = await readRequiredText(root, 'index.html');
|
const html = await readRequiredText(root, 'index.html');
|
||||||
const files = await listArtifacts(root);
|
const files = await listArtifacts(root);
|
||||||
@@ -405,8 +451,8 @@ export const verifyArtifactDirectory = async (root, inputs, contractInput) => {
|
|||||||
await assertNoProhibitedSurfaces(root, files);
|
await assertNoProhibitedSurfaces(root, files);
|
||||||
assertTrustedHtmlAssets(html, contract);
|
assertTrustedHtmlAssets(html, contract);
|
||||||
assertSemanticHtml(html, inputs, contract);
|
assertSemanticHtml(html, inputs, contract);
|
||||||
const serializedJsonLd = assertMetadata(html, inputs);
|
const serializedJsonLd = assertMetadata(html, metadata);
|
||||||
await assertExactHostArtifacts(root, inputs);
|
await assertExactHostArtifacts(root, inputs, metadata);
|
||||||
const headers = await readRequiredText(root, '_headers.example');
|
const headers = await readRequiredText(root, '_headers.example');
|
||||||
assertSecurityHeaders(headers, inputs, serializedJsonLd);
|
assertSecurityHeaders(headers, inputs, serializedJsonLd);
|
||||||
await assertCrawlArtifacts(root, inputs, headers);
|
await assertCrawlArtifacts(root, inputs, headers);
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import type {
|
|||||||
import { serializeJsonLd } from '../security/serialize-json-ld.ts';
|
import { serializeJsonLd } from '../security/serialize-json-ld.ts';
|
||||||
import { createSecurityHeaders } from '../security/security-policy.ts';
|
import { createSecurityHeaders } from '../security/security-policy.ts';
|
||||||
|
|
||||||
|
interface PlaceJsonLd {
|
||||||
|
'@type': 'Place';
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress';
|
||||||
|
addressLocality: string;
|
||||||
|
addressCountry: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface PersonJsonLd {
|
interface PersonJsonLd {
|
||||||
'@type': 'Person';
|
'@type': 'Person';
|
||||||
name: string;
|
name: string;
|
||||||
@@ -14,6 +23,7 @@ interface PersonJsonLd {
|
|||||||
description: string;
|
description: string;
|
||||||
url: string;
|
url: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
homeLocation: PlaceJsonLd;
|
||||||
sameAs?: string[];
|
sameAs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +48,12 @@ export interface PageMetadata {
|
|||||||
url: string;
|
url: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
};
|
};
|
||||||
|
twitter: {
|
||||||
|
card: 'summary' | 'summary_large_image';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
jsonLd: ProfilePageJsonLd;
|
jsonLd: ProfilePageJsonLd;
|
||||||
serializedJsonLd: string;
|
serializedJsonLd: string;
|
||||||
jsonLdCspHash: string;
|
jsonLdCspHash: string;
|
||||||
@@ -78,12 +94,20 @@ export const createPageMetadata = (
|
|||||||
description: content.metadata.description,
|
description: content.metadata.description,
|
||||||
url: site.canonicalUrl,
|
url: site.canonicalUrl,
|
||||||
email: `mailto:${content.contact.email}`,
|
email: `mailto:${content.contact.email}`,
|
||||||
|
homeLocation: {
|
||||||
|
'@type': 'Place',
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
addressLocality: content.identity.location.city,
|
||||||
|
addressCountry: content.identity.location.countryCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
...(sameAs.length === 0 ? {} : { sameAs }),
|
...(sameAs.length === 0 ? {} : { sameAs }),
|
||||||
};
|
};
|
||||||
const jsonLd: ProfilePageJsonLd = {
|
const jsonLd: ProfilePageJsonLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'ProfilePage',
|
'@type': 'ProfilePage',
|
||||||
name: canonicalTitle,
|
name: content.identity.name,
|
||||||
description: content.metadata.description,
|
description: content.metadata.description,
|
||||||
url: site.canonicalUrl,
|
url: site.canonicalUrl,
|
||||||
mainEntity,
|
mainEntity,
|
||||||
@@ -103,6 +127,12 @@ export const createPageMetadata = (
|
|||||||
url: site.canonicalUrl,
|
url: site.canonicalUrl,
|
||||||
...(image === undefined ? {} : { image }),
|
...(image === undefined ? {} : { image }),
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
card: image === undefined ? 'summary' : 'summary_large_image',
|
||||||
|
title: canonicalTitle,
|
||||||
|
description: content.metadata.description,
|
||||||
|
...(image === undefined ? {} : { image }),
|
||||||
|
},
|
||||||
jsonLd,
|
jsonLd,
|
||||||
serializedJsonLd,
|
serializedJsonLd,
|
||||||
jsonLdCspHash: `sha256-${createHash('sha256').update(serializedJsonLd).digest('base64')}`,
|
jsonLdCspHash: `sha256-${createHash('sha256').update(serializedJsonLd).digest('base64')}`,
|
||||||
|
|||||||
@@ -8,15 +8,24 @@ import { verifyProductionSmoke } from '../../scripts/smoke-production.mjs';
|
|||||||
const digest = 'a'.repeat(64);
|
const digest = 'a'.repeat(64);
|
||||||
const canonical = 'https://portfolio.example/';
|
const canonical = 'https://portfolio.example/';
|
||||||
const alternate = 'http://www.portfolio.example/';
|
const alternate = 'http://www.portfolio.example/';
|
||||||
|
const escapedTitle = 'Luca Pîslaru — System Administrator & Full-Stack Developer';
|
||||||
|
const description = 'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.';
|
||||||
|
const socialImage = `${canonical}assets/project-social-preview.webp`;
|
||||||
const html = `<!doctype html><html><head>
|
const html = `<!doctype html><html><head>
|
||||||
<title>Luca Pîslaru — System Administrator & Full-Stack Developer</title>
|
<title>${escapedTitle}</title>
|
||||||
<meta name="description" content="Systems administration and full-stack development portfolio." />
|
<meta name="description" content="${description}" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<link rel="canonical" href="${canonical}" />
|
<link rel="canonical" href="${canonical}" />
|
||||||
<meta property="og:title" content="Luca Pîslaru" />
|
<meta property="og:type" content="profile" />
|
||||||
<meta property="og:description" content="Systems administration and full-stack development portfolio." />
|
<meta property="og:title" content="${escapedTitle}" />
|
||||||
|
<meta property="og:description" content="${description}" />
|
||||||
<meta property="og:url" content="${canonical}" />
|
<meta property="og:url" content="${canonical}" />
|
||||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Person","name":"Luca Pîslaru","url":"${canonical}"}</script>
|
<meta property="og:image" content="${socialImage}" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="${escapedTitle}" />
|
||||||
|
<meta name="twitter:description" content="${description}" />
|
||||||
|
<meta name="twitter:image" content="${socialImage}" />
|
||||||
|
<script type="application/ld+json">{"@context":"https://schema.org","@type":"ProfilePage","name":"Luca Pîslaru","description":"${description}","url":"${canonical}","mainEntity":{"@type":"Person","name":"Luca Pîslaru","jobTitle":"System Administrator & Full-Stack Developer","description":"${description}","url":"${canonical}","email":"mailto:luca.pislaru@gmail.com","homeLocation":{"@type":"Place","address":{"@type":"PostalAddress","addressLocality":"Bucharest","addressCountry":"RO"}},"sameAs":["https://github.com/LucaPisl","https://gitlab.com/luca_pisl","https://linkedin.com/in/luca-pisl"]}}</script>
|
||||||
<script type="module" src="/assets/app.12345678.js"></script></head>
|
<script type="module" src="/assets/app.12345678.js"></script></head>
|
||||||
<body><main><h1>Luca Pîslaru</h1><p>System Administrator & Full-Stack Developer</p><section><h2>Selected builds</h2><p>Evidence-led project work.</p></section><section><h2>Contact</h2><p>Discuss systems and software work.</p><a href="mailto:hello@example.com">Contact</a></section></main></body></html>`;
|
<body><main><h1>Luca Pîslaru</h1><p>System Administrator & Full-Stack Developer</p><section><h2>Selected builds</h2><p>Evidence-led project work.</p></section><section><h2>Contact</h2><p>Discuss systems and software work.</p><a href="mailto:hello@example.com">Contact</a></section></main></body></html>`;
|
||||||
|
|
||||||
|
|||||||
@@ -101,8 +101,46 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('artifact verification', () => {
|
describe('artifact verification', () => {
|
||||||
it('accepts the complete private preview artifact set', async () => {
|
it('accepts one exact canonical, Open Graph, Twitter, and privacy-safe JSON-LD metadata set', async () => {
|
||||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const previewAsset = inputs.manifest.assets.find(
|
||||||
|
({ id }) => id === inputs.content.metadata.socialPreviewAssetId,
|
||||||
|
)!;
|
||||||
|
const expectedImage = `https://portfolio.localhost/assets/${previewAsset.fileName}`;
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
|
||||||
|
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href'))
|
||||||
|
.toBe('https://portfolio.localhost/');
|
||||||
|
for (const [attribute, value] of [
|
||||||
|
['meta[property="og:type"]', 'profile'],
|
||||||
|
['meta[property="og:title"]', 'Luca Pîslaru — System Administrator & Full-Stack Developer'],
|
||||||
|
['meta[property="og:description"]', inputs.content.metadata.description],
|
||||||
|
['meta[property="og:url"]', 'https://portfolio.localhost/'],
|
||||||
|
['meta[property="og:image"]', expectedImage],
|
||||||
|
['meta[name="twitter:card"]', 'summary_large_image'],
|
||||||
|
['meta[name="twitter:title"]', 'Luca Pîslaru — System Administrator & Full-Stack Developer'],
|
||||||
|
['meta[name="twitter:description"]', inputs.content.metadata.description],
|
||||||
|
['meta[name="twitter:image"]', expectedImage],
|
||||||
|
] as const) {
|
||||||
|
const nodes = document.querySelectorAll(attribute);
|
||||||
|
expect(nodes, attribute).toHaveLength(1);
|
||||||
|
expect(nodes[0]?.getAttribute('content'), attribute).toBe(value);
|
||||||
|
}
|
||||||
|
const jsonLdNodes = document.querySelectorAll('script[type="application/ld+json"]');
|
||||||
|
expect(jsonLdNodes).toHaveLength(1);
|
||||||
|
const jsonLd = JSON.parse(jsonLdNodes[0]!.textContent ?? '');
|
||||||
|
expect(jsonLd.mainEntity.homeLocation).toEqual({
|
||||||
|
'@type': 'Place',
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
addressLocality: 'Bucharest',
|
||||||
|
addressCountry: 'RO',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(jsonLd)).not.toMatch(
|
||||||
|
/streetAddress|postalCode|telephone|interactionStatistic|"image"/iu,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
|
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -204,6 +242,46 @@ describe('artifact verification', () => {
|
|||||||
mutate: (html: string) => html.replace('</head>', '<link rel="canonical" href="https://portfolio.localhost/" /></head>'),
|
mutate: (html: string) => html.replace('</head>', '<link rel="canonical" href="https://portfolio.localhost/" /></head>'),
|
||||||
error: /exactly one canonical/i,
|
error: /exactly one canonical/i,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'duplicate Twitter card metadata',
|
||||||
|
mutate: (html: string) => html.replace(
|
||||||
|
'</head>',
|
||||||
|
'<meta name="twitter:card" content="summary_large_image" /></head>',
|
||||||
|
),
|
||||||
|
error: /exactly one twitter:card/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'an Open Graph canonical drift',
|
||||||
|
mutate: (html: string) => html.replace(
|
||||||
|
'<meta property="og:url" content="https://portfolio.localhost/" />',
|
||||||
|
'<meta property="og:url" content="https://other.example.test/" />',
|
||||||
|
),
|
||||||
|
error: /og:url.*canonical|metadata.*match/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a mismatched Open Graph image',
|
||||||
|
mutate: (html: string) => html.replace(
|
||||||
|
/<meta property="og:image" content="[^"]+" \/>/u,
|
||||||
|
'<meta property="og:image" content="https://portfolio.localhost/assets/not-approved.webp" />',
|
||||||
|
),
|
||||||
|
error: /og:image.*manifest|metadata.*match/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a mismatched Twitter image',
|
||||||
|
mutate: (html: string) => html.replace(
|
||||||
|
/<meta name="twitter:image" content="[^"]+" \/>/u,
|
||||||
|
'<meta name="twitter:image" content="https://portfolio.localhost/assets/not-approved.webp" />',
|
||||||
|
),
|
||||||
|
error: /twitter:image.*manifest|metadata.*match/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'private JSON-LD location detail',
|
||||||
|
mutate: (html: string) => html.replace(
|
||||||
|
'"addressCountry":"RO"',
|
||||||
|
'"addressCountry":"RO","streetAddress":"Private street"',
|
||||||
|
),
|
||||||
|
error: /json-ld.*(?:unsafe|location|public metadata|match)/i,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'duplicate H1 identity',
|
name: 'duplicate H1 identity',
|
||||||
mutate: (html: string) => html.replace('</main>', '<h1>Luca Pîslaru</h1></main>'),
|
mutate: (html: string) => html.replace('</main>', '<h1>Luca Pîslaru</h1></main>'),
|
||||||
|
|||||||
@@ -41,10 +41,16 @@ describe('safe metadata', () => {
|
|||||||
expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/');
|
expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/');
|
||||||
const previewAsset = manifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
|
const previewAsset = manifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
|
||||||
expect(metadata.openGraph.image).toBe(`https://portfolio.example.test/assets/${previewAsset.fileName}`);
|
expect(metadata.openGraph.image).toBe(`https://portfolio.example.test/assets/${previewAsset.fileName}`);
|
||||||
|
expect(metadata.twitter).toEqual({
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: metadata.title,
|
||||||
|
description: metadata.description,
|
||||||
|
image: `https://portfolio.example.test/assets/${previewAsset.fileName}`,
|
||||||
|
});
|
||||||
expect(metadata.jsonLd).toEqual({
|
expect(metadata.jsonLd).toEqual({
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'ProfilePage',
|
'@type': 'ProfilePage',
|
||||||
name: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
name: 'Luca Pîslaru',
|
||||||
description: metadata.description,
|
description: metadata.description,
|
||||||
url: 'https://portfolio.example.test/',
|
url: 'https://portfolio.example.test/',
|
||||||
mainEntity: {
|
mainEntity: {
|
||||||
@@ -54,6 +60,14 @@ describe('safe metadata', () => {
|
|||||||
description: metadata.description,
|
description: metadata.description,
|
||||||
url: 'https://portfolio.example.test/',
|
url: 'https://portfolio.example.test/',
|
||||||
email: 'mailto:luca.pislaru@gmail.com',
|
email: 'mailto:luca.pislaru@gmail.com',
|
||||||
|
homeLocation: {
|
||||||
|
'@type': 'Place',
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
addressLocality: 'Bucharest',
|
||||||
|
addressCountry: 'RO',
|
||||||
|
},
|
||||||
|
},
|
||||||
sameAs: [
|
sameAs: [
|
||||||
'https://github.com/LucaPisl',
|
'https://github.com/LucaPisl',
|
||||||
'https://gitlab.com/luca_pisl',
|
'https://gitlab.com/luca_pisl',
|
||||||
@@ -61,10 +75,27 @@ describe('safe metadata', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(JSON.stringify(metadata.jsonLd)).not.toMatch(
|
||||||
|
/streetAddress|postalCode|telephone|interactionStatistic|"image"/iu,
|
||||||
|
);
|
||||||
const expectedHash = createHash('sha256').update(metadata.serializedJsonLd).digest('base64');
|
const expectedHash = createHash('sha256').update(metadata.serializedJsonLd).digest('base64');
|
||||||
expect(metadata.jsonLdCspHash).toBe(`sha256-${expectedHash}`);
|
expect(metadata.jsonLdCspHash).toBe(`sha256-${expectedHash}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a summary card without an image when no social preview is approved', () => {
|
||||||
|
const withoutImage = structuredClone(content);
|
||||||
|
delete withoutImage.metadata.socialPreviewAssetId;
|
||||||
|
|
||||||
|
const metadata = createPageMetadata(withoutImage, manifest, productionSite);
|
||||||
|
|
||||||
|
expect(metadata.openGraph).not.toHaveProperty('image');
|
||||||
|
expect(metadata.twitter).toEqual({
|
||||||
|
card: 'summary',
|
||||||
|
title: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||||||
|
description: content.metadata.description,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('omits sameAs when no verified social profiles exist', () => {
|
it('omits sameAs when no verified social profiles exist', () => {
|
||||||
const withoutSocial = structuredClone(content);
|
const withoutSocial = structuredClone(content);
|
||||||
withoutSocial.contact.social = [];
|
withoutSocial.contact.social = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user