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 +--- + +`` 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 `` 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 02a1b7a199..907f8f263a 100644 --- a/packages/core/src/components/EmDashHead.astro +++ b/packages/core/src/components/EmDashHead.astro @@ -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 @@ -28,6 +31,7 @@ import { import { renderFragments } from "../page/fragments.js"; import JsonLdScript from "./JsonLdScript.astro"; import { + applySeoPanelToPageContext, generateBaseSeoContributions, generateSiteSeoContributions, } from "../page/seo-contributions.js"; @@ -37,6 +41,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; @@ -51,21 +59,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 @@ -80,10 +111,10 @@ if (runtime) { const defaultOgImage = absolutizeMediaUrl( siteSettings.seo?.defaultOgImage?.url, siteSettings.url, - page, + resolvedPage, ); const baseContributions: PageMetadataContribution[] = generateBaseSeoContributions( - page, + resolvedPage, defaultOgImage, ); @@ -94,7 +125,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, }); diff --git a/packages/core/src/page/index.ts b/packages/core/src/page/index.ts index d5b4f74c6e..ace24096e9 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 { + applySeoPanelToPageContext, + generateBaseSeoContributions, + 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..2750907bd5 100644 --- a/packages/core/src/page/seo-contributions.ts +++ b/packages/core/src/page/seo-contributions.ts @@ -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"; @@ -147,6 +152,52 @@ 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, + // Mirror the resolved image into the top-level field too, so consumers + // reading page.image (e.g. page:metadata hooks) agree with og:image and + // the JSON-LD graph (#1518). + 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. * 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..67e8742f47 100644 --- a/packages/core/tests/unit/page/seo-contributions.test.ts +++ b/packages/core/tests/unit/page/seo-contributions.test.ts @@ -12,7 +12,14 @@ import { describe, it, expect } from "vitest"; -import { generateSiteSeoContributions } from "../../../src/page/seo-contributions.js"; +import type { ContentSeo } from "../../../src/database/repositories/types.js"; +import { buildBlogPostingJsonLd } from "../../../src/page/jsonld.js"; +import { + 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", () => { @@ -86,3 +93,171 @@ describe("generateSiteSeoContributions", () => { expect(result).toEqual([]); }); }); + +/** + * 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 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("applySeoPanelToPageContext (#1518)", () => { + const emptySeo: ContentSeo = { + title: null, + description: null, + image: null, + canonical: null, + noIndex: false, + }; + + 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("panel title reaches og:title and twitter:title via seo.ogTitle", () => { + const result = applySeoPanelToPageContext(createPage(), { ...emptySeo, title: "Panel Title" }); + + const contributions = generateBaseSeoContributions(result); + expect(contributions).toContainEqual({ + kind: "property", + property: "og:title", + content: "Panel Title", + }); + expect(contributions).toContainEqual({ + kind: "meta", + name: "twitter:title", + content: "Panel Title", + }); + }); + + it("panel description overrides the template description everywhere", () => { + const result = applySeoPanelToPageContext(createPage(), { + ...emptySeo, + description: "Panel desc", + }); + + 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(contributions).toContainEqual({ + kind: "meta", + name: "twitter:description", + content: "Panel desc", + }); + }); + + 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"; + 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("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"; + 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 panel canonical through unchanged", () => { + const result = applySeoPanelToPageContext( + createPage(), + { ...emptySeo, canonical: "https://other.example/page" }, + { siteUrl: "https://example.com" }, + ); + + expect(result.canonical).toBe("https://other.example/page"); + }); + + 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", + }); + + const templateRobots = applySeoPanelToPageContext( + createPage({ seo: { robots: "noindex" } }), + emptySeo, + ); + expect(templateRobots.seo?.robots).toBe("noindex"); + }); + + 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" }, + ); + + const graph = buildBlogPostingJsonLd(result); + expect(graph).toMatchObject({ + headline: "Panel Title", + image: "https://example.com/_emdash/api/media/file/01KSMEDIA", + url: "https://example.com/posts/other-post", + mainEntityOfPage: { "@id": "https://example.com/posts/other-post" }, + }); + }); +}); diff --git a/scripts/query-counts.queries.d1.json b/scripts/query-counts.queries.d1.json index 932c41f6f4..4c0e54a8c4 100644 --- a/scripts/query-counts.queries.d1.json +++ b/scripts/query-counts.queries.d1.json @@ -119,6 +119,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, @@ -132,6 +133,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, @@ -175,6 +177,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, @@ -194,6 +197,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 23f5ff3ee9..7f44617ed8 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -79,6 +79,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, @@ -87,6 +88,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, @@ -115,6 +117,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, @@ -131,6 +134,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 5e316de883..5aecac3efd 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 1459d4c436..05425fe8e7 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,