Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/seo-meta-defaults.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 19 additions & 4 deletions packages/core/src/seo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -96,7 +107,7 @@ export interface SeoMetaOptions {
* @returns Resolved meta tags ready for template use
*/
export function getSeoMeta<T>(content: SeoContentInput<T>, 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 ??
Expand All @@ -108,15 +119,19 @@ export function getSeoMeta<T>(content: SeoContentInput<T>, 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;

Expand Down
43 changes: 43 additions & 0 deletions packages/core/tests/unit/seo/get-seo-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading