diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index c4b7818fb..fdbfe5258 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./../../dist/apps/website/.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/website/src/lib/blog-authors.ts b/apps/website/src/lib/blog-authors.ts index 1093271d0..7b9e12a0a 100644 --- a/apps/website/src/lib/blog-authors.ts +++ b/apps/website/src/lib/blog-authors.ts @@ -9,7 +9,12 @@ export interface Author { * with docs and code in this repository. */ knowsAbout?: readonly string[]; + /** + * Profile handles, not URLs. Each is opt-in: `sameAs` is an identity claim, so + * a handle the record does not name must never be synthesized from another. + */ twitter?: string; + linkedin?: string; github?: string; avatar?: string; } @@ -21,6 +26,8 @@ export const blogAuthors: Record = { bio: 'Agentic software architect building developer tooling for fullstack AI-powered web applications.', knowsAbout: ['Angular', 'TypeScript', 'LangGraph', 'AG-UI', 'Generative UI', 'Agent user interfaces'], github: 'blove', + twitter: 'blovedev', + linkedin: 'blove', }, }; diff --git a/apps/website/src/lib/structured-data.spec.ts b/apps/website/src/lib/structured-data.spec.ts index b6e0603be..76b87b67f 100644 --- a/apps/website/src/lib/structured-data.spec.ts +++ b/apps/website/src/lib/structured-data.spec.ts @@ -233,6 +233,7 @@ describe('aboutPageJsonLd', () => { expect(person['name']).toBe(AUTHOR.name); expect(person['jobTitle']).toBe(AUTHOR.role); expect(person['description']).toBe(AUTHOR.bio); + // The fixture names only a GitHub handle, so only that profile may appear. expect(person['sameAs']).toEqual(['https://github.com/blove']); expect(person['url']).toBe('https://threadplane.ai/about'); }); @@ -251,12 +252,24 @@ describe('aboutPageJsonLd', () => { expect((person['worksFor'] as JsonLdNode)['@id']).toBe(ORGANIZATION_ID); }); - it('resolves the real site author to a real GitHub profile', () => { + it('omits a profile the author record does not name', () => { + // Each handle is opt-in per field: an author with only a GitHub handle must + // not acquire an invented X or LinkedIn URL. + const graph = aboutPageJsonLd({ name: 'Anon', github: 'anon' })['@graph'] as JsonLdNode[]; + const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode; + expect(person['sameAs']).toEqual(['https://github.com/anon']); + }); + + it('resolves the real site author to real profiles', () => { // The page passes `blogAuthors['brian']`; `sameAs` is an identity claim, so - // this pins the profile the repo actually knows rather than the fixture's. + // this pins the profiles the repo actually knows rather than the fixture's. const graph = aboutPageJsonLd(blogAuthors['brian'])['@graph'] as JsonLdNode[]; const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode; - expect(person['sameAs']).toEqual(['https://github.com/blove']); + expect(person['sameAs']).toEqual([ + 'https://github.com/blove', + 'https://x.com/blovedev', + 'https://www.linkedin.com/in/blove', + ]); }); it('serializes to JSON', () => { diff --git a/apps/website/src/lib/structured-data.ts b/apps/website/src/lib/structured-data.ts index e1d1357fe..33f9c9f90 100644 --- a/apps/website/src/lib/structured-data.ts +++ b/apps/website/src/lib/structured-data.ts @@ -127,6 +127,19 @@ export const PERSON_ID = `${getCanonicalUrl(ABOUT_PATH)}#person`; * Every field is derived from the caller's {@link Author} record; nothing about * the person is stated here. */ +/** + * The external profiles an author record actually names, as absolute URLs. + * + * Order is stable so the emitted JSON-LD does not churn between builds. + */ +function personProfiles(author: Author): string[] { + return [ + author.github && `https://github.com/${author.github}`, + author.twitter && `https://x.com/${author.twitter}`, + author.linkedin && `https://www.linkedin.com/in/${author.linkedin}`, + ].filter((url): url is string => Boolean(url)); +} + export function aboutPageJsonLd(author: Author) { const url = getCanonicalUrl(ABOUT_PATH); const person: JsonLdNode = { @@ -137,8 +150,9 @@ export function aboutPageJsonLd(author: Author) { ...(author.role ? { jobTitle: author.role } : {}), ...(author.bio ? { description: author.bio } : {}), // Only profiles the repo actually knows about; `sameAs` is an identity - // claim, so a guessed profile is a false one. - ...(author.github ? { sameAs: [`https://github.com/${author.github}`] } : {}), + // claim, so a guessed profile is a false one. Each handle is a separate + // opt-in field — one is never derived from another. + ...(personProfiles(author).length ? { sameAs: personProfiles(author) } : {}), ...(author.knowsAbout?.length ? { knowsAbout: [...author.knowsAbout] } : {}), worksFor: { '@id': ORGANIZATION_ID }, };