From 7a9dd06fc6f734b0ab2f48361cbf73710ee632fc Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 21 Jun 2026 20:52:31 +0200 Subject: [PATCH] feat: setup analytics-mailer with gitea cron pipeline for e-tib and klz --- .gitea/workflows/analytics-mailer.yml | 44 ++++ apps/analytics-mailer/config.json | 14 ++ apps/analytics-mailer/package.json | 26 +++ apps/analytics-mailer/src/index.ts | 143 ++++++++++++ apps/analytics-mailer/tsconfig.json | 10 + packages/mail/src/index.ts | 1 + .../templates/MonthlyAnalyticsTemplate.tsx | 205 ++++++++++++++++++ pnpm-lock.yaml | 73 ++++++- 8 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 .gitea/workflows/analytics-mailer.yml create mode 100644 apps/analytics-mailer/config.json create mode 100644 apps/analytics-mailer/package.json create mode 100644 apps/analytics-mailer/src/index.ts create mode 100644 apps/analytics-mailer/tsconfig.json create mode 100644 packages/mail/src/templates/MonthlyAnalyticsTemplate.tsx diff --git a/.gitea/workflows/analytics-mailer.yml b/.gitea/workflows/analytics-mailer.yml new file mode 100644 index 0000000..1622fc6 --- /dev/null +++ b/.gitea/workflows/analytics-mailer.yml @@ -0,0 +1,44 @@ +name: Monthly Analytics Mailer + +on: + schedule: + - cron: '0 8 1 * *' # Run at 08:00 on the 1st of every month + workflow_dispatch: # Allow manual trigger + +jobs: + send-reports: + name: 📊 Send Monthly Reports + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + UMAMI_BASE_URL: ${{ secrets.UMAMI_BASE_URL }} + UMAMI_USERNAME: ${{ secrets.UMAMI_USERNAME }} + UMAMI_PASSWORD: ${{ secrets.UMAMI_PASSWORD }} + MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} + MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }} + MAILGUN_SENDER: ${{ secrets.MAILGUN_SENDER }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node_version: 20 + + - name: Enable pnpm + run: corepack enable && corepack prepare pnpm@10.2.0 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color + + - name: Build mail templates + run: pnpm --filter @mintel/mail build + + - name: Build analytics mailer + run: pnpm --filter @mintel/analytics-mailer build + + - name: Run Analytics Mailer + run: pnpm --filter @mintel/analytics-mailer run start diff --git a/apps/analytics-mailer/config.json b/apps/analytics-mailer/config.json new file mode 100644 index 0000000..2b5b44a --- /dev/null +++ b/apps/analytics-mailer/config.json @@ -0,0 +1,14 @@ +[ + { + "websiteId": "d773ea10-a3b3-4ccf-9024-987e14c4d669", + "domain": "e-tib.com", + "clientEmail": "marc@mintel.me", + "clientName": "E-TIB GmbH" + }, + { + "websiteId": "59a7db94-0100-4c7e-98ef-99f45b17f9c3", + "domain": "klz-cables.com", + "clientEmail": "marc@mintel.me", + "clientName": "KLZ" + } +] diff --git a/apps/analytics-mailer/package.json b/apps/analytics-mailer/package.json new file mode 100644 index 0000000..dffe03b --- /dev/null +++ b/apps/analytics-mailer/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mintel/analytics-mailer", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsup src/index.ts --format esm --clean", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@mintel/mail": "workspace:*", + "axios": "^1.7.9", + "dotenv": "^16.4.7", + "form-data": "^4.0.1", + "mailgun.js": "^10.3.0" + }, + "devDependencies": { + "@mintel/eslint-config": "workspace:*", + "@mintel/tsconfig": "workspace:*", + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/analytics-mailer/src/index.ts b/apps/analytics-mailer/src/index.ts new file mode 100644 index 0000000..532712b --- /dev/null +++ b/apps/analytics-mailer/src/index.ts @@ -0,0 +1,143 @@ +import fs from "fs/promises"; +import path from "path"; +import axios from "axios"; +import { render } from "@mintel/mail"; +import { MonthlyAnalyticsTemplate } from "@mintel/mail"; +import Mailgun from "mailgun.js"; +import formData from "form-data"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const UMAMI_BASE_URL = process.env.UMAMI_BASE_URL || "https://umami.infra.mintel.me"; +const UMAMI_USERNAME = process.env.UMAMI_USERNAME; +const UMAMI_PASSWORD = process.env.UMAMI_PASSWORD; +const UMAMI_API_KEY = process.env.UMAMI_API_KEY; + +const MAILGUN_API_KEY = process.env.MAILGUN_API_KEY; +const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN; +const MAILGUN_SENDER = process.env.MAILGUN_SENDER || "reports@mintel.me"; +const MAILGUN_URL = process.env.MAILGUN_URL || "https://api.eu.mailgun.net"; + +interface ConfigClient { + websiteId: string; + domain: string; + clientEmail: string; + clientName: string; +} + +async function getUmamiToken(): Promise { + if (UMAMI_API_KEY) { + return UMAMI_API_KEY; + } + if (!UMAMI_USERNAME || !UMAMI_PASSWORD) { + throw new Error("Missing UMAMI_USERNAME and UMAMI_PASSWORD or UMAMI_API_KEY"); + } + const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, { + username: UMAMI_USERNAME, + password: UMAMI_PASSWORD, + }); + return res.data.token; +} + +async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number) { + const [statsRes, pagesRes, refRes] = await Promise.all([ + api.get(`/websites/${websiteId}/stats`, { params: { startAt, endAt } }), + api.get(`/websites/${websiteId}/metrics`, { params: { type: "url", limit: 5, startAt, endAt } }), + api.get(`/websites/${websiteId}/metrics`, { params: { type: "referrer", limit: 3, startAt, endAt } }), + ]); + + const stats = statsRes.data; + const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y })); + const topReferrers = (refRes.data || []).map((r: any) => ({ url: r.x, visitors: r.y })); + + return { + totalVisitors: stats.visitors?.value || 0, + totalPageviews: stats.pageviews?.value || 0, + topPages, + topReferrers, + }; +} + +async function main() { + const isDryRun = process.argv.includes("--dry-run"); + + // Load config + const configPath = path.resolve(process.cwd(), "config.json"); + const configRaw = await fs.readFile(configPath, "utf-8"); + const clients: ConfigClient[] = JSON.parse(configRaw); + + // Setup Umami API + const token = await getUmamiToken(); + const headers = UMAMI_API_KEY ? { "x-umami-api-key": token } : { Authorization: `Bearer ${token}` }; + const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers }); + + // Setup Mailgun + const mailgun = new Mailgun(formData); + const mg = mailgun.client({ username: "api", key: MAILGUN_API_KEY!, url: MAILGUN_URL }); + + // Time range calculation (previous month) + const now = new Date(); + // Ensure we get the correct previous month relative to current local time, but use UTC boundary or local? Umami uses ms timestamps. + // Standard way: First day of previous month, last day of previous month. + const startOfPrevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const endOfPrevMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); + + const startAt = startOfPrevMonth.getTime(); + const endAt = endOfPrevMonth.getTime(); + + const monthName = startOfPrevMonth.toLocaleString("de-DE", { month: "long" }); + const year = startOfPrevMonth.getFullYear().toString(); + + console.log(`Generating reports for ${monthName} ${year}...`); + + for (const client of clients) { + if (client.websiteId === "replace-with-umami-website-id") continue; + + console.log(`Processing ${client.domain} (${client.clientName})...`); + + try { + const data = await fetchUmamiData(api, client.websiteId, startAt, endAt); + + const html = await render(MonthlyAnalyticsTemplate({ + clientName: client.clientName, + domain: client.domain, + month: monthName, + year, + totalVisitors: data.totalVisitors, + totalPageviews: data.totalPageviews, + topPages: data.topPages, + topReferrers: data.topReferrers, + })); + + const subject = `Ihr Website-Report für ${monthName} ${year} - ${client.domain}`; + + if (isDryRun) { + console.log(`[DRY-RUN] Would send email to ${client.clientEmail} for ${client.domain}`); + const debugPath = path.resolve(process.cwd(), `dry-run-${client.domain}.html`); + await fs.writeFile(debugPath, html); + console.log(`[DRY-RUN] Saved HTML preview to ${debugPath}`); + } else { + if (!MAILGUN_API_KEY) { + throw new Error("MAILGUN_API_KEY is missing for live run."); + } + await mg.messages.create(MAILGUN_DOMAIN!, { + from: MAILGUN_SENDER, + to: [client.clientEmail], + subject, + html, + }); + console.log(`Successfully sent to ${client.clientEmail}`); + } + } catch (e: any) { + console.error(`Failed to process client ${client.domain}:`, e.response?.data || e.message); + } + } + + console.log("Done."); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/apps/analytics-mailer/tsconfig.json b/apps/analytics-mailer/tsconfig.json new file mode 100644 index 0000000..e6b6d25 --- /dev/null +++ b/apps/analytics-mailer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@mintel/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*"] +} diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index eff945e..48328e4 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -25,3 +25,4 @@ export * from "./templates/ConfirmationMessage"; export * from "./templates/FollowUpTemplate"; export * from "./templates/ProjectEstimateTemplate"; export * from "./templates/SiteAuditTemplate"; +export * from "./templates/MonthlyAnalyticsTemplate"; diff --git a/packages/mail/src/templates/MonthlyAnalyticsTemplate.tsx b/packages/mail/src/templates/MonthlyAnalyticsTemplate.tsx new file mode 100644 index 0000000..92153dc --- /dev/null +++ b/packages/mail/src/templates/MonthlyAnalyticsTemplate.tsx @@ -0,0 +1,205 @@ +import * as React from "react"; +import { Heading, Section, Text, Row, Column } from "@react-email/components"; +import { MintelLayout } from "../layouts/MintelLayout"; + +export interface MonthlyAnalyticsTemplateProps { + clientName: string; + domain: string; + month: string; + year: string; + totalVisitors: number; + totalPageviews: number; + topPages: { url: string; views: number }[]; + topReferrers: { url: string; visitors: number }[]; +} + +export const MonthlyAnalyticsTemplate = ({ + clientName, + domain, + month, + year, + totalVisitors, + totalPageviews, + topPages, + topReferrers, +}: MonthlyAnalyticsTemplateProps) => { + const preview = `Ihr Website-Report für ${month} ${year}: ${domain}`; + + return ( + + Monatsreport: {month} {year} + + Hallo {clientName},

