From 605922ca32bfc5713054416cc83632b3390050c4 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:07:15 +0200 Subject: [PATCH 1/2] fix(head): avoid CSP warnings when disabled --- .changeset/quiet-heads-rest.md | 5 ++++ packages/core/src/astro/integration/index.ts | 3 ++ packages/core/src/components/EmDashHead.astro | 11 ++------ packages/core/src/page/metadata.ts | 27 ++++++++++++++++++ packages/core/src/virtual-modules.d.ts | 1 + .../tests/unit/plugins/page-metadata.test.ts | 28 ++++++++++++++++++- 6 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 .changeset/quiet-heads-rest.md diff --git a/.changeset/quiet-heads-rest.md b/.changeset/quiet-heads-rest.md new file mode 100644 index 0000000000..18bd4d076d --- /dev/null +++ b/.changeset/quiet-heads-rest.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Avoid production render warnings when Astro's built-in CSP is disabled while preserving JSON-LD script hashes when it is enabled. diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 47985fcab3..96798766e9 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -381,6 +381,9 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { if (astroVersion !== undefined) { serializableConfig.astroVersion = astroVersion; } + // EmDashHead must not access Astro.csp when the host has disabled + // Astro's built-in CSP runtime; Astro logs a warning for that access. + serializableConfig.astroCspEnabled = Boolean(astroConfig.security.csp); // Extract i18n config from Astro config // Astro locales can be strings OR { path, codes } objects — normalize to paths if (astroConfig.i18n) { diff --git a/packages/core/src/components/EmDashHead.astro b/packages/core/src/components/EmDashHead.astro index 999952cb5e..7155ca3741 100644 --- a/packages/core/src/components/EmDashHead.astro +++ b/packages/core/src/components/EmDashHead.astro @@ -17,8 +17,9 @@ * ``` */ import type { PublicPageContext, PageMetadataContribution } from "../plugins/types.js"; +import virtualConfig from "virtual:emdash/config"; import { - createSha256CspHash, + registerJsonLdCspHashes, resolvePageMetadata, renderPageMetadata, type ResolvedPageMetadata, @@ -124,13 +125,7 @@ if (runtime) { metadataHtml = renderPageMetadata(resolved, { includeJsonLd: false }); } -if (Astro.csp && jsonLdScripts.length > 0) { - await Promise.all( - jsonLdScripts.map(async ({ json }) => { - Astro.csp?.insertScriptHash(await createSha256CspHash(json)); - }), - ); -} +await registerJsonLdCspHashes(virtualConfig.astroCspEnabled === true, () => Astro.csp, jsonLdScripts); --- diff --git a/packages/core/src/page/metadata.ts b/packages/core/src/page/metadata.ts index 6c015f0319..487feceef1 100644 --- a/packages/core/src/page/metadata.ts +++ b/packages/core/src/page/metadata.ts @@ -80,6 +80,33 @@ export async function createSha256CspHash(value: string): Promise { return `sha256-${btoa(binary)}`; } +interface CspScriptHashSink { + insertScriptHash(hash: string): void; +} + +/** + * Register JSON-LD hashes with Astro's CSP runtime when CSP is enabled. + * + * The getter stays lazy because Astro warns when `Astro.csp` is accessed on a + * project that has not enabled its built-in CSP support. + */ +export async function registerJsonLdCspHashes( + enabled: boolean, + getCsp: () => CspScriptHashSink | undefined, + scripts: ReadonlyArray<{ json: string }>, +): Promise { + if (!enabled || scripts.length === 0) return; + + const csp = getCsp(); + if (!csp) return; + + await Promise.all( + scripts.map(async ({ json }) => { + csp.insertScriptHash(await createSha256CspHash(json)); + }), + ); +} + // ── Merge / dedupe ────────────────────────────────────────────── /** diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 3ed8fd8c17..6f4ddd4a7a 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -21,6 +21,7 @@ declare module "virtual:emdash/config" { authProviders?: AuthProviderDescriptor[]; i18n?: I18nConfig | null; toolbar?: "server" | "client" | false; + astroCspEnabled?: boolean; } const config: VirtualConfig; diff --git a/packages/core/tests/unit/plugins/page-metadata.test.ts b/packages/core/tests/unit/plugins/page-metadata.test.ts index 40cccef562..abbb9712f3 100644 --- a/packages/core/tests/unit/plugins/page-metadata.test.ts +++ b/packages/core/tests/unit/plugins/page-metadata.test.ts @@ -8,7 +8,7 @@ * - HTML attribute escaping */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { resolvePageMetadata, @@ -16,6 +16,7 @@ import { safeJsonLdSerialize, escapeHtmlAttr, createSha256CspHash, + registerJsonLdCspHashes, } from "../../../src/page/metadata.js"; import type { PageMetadataContribution } from "../../../src/plugins/types.js"; @@ -317,6 +318,31 @@ describe("createSha256CspHash", () => { }); }); +describe("registerJsonLdCspHashes", () => { + it("does not access Astro's CSP getter when CSP is disabled", async () => { + const getCsp = vi.fn(() => { + throw new Error("CSP getter must stay lazy"); + }); + + await registerJsonLdCspHashes(false, getCsp, [{ json: '{"@type":"WebSite"}' }]); + + expect(getCsp).not.toHaveBeenCalled(); + }); + + it("registers every JSON-LD hash when CSP is enabled", async () => { + const insertScriptHash = vi.fn(); + const getCsp = vi.fn(() => ({ insertScriptHash })); + const scripts = [{ json: '{"@type":"WebSite"}' }, { json: '{"@type":"Organization"}' }]; + + await registerJsonLdCspHashes(true, getCsp, scripts); + + expect(getCsp).toHaveBeenCalledOnce(); + expect(insertScriptHash).toHaveBeenCalledTimes(2); + expect(insertScriptHash).toHaveBeenNthCalledWith(1, await createSha256CspHash(scripts[0].json)); + expect(insertScriptHash).toHaveBeenNthCalledWith(2, await createSha256CspHash(scripts[1].json)); + }); +}); + describe("escapeHtmlAttr", () => { it("escapes double quotes", () => { expect(escapeHtmlAttr('say "hello"')).toBe("say "hello""); From 28628ee943b4b1e1bd5486d41cd434464d0cbfbf Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:41:17 +0200 Subject: [PATCH 2/2] test(head): avoid CSP hash ordering assumptions --- packages/core/src/components/EmDashHead.astro | 1 + packages/core/tests/unit/plugins/page-metadata.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/EmDashHead.astro b/packages/core/src/components/EmDashHead.astro index 7155ca3741..02a1b7a199 100644 --- a/packages/core/src/components/EmDashHead.astro +++ b/packages/core/src/components/EmDashHead.astro @@ -17,6 +17,7 @@ * ``` */ import type { PublicPageContext, PageMetadataContribution } from "../plugins/types.js"; +// @ts-ignore - virtual module import virtualConfig from "virtual:emdash/config"; import { registerJsonLdCspHashes, diff --git a/packages/core/tests/unit/plugins/page-metadata.test.ts b/packages/core/tests/unit/plugins/page-metadata.test.ts index abbb9712f3..7d21da53af 100644 --- a/packages/core/tests/unit/plugins/page-metadata.test.ts +++ b/packages/core/tests/unit/plugins/page-metadata.test.ts @@ -335,11 +335,13 @@ describe("registerJsonLdCspHashes", () => { const scripts = [{ json: '{"@type":"WebSite"}' }, { json: '{"@type":"Organization"}' }]; await registerJsonLdCspHashes(true, getCsp, scripts); + const hash0 = await createSha256CspHash(scripts[0].json); + const hash1 = await createSha256CspHash(scripts[1].json); expect(getCsp).toHaveBeenCalledOnce(); expect(insertScriptHash).toHaveBeenCalledTimes(2); - expect(insertScriptHash).toHaveBeenNthCalledWith(1, await createSha256CspHash(scripts[0].json)); - expect(insertScriptHash).toHaveBeenNthCalledWith(2, await createSha256CspHash(scripts[1].json)); + expect(insertScriptHash).toHaveBeenCalledWith(hash0); + expect(insertScriptHash).toHaveBeenCalledWith(hash1); }); });