-
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 1 commit
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 |
|---|---|---|
|
|
@@ -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. | ||
| 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. |
||
| 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 |
|---|---|---|
|
|
@@ -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, | ||
|
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,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", | ||
|
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 — Embedded The rationale comment in If you want to align with Google's example, the embedded mainEntity becomes 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], | ||
| }, | ||
|
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,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); | ||
| }); | ||
| }); |
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.