Skip to content

Commit fb67c47

Browse files
bloveclaude
andauthored
feat(website): link Brian's X and LinkedIn profiles from the Person node (#829)
`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 <noreply@anthropic.com>
1 parent d277715 commit fb67c47

4 files changed

Lines changed: 40 additions & 6 deletions

File tree

apps/website/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./../../dist/apps/website/.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/website/src/lib/blog-authors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ export interface Author {
99
* with docs and code in this repository.
1010
*/
1111
knowsAbout?: readonly string[];
12+
/**
13+
* Profile handles, not URLs. Each is opt-in: `sameAs` is an identity claim, so
14+
* a handle the record does not name must never be synthesized from another.
15+
*/
1216
twitter?: string;
17+
linkedin?: string;
1318
github?: string;
1419
avatar?: string;
1520
}
@@ -21,6 +26,8 @@ export const blogAuthors: Record<string, Author> = {
2126
bio: 'Agentic software architect building developer tooling for fullstack AI-powered web applications.',
2227
knowsAbout: ['Angular', 'TypeScript', 'LangGraph', 'AG-UI', 'Generative UI', 'Agent user interfaces'],
2328
github: 'blove',
29+
twitter: 'blovedev',
30+
linkedin: 'blove',
2431
},
2532
};
2633

apps/website/src/lib/structured-data.spec.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ describe('aboutPageJsonLd', () => {
233233
expect(person['name']).toBe(AUTHOR.name);
234234
expect(person['jobTitle']).toBe(AUTHOR.role);
235235
expect(person['description']).toBe(AUTHOR.bio);
236+
// The fixture names only a GitHub handle, so only that profile may appear.
236237
expect(person['sameAs']).toEqual(['https://github.com/blove']);
237238
expect(person['url']).toBe('https://threadplane.ai/about');
238239
});
@@ -251,12 +252,24 @@ describe('aboutPageJsonLd', () => {
251252
expect((person['worksFor'] as JsonLdNode)['@id']).toBe(ORGANIZATION_ID);
252253
});
253254

254-
it('resolves the real site author to a real GitHub profile', () => {
255+
it('omits a profile the author record does not name', () => {
256+
// Each handle is opt-in per field: an author with only a GitHub handle must
257+
// not acquire an invented X or LinkedIn URL.
258+
const graph = aboutPageJsonLd({ name: 'Anon', github: 'anon' })['@graph'] as JsonLdNode[];
259+
const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode;
260+
expect(person['sameAs']).toEqual(['https://github.com/anon']);
261+
});
262+
263+
it('resolves the real site author to real profiles', () => {
255264
// The page passes `blogAuthors['brian']`; `sameAs` is an identity claim, so
256-
// this pins the profile the repo actually knows rather than the fixture's.
265+
// this pins the profiles the repo actually knows rather than the fixture's.
257266
const graph = aboutPageJsonLd(blogAuthors['brian'])['@graph'] as JsonLdNode[];
258267
const person = graph.find((node) => node['@type'] === 'Person') as JsonLdNode;
259-
expect(person['sameAs']).toEqual(['https://github.com/blove']);
268+
expect(person['sameAs']).toEqual([
269+
'https://github.com/blove',
270+
'https://x.com/blovedev',
271+
'https://www.linkedin.com/in/blove',
272+
]);
260273
});
261274

262275
it('serializes to JSON', () => {

apps/website/src/lib/structured-data.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,19 @@ export const PERSON_ID = `${getCanonicalUrl(ABOUT_PATH)}#person`;
127127
* Every field is derived from the caller's {@link Author} record; nothing about
128128
* the person is stated here.
129129
*/
130+
/**
131+
* The external profiles an author record actually names, as absolute URLs.
132+
*
133+
* Order is stable so the emitted JSON-LD does not churn between builds.
134+
*/
135+
function personProfiles(author: Author): string[] {
136+
return [
137+
author.github && `https://github.com/${author.github}`,
138+
author.twitter && `https://x.com/${author.twitter}`,
139+
author.linkedin && `https://www.linkedin.com/in/${author.linkedin}`,
140+
].filter((url): url is string => Boolean(url));
141+
}
142+
130143
export function aboutPageJsonLd(author: Author) {
131144
const url = getCanonicalUrl(ABOUT_PATH);
132145
const person: JsonLdNode = {
@@ -137,8 +150,9 @@ export function aboutPageJsonLd(author: Author) {
137150
...(author.role ? { jobTitle: author.role } : {}),
138151
...(author.bio ? { description: author.bio } : {}),
139152
// Only profiles the repo actually knows about; `sameAs` is an identity
140-
// claim, so a guessed profile is a false one.
141-
...(author.github ? { sameAs: [`https://github.com/${author.github}`] } : {}),
153+
// claim, so a guessed profile is a false one. Each handle is a separate
154+
// opt-in field — one is never derived from another.
155+
...(personProfiles(author).length ? { sameAs: personProfiles(author) } : {}),
142156
...(author.knowsAbout?.length ? { knowsAbout: [...author.knowsAbout] } : {}),
143157
worksFor: { '@id': ORGANIZATION_ID },
144158
};

0 commit comments

Comments
 (0)