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/emdashhead-seo-panel.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/src/content/docs/guides/site-settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/plugins/creating-plugins/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
68 changes: 49 additions & 19 deletions packages/core/src/components/EmDashHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
* 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
* `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.
* 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 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
Expand All @@ -26,6 +29,7 @@ import {
import { renderFragments } from "../page/fragments.js";
import JsonLdScript from "./JsonLdScript.astro";
import {
applySeoPanelToPageContext,
generateBaseSeoContributions,
generateSiteSeoContributions,
} from "../page/seo-contributions.js";
Expand All @@ -35,6 +39,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;
Expand All @@ -49,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
Expand All @@ -78,10 +109,10 @@ if (runtime) {
const defaultOgImage = absolutizeMediaUrl(
siteSettings.seo?.defaultOgImage?.url,
siteSettings.url,
page,
resolvedPage,
);
const baseContributions: PageMetadataContribution[] = generateBaseSeoContributions(
page,
resolvedPage,
defaultOgImage,
);

Expand All @@ -92,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,
});
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/page/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export type { ResolvedPageMetadata } from "./metadata.js";

export { resolveFragments, renderFragments } from "./fragments.js";

export { generateBaseSeoContributions, generateSiteSeoContributions } from "./seo-contributions.js";
export {
applySeoPanelToPageContext,
generateBaseSeoContributions,
generateSiteSeoContributions,
} from "./seo-contributions.js";
export { cleanJsonLd, buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";

/**
Expand Down
49 changes: 48 additions & 1 deletion packages/core/src/page/seo-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
* `[...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.
* 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.
*/

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";

Expand Down Expand Up @@ -147,6 +152,48 @@ export function generateBaseSeoContributions(
return contributions;
}

/**
* 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 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` feeds
* `og:title` / `twitter:title` / the JSON-LD headline only. Templates that
* want the panel title in `<title>` keep using `getSeoMeta()`.
*
* Unset panel fields fall back to the template-provided values, so pages
* without SEO data are unaffected.
*/
export function applySeoPanelToPageContext(
page: PublicPageContext,
seo: ContentSeo,
options: { siteUrl?: string | null } = {},
): PublicPageContext {
const siteUrl = options.siteUrl ?? undefined;
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,
Comment on lines +183 to +192

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,
},
};

},
};
}

/**
* Generate site-level SEO metadata contributions from SiteSettings.seo.
*
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/seo/media-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Loading
Loading