seo: align public identity and social metadata
This commit is contained in:
@@ -8,15 +8,24 @@ import { verifyProductionSmoke } from '../../scripts/smoke-production.mjs';
|
||||
const digest = 'a'.repeat(64);
|
||||
const canonical = 'https://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>
|
||||
<title>Luca Pîslaru — System Administrator & Full-Stack Developer</title>
|
||||
<meta name="description" content="Systems administration and full-stack development portfolio." />
|
||||
<title>${escapedTitle}</title>
|
||||
<meta name="description" content="${description}" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="${canonical}" />
|
||||
<meta property="og:title" content="Luca Pîslaru" />
|
||||
<meta property="og:description" content="Systems administration and full-stack development portfolio." />
|
||||
<meta property="og:type" content="profile" />
|
||||
<meta property="og:title" content="${escapedTitle}" />
|
||||
<meta property="og:description" content="${description}" />
|
||||
<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>
|
||||
<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', () => {
|
||||
it('accepts the complete private preview artifact set', async () => {
|
||||
const { directory, inputs, contract } = await writeValidArtifacts();
|
||||
it('accepts one exact canonical, Open Graph, Twitter, and privacy-safe JSON-LD metadata set', async () => {
|
||||
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();
|
||||
});
|
||||
@@ -204,6 +242,46 @@ describe('artifact verification', () => {
|
||||
mutate: (html: string) => html.replace('</head>', '<link rel="canonical" href="https://portfolio.localhost/" /></head>'),
|
||||
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',
|
||||
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/');
|
||||
const previewAsset = manifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
|
||||
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({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
name: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||||
name: 'Luca Pîslaru',
|
||||
description: metadata.description,
|
||||
url: 'https://portfolio.example.test/',
|
||||
mainEntity: {
|
||||
@@ -54,6 +60,14 @@ describe('safe metadata', () => {
|
||||
description: metadata.description,
|
||||
url: 'https://portfolio.example.test/',
|
||||
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',
|
||||
@@ -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');
|
||||
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', () => {
|
||||
const withoutSocial = structuredClone(content);
|
||||
withoutSocial.contact.social = [];
|
||||
|
||||
Reference in New Issue
Block a user