+ hier ist der monatliche Performance-Report für Ihre Website ({domain}). + Die Daten zeigen, wie viele Menschen Ihre Seite besucht haben und welche Inhalte am beliebtesten waren. +
+ +
+ + + BESUCHER + {totalVisitors.toLocaleString('de-DE')} + + + SEITENAUFRUFE + {totalPageviews.toLocaleString('de-DE')} + + +
+ +
+ Top Seiten + {topPages.map((page, i) => ( + + +
+ + + {page.url} + + + {page.views.toLocaleString('de-DE')} Aufrufe + + + ))} +
+ +
+ Top Besucherquellen + {topReferrers.length > 0 ? topReferrers.map((ref, i) => ( + + +
+ + + {ref.url || "Direktaufruf"} + + + {ref.visitors.toLocaleString('de-DE')} Besucher + + + )) : ( + Keine externen Quellen verzeichnet. + )} +
+ + + Haben Sie Fragen zu diesen Zahlen oder möchten Sie die Reichweite Ihrer Seite weiter ausbauen? + Antworten Sie einfach auf diese E-Mail. + + + + Beste Grüße,
+ Marc Mintel
+ Digitaler Architekt +
+
+ ); +}; + +export default MonthlyAnalyticsTemplate; + +const h1 = { + fontSize: "28px", + fontWeight: "900", + margin: "0 0 24px", + color: "#ffffff", + letterSpacing: "-0.04em", +}; + +const h2 = { + fontSize: "14px", + fontWeight: "900", + textTransform: "uppercase" as const, + color: "#444444", + margin: "0 0 16px", + letterSpacing: "0.1em", +}; + +const intro = { + fontSize: "16px", + lineHeight: "24px", + color: "#cccccc", + margin: "0 0 32px", +}; + +const metricsContainer = { + backgroundColor: "#151515", + padding: "32px", + borderRadius: "8px", + marginBottom: "32px", + border: "1px solid #222222", + textAlign: "center" as const, +}; + +const metricColumn = { + width: "50%", +}; + +const metricLabel = { + fontSize: "12px", + fontWeight: "bold", + color: "#888888", + letterSpacing: "0.1em", + margin: "0 0 8px", +}; + +const metricValue = { + fontSize: "36px", + fontWeight: "900", + color: "#4CAF50", + margin: "0", + letterSpacing: "-0.02em", +}; + +const auditContainer = { + backgroundColor: "#151515", + padding: "32px", + borderRadius: "8px", + marginBottom: "32px", + border: "1px solid #222222", +}; + +const highlightRow = { + marginBottom: "12px", +}; + +const bulletCol = { + width: "24px", + verticalAlign: "top" as const, +}; + +const bullet = { + width: "6px", + height: "6px", + backgroundColor: "#4CAF50", + marginTop: "8px", +}; + +const highlightText = { + fontSize: "15px", + color: "#ffffff", + margin: "0", + lineHeight: "22px", + wordBreak: "break-all" as const, +}; + +const highlightNumber = { + fontSize: "15px", + color: "#888888", + margin: "0", + whiteSpace: "nowrap" as const, +}; + +const bodyText = { + fontSize: "16px", + lineHeight: "24px", + color: "#888888", + margin: "0 0 32px", +}; + +const footerText = { + fontSize: "14px", + color: "#666666", + lineHeight: "20px", +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 119a993..7ba25cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,43 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + apps/analytics-mailer: + dependencies: + '@mintel/mail': + specifier: workspace:* + version: link:../../packages/mail + axios: + specifier: ^1.7.9 + version: 1.13.5 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + form-data: + specifier: ^4.0.1 + version: 4.0.5 + mailgun.js: + specifier: ^10.3.0 + version: 10.4.0 + devDependencies: + '@mintel/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + '@mintel/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + '@types/node': + specifier: ^22.10.2 + version: 22.19.10 + tsup: + specifier: ^8.3.5 + version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + apps/sample-website: dependencies: '@mintel/next-observability': @@ -235,7 +272,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/content-engine: dependencies: @@ -537,7 +574,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/meme-generator: dependencies: @@ -752,7 +789,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) packages/outline-mcp: dependencies: @@ -928,7 +965,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/serpbear-mcp: dependencies: @@ -4234,6 +4271,9 @@ packages: bare-url@2.3.2: resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -6406,6 +6446,10 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + mailgun.js@10.4.0: + resolution: {integrity: sha512-YrdaZEAJwwjXGBTfZTNQ1LM7tmkdUaz2NpZEu7+zULcG4Wrlhd7cWSNZW0bxT3bP48k5N0mZWz8C2f9gc2+Geg==} + engines: {node: '>=18.0.0'} + map-stream@0.1.0: resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} @@ -8285,6 +8329,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + use-context-selector@2.0.0: resolution: {integrity: sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==} peerDependencies: @@ -12297,6 +12344,8 @@ snapshots: dependencies: bare-path: 3.0.0 + base-64@1.0.0: {} + base64-arraybuffer@1.0.2: {} base64-js@0.0.8: {} @@ -14780,6 +14829,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mailgun.js@10.4.0: + dependencies: + axios: 1.13.5 + base-64: 1.0.0 + url-join: 4.0.1 + transitivePeerDependencies: + - debug + map-stream@0.1.0: {} marked@14.0.0: {} @@ -16983,6 +17040,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@4.0.1: {} + use-context-selector@2.0.0(react@19.2.4)(scheduler@0.25.0): dependencies: react: 19.2.4 @@ -17206,7 +17265,7 @@ snapshots: - supports-color - terser - vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): + vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)) @@ -17244,7 +17303,7 @@ snapshots: - supports-color - terser - vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -17289,7 +17348,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4