From ea71c1dbceeed31f4ceca41f05010766b71a7b9c Mon Sep 17 00:00:00 2001 From: Ryota Yamada Date: Fri, 3 Apr 2026 08:54:26 +0900 Subject: [PATCH 1/6] add google tag manager plugin --- .../plugins/google-tag-manager/package.json | 38 ++++++ .../plugins/google-tag-manager/src/index.ts | 13 ++ .../google-tag-manager/src/sandbox-entry.ts | 121 ++++++++++++++++++ .../plugins/google-tag-manager/tsconfig.json | 10 ++ 4 files changed, 182 insertions(+) create mode 100644 packages/plugins/google-tag-manager/package.json create mode 100644 packages/plugins/google-tag-manager/src/index.ts create mode 100644 packages/plugins/google-tag-manager/src/sandbox-entry.ts create mode 100644 packages/plugins/google-tag-manager/tsconfig.json diff --git a/packages/plugins/google-tag-manager/package.json b/packages/plugins/google-tag-manager/package.json new file mode 100644 index 000000000..9ef328e0c --- /dev/null +++ b/packages/plugins/google-tag-manager/package.json @@ -0,0 +1,38 @@ +{ + "name": "@emdash-cms/plugin-google-tag-manager", + "version": "0.1.0", + "description": "Google Tag Manager plugin for EmDash CMS", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./sandbox": "./src/sandbox-entry.ts" + }, + "files": [ + "src" + ], + "keywords": [ + "emdash", + "cms", + "plugin", + "gtm", + "google tag manager", + "analytics" + ], + "author": "Your Name", + "license": "MIT", + "dependencies": {}, + "peerDependencies": { + "emdash": "workspace:*" + }, + "devDependencies": {}, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "optionalDependencies": {}, + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/plugins/google-tag-manager" + } +} diff --git a/packages/plugins/google-tag-manager/src/index.ts b/packages/plugins/google-tag-manager/src/index.ts new file mode 100644 index 000000000..59ff7151a --- /dev/null +++ b/packages/plugins/google-tag-manager/src/index.ts @@ -0,0 +1,13 @@ +import type { PluginDescriptor } from "emdash"; + +export function googleTagManagerPlugin(): PluginDescriptor { + return { + id: "google-tag-manager", + version: "0.1.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-google-tag-manager/sandbox", + options: {}, + capabilities: ["page:inject"], + adminPages: [{ path: "/settings", label: "Google Tag Manager", icon: "activity" }], + }; +} diff --git a/packages/plugins/google-tag-manager/src/sandbox-entry.ts b/packages/plugins/google-tag-manager/src/sandbox-entry.ts new file mode 100644 index 000000000..c84a0ae25 --- /dev/null +++ b/packages/plugins/google-tag-manager/src/sandbox-entry.ts @@ -0,0 +1,121 @@ +import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; + +export default definePlugin({ + hooks: { + "page:fragments": async (_event: unknown, ctx: PluginContext) => { + const containerId = await ctx.kv.get("settings:gtmContainerId"); + if (!containerId) return null; + + const dataLayerName = (await ctx.kv.get("settings:gtmDataLayerName")) || "dataLayer"; + const gtmScriptUrl = (await ctx.kv.get("settings:gtmScriptUrl")) || "https://www.googletagmanager.com/gtm.js"; + const gtmNoScriptUrl = (await ctx.kv.get("settings:gtmNoScriptUrl")) || "https://www.googletagmanager.com/ns.html"; + + return [ + { + kind: "inline-script", + placement: "head", + code: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': +new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], +j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= +'${gtmScriptUrl}?id='+i+dl;f.parentNode.insertBefore(j,f); +})(window,document,'script','${dataLayerName}','${containerId}');`, + }, + { + kind: "html", + placement: "body:start", + html: ``, + }, + ]; + }, + }, + + routes: { + admin: { + handler: async ( + routeCtx: { input: unknown; request: { url: string } }, + ctx: PluginContext, + ) => { + const interaction = routeCtx.input as { + type: string; + page?: string; + action_id?: string; + values?: Record; + }; + + if (interaction.type === "page_load" && interaction.page === "/settings") { + return buildSettingsBlocks(ctx); + } + + if (interaction.type === "form_submit" && interaction.action_id === "save_gtm") { + const values = interaction.values || {}; + await ctx.kv.set("settings:gtmContainerId", values.gtm_container_id || ""); + await ctx.kv.set("settings:gtmDataLayerName", values.gtm_data_layer_name || "dataLayer"); + await ctx.kv.set("settings:gtmScriptUrl", values.gtm_script_url || "https://www.googletagmanager.com/gtm.js"); + await ctx.kv.set("settings:gtmNoScriptUrl", values.gtm_noscript_url || "https://www.googletagmanager.com/ns.html"); + + const response = await buildSettingsBlocks(ctx); + return { + ...response, + toast: { message: "GTM settings saved", type: "success" }, + }; + } + + return { blocks: [] }; + }, + }, + }, +}); + +async function buildSettingsBlocks(ctx: PluginContext) { + const containerId = (await ctx.kv.get("settings:gtmContainerId")) || ""; + const dataLayerName = (await ctx.kv.get("settings:gtmDataLayerName")) || "dataLayer"; + const gtmScriptUrl = (await ctx.kv.get("settings:gtmScriptUrl")) || "https://www.googletagmanager.com/gtm.js"; + const gtmNoScriptUrl = (await ctx.kv.get("settings:gtmNoScriptUrl")) || "https://www.googletagmanager.com/ns.html"; + + return { + blocks: [ + { type: "header", text: "Google Tag Manager" }, + { + type: "section", + text: "Configure your Google Tag Manager container and advanced URLs.", + }, + { type: "divider" }, + { + type: "form", + block_id: "gtm_settings_form", + fields: [ + { + type: "text_input", + action_id: "gtm_container_id", + label: "Container ID", + initial_value: containerId, + placeholder: "GTM-XXXXXXX", + }, + { + type: "text_input", + action_id: "gtm_data_layer_name", + label: "Data Layer Name", + initial_value: dataLayerName, + placeholder: "dataLayer", + }, + { + type: "text_input", + action_id: "gtm_script_url", + label: "GTM Script URL (gtm.js)", + initial_value: gtmScriptUrl, + placeholder: "https://www.googletagmanager.com/gtm.js", + }, + { + type: "text_input", + action_id: "gtm_noscript_url", + label: "GTM NoScript URL (ns.html)", + initial_value: gtmNoScriptUrl, + placeholder: "https://www.googletagmanager.com/ns.html", + }, + ], + submit: { label: "Save Settings", action_id: "save_gtm" }, + }, + ], + }; +} diff --git a/packages/plugins/google-tag-manager/tsconfig.json b/packages/plugins/google-tag-manager/tsconfig.json new file mode 100644 index 000000000..f7160bc76 --- /dev/null +++ b/packages/plugins/google-tag-manager/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 42ba0dba109fbceb14b467ee087ed479c03af863 Mon Sep 17 00:00:00 2001 From: Ryota Yamada Date: Fri, 3 Apr 2026 10:50:00 +0900 Subject: [PATCH 2/6] enable page plugin contributions for public routes --- packages/core/src/astro/middleware.ts | 45 ++---- .../core/tests/unit/astro/middleware.test.ts | 141 ++++++++++++++++++ 2 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 packages/core/tests/unit/astro/middleware.test.ts diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 67475220d..ba330bf3b 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -192,48 +192,17 @@ export const onRequest = defineMiddleware(async (context, next) => { const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true"; const hasPreviewToken = url.searchParams.has("_preview"); - // Playground mode: the playground middleware stashes the per-session DO database - // on locals.__playgroundDb. When present, use runWithContext() to make it - // available to getDb() and the runtime's db getter via the correct ALS instance. - const playgroundDb = locals.__playgroundDb; - - if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) { - const sessionUser = await context.session?.get("user"); - if (!sessionUser && !playgroundDb) { - // On a fresh deployment the database may be completely empty. - // Public pages call getSiteSettings() / getMenu() via getDb(), which - // bypasses runtime init and would crash with "no such table: options". - // Do a one-time lightweight probe using the same getDb() instance the - // page will use: if the migrations table doesn't exist, no migrations - // have ever run -- redirect to the setup wizard. - if (!setupVerified) { - try { - const { getDb } = await import("../loader.js"); - const db = await getDb(); - await db - .selectFrom("_emdash_migrations" as keyof Database) - .selectAll() - .limit(1) - .execute(); - setupVerified = true; - } catch { - // Table doesn't exist -> fresh database, redirect to setup - return context.redirect("/_emdash/admin/setup"); - } - } - - const response = await next(); - setBaselineSecurityHeaders(response); - return response; - } - } - const config = getConfig(); if (!config) { console.error("EmDash: No configuration found"); return next(); } + // Playground mode: the playground middleware stashes the per-session DO database + // on locals.__playgroundDb. When present, use runWithContext() to make it + // available to getDb() and the runtime's db getter via the correct ALS instance. + const playgroundDb = locals.__playgroundDb; + // In playground mode, wrap the entire runtime init + request handling in // runWithContext so that getDatabase() and all init queries use the real // DO database via the same AsyncLocalStorage instance as the loader. @@ -265,6 +234,10 @@ export const onRequest = defineMiddleware(async (context, next) => { handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime), handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime), + // Page Contributions + collectPageMetadata: runtime.collectPageMetadata.bind(runtime), + collectPageFragments: runtime.collectPageFragments.bind(runtime), + // Duplicate handler handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime), diff --git a/packages/core/tests/unit/astro/middleware.test.ts b/packages/core/tests/unit/astro/middleware.test.ts new file mode 100644 index 000000000..fab92180a --- /dev/null +++ b/packages/core/tests/unit/astro/middleware.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Hoist the mock runtime so it can be used in mocks +const { mockRuntime } = vi.hoisted(() => { + const mock: any = { + getManifest: vi.fn().mockResolvedValue({}), + collectPageMetadata: vi.fn(), + collectPageFragments: vi.fn(), + // Add some properties that are accessed directly + storage: {}, + db: {}, + hooks: {}, + email: {}, + configuredPlugins: [], + // Add missing handlers to prevent .bind errors + handleContentList: vi.fn(), + handleContentGet: vi.fn(), + handleContentCreate: vi.fn(), + handleContentUpdate: vi.fn(), + handleContentDelete: vi.fn(), + handleContentListTrashed: vi.fn(), + handleContentRestore: vi.fn(), + handleContentPermanentDelete: vi.fn(), + handleContentCountTrashed: vi.fn(), + handleContentGetIncludingTrashed: vi.fn(), + handleContentDuplicate: vi.fn(), + handleContentPublish: vi.fn(), + handleContentUnpublish: vi.fn(), + handleContentSchedule: vi.fn(), + handleContentUnschedule: vi.fn(), + handleContentCountScheduled: vi.fn(), + handleContentDiscardDraft: vi.fn(), + handleContentCompare: vi.fn(), + handleContentTranslations: vi.fn(), + handleMediaList: vi.fn(), + handleMediaGet: vi.fn(), + handleMediaCreate: vi.fn(), + handleMediaUpdate: vi.fn(), + handleMediaDelete: vi.fn(), + handleRevisionList: vi.fn(), + handleRevisionGet: vi.fn(), + handleRevisionRestore: vi.fn(), + handlePluginApiRoute: vi.fn(), + getPluginRouteMeta: vi.fn(), + getMediaProvider: vi.fn(), + getMediaProviderList: vi.fn(), + invalidateManifest: vi.fn(), + getSandboxRunner: vi.fn(), + syncMarketplacePlugins: vi.fn(), + setPluginStatus: vi.fn(), + }; + + return { mockRuntime: mock }; +}); + +// Mock virtual modules +vi.mock("virtual:emdash/config", () => ({ + default: { + database: { config: {} }, + plugins: [], + }, +})); + +vi.mock("virtual:emdash/dialect", () => ({ + createDialect: vi.fn(), + isSessionEnabled: vi.fn().mockReturnValue(false), + getD1Binding: vi.fn(), + getDefaultConstraint: vi.fn(), + getBookmarkCookieName: vi.fn(), + createSessionDialect: vi.fn(), +})); + +vi.mock("virtual:emdash/media-providers", () => ({ + mediaProviders: [], +})); + +vi.mock("virtual:emdash/plugins", () => ({ + plugins: [], +})); + +vi.mock("virtual:emdash/sandbox-runner", () => ({ + createSandboxRunner: vi.fn(), + sandboxEnabled: false, +})); + +vi.mock("virtual:emdash/sandboxed-plugins", () => ({ + sandboxedPlugins: [], +})); + +vi.mock("virtual:emdash/storage", () => ({ + createStorage: vi.fn(), +})); + +// Mock astro:middleware +vi.mock("astro:middleware", () => ({ + defineMiddleware: (fn: any) => fn, +})); + +// Mock EmDashRuntime +vi.mock("../../../src/emdash-runtime.js", () => ({ + EmDashRuntime: { + create: vi.fn().mockResolvedValue(mockRuntime), + }, +})); + +// Import the middleware +import { onRequest } from "../../../src/astro/middleware.js"; + +describe("EmDash Middleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("initializes runtime and attaches methods for all pages", async () => { + const context = { + request: new Request("https://example.com/some-page"), + url: new URL("https://example.com/some-page"), + locals: {} as any, + cookies: { + get: vi.fn().mockReturnValue({ value: undefined }), + }, + redirect: vi.fn(), + }; + + const next = vi.fn().mockResolvedValue(new Response()); + + await onRequest(context as any, next); + + expect(context.locals.emdash).toBeDefined(); + expect(context.locals.emdash.collectPageMetadata).toBeDefined(); + expect(context.locals.emdash.collectPageFragments).toBeDefined(); + + // Verify they are bound to the runtime + const pageCtx = {} as any; + await context.locals.emdash.collectPageMetadata(pageCtx); + expect(mockRuntime.collectPageMetadata).toHaveBeenCalledWith(pageCtx); + + await context.locals.emdash.collectPageFragments(pageCtx); + expect(mockRuntime.collectPageFragments).toHaveBeenCalledWith(pageCtx); + }); +}); From 875bd81f7a7606d020bebd12e77b17486afd3499 Mon Sep 17 00:00:00 2001 From: Ryota Yamada Date: Fri, 3 Apr 2026 11:15:38 +0900 Subject: [PATCH 3/6] apply format --- .../core/tests/unit/astro/middleware.test.ts | 2 +- .../google-tag-manager/src/sandbox-entry.ts | 28 ++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/core/tests/unit/astro/middleware.test.ts b/packages/core/tests/unit/astro/middleware.test.ts index fab92180a..e70bbae47 100644 --- a/packages/core/tests/unit/astro/middleware.test.ts +++ b/packages/core/tests/unit/astro/middleware.test.ts @@ -129,7 +129,7 @@ describe("EmDash Middleware", () => { expect(context.locals.emdash).toBeDefined(); expect(context.locals.emdash.collectPageMetadata).toBeDefined(); expect(context.locals.emdash.collectPageFragments).toBeDefined(); - + // Verify they are bound to the runtime const pageCtx = {} as any; await context.locals.emdash.collectPageMetadata(pageCtx); diff --git a/packages/plugins/google-tag-manager/src/sandbox-entry.ts b/packages/plugins/google-tag-manager/src/sandbox-entry.ts index c84a0ae25..ee54165ad 100644 --- a/packages/plugins/google-tag-manager/src/sandbox-entry.ts +++ b/packages/plugins/google-tag-manager/src/sandbox-entry.ts @@ -8,8 +8,12 @@ export default definePlugin({ if (!containerId) return null; const dataLayerName = (await ctx.kv.get("settings:gtmDataLayerName")) || "dataLayer"; - const gtmScriptUrl = (await ctx.kv.get("settings:gtmScriptUrl")) || "https://www.googletagmanager.com/gtm.js"; - const gtmNoScriptUrl = (await ctx.kv.get("settings:gtmNoScriptUrl")) || "https://www.googletagmanager.com/ns.html"; + const gtmScriptUrl = + (await ctx.kv.get("settings:gtmScriptUrl")) || + "https://www.googletagmanager.com/gtm.js"; + const gtmNoScriptUrl = + (await ctx.kv.get("settings:gtmNoScriptUrl")) || + "https://www.googletagmanager.com/ns.html"; return [ { @@ -51,9 +55,15 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= const values = interaction.values || {}; await ctx.kv.set("settings:gtmContainerId", values.gtm_container_id || ""); await ctx.kv.set("settings:gtmDataLayerName", values.gtm_data_layer_name || "dataLayer"); - await ctx.kv.set("settings:gtmScriptUrl", values.gtm_script_url || "https://www.googletagmanager.com/gtm.js"); - await ctx.kv.set("settings:gtmNoScriptUrl", values.gtm_noscript_url || "https://www.googletagmanager.com/ns.html"); - + await ctx.kv.set( + "settings:gtmScriptUrl", + values.gtm_script_url || "https://www.googletagmanager.com/gtm.js", + ); + await ctx.kv.set( + "settings:gtmNoScriptUrl", + values.gtm_noscript_url || "https://www.googletagmanager.com/ns.html", + ); + const response = await buildSettingsBlocks(ctx); return { ...response, @@ -70,8 +80,12 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= async function buildSettingsBlocks(ctx: PluginContext) { const containerId = (await ctx.kv.get("settings:gtmContainerId")) || ""; const dataLayerName = (await ctx.kv.get("settings:gtmDataLayerName")) || "dataLayer"; - const gtmScriptUrl = (await ctx.kv.get("settings:gtmScriptUrl")) || "https://www.googletagmanager.com/gtm.js"; - const gtmNoScriptUrl = (await ctx.kv.get("settings:gtmNoScriptUrl")) || "https://www.googletagmanager.com/ns.html"; + const gtmScriptUrl = + (await ctx.kv.get("settings:gtmScriptUrl")) || + "https://www.googletagmanager.com/gtm.js"; + const gtmNoScriptUrl = + (await ctx.kv.get("settings:gtmNoScriptUrl")) || + "https://www.googletagmanager.com/ns.html"; return { blocks: [ From 7eb084f4f2de4ee2644542ab2c80c5d0c5ea3029 Mon Sep 17 00:00:00 2001 From: Ryota Yamada Date: Fri, 3 Apr 2026 11:28:04 +0900 Subject: [PATCH 4/6] delete unused-vars --- packages/core/src/astro/middleware.ts | 29 +-------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index ba330bf3b..f12cd327e 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -54,16 +54,6 @@ let runtimeInitializing = false; /** Whether i18n config has been initialized from the virtual module */ let i18nInitialized = false; -/** - * Whether we've verified the database has been set up. - * On a fresh deployment the first request may hit a public page, bypassing - * runtime init. Without this check, template helpers like getSiteSettings() - * would query an empty database and crash. Once verified (or once the runtime - * has initialized via an admin/API request), this stays true for the worker's - * lifetime. - */ -let setupVerified = false; - /** * Get EmDash configuration from virtual module */ @@ -175,22 +165,8 @@ function setBaselineSecurityHeaders(response: Response): void { } } -/** Public routes that require the runtime (sitemap, robots.txt, etc.) */ -const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]); - export const onRequest = defineMiddleware(async (context, next) => { - const { request, locals, cookies } = context; - const url = context.url; - - // Process /_emdash routes and public routes with an active session - // (logged-in editors need the runtime for toolbar/visual editing on public pages) - const isEmDashRoute = url.pathname.startsWith("/_emdash"); - const isPublicRuntimeRoute = PUBLIC_RUNTIME_ROUTES.has(url.pathname); - - // Check for edit mode cookie - editors viewing public pages need the runtime - // so auth middleware can verify their session for visual editing - const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true"; - const hasPreviewToken = url.searchParams.has("_preview"); + const { request, locals } = context; const config = getConfig(); if (!config) { @@ -211,9 +187,6 @@ export const onRequest = defineMiddleware(async (context, next) => { // Get or create runtime const runtime = await getRuntime(config); - // Runtime init runs migrations, so the DB is guaranteed set up - setupVerified = true; - // Get manifest (cached after first call) const manifest = await runtime.getManifest(); From fc07fbfd33ff8981dae37f63f90ed72ae43003a2 Mon Sep 17 00:00:00 2001 From: Ryota Yamada Date: Wed, 15 Apr 2026 20:03:54 +0900 Subject: [PATCH 5/6] remote middleware test code --- .../core/tests/unit/astro/middleware.test.ts | 141 ------------------ 1 file changed, 141 deletions(-) delete mode 100644 packages/core/tests/unit/astro/middleware.test.ts diff --git a/packages/core/tests/unit/astro/middleware.test.ts b/packages/core/tests/unit/astro/middleware.test.ts deleted file mode 100644 index e70bbae47..000000000 --- a/packages/core/tests/unit/astro/middleware.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -// Hoist the mock runtime so it can be used in mocks -const { mockRuntime } = vi.hoisted(() => { - const mock: any = { - getManifest: vi.fn().mockResolvedValue({}), - collectPageMetadata: vi.fn(), - collectPageFragments: vi.fn(), - // Add some properties that are accessed directly - storage: {}, - db: {}, - hooks: {}, - email: {}, - configuredPlugins: [], - // Add missing handlers to prevent .bind errors - handleContentList: vi.fn(), - handleContentGet: vi.fn(), - handleContentCreate: vi.fn(), - handleContentUpdate: vi.fn(), - handleContentDelete: vi.fn(), - handleContentListTrashed: vi.fn(), - handleContentRestore: vi.fn(), - handleContentPermanentDelete: vi.fn(), - handleContentCountTrashed: vi.fn(), - handleContentGetIncludingTrashed: vi.fn(), - handleContentDuplicate: vi.fn(), - handleContentPublish: vi.fn(), - handleContentUnpublish: vi.fn(), - handleContentSchedule: vi.fn(), - handleContentUnschedule: vi.fn(), - handleContentCountScheduled: vi.fn(), - handleContentDiscardDraft: vi.fn(), - handleContentCompare: vi.fn(), - handleContentTranslations: vi.fn(), - handleMediaList: vi.fn(), - handleMediaGet: vi.fn(), - handleMediaCreate: vi.fn(), - handleMediaUpdate: vi.fn(), - handleMediaDelete: vi.fn(), - handleRevisionList: vi.fn(), - handleRevisionGet: vi.fn(), - handleRevisionRestore: vi.fn(), - handlePluginApiRoute: vi.fn(), - getPluginRouteMeta: vi.fn(), - getMediaProvider: vi.fn(), - getMediaProviderList: vi.fn(), - invalidateManifest: vi.fn(), - getSandboxRunner: vi.fn(), - syncMarketplacePlugins: vi.fn(), - setPluginStatus: vi.fn(), - }; - - return { mockRuntime: mock }; -}); - -// Mock virtual modules -vi.mock("virtual:emdash/config", () => ({ - default: { - database: { config: {} }, - plugins: [], - }, -})); - -vi.mock("virtual:emdash/dialect", () => ({ - createDialect: vi.fn(), - isSessionEnabled: vi.fn().mockReturnValue(false), - getD1Binding: vi.fn(), - getDefaultConstraint: vi.fn(), - getBookmarkCookieName: vi.fn(), - createSessionDialect: vi.fn(), -})); - -vi.mock("virtual:emdash/media-providers", () => ({ - mediaProviders: [], -})); - -vi.mock("virtual:emdash/plugins", () => ({ - plugins: [], -})); - -vi.mock("virtual:emdash/sandbox-runner", () => ({ - createSandboxRunner: vi.fn(), - sandboxEnabled: false, -})); - -vi.mock("virtual:emdash/sandboxed-plugins", () => ({ - sandboxedPlugins: [], -})); - -vi.mock("virtual:emdash/storage", () => ({ - createStorage: vi.fn(), -})); - -// Mock astro:middleware -vi.mock("astro:middleware", () => ({ - defineMiddleware: (fn: any) => fn, -})); - -// Mock EmDashRuntime -vi.mock("../../../src/emdash-runtime.js", () => ({ - EmDashRuntime: { - create: vi.fn().mockResolvedValue(mockRuntime), - }, -})); - -// Import the middleware -import { onRequest } from "../../../src/astro/middleware.js"; - -describe("EmDash Middleware", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("initializes runtime and attaches methods for all pages", async () => { - const context = { - request: new Request("https://example.com/some-page"), - url: new URL("https://example.com/some-page"), - locals: {} as any, - cookies: { - get: vi.fn().mockReturnValue({ value: undefined }), - }, - redirect: vi.fn(), - }; - - const next = vi.fn().mockResolvedValue(new Response()); - - await onRequest(context as any, next); - - expect(context.locals.emdash).toBeDefined(); - expect(context.locals.emdash.collectPageMetadata).toBeDefined(); - expect(context.locals.emdash.collectPageFragments).toBeDefined(); - - // Verify they are bound to the runtime - const pageCtx = {} as any; - await context.locals.emdash.collectPageMetadata(pageCtx); - expect(mockRuntime.collectPageMetadata).toHaveBeenCalledWith(pageCtx); - - await context.locals.emdash.collectPageFragments(pageCtx); - expect(mockRuntime.collectPageFragments).toHaveBeenCalledWith(pageCtx); - }); -}); From f146bd909a2752be06be04643d152d2c53dad982 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 27 Apr 2026 15:23:03 +0100 Subject: [PATCH 6/6] Update lockfile --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ab30c379..59f72f039 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1364,6 +1364,12 @@ importers: specifier: ^2.4.1 version: 2.4.1 + packages/plugins/google-tag-manager: + dependencies: + emdash: + specifier: workspace:* + version: link:../../core + packages/plugins/marketplace-test: dependencies: emdash: