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 },
};