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, /<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); 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 = `<!doctype html><html><head> -<title>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 = [];