-
Notifications
You must be signed in to change notification settings - Fork 0
feat(seo): Tier C identity migration + ProfilePage schema #400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5fa4ade
de36d39
d6deaa9
d86de0a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMPORTANT — Missing
// src/lib/schema/config.ts
export const AUTHOR_CONFIG = {
name: "Julian Kennon",
alternateName: "detached-node",
// ...
} as const;Then surface it on both emission paths ( 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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION — No unit coverage for The Tier C additions ( |
||
| knowsAbout: [...AUTHOR_CONFIG.knowsAbout], | ||
| }; | ||
| } | ||
| 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], | ||
| }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION —
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION — DRY: the embedded The type system catches a missing required field on 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 |
||
| }; | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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:
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.