diff --git a/.changeset/olive-cups-shine.md b/.changeset/olive-cups-shine.md new file mode 100644 index 0000000000..5c7e924427 --- /dev/null +++ b/.changeset/olive-cups-shine.md @@ -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`: `#article`, `/#organization`, and `/#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 `/#organization` — with the slash before the fragment. `https://example.com#organization` is a different IRI, and a mismatch produces two organisations rather than one. diff --git a/packages/core/src/page/jsonld.ts b/packages/core/src/page/jsonld.ts index 2a2be7f77d..d52ab388b0 100644 --- a/packages/core/src/page/jsonld.ts +++ b/packages/core/src/page/jsonld.ts @@ -26,6 +26,28 @@ export function cleanJsonLd(obj: Record): Record { 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; + 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; + + // 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; + 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" }); + }); + }); });