Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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>
);
}
32 changes: 21 additions & 11 deletions src/lib/schema/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,27 @@ 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.

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
2 changes: 2 additions & 0 deletions src/lib/schema/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ export function generatePersonSchema(): PersonSchema {
// 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],
};
}
33 changes: 33 additions & 0 deletions src/lib/schema/profile-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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 },
mainEntity: {
"@context": "https://schema.org",
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 — Embedded Person carries its own @context.

The rationale comment in types.ts:71-78 is honest about this being a divergent choice, and it's harmless: Google's parser tolerates duplicate @context on nested entities. But Google's own ProfilePage example shows @context only at the top level — the embedded Person has just @type and properties. The current emission produces ~30 extra bytes per ProfilePage response and runs slightly against the documented shape.

If you want to align with Google's example, the embedded mainEntity becomes Omit<PersonSchema, "@context"> and the spread in profile-page.ts:22 drops the line. The test's mainEntity["@context"] assertion in profile-page.test.ts:71 would invert to not.toHaveProperty("@context").

Strictly cleanup — the current shape validates and renders. Filing as SUGGESTION because the comment explicitly notes "no @context-stripping convention exists in this codebase for embedded entities yet"; this PR is a reasonable place to establish one, but the cost of deferring is zero.

"@type": "Person",
"@id": AUTHOR_CONFIG.id,
name: AUTHOR_CONFIG.name,
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.

};
}
22 changes: 22 additions & 0 deletions src/lib/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ export interface PersonSchema extends SchemaBase {
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 retains @context because Google
// historically tolerates it on embedded entities and several validators
// expect it; this matches the convention used for the top-level Person
// emitted elsewhere on the site (no @context-stripping convention exists
// in this codebase for embedded entities yet).
// -------------------------------------------------------------------------

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

// -------------------------------------------------------------------------
Expand Down
103 changes: 103 additions & 0 deletions tests/unit/lib/schema/profile-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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",
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 @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 @context and @id", () => {
const schema = generateProfilePageSchema();
expect(schema.mainEntity["@context"]).toBe("https://schema.org");
expect(schema.mainEntity["@type"]).toBe("Person");
expect(schema.mainEntity["@id"]).toBe(AUTHOR_CONFIG.id);
});

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.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