Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions .changeset/olive-cups-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"emdash": patch
---

Fixes the JSON-LD that core emits on public pages, which published nodes nothing could reference. `BlogPosting`, its `publisher`, and `WebSite` had no `@id`, so an Organization graph describing the site — from a plugin or written into a template — stood beside the article's publisher as a second, competing organisation instead of merging with it. Article pages carried two organisations, and the fuller one was not the article's publisher.

Each of the three nodes now carries an `@id`: `<canonical>#article`, `<origin>/#organization`, and `<origin>/#website`. The publisher keeps its `@type` and `name`, so a site publishing no Organization graph of its own is unaffected.

Sites already emitting an Organization graph should confirm its `@id` is `<origin>/#organization` — with the slash before the fragment. `https://example.com#organization` is a different IRI, and a mismatch produces two organisations rather than one.
37 changes: 26 additions & 11 deletions packages/core/src/page/jsonld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ export function cleanJsonLd(obj: Record<string, unknown>): Record<string, unknow
return cleaned;
}

/**
* Site origin to use for JSON-LD node identifiers.
*
* `page.siteUrl` wins over `page.url` so IDs stay stable when a theme
* overrides the public origin. Falls back to the raw canonical or URL only
* when neither parses as a URL.
*/
Comment on lines +29 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The siteOrigin JSDoc is written as PR narrative, not as a comment for future readers of the code. It explains why the helper was extracted from buildWebSiteJsonLd, why resolveSiteOrigin() was rejected, and frames that rejection as a design justification (Deliberately NOT, That may well be the better order, but...).

AGENTS.md's Comments section forbids comments that restate the change, address the reviewer, justify decisions, or narrate rejected alternatives. Replace this with a short description of the helper's contract (or omit it), and keep the precedence rationale in the commit message.

Suggested change
/**
* The site's public origin, as the graphs below refer to it.
*
* Lifted out of `buildWebSiteJsonLd` unchanged, because `buildBlogPostingJsonLd`
* now needs the same answer and two copies of this chain would eventually
* disagree which, for values used to build an `@id`, means silently
* publishing two entities instead of one.
*
* Deliberately NOT `resolveSiteOrigin()` from `absolute-url.ts`: that gives
* `SiteSettings.url` precedence over `page.siteUrl`, which would change which
* origin these graphs carry. That may well be the better order, but it is a
* behaviour change and does not belong in a fix about node identity.
*/
/**
* Site origin to use for JSON-LD node identifiers.
*
* `page.siteUrl` wins over `page.url` so IDs stay stable when a theme
* overrides the public origin. Falls back to the raw canonical or URL
* only when neither parses as a URL.
*/
function siteOrigin(page: PublicPageContext): string {

function siteOrigin(page: PublicPageContext): string {
if (page.siteUrl) {
try {
return new URL(page.siteUrl).origin;
} catch {
return page.siteUrl;
}
}
try {
return new URL(page.url).origin;
} catch {
return page.canonical || page.url;
}
}

/**
* Build a BlogPosting JSON-LD graph from page context.
* Used for article-type content pages.
Expand All @@ -52,6 +74,7 @@ export function buildBlogPostingJsonLd(
return cleanJsonLd({
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": `${page.canonical}#article`,
headline: ogTitle,
description,
image: ogImage || undefined,
Expand All @@ -67,6 +90,7 @@ export function buildBlogPostingJsonLd(
publisher: siteName
? {
"@type": "Organization",
"@id": `${siteOrigin(page)}/#organization`,
name: siteName,
}
: undefined,
Expand All @@ -85,21 +109,12 @@ export function buildWebSiteJsonLd(page: PublicPageContext): Record<string, unkn
const siteName = page.siteName;
if (!siteName) return null;

// Use configured public origin, falling back to page URL origin
let siteUrl: string;
if (page.siteUrl) {
siteUrl = page.siteUrl;
} else {
try {
siteUrl = new URL(page.url).origin;
} catch {
siteUrl = page.canonical || page.url;
}
}
const siteUrl = siteOrigin(page);

return cleanJsonLd({
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
name: siteName,
url: siteUrl,
});
Expand Down
56 changes: 55 additions & 1 deletion packages/core/tests/unit/plugins/page-seo.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { buildBlogPostingJsonLd } from "../../../src/page/jsonld.js";
import { buildBlogPostingJsonLd, buildWebSiteJsonLd } from "../../../src/page/jsonld.js";
import { generateBaseSeoContributions } from "../../../src/page/seo-contributions.js";
import type { PublicPageContext } from "../../../src/plugins/types.js";

Expand Down Expand Up @@ -180,4 +180,58 @@ describe("page SEO metadata", () => {
expect(graph).toMatchObject({ image: "https://example.com/post-hero.png" });
});
});

describe("node identity", () => {
// Without an `@id` a node is anonymous: nothing can reference it, and a
// richer description of the same thing published alongside it stays a
// separate entity rather than merging into one.
it("gives the article an @id distinct from the WebPage it is on", () => {
const graph = buildBlogPostingJsonLd(createPage());
expect(graph).not.toBeNull();

// Not the bare canonical: `mainEntityOfPage` already claims that for
// the WebPage, and reusing it would merge the article with the page.
expect(graph).toMatchObject({ "@id": "https://example.com/posts/hello#article" });
const mainEntity = graph?.mainEntityOfPage as Record<string, unknown>;
expect(graph?.["@id"]).not.toBe(mainEntity["@id"]);
});

it("identifies the publisher, so a fuller Organization graph merges with it", () => {
const graph = buildBlogPostingJsonLd(createPage({ siteUrl: "https://example.com" }));

// The trailing slash before the fragment is load-bearing:
// `https://example.com#organization` is a different IRI, and a
// mismatch publishes two organisations instead of one.
expect(graph?.publisher).toEqual({
"@type": "Organization",
"@id": "https://example.com/#organization",
name: "My Site",
});
});

it("keeps the publisher self-sufficient when nothing else describes it", () => {
const publisher = buildBlogPostingJsonLd(createPage())?.publisher as Record<string, unknown>;

// A bare `{ "@id": … }` would be a dangling reference on a site with
// no Organization graph — worse than the anonymous node it replaces.
expect(publisher["@type"]).toBe("Organization");
expect(publisher.name).toBe("My Site");
});

it("normalises a configured siteUrl to an origin", () => {
// A trailing slash on `page.siteUrl` would otherwise reach the id as
// `https://example.com//#organization`, which is a different IRI.
const graph = buildBlogPostingJsonLd(createPage({ siteUrl: "https://example.com/" }));
expect(graph).not.toBeNull();

const publisher = graph?.publisher as Record<string, unknown>;
expect(publisher["@id"]).toBe("https://example.com/#organization");
});

it("gives the WebSite an @id so a plugin can extend it", () => {
const graph = buildWebSiteJsonLd(createPage({ pageType: "website" }));

expect(graph).toMatchObject({ "@id": "https://example.com/#website" });
});
});
});
Loading