diff --git a/.changeset/expose-trailing-slash.md b/.changeset/expose-trailing-slash.md new file mode 100644 index 0000000000..ef03849042 --- /dev/null +++ b/.changeset/expose-trailing-slash.md @@ -0,0 +1,7 @@ +--- +"emdash": minor +"@emdash-cms/cloudflare": minor +"@emdash-cms/sandbox-workerd": minor +--- + +Expose the host Astro project's `trailingSlash` config to plugins via `ctx.site.trailingSlash`, so plugins that build absolute URLs (sitemaps, canonical, hreflang) can match the site's routing policy. Available to in-process and sandboxed plugins alike. diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index d7c8f381ec..8dd780c7dc 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -102,7 +102,12 @@ export class CloudflareSandboxRunner implements SandboxRunner { private plugins = new Map(); private options: SandboxOptions; private resolvedLimits: ResolvedLimits; - private siteInfo?: { name: string; url: string; locale: string }; + private siteInfo?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }; constructor(options: SandboxOptions) { this.options = options; @@ -203,7 +208,12 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance { private code: string; private wrapperCode: string | null = null; private limits: ResolvedLimits; - private siteInfo?: { name: string; url: string; locale: string }; + private siteInfo?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }; constructor( manifest: PluginManifest, @@ -211,7 +221,12 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance { loader: WorkerLoader, createBridge: (opts: { props: PluginBridgeProps }) => PluginBridgeBinding, limits: ResolvedLimits, - siteInfo?: { name: string; url: string; locale: string }, + siteInfo?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }, ) { this.id = `${manifest.id}:${manifest.version}`; this.manifest = manifest; diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index fae6d00877..320c3752e7 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -27,7 +27,12 @@ const COMMENT_CLOSE_RE = /\*\//g; */ export interface WrapperOptions { /** Site info to inject into the context (no RPC needed) */ - site?: { name: string; url: string; locale: string }; + site?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }; } export function generatePluginWrapper(manifest: PluginManifest, options?: WrapperOptions): string { diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 7fdf8102c9..8e392735ae 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -412,6 +412,9 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { // 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); + // Expose Astro's trailingSlash routing policy so plugins can build + // URLs (sitemap/canonical/hreflang) that match what the site serves. + serializableConfig.trailingSlash = astroConfig.trailingSlash; // 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/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 7aaa058fd5..a0287f6b98 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -352,7 +352,12 @@ export interface EmDashRuntimeParts { db: Kysely; getDb?: () => Kysely; storage?: Storage; - siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }; + siteInfo?: { + siteName?: string; + siteUrl?: string; + locale?: string; + trailingSlash?: "always" | "never" | "ignore"; + }; }; runtimeDeps: RuntimeDependencies; pipelineRef: { current: HookPipeline }; @@ -534,7 +539,12 @@ export class EmDashRuntime { db: Kysely; getDb?: () => Kysely; storage?: Storage; - siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }; + siteInfo?: { + siteName?: string; + siteUrl?: string; + locale?: string; + trailingSlash?: "always" | "never" | "ignore"; + }; }; /** Dependencies needed for exclusive hook resolution */ private runtimeDeps: RuntimeDependencies; @@ -1106,7 +1116,14 @@ export class EmDashRuntime { const storage = EmDashRuntime.getStorage(deps); let pluginStates: Map = new Map(); - let siteInfo: { siteName?: string; siteUrl?: string; locale?: string } | undefined; + let siteInfo: + | { + siteName?: string; + siteUrl?: string; + locale?: string; + trailingSlash?: "always" | "never" | "ignore"; + } + | undefined; // "Already set up" by default so a read failure (e.g. tables absent on a // pre-migration db) skips seeding rather than seeding a half-built db. let seedGate = { collectionCount: 1, setupDone: true }; @@ -1149,6 +1166,10 @@ export class EmDashRuntime { siteName: siteOpts.get("emdash:site_title") ?? undefined, siteUrl: siteOpts.get("emdash:site_url") ?? undefined, locale: siteOpts.get("emdash:locale") ?? undefined, + // trailingSlash is a build-time Astro routing decision, not a + // user-editable setting, so it comes from the Astro config + // (virtual:emdash/config), not the options table. + trailingSlash: virtualConfig?.trailingSlash, }; }; @@ -1831,7 +1852,12 @@ export class EmDashRuntime { deps: RuntimeDependencies, db: Kysely, mediaStorage?: Storage | null, - siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }, + siteInfo?: { + siteName?: string; + siteUrl?: string; + locale?: string; + trailingSlash?: "always" | "never" | "ignore"; + }, ): Promise> { // Return cached plugins if already loaded if (sandboxedPluginCache.size > 0) { @@ -1946,7 +1972,12 @@ export class EmDashRuntime { storage: Storage, deps: RuntimeDependencies, cache: Map, - siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }, + siteInfo?: { + siteName?: string; + siteUrl?: string; + locale?: string; + trailingSlash?: "always" | "never" | "ignore"; + }, ): Promise { // Ensure sandbox runner exists with media storage wired up. // (storage here is the media Storage adapter from the runtime.) diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 8945a94748..233a6e16a5 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -882,6 +882,8 @@ export interface SiteInfoOptions { siteUrl?: string; /** Site locale from options table */ locale?: string; + /** Astro's `trailingSlash` config (from `virtual:emdash/config`). */ + trailingSlash?: "always" | "never" | "ignore"; } /** @@ -897,6 +899,7 @@ export function createSiteInfo(options: SiteInfoOptions): SiteInfo { name: options.siteName ?? "", url: (options.siteUrl ?? "").replace(TRAILING_SLASH_RE, ""), // strip trailing slash locale: options.locale ?? "en", + trailingSlash: options.trailingSlash ?? "ignore", // Astro's default }; } diff --git a/packages/core/src/plugins/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index e95545353d..3dfb344d59 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -72,7 +72,12 @@ export interface SandboxOptions { /** Default resource limits */ limits?: ResourceLimits; /** Site info for plugin context (injected into wrapper at generation time) */ - siteInfo?: { name: string; url: string; locale: string }; + siteInfo?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }; /** Email send callback, wired from the EmailPipeline by the runtime */ emailSend?: SandboxEmailSendCallback; /** diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index a9e52caba6..4b5cfc247d 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -419,6 +419,14 @@ export interface SiteInfo { url: string; /** Site locale (from settings, defaults to "en") */ locale: string; + /** + * Astro's `trailingSlash` routing policy, from the host's Astro config. + * Plugins that build absolute URLs (sitemap, canonical, hreflang) should + * honor this so the URLs they emit match what the site serves. `createSiteInfo` + * always populates it (defaulting to `"ignore"`, Astro's default); it is + * optional on the type so pre-existing `SiteInfo` construction stays valid. + */ + trailingSlash?: "always" | "never" | "ignore"; } /** diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 1b377fd8e0..9ce6519842 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -24,6 +24,7 @@ declare module "virtual:emdash/config" { /** Public origin from astro.config.mjs, origin-normalized at startup. */ siteUrl?: string; astroCspEnabled?: boolean; + trailingSlash?: "always" | "never" | "ignore"; } const config: VirtualConfig; diff --git a/packages/core/tests/integration/plugins/capabilities.test.ts b/packages/core/tests/integration/plugins/capabilities.test.ts index 287493f196..0981c29a55 100644 --- a/packages/core/tests/integration/plugins/capabilities.test.ts +++ b/packages/core/tests/integration/plugins/capabilities.test.ts @@ -999,11 +999,13 @@ describe("Capability Enforcement Integration (v2)", () => { siteName: "My Site", siteUrl: "https://example.com/", locale: "fr", + trailingSlash: "never", }); expect(info.name).toBe("My Site"); expect(info.url).toBe("https://example.com"); // trailing slash stripped expect(info.locale).toBe("fr"); + expect(info.trailingSlash).toBe("never"); }); it("uses defaults for missing values", () => { @@ -1012,6 +1014,7 @@ describe("Capability Enforcement Integration (v2)", () => { expect(info.name).toBe(""); expect(info.url).toBe(""); expect(info.locale).toBe("en"); + expect(info.trailingSlash).toBe("ignore"); // Astro's default }); it("strips trailing slash from URL", () => { diff --git a/packages/core/tests/integration/runtime/create.test.ts b/packages/core/tests/integration/runtime/create.test.ts index 9bea7ac7af..65b249174f 100644 --- a/packages/core/tests/integration/runtime/create.test.ts +++ b/packages/core/tests/integration/runtime/create.test.ts @@ -150,6 +150,7 @@ describe("EmDashRuntime.create — cold boot", () => { name: "Example Site", url: "https://example.com", locale: "nl", + trailingSlash: "ignore", }, }), ); diff --git a/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts b/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts index 112ffe9c0c..dee591136a 100644 --- a/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts +++ b/packages/core/tests/integration/runtime/plugin-route-site-info.test.ts @@ -83,6 +83,7 @@ describe("EmDashRuntime.handlePluginApiRoute site context", () => { name: "Example Site", url: "https://example.com", locale: "nl", + trailingSlash: "ignore", }, url: "https://example.com/checkout/success", }, diff --git a/packages/core/tests/unit/plugins/sandbox-runner-options.test.ts b/packages/core/tests/unit/plugins/sandbox-runner-options.test.ts index b241468a1f..6720ae6ceb 100644 --- a/packages/core/tests/unit/plugins/sandbox-runner-options.test.ts +++ b/packages/core/tests/unit/plugins/sandbox-runner-options.test.ts @@ -20,6 +20,7 @@ describe("createSandboxRunnerOptions", () => { name: "Example Site", url: "https://example.com", locale: "nl", + trailingSlash: "ignore", }, }); }); @@ -33,6 +34,6 @@ describe("createSandboxRunnerOptions", () => { expect(options.db).toBe(db); expect(options.mediaStorage).toBe(mediaStorage); - expect(options.siteInfo).toEqual({ name: "", url: "", locale: "en" }); + expect(options.siteInfo).toEqual({ name: "", url: "", locale: "en", trailingSlash: "ignore" }); }); }); diff --git a/packages/workerd/src/sandbox/dev-runner.ts b/packages/workerd/src/sandbox/dev-runner.ts index 3ecacedee6..fe1da7039b 100644 --- a/packages/workerd/src/sandbox/dev-runner.ts +++ b/packages/workerd/src/sandbox/dev-runner.ts @@ -51,7 +51,12 @@ const EMDASH_SHIM = "export const definePlugin = (d) => d;\n"; */ export class MiniflareDevRunner implements SandboxRunner { private options: SandboxOptions; - private siteInfo?: { name: string; url: string; locale: string }; + private siteInfo?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }; private emailSendCallback: SandboxEmailSendCallback | null = null; /** Miniflare instance (lazily created) */ diff --git a/packages/workerd/src/sandbox/runner.ts b/packages/workerd/src/sandbox/runner.ts index f5e1a48bed..7624485d3e 100644 --- a/packages/workerd/src/sandbox/runner.ts +++ b/packages/workerd/src/sandbox/runner.ts @@ -292,7 +292,12 @@ function isTokenClaims(value: unknown): value is PluginTokenClaims { export class WorkerdSandboxRunner implements SandboxRunner { private options: SandboxOptions; private limits: ResolvedLimits; - private siteInfo?: { name: string; url: string; locale: string }; + private siteInfo?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }; /** Loaded plugins indexed by pluginId (manifest.id:manifest.version) */ private plugins = new Map(); diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index ce2fd9b26d..b45be3fde6 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -18,7 +18,12 @@ const NEWLINE_RE = /[\n\r]/g; const COMMENT_CLOSE_RE = /\*\//g; export interface WrapperOptions { - site?: { name: string; url: string; locale: string }; + site?: { + name: string; + url: string; + locale: string; + trailingSlash?: "always" | "never" | "ignore"; + }; /** URL of the Node backing service (e.g., http://127.0.0.1:18787) */ backingServiceUrl: string; /** Auth token the plugin sends on outbound bridge calls to Node */