feat(core): apply SEO panel values by default in EmDashHead#1963
feat(core): apply SEO panel values by default in EmDashHead#1963swissky wants to merge 2 commits into
Conversation
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 detectedLatest commit: 70ead45 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
Query-count snapshot changes8 routes changed, total Δ +8 queries. SQLite
D1
Comparing snapshot files between base and head. Updated automatically on each push. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
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:
-
Schema cache invalidation gap.
getCollectionInfo()caches underCacheNamespace.SCHEMA, but collection create/update/delete routes never callinvalidateSchemaObjectCache(). On sites that have an object-cache backend configured, enabling SEO on a collection can leave the cachedhasSeo: falsein 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: invalidateCacheNamespace.SCHEMAfrom the collection write paths (route or handler). -
JSON-LD panel values. The panel image and canonical are emitted as meta/link/property tags but are not fed into
buildBlogPostingJsonLd(), which still readspage.canonicalandpage.seo?.ogImage || page.image. That means<link rel="canonical">andog:urlcan say one thing while the structured-dataurl/mainEntityOfPagesay another. Templates that still callgetSeoMeta()populatepage.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) { |
There was a problem hiding this comment.
[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.
| if (info?.hasSeo) { | |
| if (info?.hasSeo) { |
Then, in the collection create/update/delete routes/handlers:
import { invalidateSchemaObjectCache } from "emdash";
// ...after the write succeeds:
invalidateSchemaObjectCache();There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
…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).
There was a problem hiding this comment.
This is a re-review of #1963. The two issues from the previous review are now resolved:
-
Schema cache invalidation is wired correctly. The collection create/update/delete routes (
packages/core/src/astro/routes/api/schema/collections/index.tsand[slug]/index.ts) callemdash.invalidateUrlPatternCache(), which in turn bumpsCacheNamespace.SCHEMA. That invalidates thegetCollectionInfo()cache used to gate the_emdash_seolookup, so togglinghasSeoon a collection no longer leaves stalehasSeo: falseentries behind. -
JSON-LD now stays consistent with the head tags.
applySeoPanelToPageContext()overlayspage.canonicalandpage.seo.ogImagewith the panel's absolutized canonical and image beforegenerateBaseSeoContributions()callsbuildBlogPostingJsonLd(), so the structured-dataurl/mainEntityOfPageandimagematch<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.imageso pluginpage:metadatahooks reading the top-level field see the same value as the base/JSON-LD consumers. - The JSON-LD consistency test should assert
url(and/ormainEntityOfPage) in addition toheadlineandimage, since the canonical overlay is the main fix being verified.
| 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, |
There was a problem hiding this comment.
[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:
| 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, | |
| }, | |
| }; |
| expect(graph).toMatchObject({ | ||
| headline: "Panel Title", | ||
| image: "https://example.com/_emdash/api/media/file/01KSMEDIA", | ||
| }); |
There was a problem hiding this comment.
[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:
| 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", | |
| }); |
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 wiringgetSeoMeta().When the page context references a content entry (
page.content),EmDashHeadnow fetches the entry's_emdash_seorow 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 upgradestwitter:cardtosummary_large_image(the base layer only does that for its own image); the canonical feeds both<link rel="canonical">andog:url, absolutized against the site URL via the new sharedresolveSeoCanonicalUrl().hasSeoflag viagetCollectionInfo()(request- and worker-cached), so pages of collections without an SEO panel skip the_emdash_seoquery entirely. The row fetch itself is request-cached. Query-count snapshots updated accordingly (+1 PK lookup on content pages of SEO-enabled collections).<title>element stays the template's responsibility — a head component can't replace it.seo.titleis emitted forog:title/twitter:title; templates that want the panel title in<title>keep usinggetSeoMeta()(see also feat(seo): getSeoMeta accepts defaultTitle/defaultDescription (#1518) #1957 for thedefaultTitleergonomics).Related Discussion: #1956 (covers the #1518 follow-ups; this PR implements the maintainer-suggested default behavior).
Closes #1518
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. — n/a, no admin UI changesAI-generated code disclosure
Screenshots / test output
Unit tests for the panel generator (incl. precedence under first-wins dedup):
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_seoquery (collection without SEO panel skips the lookup)