Skip to content

feat(core): apply SEO panel values by default in EmDashHead#1963

Open
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/1518-emdashhead-panel-seo
Open

feat(core): apply SEO panel values by default in EmDashHead#1963
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/1518-emdashhead-panel-seo

Conversation

@swissky

@swissky swissky commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Implements @ascorbic's suggestion on #1518 (comment): apply the SEO panel by default in the <EmDashHead> component, so editor-set values take effect without the template wiring getSeoMeta().

When the page context references a content entry (page.content), EmDashHead now fetches the entry's _emdash_seo row and inserts the panel values as a contribution layer between plugins and the template-provided base metadata. With the existing first-wins dedup that means:

plugins → site settings → SEO panel → template base

so editors' panel settings (description, OG/Twitter title, image, canonical, noindex) override whatever the template passed into the page context, while plugin contributions still win over everything.

Details:

  • generateSeoPanelContributions() — pure generator for the panel layer. A panel image also upgrades twitter:card to summary_large_image (the base layer only does that for its own image); the canonical feeds both <link rel="canonical"> and og:url, absolutized against the site URL via the new shared resolveSeoCanonicalUrl().
  • Query cost: the lookup is gated on the collection's hasSeo flag via getCollectionInfo() (request- and worker-cached), so pages of collections without an SEO panel skip the _emdash_seo query entirely. The row fetch itself is request-cached. Query-count snapshots updated accordingly (+1 PK lookup on content pages of SEO-enabled collections).
  • Scope note: the <title> element stays the template's responsibility — a head component can't replace it. seo.title is emitted for og:title / twitter:title; templates that want the panel title in <title> keep using getSeoMeta() (see also feat(seo): getSeoMeta accepts defaultTitle/defaultDescription (#1518) #1957 for the defaultTitle ergonomics).

Related Discussion: #1956 (covers the #1518 follow-ups; this PR implements the maintainer-suggested default behavior).

Closes #1518

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. — n/a, no admin UI changes
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: getSeoMeta defaults: implement suggestion 2 from #1518 #1956

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Fable 5

Screenshots / test output

Unit tests for the panel generator (incl. precedence under first-wins dedup):

✓ tests/unit/page/seo-contributions.test.ts (15 tests) — all passing

Query-count trace confirming the gating works on real rendered pages:

  • GET /posts/building-for-the-long-term: + select * from "_emdash_seo" where "collection" = ? and "content_id" = ? (posts has SEO enabled)
  • GET /pages/about: no _emdash_seo query (collection without SEO panel skips the lookup)

Values set in the admin SEO panel were silently ignored unless the page
template manually wired getSeoMeta() (emdash-cms#1518). EmDashHead now fetches the
entry's _emdash_seo row when the page context references a content entry
and inserts the panel values as a contribution layer between plugins and
the template-provided base metadata: editors' panel settings take effect
by default, plugins can still override everything.

- generateSeoPanelContributions(): pure generator for the panel layer
  (og/twitter title, description, image incl. large-card upgrade,
  canonical + og:url, robots noindex)
- resolveSeoCanonicalUrl(): shared canonical resolution, absolutized
  against the site URL for <link rel="canonical"> / og:url
- Gated on the collection's hasSeo flag via the request- and
  worker-cached getCollectionInfo(), so non-SEO collections skip the
  lookup; the seo row fetch itself is request-cached
- The <title> element remains the template's responsibility (head
  components cannot replace it); seo.title feeds og:title/twitter:title

Query-count snapshots updated: +1 on content pages of SEO-enabled
collections (the _emdash_seo PK lookup), +1 collection-info lookup where
it was not already cached.
@changeset-bot

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 70ead45

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core area/docs size/L query-count changed PR diff modifies query-count snapshot files labels Jul 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Query-count snapshot changes

8 routes changed, total Δ +8 queries.

SQLite

Route Before After Δ
GET /pages/about (cold) 6 7 +1
GET /pages/about (warm) 6 7 +1
GET /posts/building-for-the-long-term (cold) 18 19 +1
GET /posts/building-for-the-long-term (warm) 18 19 +1

D1

Route Before After Δ
GET /pages/about (cold) 17 18 +1
GET /pages/about (warm) 6 7 +1
GET /posts/building-for-the-long-term (cold) 29 30 +1
GET /posts/building-for-the-long-term (warm) 18 19 +1

Comparing snapshot files between base and head. Updated automatically on each push.

@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1963

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1963

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1963

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1963

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1963

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1963

emdash

npm i https://pkg.pr.new/emdash@1963

create-emdash

npm i https://pkg.pr.new/create-emdash@1963

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1963

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1963

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1963

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1963

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1963

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1963

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1963

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1963

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1963

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1963

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1963

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1963

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1963

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1963

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1963

commit: 70ead45

@emdashbot emdashbot Bot left a comment

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.

This is the right change: it implements the maintainer-suggested default behavior from #1518 by folding SEO panel values into <EmDashHead> without requiring templates to wire getSeoMeta(). The precedence story (plugins → site → panel → template base) is clearly documented, the implementation is mostly idiomatic, and the query-cost gating via getCollectionInfo() is a sensible optimization.

I read the full diff, the changed source files, the existing SeoRepository/resolvePageMetadata/requestCached machinery, and the schema route/handlers. The PR adds good unit tests, updates query-count snapshots correctly, documents the change, and includes a clean changeset. I did not find SQL-safety, authorization, or XSS issues in the new code.

Two issues remain:

  1. Schema cache invalidation gap. getCollectionInfo() caches under CacheNamespace.SCHEMA, but collection create/update/delete routes never call invalidateSchemaObjectCache(). On sites that have an object-cache backend configured, enabling SEO on a collection can leave the cached hasSeo: false in place for up to the TTL, so <EmDashHead> will silently skip the panel lookup despite SEO being enabled. This is both an AGENTS.md invalidation gap and a correctness problem for the new gating logic. The fix is straightforward: invalidate CacheNamespace.SCHEMA from the collection write paths (route or handler).

  2. JSON-LD panel values. The panel image and canonical are emitted as meta/link/property tags but are not fed into buildBlogPostingJsonLd(), which still reads page.canonical and page.seo?.ogImage || page.image. That means <link rel="canonical"> and og:url can say one thing while the structured-data url/mainEntityOfPage say another. Templates that still call getSeoMeta() populate page.seo, so they won't have this inconsistency. Worth a follow-up to either fold panel values into the page context or generate JSON-LD from resolved metadata.

Beyond those, the implementation is clean and I'd be happy to see it land once the invalidation is wired.

if (page.content) {
const { collection, id } = page.content;
const info = await getCollectionInfo(collection);
if (info?.hasSeo) {

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] This gates the _emdash_seo lookup on info?.hasSeo from getCollectionInfo(). That function caches under CacheNamespace.SCHEMA via cachedQuery, but collection create/update/delete routes only call emdash.invalidateUrlPatternCache() and never invalidateSchemaObjectCache(). On deployments with an object-cache backend, an admin enabling SEO on a collection can leave a stale hasSeo: false in the cache for the TTL, so <EmDashHead> will silently skip the panel lookup and editor-set SEO values won't render.

Add invalidateSchemaObjectCache() to the collection write paths (alongside the existing URL-pattern cache invalidation) so the schema cache is flushed when has_seo or other collection metadata changes.

Suggested change
if (info?.hasSeo) {
if (info?.hasSeo) {

Then, in the collection create/update/delete routes/handlers:

import { invalidateSchemaObjectCache } from "emdash";
// ...after the write succeeds:
invalidateSchemaObjectCache();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The invalidation already exists: since #1378, invalidateUrlPatternCache() also calls invalidateSchemaObjectCache() ("every schema-mutation path already routes through here" — see its JSDoc in query.ts), and the collection create/update/delete routes all call emdash.invalidateUrlPatternCache() after the write. So enabling SEO on a collection busts the SCHEMA namespace the hasSeo gate reads from. I've extended the comment at the gate (70ead45) to make that dependency explicit.

* Returns an empty array when no panel field is set, so pages without
* SEO data are unaffected.
*/
export function generateSeoPanelContributions(

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.

[suggestion] Panel values are emitted as meta/link/property contributions, but they don't reach buildBlogPostingJsonLd() in the base layer. That builder still reads page.canonical and page.seo?.ogImage || page.image, so a panel canonical or image can disagree with the emitted <link rel="canonical">, og:url, and og:image. Templates that still call getSeoMeta() avoid this because they populate page.seo, which feeds JSON-LD.

Consider a follow-up that either folds panel SEO into the page context or generates the JSON-LD graph from the resolved metadata layer so structured data stays consistent with the head tags.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 70ead45 by switching from a separate contribution layer to overlaying the panel onto the page context (applySeoPanelToPageContext) before anything consumes it. Base contributions, buildBlogPostingJsonLd(), and plugin page:metadata hooks now all see the same panel-resolved values, so structured data can't disagree with the head tags. The twitter:card large-image upgrade also falls out of the base layer's own logic now. Plugins still override the rendered output via first-wins dedup. Added a regression test asserting the JSON-LD headline/image match the panel values.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 12, 2026
…bution layer

Review follow-up: emitting the panel as a separate contribution layer left
buildBlogPostingJsonLd() reading the un-overlaid page context, so a panel
canonical/image/title could disagree with the emitted head tags. Overlaying
the panel onto the page context before anything consumes it keeps base
contributions, JSON-LD, and plugin page:metadata hooks consistent — and
the twitter:card large-image upgrade now falls out of the base layer's own
logic. Plugins still override the rendered output via first-wins dedup.

Also documents that the hasSeo gate's schema cache is busted by
invalidateUrlPatternCache() on every collection write path (since emdash-cms#1378).
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 12, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 12, 2026

@emdashbot emdashbot Bot left a comment

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.

This is a re-review of #1963. The two issues from the previous review are now resolved:

  1. Schema cache invalidation is wired correctly. The collection create/update/delete routes (packages/core/src/astro/routes/api/schema/collections/index.ts and [slug]/index.ts) call emdash.invalidateUrlPatternCache(), which in turn bumps CacheNamespace.SCHEMA. That invalidates the getCollectionInfo() cache used to gate the _emdash_seo lookup, so toggling hasSeo on a collection no longer leaves stale hasSeo: false entries behind.

  2. JSON-LD now stays consistent with the head tags. applySeoPanelToPageContext() overlays page.canonical and page.seo.ogImage with the panel's absolutized canonical and image before generateBaseSeoContributions() calls buildBlogPostingJsonLd(), so the structured-data url/mainEntityOfPage and image match <link rel="canonical"> / og:url / og:image.

Overall this is the right change for #1518: it applies SEO panel values by default in <EmDashHead> without requiring every template to wire getSeoMeta(), the precedence story (plugins → site → panel → template base) is clearly documented, query-count snapshots are updated honestly, and the changeset is clean.

I have two minor polish suggestions that don't block merge:

  • The panel image should also be reflected on page.image so plugin page:metadata hooks reading the top-level field see the same value as the base/JSON-LD consumers.
  • The JSON-LD consistency test should assert url (and/or mainEntityOfPage) in addition to headline and image, since the canonical overlay is the main fix being verified.

Comment on lines +183 to +192
return {
...page,
description: seo.description || page.description,
canonical: canonical || page.canonical,
seo: {
...page.seo,
ogTitle: seo.title || page.seo?.ogTitle,
ogDescription: seo.description || page.seo?.ogDescription,
ogImage: image || page.seo?.ogImage,
robots: seo.noIndex ? "noindex, nofollow" : page.seo?.robots,

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.

[suggestion] The overlay updates description, canonical, and seo.ogImage, but it leaves page.image holding the template's original value. Because the overlaid resolvedPage is what plugin page:metadata hooks receive, a hook that reads page.image directly (instead of page.seo?.ogImage) will disagree with the base/JSON-LD output.

For consistency with the documented behavior — "editor-set panel values override whatever the template passed in" — mirror the image into the top-level field as well:

Suggested change
return {
...page,
description: seo.description || page.description,
canonical: canonical || page.canonical,
seo: {
...page.seo,
ogTitle: seo.title || page.seo?.ogTitle,
ogDescription: seo.description || page.seo?.ogDescription,
ogImage: image || page.seo?.ogImage,
robots: seo.noIndex ? "noindex, nofollow" : page.seo?.robots,
return {
...page,
description: seo.description || page.description,
canonical: canonical || page.canonical,
image: image || page.image,
seo: {
...page.seo,
ogTitle: seo.title || page.seo?.ogTitle,
ogDescription: seo.description || page.seo?.ogDescription,
ogImage: image || page.seo?.ogImage,
robots: seo.noIndex ? "noindex, nofollow" : page.seo?.robots,
},
};

Comment on lines +256 to +259
expect(graph).toMatchObject({
headline: "Panel Title",
image: "https://example.com/_emdash/api/media/file/01KSMEDIA",
});

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.

[suggestion] The test name promises that JSON-LD stays consistent with the head tags for both image and canonical, but the assertion only checks headline and image. Since the canonical overlay is the other half of the consistency fix, also assert url (and/or mainEntityOfPage) so a regression in buildBlogPostingJsonLd reading page.canonical would be caught:

Suggested change
expect(graph).toMatchObject({
headline: "Panel Title",
image: "https://example.com/_emdash/api/media/file/01KSMEDIA",
});
expect(graph).toMatchObject({
headline: "Panel Title",
image: "https://example.com/_emdash/api/media/file/01KSMEDIA",
url: "https://example.com/posts/other-post",
});

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core area/docs bot:review Trigger an emdashbot code review on this PR cla: signed overlap query-count changed PR diff modifies query-count snapshot files review/awaiting-author Reviewed; waiting on the author to respond size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SEO panel fields silently ignored unless the page wires getSeoMeta (footgun for content editors)

1 participant