diff --git a/src/app/(frontend)/about/page.tsx b/src/app/(frontend)/about/page.tsx index d3e93dc..95c4711 100644 --- a/src/app/(frontend)/about/page.tsx +++ b/src/app/(frontend)/about/page.tsx @@ -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"; @@ -37,7 +37,7 @@ export default async function AboutPage() { if (!page) { return ( - + - +
diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index c17cf9f..c5b6110 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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 tag in below, // not via alternates.types, because page-level alternates.canonical // would clobber it (Next.js metadata merging replaces alternates entirely). diff --git a/src/app/(frontend)/posts/[slug]/page.tsx b/src/app/(frontend)/posts/[slug]/page.tsx index ca0e46f..35993b8 100644 --- a/src/app/(frontend)/posts/[slug]/page.tsx +++ b/src/app/(frontend)/posts/[slug]/page.tsx @@ -129,6 +129,16 @@ export default async function PostPage({ params }: PostPageProps) { {post.publishedAt && (

{formatDate(post.publishedAt)}

)} +

+ by{" "} + + Julian (detached-node) + +

{post.summary && ( diff --git a/src/components/agentic-patterns/PatternHeader.tsx b/src/components/agentic-patterns/PatternHeader.tsx index f47a8a8..83bcf1e 100644 --- a/src/components/agentic-patterns/PatternHeader.tsx +++ b/src/components/agentic-patterns/PatternHeader.tsx @@ -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"; @@ -56,6 +57,16 @@ export function PatternHeader({ pattern }: PatternHeaderProps) { > Last edited {formatDate(pattern.dateModified)} +

+ by{" "} + + Julian (detached-node) + +

); } diff --git a/src/lib/schema/config.ts b/src/lib/schema/config.ts index 9bea87e..5724881 100644 --- a/src/lib/schema/config.ts +++ b/src/lib/schema/config.ts @@ -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' @@ -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. + name: "Julian Kennon", + // 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; diff --git a/src/lib/schema/index.ts b/src/lib/schema/index.ts index af432b6..8a0979c 100644 --- a/src/lib/schema/index.ts +++ b/src/lib/schema/index.ts @@ -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"; @@ -19,6 +20,7 @@ export { generateFaqPageSchema } from "./faq-page"; export type { SchemaBase, PersonSchema, + ProfilePageSchema, WebSiteSchema, BlogPostingSchema, BreadcrumbListSchema, diff --git a/src/lib/schema/person.ts b/src/lib/schema/person.ts index 9e79004..4b05951 100644 --- a/src/lib/schema/person.ts +++ b/src/lib/schema/person.ts @@ -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, + knowsAbout: [...AUTHOR_CONFIG.knowsAbout], }; } diff --git a/src/lib/schema/profile-page.ts b/src/lib/schema/profile-page.ts new file mode 100644 index 0000000..036d9ef --- /dev/null +++ b/src/lib/schema/profile-page.ts @@ -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 +// . + +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], + }, + }; +} diff --git a/src/lib/schema/types.ts b/src/lib/schema/types.ts index f449359..ffac970 100644 --- a/src/lib/schema/types.ts +++ b/src/lib/schema/types.ts @@ -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; } // ------------------------------------------------------------------------- diff --git a/tests/e2e/fixtures/page-objects/post-detail.page.ts b/tests/e2e/fixtures/page-objects/post-detail.page.ts index d18117c..5082b0b 100644 --- a/tests/e2e/fixtures/page-objects/post-detail.page.ts +++ b/tests/e2e/fixtures/page-objects/post-detail.page.ts @@ -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 }) diff --git a/tests/unit/lib/schema/profile-page.test.ts b/tests/unit/lib/schema/profile-page.test.ts new file mode 100644 index 0000000..c10edae --- /dev/null +++ b/tests/unit/lib/schema/profile-page.test.ts @@ -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); + }); +});