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
6 changes: 3 additions & 3 deletions src/app/(frontend)/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { RichText } from "@payloadcms/richtext-lexical/react";
import { PageHeader } from "@/components/PageHeader";
import { PageLayout } from "@/components/PageLayout";
import { SchemaScript } from "@/components/SchemaScript";
import { generatePersonSchema, generatePageBreadcrumbSchema } from "@/lib/schema";
import { generateProfilePageSchema, generatePageBreadcrumbSchema } from "@/lib/schema";
import { getPageBySlug } from "@/lib/queries/pages";
import { siteUrl } from "@/lib/site-config";

Expand Down Expand Up @@ -37,7 +37,7 @@ export default async function AboutPage() {
if (!page) {
return (
<PageLayout>
<SchemaScript schema={[generatePersonSchema(), generatePageBreadcrumbSchema("about", "About")]} />
<SchemaScript schema={[generateProfilePageSchema(), generatePageBreadcrumbSchema("about", "About")]} />
<PageHeader
title="About"
subtitle="What this is, why it exists, and how to read it."
Expand Down Expand Up @@ -66,7 +66,7 @@ export default async function AboutPage() {

return (
<PageLayout>
<SchemaScript schema={[generatePersonSchema(), generatePageBreadcrumbSchema("about", "About")]} />
<SchemaScript schema={[generateProfilePageSchema(), generatePageBreadcrumbSchema("about", "About")]} />
<PageHeader title={page.title} subtitle={page.description ?? undefined} />
<section className="prose dark:prose-invert max-w-none">
<RichText data={page.body} />
Expand Down
2 changes: 1 addition & 1 deletion src/app/(frontend)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const metadata: Metadata = {
},
description: siteDescription,
keywords: siteKeywords,
authors: [{ name: siteName }],
authors: [{ name: "Julian Kennon" }],
// RSS autodiscovery is rendered as a direct <link> tag in <head> below,
// not via alternates.types, because page-level alternates.canonical
// would clobber it (Next.js metadata merging replaces alternates entirely).
Expand Down
10 changes: 10 additions & 0 deletions src/app/(frontend)/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ export default async function PostPage({ params }: PostPageProps) {
{post.publishedAt && (
<p className="text-sm tracking-[0.03em] text-text-tertiary">{formatDate(post.publishedAt)}</p>
)}
<p className="text-sm text-text-tertiary">
by{" "}
<Link
href="/about"
rel="author"
className="hover:text-accent transition-colors"
>
Julian (detached-node)
</Link>
</p>
</header>

{post.summary && (
Expand Down
11 changes: 11 additions & 0 deletions src/components/agentic-patterns/PatternHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// Renders alternative names inline as a comma-separated synonym list, since
// these are how readers find the pattern under names from other sources.

import { Link } from "next-view-transitions";
import type { LayerId, Pattern } from "@/data/agentic-design-patterns/types";
import { LAYERS } from "@/data/agentic-design-patterns/layers";
import { formatDate } from "@/lib/formatting";
Expand Down Expand Up @@ -56,6 +57,16 @@ export function PatternHeader({ pattern }: PatternHeaderProps) {
>
Last edited {formatDate(pattern.dateModified)}
</time>
<p className="text-xs text-text-tertiary">
by{" "}
<Link
href="/about"
rel="author"
className="hover:text-accent transition-colors"
>
Julian (detached-node)
</Link>
</p>
</header>
);
}
49 changes: 29 additions & 20 deletions src/lib/schema/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@
//
// Author entity configuration and site constants for JSON-LD schema generation.
//
// AUTHOR IDENTITY DECISION POINT:
// The author name and sameAs values are set here. When identity is finalized,
// update:
// 1. AUTHOR_CONFIG.name — real name or finalized pseudonym
// 2. AUTHOR_CONFIG.sameAs — add LinkedIn, Twitter, or other verifiable profiles
// 3. AUTHOR_CONFIG.description — update biography text
//
// These are the only changes needed for author identity migration. All schema
// generators reference AUTHOR_CONFIG and will pick up the update automatically.
// Tier C identity migration complete: real name lives in AUTHOR_CONFIG.name,
// the brand "detached-node" is retained as siteName, and AUTHOR_CONFIG.alternateName
// bridges the two so search and LLM citation pipelines can resolve the alias.
// All schema generators reference AUTHOR_CONFIG; updates here propagate automatically.

import { siteUrl } from '../site-url'

Expand All @@ -30,17 +25,31 @@ export const SITE_CONFIG = {
} as const;

export const AUTHOR_CONFIG = {
// Decision: Using pseudonym "detached-node" as the author identity.
// Update name to real name or final pseudonym when identity is decided.
name: "detached-node",
// Canonical author page — the semantic owner of the Person schema
// Tier C identity migration: real name in schema; brand "detached-node" stays as siteName.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION — File-header comment is stale.

This PR correctly updates the in-block comment here ("Tier C identity migration: real name in schema..."), but the file-header block at lines 5-13 (unchanged, outside diff) still reads:

AUTHOR IDENTITY DECISION POINT: The author name and sameAs values are set here. When identity is finalized, update: 1. AUTHOR_CONFIG.name — real name or finalized pseudonym, 2. sameAs, 3. description.

That framing describes a pre-migration state that no longer exists — name is now "Julian Kennon", sameAs has the LinkedIn entry, description is the expanded biography. A future maintainer reading the top of this file will think the migration is still pending.

Suggested rewrite: shorten the header to a one-line summary of the current state ("Author entity config — real name in JSON-LD; brand-only as siteName") and drop the migration checklist. Pure doc-rot cleanup.

name: "Julian Kennon",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT — Missing Person.alternateName for the brand alias.

AUTHOR_CONFIG.name is now "Julian Kennon" and JSON-LD emits that on / (Person), /about (ProfilePage.mainEntity), and via <meta name="author">. But the visible bylines you added in posts/[slug]/page.tsx and PatternHeader.tsx render "Julian (detached-node)", and siteName stays "detached-node". There is no machine-readable bridge between the legal name in structured data and the brand string that readers actually see — grep -rn alternateName src/ tests/ returns zero hits in the PR-branch worktree.

Person.alternateName is the canonical schema.org field for this. Adding it lets entity-graph consumers (Google Knowledge Graph, Bing, ClaudeBot, GPTBot) link "detached-node" mentions back to the same @id as the real-name Person, which is exactly the EEAT signal Tier C is targeting.

// src/lib/schema/config.ts
export const AUTHOR_CONFIG = {
  name: "Julian Kennon",
  alternateName: "detached-node",
  // ...
} as const;

Then surface it on both emission paths (PersonSchema interface in types.ts, the spread in person.ts, and the embedded mainEntity in profile-page.ts). One test in the new profile-page.test.ts would lock it down.

Not gating merge — addressable as a 2-line follow-up — but this is the only finding that closes a real signal gap, not a stylistic one.

// alternateName bridges the visible byline "Julian (detached-node)" to a
// machine-readable alias so search and LLM citation pipelines can resolve
// the brand to this Person entity.
alternateName: "detached-node",
url: `${siteUrl}/about`,
// @id: canonical identifier referenced by WebSite, BlogPosting, and Person schemas.
// Must be identical in every schema that references the author entity.
id: `${siteUrl}/#author`,
// sameAs: external verifiable profiles for entity disambiguation.
// GitHub org is the one confirmed external reference at launch.
// Add LinkedIn/Twitter/etc. when/if disclosed.
sameAs: ["https://github.com/julianken"] as string[],
description: "Writing on agentic AI in software engineering.",
sameAs: [
"https://github.com/julianken",
"https://www.linkedin.com/in/julian-kennon",
] as string[],
description:
"Software engineer with a decade-plus of full-stack experience, focused on agentic AI workflows and autonomous system design. Author of the agentic design patterns reference catalog at detached-node.dev.",
jobTitle: "Software Engineer",
knowsAbout: [
"Agentic AI design patterns",
"Autonomous software systems",
"Full-stack software engineering",
"LLM orchestration",
"Multi-agent systems",
"Prompt engineering",
"Model Context Protocol",
"AI-assisted software development",
"TypeScript",
"Distributed systems",
],
} as const;
2 changes: 2 additions & 0 deletions src/lib/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

export { generateWebSiteSchema } from "./website";
export { generatePersonSchema } from "./person";
export { generateProfilePageSchema } from "./profile-page";
export { generateBlogPostingSchema } from "./blog-posting";
export { generateBreadcrumbSchema, generatePageBreadcrumbSchema, generateHubChildBreadcrumb } from "./breadcrumb";
export { generateCollectionPageSchema } from "./collection-page";
Expand All @@ -19,6 +20,7 @@ export { generateFaqPageSchema } from "./faq-page";
export type {
SchemaBase,
PersonSchema,
ProfilePageSchema,
WebSiteSchema,
BlogPostingSchema,
BreadcrumbListSchema,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/schema/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ export function generatePersonSchema(): PersonSchema {
// all reference. Mismatch here breaks the entity graph.
"@id": AUTHOR_CONFIG.id,
name: AUTHOR_CONFIG.name,
alternateName: AUTHOR_CONFIG.alternateName,
url: AUTHOR_CONFIG.url,
// Spread to avoid mutating the const array
sameAs: [...AUTHOR_CONFIG.sameAs],
description: AUTHOR_CONFIG.description,
jobTitle: AUTHOR_CONFIG.jobTitle,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION — No unit coverage for generatePersonSchema() after the new fields land.

The Tier C additions (alternateName, jobTitle, knowsAbout) now flow through both generatePersonSchema() (used on /) and generateProfilePageSchema() (used on /about), but tests/unit/lib/schema/ only adds profile-page.test.ts — there is no test file for person.ts at all (verified: find tests -name 'person*.test.*' returns nothing). The new fields are confirmed via manual smoke on the homepage in the PR body, but a future refactor that drops e.g. knowsAbout from generatePersonSchema() would render on / with missing data and the CI suite wouldn't notice. A small tests/unit/lib/schema/person.test.ts mirroring the ProfilePage suite's assertions on the new fields closes the gap.

knowsAbout: [...AUTHOR_CONFIG.knowsAbout],
};
}
35 changes: 35 additions & 0 deletions src/lib/schema/profile-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// src/lib/schema/profile-page.ts
//
// Generates ProfilePage schema for /about — the canonical author-entity page.
// ProfilePage embeds the Person schema as mainEntity. Google + AI citation
// pipelines use this combo as the authoritative author-identity declaration.
//
// Usage: import { generateProfilePageSchema } from '@/lib/schema'; emit via
// <SchemaScript schema={[generateProfilePageSchema(), ...]} />.

import { AUTHOR_CONFIG, SITE_CONFIG } from "./config";
import type { ProfilePageSchema } from "./types";

export function generateProfilePageSchema(): ProfilePageSchema {
return {
"@context": "https://schema.org",
"@type": "ProfilePage",
"@id": `${SITE_CONFIG.url}/about#profile-page`,
url: `${SITE_CONFIG.url}/about`,
name: `About ${AUTHOR_CONFIG.name} — ${SITE_CONFIG.name}`,
isPartOf: { "@id": SITE_CONFIG.websiteId },
// Embedded Person mainEntity intentionally omits @context — it lives on
// the top-level ProfilePage only, matching Google's documented example.
mainEntity: {
"@type": "Person",
"@id": AUTHOR_CONFIG.id,
name: AUTHOR_CONFIG.name,
alternateName: AUTHOR_CONFIG.alternateName,
url: AUTHOR_CONFIG.url,
sameAs: [...AUTHOR_CONFIG.sameAs],
description: AUTHOR_CONFIG.description,
jobTitle: AUTHOR_CONFIG.jobTitle,
knowsAbout: [...AUTHOR_CONFIG.knowsAbout],
},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION — ProfilePage.mainEntity rebuilds the Person entity by hand.

generateProfilePageSchema() (this function, lines 22-33) and generatePersonSchema() in src/lib/schema/person.ts:17-30 both spell out the same @type/@id/name/alternateName/url/sameAs/description/jobTitle/knowsAbout shape. When AUTHOR_CONFIG grows another field tomorrow (e.g. image, worksFor), both call sites have to be updated — and that drift cost is not hypothetical: the immediately-prior fixup commit (de36d39) had to touch both files just to thread alternateName through. The type Omit<PersonSchema, "@context"> already names the embedded shape; a small buildPersonEntity({ includeContext: boolean }) helper (or simpler: destructure @context from generatePersonSchema() for the embedded copy) would collapse the duplication and prevent future drift. Non-blocking; flagging as a follow-up.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION — DRY: the embedded mainEntity Person here duplicates the construction in generatePersonSchema() (src/lib/schema/person.ts:17-32) field-for-field, minus @context. Eight fields are repeated across both files.

The type system catches a missing required field on mainEntity (it is typed as Omit<PersonSchema, "@context">), but it will not catch a future optional field being added to one site and not the other (e.g. image, email, worksFor).

A small refactor folds them together:

export function generateProfilePageSchema(): ProfilePageSchema {
  // Reuse the standalone Person body; `@context` is owned by the top-level ProfilePage.
  const { "@context": _ctx, ...person } = generatePersonSchema();
  return {
    "@context": "https://schema.org",
    "@type": "ProfilePage",
    "@id": `${SITE_CONFIG.url}/about#profile-page`,
    url: `${SITE_CONFIG.url}/about`,
    name: `About ${AUTHOR_CONFIG.name}${SITE_CONFIG.name}`,
    isPartOf: { "@id": SITE_CONFIG.websiteId },
    mainEntity: person,
  };
}

Non-blocking — every emitted field is correct today, and the two existing sites stay in sync as long as AUTHOR_CONFIG is the only source of truth for both. Future-proofing only.

};
}
21 changes: 21 additions & 0 deletions src/lib/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,30 @@ export interface PersonSchema extends SchemaBase {
"@type": "Person";
"@id": string;
name: string;
alternateName?: string;
url: string;
sameAs?: string[];
description?: string;
jobTitle?: string;
knowsAbout?: string[];
}

// -------------------------------------------------------------------------
// ProfilePage — top-level schema for /about
// Embeds the canonical Person as mainEntity. Google + AI citation pipelines
// use the ProfilePage + Person combo as the authoritative author-identity
// declaration. The embedded Person omits @context to match Google's documented
// ProfilePage example (see developers.google.com/search/docs/appearance/structured-data/profile-page);
// @context lives on the top-level ProfilePage only.
// -------------------------------------------------------------------------

export interface ProfilePageSchema extends SchemaBase {
"@type": "ProfilePage";
"@id": string;
url: string;
name: string;
isPartOf: { "@id": string };
mainEntity: Omit<PersonSchema, "@context">;
}

// -------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/fixtures/page-objects/post-detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class PostDetailPage {
this.page = page
this.navigation = page.locator('nav[aria-label="Main navigation"]')
this.postTitle = page.locator('article h1').first()
this.postDate = page.locator('article header p').last()
this.postDate = page.locator('article header p:not(:has(a[rel="author"]))')
this.postSummary = page.locator('article p.text-lg').first()
this.postContent = page.locator('article section.prose')
this.backToPostsLink = page.getByRole('link', { name: /back to posts/i })
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/lib/schema/profile-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from "vitest";

// Mock the schema config module to avoid the NEXT_PUBLIC_SERVER_URL env check
// that fires at import time in src/lib/site-url.ts. Mocks reflect post-Tier-C
// AUTHOR_CONFIG shape (real name + jobTitle + knowsAbout).
vi.mock("@/lib/schema/config", () => ({
SITE_CONFIG: {
url: "https://detached-node.com",
name: "detached-node",
description: "Test description",
websiteId: "https://detached-node.com/#website",
},
AUTHOR_CONFIG: {
name: "Julian Kennon",
alternateName: "detached-node",
url: "https://detached-node.com/about",
id: "https://detached-node.com/#author",
sameAs: [
"https://github.com/julianken",
"https://www.linkedin.com/in/julian-kennon",
],
description: "Software engineer focused on agentic AI workflows.",
jobTitle: "Software Engineer",
knowsAbout: [
"Agentic AI design patterns",
"Distributed systems",
"TypeScript",
],
},
}));

import { generateProfilePageSchema } from "@/lib/schema/profile-page";
import { SITE_CONFIG, AUTHOR_CONFIG } from "@/lib/schema/config";

// ---------------------------------------------------------------------------
// generateProfilePageSchema
// ---------------------------------------------------------------------------

describe("generateProfilePageSchema", () => {
it("returns top-level @context and @type ProfilePage", () => {
const schema = generateProfilePageSchema();
expect(schema["@context"]).toBe("https://schema.org");
expect(schema["@type"]).toBe("ProfilePage");
});

it("@id is the canonical ProfilePage identifier (about#profile-page)", () => {
const schema = generateProfilePageSchema();
expect(schema["@id"]).toBe(`${SITE_CONFIG.url}/about#profile-page`);
});

it("url points at /about", () => {
const schema = generateProfilePageSchema();
expect(schema.url).toBe(`${SITE_CONFIG.url}/about`);
});

it("name embeds author display name and site name", () => {
const schema = generateProfilePageSchema();
expect(schema.name).toBe(
`About ${AUTHOR_CONFIG.name} — ${SITE_CONFIG.name}`
);
});

it("isPartOf links to the canonical WebSite via @id", () => {
const schema = generateProfilePageSchema();
expect(schema.isPartOf).toEqual({ "@id": SITE_CONFIG.websiteId });
});

it("embeds Person as mainEntity with @type and @id, but NOT @context (matches Google's example)", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity["@type"]).toBe("Person");
expect(schema.mainEntity["@id"]).toBe(AUTHOR_CONFIG.id);
// Embedded Person must not carry @context — it lives on the top-level
// ProfilePage only.
expect("@context" in schema.mainEntity).toBe(false);
});

it("mainEntity.name matches AUTHOR_CONFIG.name (real name, not pseudonym)", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity.name).toBe(AUTHOR_CONFIG.name);
expect(schema.mainEntity.name).toBe("Julian Kennon");
});

it("mainEntity.alternateName is the brand alias 'detached-node'", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity.alternateName).toBe(AUTHOR_CONFIG.alternateName);
expect(schema.mainEntity.alternateName).toBe("detached-node");
});

it("mainEntity.url points at /about (canonical author page)", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity.url).toBe(AUTHOR_CONFIG.url);
});

it("mainEntity.sameAs is a fresh array, not the same reference as AUTHOR_CONFIG.sameAs", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity.sameAs).toEqual([...AUTHOR_CONFIG.sameAs]);
// Mutation of the schema array must not affect the const config array.
expect(schema.mainEntity.sameAs).not.toBe(AUTHOR_CONFIG.sameAs);
});

it("mainEntity carries description, jobTitle, knowsAbout from config", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity.description).toBe(AUTHOR_CONFIG.description);
expect(schema.mainEntity.jobTitle).toBe(AUTHOR_CONFIG.jobTitle);
expect(schema.mainEntity.knowsAbout).toEqual([...AUTHOR_CONFIG.knowsAbout]);
});

it("mainEntity.knowsAbout is a fresh array, not the same reference as AUTHOR_CONFIG.knowsAbout", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity.knowsAbout).not.toBe(AUTHOR_CONFIG.knowsAbout);
});
});
Loading