From 82e62e76953b7276fd17a3b1bb3aacebcfcbfb95 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 24 Aug 2026 17:39:20 -0700 Subject: [PATCH] feat(website): link Brian's X and LinkedIn profiles from the Person node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sameAs` is how a Person node resolves to a real-world identity, and answer engines lean on it for entity disambiguation — the reason /about carries a Person node at all. It listed only GitHub, so the strongest disambiguating signals were missing. Add the two profiles Brian already links publicly from brianflove.com, verified against that page's raw HTML rather than a summary. (LinkedIn answers 999 to automated requests; that is its anti-bot response, not a dead link.) Keep the existing invariant intact: `sameAs` states only profiles the author record actually names. Each handle is its own opt-in field, so one is never synthesized from another — an author with a GitHub handle does not acquire an invented X URL — and `personProfiles()` emits them in a stable order so the JSON-LD does not churn between builds. A test covers exactly that case. `twitter` was already declared on the Author interface and read by nothing; populating it now feeds only `sameAs`. Co-Authored-By: Claude Opus 5 --- apps/website/next-env.d.ts | 2 +- apps/website/src/lib/blog-authors.ts | 7 +++++++ apps/website/src/lib/structured-data.spec.ts | 19 ++++++++++++++++--- apps/website/src/lib/structured-data.ts | 18 ++++++++++++++++-- 4 files changed, 40 insertions(+), 6 deletions(-) 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 }, };