From 4c548bb557d0992927a5305c080f285e3bb60914 Mon Sep 17 00:00:00 2001 From: Marcin Misiewicz Date: Thu, 10 Sep 2026 15:22:40 +0200 Subject: [PATCH 1/2] fix(core): give the JSON-LD nodes an @id so they can be referenced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BlogPosting`, its `publisher` and `WebSite` were emitted without `@id`, which makes each an anonymous node: nothing can point at it, and a fuller description of the same thing published alongside it stays a separate entity. The visible consequence is on article pages. A site that publishes an Organization graph — from a plugin, or hand-written in a template — ends up with two organisations: the rich one, and core's `{ "@type": "Organization", name }` sitting inside `publisher`. A consumer has no way to merge them, so the article's publisher is the one carrying nothing but a name, and every property the site took care to state is attached to something the article does not reference. `WebSite` has the same problem from the other direction: with no `@id` there is nowhere to attach a `potentialAction`, so adding a sitelinks SearchAction means emitting a second WebSite node and claiming the site is two sites. Three ids, chosen to be joinable: - `BlogPosting` -> `#article`. A fragment rather than the bare canonical, because `mainEntityOfPage` already identifies the WebPage by that IRI and reusing it would state that the article and its page are one thing. - `publisher` -> `/#organization`, the conventional form, and the one already produced by `url.replace(/\/$/, "") + "/#organization"`. The slash before the fragment is load-bearing: `https://x.com#organization` is a different IRI, and a mismatch does not error — it publishes two organisations. - `WebSite` -> `#website`. `publisher` keeps its `@type` and `name`. Emitting a bare `{ "@id": … }` would leave a dangling reference on every site that publishes no Organization graph, which is worse than the anonymous node it replaces. Nothing changes for those sites. `buildWebSiteJsonLd` had the origin-resolution chain inline; `buildBlogPostingJsonLd` now needs the same answer, so it moves to a shared helper. Two copies would eventually disagree, and for a value used to build an `@id` that means silently publishing two entities. Deliberately not `resolveSiteOrigin()` from `absolute-url.ts`: that gives `SiteSettings.url` precedence over `page.siteUrl`, which would change which origin these graphs carry — plausibly an improvement, but a behaviour change that does not belong in a fix about node identity. --- .changeset/olive-cups-shine.md | 9 ++++ packages/core/src/page/jsonld.ts | 47 ++++++++++++++----- .../core/tests/unit/plugins/page-seo.test.ts | 46 +++++++++++++++++- 3 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 .changeset/olive-cups-shine.md diff --git a/.changeset/olive-cups-shine.md b/.changeset/olive-cups-shine.md new file mode 100644 index 0000000000..1610597d9d --- /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..e5687f48b9 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("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" }); + }); + }); }); From 3254c1540cf01d5a36174ef1c2063f604d1b0e3b Mon Sep 17 00:00:00 2001 From: Marcin Misiewicz Date: Thu, 10 Sep 2026 22:15:36 +0200 Subject: [PATCH 2/2] fix(core): normalise the configured origin before building a node id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `siteOrigin` returned `page.siteUrl` verbatim. That value comes from user or theme configuration and commonly carries a trailing slash — `Astro.site` is usually written that way — so the publisher id became `https://example.com//#organization`. A doubled slash is a different IRI, which is exactly the two-organisation problem this branch set out to fix, reintroduced silently and only on the sites that configure an origin at all. Parsed through `new URL(...).origin` now, falling back to the raw string only when it does not parse, so the helper returns what its name promises. Covered by a test that pins the slashed form. `WebSite` moves to `/#website`, matching the organisation's fragment form, so a plugin extending either node follows one rule rather than two. Also strips the comments added with the previous commit. They narrated the change, justified the choice against `resolveSiteOrigin()`, and described alternatives that were considered and rejected — all of which AGENTS.md excludes from comments, and all of which is already in the commit message and the PR where it belongs. --- .changeset/olive-cups-shine.md | 2 +- packages/core/src/page/jsonld.ts | 34 +++++++------------ .../core/tests/unit/plugins/page-seo.test.ts | 12 ++++++- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.changeset/olive-cups-shine.md b/.changeset/olive-cups-shine.md index 1610597d9d..5c7e924427 100644 --- a/.changeset/olive-cups-shine.md +++ b/.changeset/olive-cups-shine.md @@ -4,6 +4,6 @@ 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. +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 e5687f48b9..d52ab388b0 100644 --- a/packages/core/src/page/jsonld.ts +++ b/packages/core/src/page/jsonld.ts @@ -27,20 +27,20 @@ export function cleanJsonLd(obj: Record): Record { 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" }); + expect(graph).toMatchObject({ "@id": "https://example.com/#website" }); }); }); });