diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs
index f1e0752..24f0867 100644
--- a/scripts/prerender.mjs
+++ b/scripts/prerender.mjs
@@ -33,9 +33,6 @@ const exactlyOne = (source, expression, label) => {
};
const metadataMarkup = (metadata) => {
- const image = metadata.openGraph.image === undefined
- ? ''
- : `\n `;
return [
` `,
` `,
@@ -43,7 +40,16 @@ const metadataMarkup = (metadata) => {
` `,
` `,
` `,
- ` ${image}`,
+ ` `,
+ ...(metadata.openGraph.image === undefined
+ ? []
+ : [` `]),
+ ` `,
+ ` `,
+ ` `,
+ ...(metadata.twitter.image === undefined
+ ? []
+ : [` `]),
` `,
].join('\n');
};
diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs
index 89aff1d..88994a1 100644
--- a/scripts/verify-artifacts.mjs
+++ b/scripts/verify-artifacts.mjs
@@ -26,11 +26,6 @@ const prohibitedSurfaces = [
];
const escapeRegularExpression = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
-const escapeHtml = (value) => value
- .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, /
([\s\S]*?)<\/title>/gu, 'page title');
- if (title[1] !== expectedTitle) throw new Error('Page title does not match public content.');
- const canonical = exactlyOneMatch(html, //gu, 'canonical link');
- if (canonical[1] !== inputs.site.canonicalUrl) throw new Error('Canonical URL does not match site config.');
- const description = exactlyOneMatch(html, //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(``, 'gu'), `${property} metadata`);
- }
- const ogImages = [...html.matchAll(/ {
+ 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(' {
}
};
-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);
diff --git a/src/content/metadata.ts b/src/content/metadata.ts
index f614397..ebe790f 100644
--- a/src/content/metadata.ts
+++ b/src/content/metadata.ts
@@ -7,6 +7,15 @@ import type {
import { serializeJsonLd } from '../security/serialize-json-ld.ts';
import { createSecurityHeaders } from '../security/security-policy.ts';
+interface PlaceJsonLd {
+ '@type': 'Place';
+ address: {
+ '@type': 'PostalAddress';
+ addressLocality: string;
+ addressCountry: string;
+ };
+}
+
interface PersonJsonLd {
'@type': 'Person';
name: string;
@@ -14,6 +23,7 @@ interface PersonJsonLd {
description: string;
url: string;
email: string;
+ homeLocation: PlaceJsonLd;
sameAs?: string[];
}
@@ -38,6 +48,12 @@ export interface PageMetadata {
url: string;
image?: string;
};
+ twitter: {
+ card: 'summary' | 'summary_large_image';
+ title: string;
+ description: string;
+ image?: string;
+ };
jsonLd: ProfilePageJsonLd;
serializedJsonLd: string;
jsonLdCspHash: string;
@@ -78,12 +94,20 @@ export const createPageMetadata = (
description: content.metadata.description,
url: site.canonicalUrl,
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 }),
};
const jsonLd: ProfilePageJsonLd = {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
- name: canonicalTitle,
+ name: content.identity.name,
description: content.metadata.description,
url: site.canonicalUrl,
mainEntity,
@@ -103,6 +127,12 @@ export const createPageMetadata = (
url: site.canonicalUrl,
...(image === undefined ? {} : { image }),
},
+ twitter: {
+ card: image === undefined ? 'summary' : 'summary_large_image',
+ title: canonicalTitle,
+ description: content.metadata.description,
+ ...(image === undefined ? {} : { image }),
+ },
jsonLd,
serializedJsonLd,
jsonLdCspHash: `sha256-${createHash('sha256').update(serializedJsonLd).digest('base64')}`,
diff --git a/tests/deployment/seo.spec.ts b/tests/deployment/seo.spec.ts
index 8289c10..9b38a97 100644
--- a/tests/deployment/seo.spec.ts
+++ b/tests/deployment/seo.spec.ts
@@ -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 = `
-Luca Pîslaru — System Administrator & Full-Stack Developer
-
+${escapedTitle}
+
-
-
+
+
+
-
+
+
+
+
+
+
Luca Pîslaru
System Administrator & Full-Stack Developer
Selected builds
Evidence-led project work.
Contact
Discuss systems and software work.
Contact`;
diff --git a/tests/integration/verify-artifacts.test.ts b/tests/integration/verify-artifacts.test.ts
index 7d10bdc..ffca913 100644
--- a/tests/integration/verify-artifacts.test.ts
+++ b/tests/integration/verify-artifacts.test.ts
@@ -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('', ''),
error: /exactly one canonical/i,
},
+ {
+ name: 'duplicate Twitter card metadata',
+ mutate: (html: string) => html.replace(
+ '',
+ '',
+ ),
+ error: /exactly one twitter:card/i,
+ },
+ {
+ name: 'an Open Graph canonical drift',
+ mutate: (html: string) => html.replace(
+ '',
+ '',
+ ),
+ error: /og:url.*canonical|metadata.*match/i,
+ },
+ {
+ name: 'a mismatched Open Graph image',
+ mutate: (html: string) => html.replace(
+ //u,
+ '',
+ ),
+ error: /og:image.*manifest|metadata.*match/i,
+ },
+ {
+ name: 'a mismatched Twitter image',
+ mutate: (html: string) => html.replace(
+ //u,
+ '',
+ ),
+ 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('', 'Luca Pîslaru
'),
diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts
index b02a236..ce53a2e 100644
--- a/tests/unit/metadata.test.ts
+++ b/tests/unit/metadata.test.ts
@@ -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 = [];