Skip to content
Merged
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/quiet-heads-rest.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 4 additions & 8 deletions packages/core/src/components/EmDashHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
* ```
*/
import type { PublicPageContext, PageMetadataContribution } from "../plugins/types.js";
// @ts-ignore - virtual module
import virtualConfig from "virtual:emdash/config";

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] AGENTS.md’s Imports convention asks for a // @ts-ignore - virtual module comment above imports from virtual:emdash/config. Other files in the repo follow this pattern. Consider adding the comment for consistency, especially since .astro components are excluded from the core tsconfig.json.

Suggested change
import virtualConfig from "virtual:emdash/config";
// @ts-ignore - virtual module
import virtualConfig from "virtual:emdash/config";

import {
createSha256CspHash,
registerJsonLdCspHashes,
resolvePageMetadata,
renderPageMetadata,
type ResolvedPageMetadata,
Expand Down Expand Up @@ -124,13 +126,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);
---

<Fragment set:html={metadataHtml} />
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/page/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@ export async function createSha256CspHash(value: string): Promise<string> {
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<void> {
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 ──────────────────────────────────────────────

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/virtual-modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ declare module "virtual:emdash/config" {
authProviders?: AuthProviderDescriptor[];
i18n?: I18nConfig | null;
toolbar?: "server" | "client" | false;
astroCspEnabled?: boolean;
}

const config: VirtualConfig;
Expand Down
30 changes: 29 additions & 1 deletion packages/core/tests/unit/plugins/page-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
* - HTML attribute escaping
*/

import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";

import {
resolvePageMetadata,
renderPageMetadata,
safeJsonLdSerialize,
escapeHtmlAttr,
createSha256CspHash,
registerJsonLdCspHashes,
} from "../../../src/page/metadata.js";
import type { PageMetadataContribution } from "../../../src/plugins/types.js";

Expand Down Expand Up @@ -317,6 +318,33 @@ 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);
const hash0 = await createSha256CspHash(scripts[0].json);
const hash1 = await createSha256CspHash(scripts[1].json);

expect(getCsp).toHaveBeenCalledOnce();
expect(insertScriptHash).toHaveBeenCalledTimes(2);
expect(insertScriptHash).toHaveBeenCalledWith(hash0);
expect(insertScriptHash).toHaveBeenCalledWith(hash1);
});
});

describe("escapeHtmlAttr", () => {
it("escapes double quotes", () => {
expect(escapeHtmlAttr('say "hello"')).toBe("say &quot;hello&quot;");
Expand Down
Loading