Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -18,6 +19,7 @@ export { generatePatternArticleSchema, referenceToPatternCitation } from "./agen
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
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