From 8c6fc5eb3ae31cac5881eca69c873894e4fc3b5c Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:13:44 +0200 Subject: [PATCH 1/2] feat(core): apply SEO panel values by default in EmDashHead Values set in the admin SEO panel were silently ignored unless the page template manually wired getSeoMeta() (#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 / 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 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/emdashhead-seo-panel.md | 5 + .../src/content/docs/guides/site-settings.mdx | 7 + .../docs/plugins/creating-plugins/hooks.mdx | 2 +- packages/core/src/components/EmDashHead.astro | 38 +++++- packages/core/src/page/index.ts | 6 +- packages/core/src/page/seo-contributions.ts | 72 +++++++++- packages/core/src/seo/media-url.ts | 19 +++ .../tests/unit/page/seo-contributions.test.ts | 124 +++++++++++++++++- scripts/query-counts.queries.d1.json | 4 + scripts/query-counts.queries.sqlite.json | 4 + scripts/query-counts.snapshot.d1.json | 8 +- scripts/query-counts.snapshot.sqlite.json | 8 +- 12 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 .changeset/emdashhead-seo-panel.md diff --git a/.changeset/emdashhead-seo-panel.md b/.changeset/emdashhead-seo-panel.md new file mode 100644 index 0000000000..d241da3c7d --- /dev/null +++ b/.changeset/emdashhead-seo-panel.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +`<EmDashHead>` now applies the entry's SEO panel values (title, description, image, canonical, noindex) automatically on content pages. Editor-set panel values override template-provided metadata, while plugin contributions still take precedence. Previously the panel was silently ignored unless the page wired `getSeoMeta()` by hand. The `<title>` element remains the template's responsibility. diff --git a/docs/src/content/docs/guides/site-settings.mdx b/docs/src/content/docs/guides/site-settings.mdx index 586e80477a..ee1f6e9bcc 100644 --- a/docs/src/content/docs/guides/site-settings.mdx +++ b/docs/src/content/docs/guides/site-settings.mdx @@ -171,6 +171,13 @@ const platforms = [ ### SEO Meta Tags +<Aside> + On content pages that include the `<EmDashHead>` component, values from the entry's SEO panel + (title, description, image, canonical, noindex) are applied automatically — editors' panel + settings override whatever the template passed into the page context. Hand-rolled meta tags like + the component below bypass that behavior, so prefer `<EmDashHead>` for content pages. +</Aside> + The following component builds document and Open Graph meta tags from site settings: ```astro title="src/components/SEO.astro" diff --git a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx index 6573fccc4a..31cf63d21a 100644 --- a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx @@ -315,7 +315,7 @@ Contributes typed metadata to `<head>` — meta tags, OpenGraph properties, allo | `link` | `<link rel="canonical\|alternate" href="...">` | canonical: singleton; alternate: `key` or `hreflang` | | `jsonld` | `<script type="application/ld+json">` | `id` (if present) | -First contribution wins for any dedupe key. Link `rel` is restricted to a security-locked allowlist (`canonical`, `alternate`, `author`, `license`, `nlweb`, `site.standard.document`); `href` must be HTTP or HTTPS. +First contribution wins for any dedupe key. `<EmDashHead>` composes contributions in the order plugins → site settings → the entry's SEO panel values → template-provided base metadata, so plugin contributions override everything below them and editor-set SEO panel values override the template defaults. Link `rel` is restricted to a security-locked allowlist (`canonical`, `alternate`, `author`, `license`, `nlweb`, `site.standard.document`); `href` must be HTTP or HTTPS. ### `page:fragments` diff --git a/packages/core/src/components/EmDashHead.astro b/packages/core/src/components/EmDashHead.astro index 999952cb5e..7949b780b5 100644 --- a/packages/core/src/components/EmDashHead.astro +++ b/packages/core/src/components/EmDashHead.astro @@ -3,11 +3,13 @@ * Renders base SEO metadata, plugin-contributed metadata, and trusted head fragments. * * Base SEO metadata (meta tags, OG, Twitter Card, canonical, JSON-LD) is generated - * from the page context's seo/articleMeta/siteName fields. Contributions are - * composed in the order `[...plugin, ...site, ...base]` and resolved by + * from the page context's seo/articleMeta/siteName fields. When the page renders + * a content entry (`page.content` is set), the entry's SEO panel values are + * fetched and applied automatically (#1518). Contributions are composed in the + * order `[...plugin, ...site, ...panel, ...base]` and resolved by * `resolvePageMetadata()` with first-wins dedup. Plugins sit at the front of * the array, so for any given key plugin contributions override site-level - * ones, which override base ones. + * ones, which override panel values, which override base ones. * * Usage: * ```astro @@ -27,6 +29,7 @@ import { renderFragments } from "../page/fragments.js"; import JsonLdScript from "./JsonLdScript.astro"; import { generateBaseSeoContributions, + generateSeoPanelContributions, generateSiteSeoContributions, } from "../page/seo-contributions.js"; import { renderSiteIdentity } from "../page/site-identity.js"; @@ -35,6 +38,10 @@ import { getSiteSettings } from "../settings/index.js"; import { absolutizeMediaUrl } from "../page/absolute-url.js"; import { getHreflangAlternates } from "../seo/hreflang.js"; import { isI18nEnabled } from "../i18n/config.js"; +import { getCollectionInfo } from "../schema/query.js"; +import { SeoRepository } from "../database/repositories/seo.js"; +import { getDb } from "../loader.js"; +import { requestCached } from "../request-cache.js"; interface Props { page: PublicPageContext; @@ -85,6 +92,27 @@ if (runtime) { defaultOgImage, ); + // SEO panel values for the rendered content entry (#1518). Applied by + // default so editor-set title/description/image/canonical/noindex take + // effect without the template wiring getSeoMeta(). Gated on the + // collection's hasSeo flag (getCollectionInfo is request- and + // worker-cached), so pages of collections without an SEO panel skip + // the _emdash_seo lookup entirely. + let panelContributions: PageMetadataContribution[] = []; + if (page.content) { + const { collection, id } = page.content; + const info = await getCollectionInfo(collection); + if (info?.hasSeo) { + const panelSeo = await requestCached(`seo-panel:${collection}:${id}`, async () => { + const db = await getDb(); + return new SeoRepository(db).get(collection, id); + }); + panelContributions = generateSeoPanelContributions(panelSeo, { + siteUrl: page.siteUrl || siteSettings.url || new URL(page.url).origin, + }); + } + } + // hreflang alternates for translated content entries (#1690). Only // queried when i18n is enabled and this page renders a content entry; // non-i18n sites skip this entirely. Contributions sit with the base @@ -105,9 +133,13 @@ if (runtime) { } const siteContributions = generateSiteSeoContributions(siteSettings.seo); + // Panel contributions sit between plugins and the template-provided + // base layer: plugins can still override everything, while editor-set + // panel values win over base metadata via first-wins dedup. const allContributions = [ ...pluginContributions, ...siteContributions, + ...panelContributions, ...baseContributions, ...hreflangContributions, ]; diff --git a/packages/core/src/page/index.ts b/packages/core/src/page/index.ts index d5b4f74c6e..56ca82acf4 100644 --- a/packages/core/src/page/index.ts +++ b/packages/core/src/page/index.ts @@ -24,7 +24,11 @@ export type { ResolvedPageMetadata } from "./metadata.js"; export { resolveFragments, renderFragments } from "./fragments.js"; -export { generateBaseSeoContributions, generateSiteSeoContributions } from "./seo-contributions.js"; +export { + generateBaseSeoContributions, + generateSeoPanelContributions, + generateSiteSeoContributions, +} from "./seo-contributions.js"; export { cleanJsonLd, buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js"; /** diff --git a/packages/core/src/page/seo-contributions.ts b/packages/core/src/page/seo-contributions.ts index 403dcbf3fa..ec773fec43 100644 --- a/packages/core/src/page/seo-contributions.ts +++ b/packages/core/src/page/seo-contributions.ts @@ -2,16 +2,19 @@ * Generate base SEO metadata contributions from PublicPageContext. * * EmDashHead.astro composes the final contribution list as - * `[...plugin, ...site, ...base]` and feeds it to `resolvePageMetadata()`, - * which is first-wins. That ordering means plugin contributions override - * site-level ones override base ones for any given key — base values are - * the fallback, not the source of truth. + * `[...plugin, ...site, ...panel, ...base]` and feeds it to + * `resolvePageMetadata()`, which is first-wins. That ordering means plugin + * contributions override site-level ones override SEO-panel values override + * base ones for any given key — base values are the fallback, not the + * source of truth. * * This replaces the per-template SEO.astro components, eliminating * the class of XSS bugs where templates hand-rolled JSON-LD serialization. */ +import type { ContentSeo } from "../database/repositories/types.js"; import type { PageMetadataContribution, PublicPageContext } from "../plugins/types.js"; +import { buildSeoImageUrl, resolveSeoCanonicalUrl } from "../seo/media-url.js"; import type { SeoSettings } from "../settings/types.js"; import { buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js"; @@ -147,6 +150,67 @@ export function generateBaseSeoContributions( return contributions; } +/** + * Generate metadata contributions from a content entry's SEO panel data (#1518). + * + * `EmDashHead` fetches the entry's `_emdash_seo` row when the page context + * references a content entry and inserts these contributions between the + * plugin and base layers: editor-set panel values override whatever the + * template passed into the page context (via first-wins dedup), while + * plugins can still override everything. + * + * The `<title>` element itself stays the template's responsibility — + * head components can't replace it — so `seo.title` is emitted for + * `og:title` / `twitter:title` only. Templates that want the panel title + * in `<title>` keep using `getSeoMeta()`. + * + * Returns an empty array when no panel field is set, so pages without + * SEO data are unaffected. + */ +export function generateSeoPanelContributions( + seo: ContentSeo, + options: { siteUrl?: string | null } = {}, +): PageMetadataContribution[] { + const contributions: PageMetadataContribution[] = []; + const siteUrl = options.siteUrl ?? undefined; + + if (seo.title) { + contributions.push({ kind: "property", property: "og:title", content: seo.title }); + contributions.push({ kind: "meta", name: "twitter:title", content: seo.title }); + } + + if (seo.description) { + contributions.push({ kind: "meta", name: "description", content: seo.description }); + contributions.push({ + kind: "property", + property: "og:description", + content: seo.description, + }); + contributions.push({ kind: "meta", name: "twitter:description", content: seo.description }); + } + + if (seo.image) { + const image = buildSeoImageUrl(seo.image, siteUrl); + contributions.push({ kind: "property", property: "og:image", content: image }); + contributions.push({ kind: "meta", name: "twitter:image", content: image }); + // The base layer only picks the large-image card when it has an image + // of its own; with a panel image the large card must win too. + contributions.push({ kind: "meta", name: "twitter:card", content: "summary_large_image" }); + } + + if (seo.canonical) { + const canonical = resolveSeoCanonicalUrl(seo.canonical, siteUrl); + contributions.push({ kind: "link", rel: "canonical", href: canonical }); + contributions.push({ kind: "property", property: "og:url", content: canonical }); + } + + if (seo.noIndex) { + contributions.push({ kind: "meta", name: "robots", content: "noindex, nofollow" }); + } + + return contributions; +} + /** * Generate site-level SEO metadata contributions from SiteSettings.seo. * diff --git a/packages/core/src/seo/media-url.ts b/packages/core/src/seo/media-url.ts index 4efc1ec421..164178f527 100644 --- a/packages/core/src/seo/media-url.ts +++ b/packages/core/src/seo/media-url.ts @@ -30,3 +30,22 @@ export function buildSeoImageUrl(imageRef: string, siteUrl?: string): string { const mediaPath = `/_emdash/api/media/file/${imageRef}`; return siteUrl ? `${siteUrl.replace(TRAILING_SLASH_RE, "")}${mediaPath}` : mediaPath; } + +/** + * Resolve a stored SEO canonical value to a URL. + * + * The SEO panel accepts absolute URLs, root-relative paths, and bare + * relative paths. Relative paths are joined with `siteUrl` (adding a + * leading slash when missing, so we never produce + * `https://example.composts/x`). Without a `siteUrl` the value is + * returned as-is. Absolute output matters here: the resolved value feeds + * `<link rel="canonical">` and `og:url`, both of which search engines and + * scrapers expect fully qualified. + */ +export function resolveSeoCanonicalUrl(canonical: string, siteUrl?: string): string { + if (!siteUrl || ABSOLUTE_URL_RE.test(canonical)) { + return canonical; + } + const path = canonical.startsWith("/") ? canonical : `/${canonical}`; + return `${siteUrl.replace(TRAILING_SLASH_RE, "")}${path}`; +} diff --git a/packages/core/tests/unit/page/seo-contributions.test.ts b/packages/core/tests/unit/page/seo-contributions.test.ts index 85969c69db..9c47a78be0 100644 --- a/packages/core/tests/unit/page/seo-contributions.test.ts +++ b/packages/core/tests/unit/page/seo-contributions.test.ts @@ -12,7 +12,12 @@ import { describe, it, expect } from "vitest"; -import { generateSiteSeoContributions } from "../../../src/page/seo-contributions.js"; +import type { ContentSeo } from "../../../src/database/repositories/types.js"; +import { resolvePageMetadata } from "../../../src/page/metadata.js"; +import { + generateSeoPanelContributions, + generateSiteSeoContributions, +} from "../../../src/page/seo-contributions.js"; describe("generateSiteSeoContributions", () => { it("returns empty array when no settings provided", () => { @@ -86,3 +91,120 @@ describe("generateSiteSeoContributions", () => { expect(result).toEqual([]); }); }); + +/** + * generateSeoPanelContributions() — #1518. + * + * Bug context: values set in the admin SEO panel were silently ignored + * unless the template manually wired getSeoMeta(). EmDashHead now fetches + * the panel row for content pages and inserts these contributions between + * the plugin and base layers, so editor-set values apply by default. + */ +describe("generateSeoPanelContributions (#1518)", () => { + const emptySeo: ContentSeo = { + title: null, + description: null, + image: null, + canonical: null, + noIndex: false, + }; + + it("returns empty array when no panel field is set", () => { + expect(generateSeoPanelContributions(emptySeo)).toEqual([]); + }); + + it("emits og:title and twitter:title for a panel title", () => { + const result = generateSeoPanelContributions({ ...emptySeo, title: "Panel Title" }); + + expect(result).toContainEqual({ + kind: "property", + property: "og:title", + content: "Panel Title", + }); + expect(result).toContainEqual({ + kind: "meta", + name: "twitter:title", + content: "Panel Title", + }); + }); + + it("emits description, og:description, and twitter:description", () => { + const result = generateSeoPanelContributions({ ...emptySeo, description: "Panel desc" }); + + expect(result).toContainEqual({ kind: "meta", name: "description", content: "Panel desc" }); + expect(result).toContainEqual({ + kind: "property", + property: "og:description", + content: "Panel desc", + }); + expect(result).toContainEqual({ + kind: "meta", + name: "twitter:description", + content: "Panel desc", + }); + }); + + it("resolves a bare media id image against siteUrl and upgrades the twitter card", () => { + const result = generateSeoPanelContributions( + { ...emptySeo, image: "01KSMEDIA" }, + { siteUrl: "https://example.com/" }, + ); + + const expected = "https://example.com/_emdash/api/media/file/01KSMEDIA"; + expect(result).toContainEqual({ kind: "property", property: "og:image", content: expected }); + expect(result).toContainEqual({ kind: "meta", name: "twitter:image", content: expected }); + expect(result).toContainEqual({ + kind: "meta", + name: "twitter:card", + content: "summary_large_image", + }); + }); + + it("emits canonical link and og:url, absolutized against siteUrl", () => { + const result = generateSeoPanelContributions( + { ...emptySeo, canonical: "/posts/other-post" }, + { siteUrl: "https://example.com" }, + ); + + const expected = "https://example.com/posts/other-post"; + expect(result).toContainEqual({ kind: "link", rel: "canonical", href: expected }); + expect(result).toContainEqual({ kind: "property", property: "og:url", content: expected }); + }); + + it("passes an absolute canonical through unchanged", () => { + const result = generateSeoPanelContributions( + { ...emptySeo, canonical: "https://other.example/page" }, + { siteUrl: "https://example.com" }, + ); + + expect(result).toContainEqual({ + kind: "link", + rel: "canonical", + href: "https://other.example/page", + }); + }); + + it("emits a robots noindex meta when noIndex is set", () => { + const result = generateSeoPanelContributions({ ...emptySeo, noIndex: true }); + + expect(result).toEqual([{ kind: "meta", name: "robots", content: "noindex, nofollow" }]); + }); + + it("wins over base contributions but loses to plugins under first-wins dedup", () => { + const plugin = [{ kind: "meta", name: "description", content: "from plugin" } as const]; + const panel = generateSeoPanelContributions({ + ...emptySeo, + description: "from panel", + title: "from panel", + }); + const base = [ + { kind: "meta", name: "description", content: "from template" } as const, + { kind: "property", property: "og:title", content: "from template" } as const, + ]; + + const resolved = resolvePageMetadata([...plugin, ...panel, ...base]); + + expect(resolved.meta).toContainEqual({ name: "description", content: "from plugin" }); + expect(resolved.properties).toContainEqual({ property: "og:title", content: "from panel" }); + }); +}); diff --git a/scripts/query-counts.queries.d1.json b/scripts/query-counts.queries.d1.json index 8817345ae2..1590bc2ee7 100644 --- a/scripts/query-counts.queries.d1.json +++ b/scripts/query-counts.queries.d1.json @@ -117,6 +117,7 @@ "select \"plugin_id\", \"status\" from \"_plugin_state\"": 1, "select \"value\" from \"options\" where \"name\" = ?": 2, "select * from \"_emdash_byline_fields\" order by \"sort_order\" asc, \"created_at\" asc": 1, + "select * from \"_emdash_collections\" where \"slug\" = ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "select * from \"_emdash_migrations\" limit ?": 1, @@ -130,6 +131,7 @@ "GET /pages/about (warm)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, "select \"value\" from \"options\" where \"name\" = ?": 1, + "select * from \"_emdash_collections\" where \"slug\" = ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, @@ -173,6 +175,7 @@ "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "select * from \"_emdash_migrations\" limit ?": 1, "select * from \"_emdash_redirects\" where \"enabled\" = ?": 1, + "select * from \"_emdash_seo\" where \"collection\" = ? and \"content_id\" = ?": 1, "select * from \"_emdash_taxonomy_defs\" where \"name\" = ? order by \"locale\" asc": 2, "select * from \"taxonomies\" where \"name\" = ? order by \"label\" asc": 2, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, @@ -192,6 +195,7 @@ "select * from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ? order by \"created_at\" asc, \"id\" asc limit ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, + "select * from \"_emdash_seo\" where \"collection\" = ? and \"content_id\" = ?": 1, "select * from \"_emdash_taxonomy_defs\" where \"name\" = ? order by \"locale\" asc": 2, "select * from \"taxonomies\" where \"name\" = ? order by \"label\" asc": 2, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, diff --git a/scripts/query-counts.queries.sqlite.json b/scripts/query-counts.queries.sqlite.json index 293d6a8839..487f1a09ba 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -77,6 +77,7 @@ "GET /pages/about (cold)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, "select \"value\" from \"options\" where \"name\" = ?": 1, + "select * from \"_emdash_collections\" where \"slug\" = ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, @@ -85,6 +86,7 @@ "GET /pages/about (warm)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, "select \"value\" from \"options\" where \"name\" = ?": 1, + "select * from \"_emdash_collections\" where \"slug\" = ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, @@ -113,6 +115,7 @@ "select * from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ? order by \"created_at\" asc, \"id\" asc limit ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, + "select * from \"_emdash_seo\" where \"collection\" = ? and \"content_id\" = ?": 1, "select * from \"_emdash_taxonomy_defs\" where \"name\" = ? order by \"locale\" asc": 2, "select * from \"taxonomies\" where \"name\" = ? order by \"label\" asc": 2, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, @@ -129,6 +132,7 @@ "select * from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ? order by \"created_at\" asc, \"id\" asc limit ?": 1, "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, + "select * from \"_emdash_seo\" where \"collection\" = ? and \"content_id\" = ?": 1, "select * from \"_emdash_taxonomy_defs\" where \"name\" = ? order by \"locale\" asc": 2, "select * from \"taxonomies\" where \"name\" = ? order by \"label\" asc": 2, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, diff --git a/scripts/query-counts.snapshot.d1.json b/scripts/query-counts.snapshot.d1.json index b93ebe8f6d..cf1f13462b 100644 --- a/scripts/query-counts.snapshot.d1.json +++ b/scripts/query-counts.snapshot.d1.json @@ -7,12 +7,12 @@ "GET /contributors (warm)": 6, "GET /contributors-naive (cold)": 24, "GET /contributors-naive (warm)": 13, - "GET /pages/about (cold)": 17, - "GET /pages/about (warm)": 6, + "GET /pages/about (cold)": 18, + "GET /pages/about (warm)": 7, "GET /posts (cold)": 17, "GET /posts (warm)": 6, - "GET /posts/building-for-the-long-term (cold)": 29, - "GET /posts/building-for-the-long-term (warm)": 18, + "GET /posts/building-for-the-long-term (cold)": 30, + "GET /posts/building-for-the-long-term (warm)": 19, "GET /rss.xml (cold)": 12, "GET /rss.xml (warm)": 2, "GET /search (cold)": 22, diff --git a/scripts/query-counts.snapshot.sqlite.json b/scripts/query-counts.snapshot.sqlite.json index 222f7d5ddf..c0990219dc 100644 --- a/scripts/query-counts.snapshot.sqlite.json +++ b/scripts/query-counts.snapshot.sqlite.json @@ -7,12 +7,12 @@ "GET /contributors (warm)": 6, "GET /contributors-naive (cold)": 13, "GET /contributors-naive (warm)": 13, - "GET /pages/about (cold)": 6, - "GET /pages/about (warm)": 6, + "GET /pages/about (cold)": 7, + "GET /pages/about (warm)": 7, "GET /posts (cold)": 6, "GET /posts (warm)": 6, - "GET /posts/building-for-the-long-term (cold)": 18, - "GET /posts/building-for-the-long-term (warm)": 18, + "GET /posts/building-for-the-long-term (cold)": 19, + "GET /posts/building-for-the-long-term (warm)": 19, "GET /rss.xml (cold)": 2, "GET /rss.xml (warm)": 2, "GET /search (cold)": 11, From 70ead4552f4d432f0051a48752a38da0ed7c5c0f Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:42:42 +0200 Subject: [PATCH 2/2] refactor: overlay SEO panel onto the page context instead of a contribution layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #1378). --- packages/core/src/components/EmDashHead.astro | 88 +++++----- packages/core/src/page/index.ts | 2 +- packages/core/src/page/seo-contributions.ts | 91 +++++------ .../tests/unit/page/seo-contributions.test.ts | 153 ++++++++++++------ 4 files changed, 183 insertions(+), 151 deletions(-) diff --git a/packages/core/src/components/EmDashHead.astro b/packages/core/src/components/EmDashHead.astro index 7949b780b5..636884b5bd 100644 --- a/packages/core/src/components/EmDashHead.astro +++ b/packages/core/src/components/EmDashHead.astro @@ -5,11 +5,12 @@ * Base SEO metadata (meta tags, OG, Twitter Card, canonical, JSON-LD) is generated * from the page context's seo/articleMeta/siteName fields. When the page renders * a content entry (`page.content` is set), the entry's SEO panel values are - * fetched and applied automatically (#1518). Contributions are composed in the - * order `[...plugin, ...site, ...panel, ...base]` and resolved by - * `resolvePageMetadata()` with first-wins dedup. Plugins sit at the front of - * the array, so for any given key plugin contributions override site-level - * ones, which override panel values, which override base ones. + * fetched and overlaid onto the page context automatically (#1518), so they + * flow into the base contributions, the JSON-LD builders, and plugin hooks + * alike. Contributions are composed in the order `[...plugin, ...site, + * ...base]` and resolved by `resolvePageMetadata()` with first-wins dedup. + * Plugins sit at the front of the array, so for any given key plugin + * contributions override site-level ones, which override base ones. * * Usage: * ```astro @@ -28,8 +29,8 @@ import { import { renderFragments } from "../page/fragments.js"; import JsonLdScript from "./JsonLdScript.astro"; import { + applySeoPanelToPageContext, generateBaseSeoContributions, - generateSeoPanelContributions, generateSiteSeoContributions, } from "../page/seo-contributions.js"; import { renderSiteIdentity } from "../page/site-identity.js"; @@ -56,21 +57,44 @@ let fragmentsHtml = ""; let jsonLdScripts: ResolvedPageMetadata["jsonld"] = []; if (runtime) { - // Run independent async loads in parallel: site settings (SEO meta - // tags + favicon) and plugin page-metadata contributions. Plugin - // contributions come BEFORE site/base in the contribution array, - // so resolvePageMetadata's first-wins dedup lets plugins override - // defaults. - // // `getSiteSettings()` is request-cached and worker-cached, so when // a parent template has already called it (the standard pattern in // `Base.astro`), this is free. Otherwise it's a single batched // query that supersedes any per-key fetch this component would - // otherwise have done. - const [siteSettings, pluginContributions, fragments] = await Promise.all([ - getSiteSettings(), - runtime.collectPageMetadata(page), - runtime.collectPageFragments(page), + // otherwise have done. Loaded first because the site URL feeds the + // SEO-panel overlay below. + const siteSettings = await getSiteSettings(); + const siteUrl = page.siteUrl || siteSettings.url || new URL(page.url).origin; + + // SEO panel values for the rendered content entry (#1518). Overlaid + // onto the page context before anything consumes it, so editor-set + // description/image/canonical/noindex take effect by default — in the + // rendered head tags, in the JSON-LD builders, and in what plugin + // hooks see — without the template wiring getSeoMeta(). Gated on the + // collection's hasSeo flag (getCollectionInfo is request- and + // worker-cached, and busted by invalidateUrlPatternCache() on schema + // mutations), so pages of collections without an SEO panel skip the + // _emdash_seo lookup entirely. + let resolvedPage = page; + if (page.content) { + const { collection, id } = page.content; + const info = await getCollectionInfo(collection); + if (info?.hasSeo) { + const panelSeo = await requestCached(`seo-panel:${collection}:${id}`, async () => { + const db = await getDb(); + return new SeoRepository(db).get(collection, id); + }); + resolvedPage = applySeoPanelToPageContext(page, panelSeo, { siteUrl }); + } + } + + // Plugin page-metadata contributions and body fragments run in + // parallel. Plugin contributions come BEFORE site/base in the + // contribution array, so resolvePageMetadata's first-wins dedup lets + // plugins override defaults. + const [pluginContributions, fragments] = await Promise.all([ + runtime.collectPageMetadata(resolvedPage), + runtime.collectPageFragments(resolvedPage), ]); // Site-level default OG image: applied per-page in base contributions @@ -85,34 +109,13 @@ if (runtime) { const defaultOgImage = absolutizeMediaUrl( siteSettings.seo?.defaultOgImage?.url, siteSettings.url, - page, + resolvedPage, ); const baseContributions: PageMetadataContribution[] = generateBaseSeoContributions( - page, + resolvedPage, defaultOgImage, ); - // SEO panel values for the rendered content entry (#1518). Applied by - // default so editor-set title/description/image/canonical/noindex take - // effect without the template wiring getSeoMeta(). Gated on the - // collection's hasSeo flag (getCollectionInfo is request- and - // worker-cached), so pages of collections without an SEO panel skip - // the _emdash_seo lookup entirely. - let panelContributions: PageMetadataContribution[] = []; - if (page.content) { - const { collection, id } = page.content; - const info = await getCollectionInfo(collection); - if (info?.hasSeo) { - const panelSeo = await requestCached(`seo-panel:${collection}:${id}`, async () => { - const db = await getDb(); - return new SeoRepository(db).get(collection, id); - }); - panelContributions = generateSeoPanelContributions(panelSeo, { - siteUrl: page.siteUrl || siteSettings.url || new URL(page.url).origin, - }); - } - } - // hreflang alternates for translated content entries (#1690). Only // queried when i18n is enabled and this page renders a content entry; // non-i18n sites skip this entirely. Contributions sit with the base @@ -120,7 +123,6 @@ if (runtime) { // key is the hreflang value). let hreflangContributions: PageMetadataContribution[] = []; if (page.content && isI18nEnabled()) { - const siteUrl = page.siteUrl || siteSettings.url || new URL(page.url).origin; const alternates = await getHreflangAlternates(page.content.collection, page.content.id, { siteUrl, }); @@ -133,13 +135,9 @@ if (runtime) { } const siteContributions = generateSiteSeoContributions(siteSettings.seo); - // Panel contributions sit between plugins and the template-provided - // base layer: plugins can still override everything, while editor-set - // panel values win over base metadata via first-wins dedup. const allContributions = [ ...pluginContributions, ...siteContributions, - ...panelContributions, ...baseContributions, ...hreflangContributions, ]; diff --git a/packages/core/src/page/index.ts b/packages/core/src/page/index.ts index 56ca82acf4..ace24096e9 100644 --- a/packages/core/src/page/index.ts +++ b/packages/core/src/page/index.ts @@ -25,8 +25,8 @@ export type { ResolvedPageMetadata } from "./metadata.js"; export { resolveFragments, renderFragments } from "./fragments.js"; export { + applySeoPanelToPageContext, generateBaseSeoContributions, - generateSeoPanelContributions, generateSiteSeoContributions, } from "./seo-contributions.js"; export { cleanJsonLd, buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js"; diff --git a/packages/core/src/page/seo-contributions.ts b/packages/core/src/page/seo-contributions.ts index ec773fec43..16ef56eccd 100644 --- a/packages/core/src/page/seo-contributions.ts +++ b/packages/core/src/page/seo-contributions.ts @@ -2,11 +2,13 @@ * Generate base SEO metadata contributions from PublicPageContext. * * EmDashHead.astro composes the final contribution list as - * `[...plugin, ...site, ...panel, ...base]` and feeds it to - * `resolvePageMetadata()`, which is first-wins. That ordering means plugin - * contributions override site-level ones override SEO-panel values override - * base ones for any given key — base values are the fallback, not the - * source of truth. + * `[...plugin, ...site, ...base]` and feeds it to `resolvePageMetadata()`, + * which is first-wins. That ordering means plugin contributions override + * site-level ones override base ones for any given key — base values are + * the fallback, not the source of truth. For content pages, the entry's + * SEO panel values are overlaid onto the page context before the base + * contributions (and JSON-LD) are generated, so editor-set values + * override the template-provided fields. * * This replaces the per-template SEO.astro components, eliminating * the class of XSS bugs where templates hand-rolled JSON-LD serialization. @@ -151,64 +153,45 @@ export function generateBaseSeoContributions( } /** - * Generate metadata contributions from a content entry's SEO panel data (#1518). + * Overlay a content entry's SEO panel data onto the page context (#1518). * * `EmDashHead` fetches the entry's `_emdash_seo` row when the page context - * references a content entry and inserts these contributions between the - * plugin and base layers: editor-set panel values override whatever the - * template passed into the page context (via first-wins dedup), while - * plugins can still override everything. + * references a content entry and applies this overlay before anything + * consumes the context: editor-set panel values override whatever the + * template passed in, and because the overlaid context feeds plugin hooks, + * the base contributions, and the JSON-LD builders alike, structured data + * and head tags always agree. Plugins still override the rendered output + * via first-wins dedup. * * The `<title>` element itself stays the template's responsibility — - * head components can't replace it — so `seo.title` is emitted for - * `og:title` / `twitter:title` only. Templates that want the panel title - * in `<title>` keep using `getSeoMeta()`. + * head components can't replace it — so `seo.title` feeds + * `og:title` / `twitter:title` / the JSON-LD headline only. Templates that + * want the panel title in `<title>` keep using `getSeoMeta()`. * - * Returns an empty array when no panel field is set, so pages without - * SEO data are unaffected. + * Unset panel fields fall back to the template-provided values, so pages + * without SEO data are unaffected. */ -export function generateSeoPanelContributions( +export function applySeoPanelToPageContext( + page: PublicPageContext, seo: ContentSeo, options: { siteUrl?: string | null } = {}, -): PageMetadataContribution[] { - const contributions: PageMetadataContribution[] = []; +): PublicPageContext { const siteUrl = options.siteUrl ?? undefined; - - if (seo.title) { - contributions.push({ kind: "property", property: "og:title", content: seo.title }); - contributions.push({ kind: "meta", name: "twitter:title", content: seo.title }); - } - - if (seo.description) { - contributions.push({ kind: "meta", name: "description", content: seo.description }); - contributions.push({ - kind: "property", - property: "og:description", - content: seo.description, - }); - contributions.push({ kind: "meta", name: "twitter:description", content: seo.description }); - } - - if (seo.image) { - const image = buildSeoImageUrl(seo.image, siteUrl); - contributions.push({ kind: "property", property: "og:image", content: image }); - contributions.push({ kind: "meta", name: "twitter:image", content: image }); - // The base layer only picks the large-image card when it has an image - // of its own; with a panel image the large card must win too. - contributions.push({ kind: "meta", name: "twitter:card", content: "summary_large_image" }); - } - - if (seo.canonical) { - const canonical = resolveSeoCanonicalUrl(seo.canonical, siteUrl); - contributions.push({ kind: "link", rel: "canonical", href: canonical }); - contributions.push({ kind: "property", property: "og:url", content: canonical }); - } - - if (seo.noIndex) { - contributions.push({ kind: "meta", name: "robots", content: "noindex, nofollow" }); - } - - return contributions; + const image = seo.image ? buildSeoImageUrl(seo.image, siteUrl) : null; + const canonical = seo.canonical ? resolveSeoCanonicalUrl(seo.canonical, siteUrl) : null; + + 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, + }, + }; } /** diff --git a/packages/core/tests/unit/page/seo-contributions.test.ts b/packages/core/tests/unit/page/seo-contributions.test.ts index 9c47a78be0..38df204d7a 100644 --- a/packages/core/tests/unit/page/seo-contributions.test.ts +++ b/packages/core/tests/unit/page/seo-contributions.test.ts @@ -13,11 +13,13 @@ import { describe, it, expect } from "vitest"; import type { ContentSeo } from "../../../src/database/repositories/types.js"; -import { resolvePageMetadata } from "../../../src/page/metadata.js"; +import { buildBlogPostingJsonLd } from "../../../src/page/jsonld.js"; import { - generateSeoPanelContributions, + applySeoPanelToPageContext, + generateBaseSeoContributions, generateSiteSeoContributions, } from "../../../src/page/seo-contributions.js"; +import type { PublicPageContext } from "../../../src/plugins/types.js"; describe("generateSiteSeoContributions", () => { it("returns empty array when no settings provided", () => { @@ -93,14 +95,16 @@ describe("generateSiteSeoContributions", () => { }); /** - * generateSeoPanelContributions() — #1518. + * applySeoPanelToPageContext() — #1518. * * Bug context: values set in the admin SEO panel were silently ignored * unless the template manually wired getSeoMeta(). EmDashHead now fetches - * the panel row for content pages and inserts these contributions between - * the plugin and base layers, so editor-set values apply by default. + * the panel row for content pages and overlays it onto the page context + * before base contributions and JSON-LD are generated, so editor-set + * values apply by default and structured data stays consistent with the + * head tags. */ -describe("generateSeoPanelContributions (#1518)", () => { +describe("applySeoPanelToPageContext (#1518)", () => { const emptySeo: ContentSeo = { title: null, description: null, @@ -109,102 +113,149 @@ describe("generateSeoPanelContributions (#1518)", () => { noIndex: false, }; - it("returns empty array when no panel field is set", () => { - expect(generateSeoPanelContributions(emptySeo)).toEqual([]); + function createPage(overrides: Partial<PublicPageContext> = {}): PublicPageContext { + return { + url: "https://example.com/posts/hello", + path: "/posts/hello", + locale: null, + kind: "content", + pageType: "article", + title: "Template Title | My Site", + pageTitle: "Template Title", + description: "Template description", + canonical: "https://example.com/posts/hello", + image: "https://example.com/template-og.png", + siteName: "My Site", + ...overrides, + }; + } + + it("leaves the page context unchanged when no panel field is set", () => { + const page = createPage(); + const result = applySeoPanelToPageContext(page, emptySeo); + + expect(generateBaseSeoContributions(result)).toEqual(generateBaseSeoContributions(page)); }); - it("emits og:title and twitter:title for a panel title", () => { - const result = generateSeoPanelContributions({ ...emptySeo, title: "Panel Title" }); + it("panel title reaches og:title and twitter:title via seo.ogTitle", () => { + const result = applySeoPanelToPageContext(createPage(), { ...emptySeo, title: "Panel Title" }); - expect(result).toContainEqual({ + const contributions = generateBaseSeoContributions(result); + expect(contributions).toContainEqual({ kind: "property", property: "og:title", content: "Panel Title", }); - expect(result).toContainEqual({ + expect(contributions).toContainEqual({ kind: "meta", name: "twitter:title", content: "Panel Title", }); }); - it("emits description, og:description, and twitter:description", () => { - const result = generateSeoPanelContributions({ ...emptySeo, description: "Panel desc" }); + it("panel description overrides the template description everywhere", () => { + const result = applySeoPanelToPageContext(createPage(), { + ...emptySeo, + description: "Panel desc", + }); - expect(result).toContainEqual({ kind: "meta", name: "description", content: "Panel desc" }); - expect(result).toContainEqual({ + const contributions = generateBaseSeoContributions(result); + expect(contributions).toContainEqual({ + kind: "meta", + name: "description", + content: "Panel desc", + }); + expect(contributions).toContainEqual({ kind: "property", property: "og:description", content: "Panel desc", }); - expect(result).toContainEqual({ + expect(contributions).toContainEqual({ kind: "meta", name: "twitter:description", content: "Panel desc", }); }); - it("resolves a bare media id image against siteUrl and upgrades the twitter card", () => { - const result = generateSeoPanelContributions( + it("resolves a bare media id image against siteUrl and wins the og/twitter image", () => { + const result = applySeoPanelToPageContext( + createPage(), { ...emptySeo, image: "01KSMEDIA" }, { siteUrl: "https://example.com/" }, ); const expected = "https://example.com/_emdash/api/media/file/01KSMEDIA"; - expect(result).toContainEqual({ kind: "property", property: "og:image", content: expected }); - expect(result).toContainEqual({ kind: "meta", name: "twitter:image", content: expected }); - expect(result).toContainEqual({ + const contributions = generateBaseSeoContributions(result); + expect(contributions).toContainEqual({ + kind: "property", + property: "og:image", + content: expected, + }); + expect(contributions).toContainEqual({ + kind: "meta", + name: "twitter:image", + content: expected, + }); + expect(contributions).toContainEqual({ kind: "meta", name: "twitter:card", content: "summary_large_image", }); }); - it("emits canonical link and og:url, absolutized against siteUrl", () => { - const result = generateSeoPanelContributions( + it("absolutizes a relative panel canonical for the link tag and og:url", () => { + const result = applySeoPanelToPageContext( + createPage(), { ...emptySeo, canonical: "/posts/other-post" }, { siteUrl: "https://example.com" }, ); const expected = "https://example.com/posts/other-post"; - expect(result).toContainEqual({ kind: "link", rel: "canonical", href: expected }); - expect(result).toContainEqual({ kind: "property", property: "og:url", content: expected }); + const contributions = generateBaseSeoContributions(result); + expect(contributions).toContainEqual({ kind: "link", rel: "canonical", href: expected }); + expect(contributions).toContainEqual({ + kind: "property", + property: "og:url", + content: expected, + }); }); - it("passes an absolute canonical through unchanged", () => { - const result = generateSeoPanelContributions( + it("passes an absolute panel canonical through unchanged", () => { + const result = applySeoPanelToPageContext( + createPage(), { ...emptySeo, canonical: "https://other.example/page" }, { siteUrl: "https://example.com" }, ); - expect(result).toContainEqual({ - kind: "link", - rel: "canonical", - href: "https://other.example/page", - }); + expect(result.canonical).toBe("https://other.example/page"); }); - it("emits a robots noindex meta when noIndex is set", () => { - const result = generateSeoPanelContributions({ ...emptySeo, noIndex: true }); + it("panel noindex emits robots but a template robots value survives without it", () => { + const withNoindex = applySeoPanelToPageContext(createPage(), { ...emptySeo, noIndex: true }); + expect(generateBaseSeoContributions(withNoindex)).toContainEqual({ + kind: "meta", + name: "robots", + content: "noindex, nofollow", + }); - expect(result).toEqual([{ kind: "meta", name: "robots", content: "noindex, nofollow" }]); + const templateRobots = applySeoPanelToPageContext( + createPage({ seo: { robots: "noindex" } }), + emptySeo, + ); + expect(templateRobots.seo?.robots).toBe("noindex"); }); - it("wins over base contributions but loses to plugins under first-wins dedup", () => { - const plugin = [{ kind: "meta", name: "description", content: "from plugin" } as const]; - const panel = generateSeoPanelContributions({ - ...emptySeo, - description: "from panel", - title: "from panel", - }); - const base = [ - { kind: "meta", name: "description", content: "from template" } as const, - { kind: "property", property: "og:title", content: "from template" } as const, - ]; - - const resolved = resolvePageMetadata([...plugin, ...panel, ...base]); + it("keeps JSON-LD consistent with the head tags (panel image and canonical)", () => { + const result = applySeoPanelToPageContext( + createPage({ articleMeta: { publishedTime: "2026-04-03T12:00:00.000Z" } }), + { ...emptySeo, title: "Panel Title", image: "01KSMEDIA", canonical: "/posts/other-post" }, + { siteUrl: "https://example.com" }, + ); - expect(resolved.meta).toContainEqual({ name: "description", content: "from plugin" }); - expect(resolved.properties).toContainEqual({ property: "og:title", content: "from panel" }); + const graph = buildBlogPostingJsonLd(result); + expect(graph).toMatchObject({ + headline: "Panel Title", + image: "https://example.com/_emdash/api/media/file/01KSMEDIA", + }); }); });