diff --git a/.changeset/seo-meta-defaults.md b/.changeset/seo-meta-defaults.md new file mode 100644 index 0000000000..b6ef0cbe84 --- /dev/null +++ b/.changeset/seo-meta-defaults.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds `defaultTitle` and `defaultDescription` options to `getSeoMeta` so pages with computed titles or descriptions can pass their own fallbacks while values set in the admin SEO panel still take precedence. Previously such pages had to drop down to `getContentSeo` and merge panel values by hand. diff --git a/packages/core/src/seo/index.ts b/packages/core/src/seo/index.ts index bd053b42b5..f1e56f2851 100644 --- a/packages/core/src/seo/index.ts +++ b/packages/core/src/seo/index.ts @@ -83,6 +83,17 @@ export interface SeoMetaOptions { path?: string; /** Default OG image URL if content has none */ defaultOgImage?: string; + /** + * Default page title used when the SEO panel has none. Ranks above the + * `data.title` fallback, so computed titles (e.g. `` `${title} (cover of + * ${artist})` ``) apply while an editor-set SEO title still wins. + */ + defaultTitle?: string; + /** + * Default description used when the SEO panel has none. Ranks above the + * `data.excerpt` fallback; an editor-set SEO description still wins. + */ + defaultDescription?: string; } /** @@ -96,7 +107,7 @@ export interface SeoMetaOptions { * @returns Resolved meta tags ready for template use */ export function getSeoMeta(content: SeoContentInput, options: SeoMetaOptions = {}): SeoMeta { - const { siteTitle, siteUrl, path, defaultOgImage } = options; + const { siteTitle, siteUrl, path, defaultOgImage, defaultTitle, defaultDescription } = options; const separator = options.titleSeparator || " | "; // SEO can be in content.seo (ContentItem) or content.data.seo (ContentEntry) const seo = content.seo ?? @@ -108,15 +119,19 @@ export function getSeoMeta(content: SeoContentInput, options: SeoMetaOptio noIndex: false, }; - // Title: SEO title > content title > fallback + // Title: SEO panel title > caller default > content title const pageTitle = - seo.title || (typeof content.data.title === "string" ? content.data.title : null) || ""; + seo.title || + defaultTitle || + (typeof content.data.title === "string" ? content.data.title : null) || + ""; const fullTitle = siteTitle && pageTitle ? `${pageTitle}${separator}${siteTitle}` : pageTitle; - // Description: SEO description > excerpt + // Description: SEO panel description > caller default > excerpt const description = seo.description || + defaultDescription || (typeof content.data.excerpt === "string" ? content.data.excerpt : null) || null; diff --git a/packages/core/tests/unit/seo/get-seo-meta.test.ts b/packages/core/tests/unit/seo/get-seo-meta.test.ts index a17bda4abe..30020b41cf 100644 --- a/packages/core/tests/unit/seo/get-seo-meta.test.ts +++ b/packages/core/tests/unit/seo/get-seo-meta.test.ts @@ -47,3 +47,46 @@ describe("getSeoMeta ogImage URL building", () => { expect(meta.ogImage).toBe("https://example.com/_emdash/api/media/file/01KS.svg"); }); }); + +describe("getSeoMeta defaultTitle / defaultDescription (#1518)", () => { + const entry = { + data: { title: "Raw Title", excerpt: "Raw excerpt" }, + }; + + it("uses computed defaults over data.title / data.excerpt", () => { + const meta = getSeoMeta(entry, { + defaultTitle: "Raw Title (cover of Artist)", + defaultDescription: "A computed description", + }); + expect(meta.title).toBe("Raw Title (cover of Artist)"); + expect(meta.description).toBe("A computed description"); + }); + + it("lets an editor-set SEO panel value win over the caller default", () => { + const meta = getSeoMeta( + { + data: { + ...entry.data, + seo: { title: "Panel Title", description: "Panel description" }, + }, + }, + { + defaultTitle: "Computed default", + defaultDescription: "Computed description", + }, + ); + expect(meta.title).toBe("Panel Title"); + expect(meta.description).toBe("Panel description"); + }); + + it("still falls back to data.title / data.excerpt without defaults", () => { + const meta = getSeoMeta(entry, {}); + expect(meta.title).toBe("Raw Title"); + expect(meta.description).toBe("Raw excerpt"); + }); + + it("applies the siteTitle suffix to the default title", () => { + const meta = getSeoMeta(entry, { defaultTitle: "Computed", siteTitle: "My Site" }); + expect(meta.title).toBe("Computed | My Site"); + }); +});