Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
7 changes: 7 additions & 0 deletions apps/website/src/lib/blog-authors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -21,6 +26,8 @@ export const blogAuthors: Record<string, Author> = {
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',
},
};

Expand Down
19 changes: 16 additions & 3 deletions apps/website/src/lib/structured-data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand All @@ -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', () => {
Expand Down
18 changes: 16 additions & 2 deletions apps/website/src/lib/structured-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 },
};
Expand Down
Loading