diff --git a/apps/raycast-extension/README.md b/apps/raycast-extension/README.md new file mode 100644 index 00000000000..031c0dd3dd4 --- /dev/null +++ b/apps/raycast-extension/README.md @@ -0,0 +1,9 @@ +# Cap.so Raycast Extension + +Control [Cap](https://cap.so) screen recordings and open your web dashboard directly from Raycast. + +## Commands + +- **Start Recording** (`start-recording`): Starts a Cap screen recording using your configured default recording mode (`instant` or `studio`), target screen, camera, microphone, and system audio settings via `cap-desktop://action?value=...`. +- **Stop Recording** (`stop-recording`): Stops the active Cap recording via `cap-desktop://action?value=%22stop_recording%22` and preserves/copies the shareable Cap video link (`https://cap.so/s/...`) to your clipboard by default. +- **Open Web Dashboard** (`open-dashboard`): Opens `https://cap.so/dashboard` (or your custom self-hosted Cap Web URL) in your default browser. diff --git a/apps/raycast-extension/assets/extension-icon.png b/apps/raycast-extension/assets/extension-icon.png new file mode 100644 index 00000000000..b7657009ef3 Binary files /dev/null and b/apps/raycast-extension/assets/extension-icon.png differ diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json new file mode 100644 index 00000000000..42b7b2cf2c6 --- /dev/null +++ b/apps/raycast-extension/package.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "@cap/raycast-extension", + "title": "Cap Screen Recorder", + "description": "Start and stop Cap recordings and open your Cap web dashboard directly from Raycast.", + "icon": "extension-icon.png", + "author": "capsoftware", + "categories": ["Productivity", "Media", "Developer Tools"], + "license": "MIT", + "preferences": [ + { + "name": "defaultMode", + "title": "Default Recording Mode", + "description": "Recording mode used when starting a recording from Raycast.", + "type": "dropdown", + "required": false, + "default": "instant", + "data": [ + { + "title": "Instant Mode", + "value": "instant" + }, + { + "title": "Studio Mode", + "value": "studio" + } + ] + }, + { + "name": "defaultScreen", + "title": "Default Screen Name", + "description": "Exact display name of the screen to capture (leave empty to detect the active macOS display).", + "type": "textfield", + "required": false, + "default": "" + }, + { + "name": "cameraDeviceId", + "title": "Camera Device ID", + "description": "Optional camera device identifier to enable during recording.", + "type": "textfield", + "required": false, + "default": "" + }, + { + "name": "micLabel", + "title": "Microphone Name", + "description": "Optional microphone display name to capture audio from.", + "type": "textfield", + "required": false, + "default": "" + }, + { + "name": "captureSystemAudio", + "title": "Capture System Audio", + "description": "Include system audio when starting a recording.", + "type": "checkbox", + "label": "Capture system audio by default", + "required": false, + "default": true + }, + { + "name": "copyUrlOnStop", + "title": "Copy Video URL on Stop", + "description": "Copy the shareable Cap video URL to the clipboard when stopping a recording.", + "type": "checkbox", + "label": "Copy shareable URL automatically", + "required": false, + "default": true + }, + { + "name": "dashboardUrl", + "title": "Cap Dashboard URL", + "description": "URL of your Cap web dashboard (supports Cap Cloud and self-hosted instances).", + "type": "textfield", + "required": false, + "default": "https://cap.so/dashboard" + } + ], + "commands": [ + { + "name": "start-recording", + "title": "Start Recording", + "description": "Start a Cap recording using your configured default settings.", + "mode": "no-view" + }, + { + "name": "stop-recording", + "title": "Stop Recording", + "description": "Stop the active Cap recording and copy the video URL to your clipboard.", + "mode": "no-view" + }, + { + "name": "open-dashboard", + "title": "Open Web Dashboard", + "description": "Open the Cap web dashboard in your default browser.", + "mode": "no-view" + } + ], + "dependencies": { + "@raycast/api": "^1.83.1" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "typescript": "^5.8.3", + "vitest": "^3.2.0" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop", + "lint": "ray lint", + "fix-lint": "ray lint --fix", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + } +} diff --git a/apps/raycast-extension/src/deeplink.test.ts b/apps/raycast-extension/src/deeplink.test.ts new file mode 100644 index 00000000000..ec568087514 --- /dev/null +++ b/apps/raycast-extension/src/deeplink.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + type StartRecordingPreferences, + buildStartRecordingDeepLink, + buildStartRecordingPayload, + buildStopRecordingDeepLink, + extractShareableCapUrl, + parseUnambiguousDisplayFromSystemProfiler, + resolveDashboardUrl, +} from "./deeplink"; + +describe("Cap Raycast deep-link builders", () => { + it("constructs start_recording payloads preserving mode, screen, devices, and audio", () => { + const prefs: StartRecordingPreferences = { + defaultMode: "studio", + defaultScreen: "Built-in Retina Display", + cameraDeviceId: "cam-01", + micLabel: "Studio Mic", + captureSystemAudio: false, + }; + const payload = buildStartRecordingPayload(prefs); + + expect(payload).toEqual({ + start_recording: { + capture_mode: { screen: "Built-in Retina Display" }, + camera: { DeviceID: "cam-01" }, + mic_label: "Studio Mic", + capture_system_audio: false, + mode: "studio", + }, + }); + + const url = new URL(buildStartRecordingDeepLink(prefs)); + expect(url.protocol).toBe("cap-desktop:"); + expect(url.host).toBe("action"); + const decoded = JSON.parse(url.searchParams.get("value") || "{}"); + expect(decoded).toEqual(payload); + }); + + it("falls back to detected macOS display name or throws when none is available", () => { + const payload = buildStartRecordingPayload({}, () => "Studio Display"); + expect(payload.start_recording.capture_mode).toEqual({ + screen: "Studio Display", + }); + expect(() => buildStartRecordingPayload({}, () => null)).toThrow( + /No target screen name/, + ); + + const singleDisplayJson = JSON.stringify({ + SPDisplaysDataType: [ + { spdisplays_ndrvs: [{ _name: "Built-in Retina Display" }] }, + ], + }); + expect(parseUnambiguousDisplayFromSystemProfiler(singleDisplayJson)).toBe( + "Built-in Retina Display", + ); + + const multiDisplayJson = JSON.stringify({ + SPDisplaysDataType: [ + { + spdisplays_ndrvs: [ + { _name: "Built-in Retina Display" }, + { _name: "Studio Display" }, + ], + }, + ], + }); + expect( + parseUnambiguousDisplayFromSystemProfiler(multiDisplayJson), + ).toBeNull(); + }); + + it("encodes stop_recording and resolves dashboard and share URLs", () => { + const stopUrl = new URL(buildStopRecordingDeepLink()); + expect(stopUrl.searchParams.get("value")).toBe('"stop_recording"'); + + expect(resolveDashboardUrl()).toBe("https://cap.so/dashboard"); + expect(resolveDashboardUrl("https://cap.internal.local/dashboard/")).toBe( + "https://cap.internal.local/dashboard", + ); + + expect(extractShareableCapUrl("https://cap.so/s/abc123xyz")).toBe( + "https://cap.so/s/abc123xyz", + ); + expect(extractShareableCapUrl("not a link")).toBeNull(); + }); +}); diff --git a/apps/raycast-extension/src/deeplink.ts b/apps/raycast-extension/src/deeplink.ts new file mode 100644 index 00000000000..23f8aa742f2 --- /dev/null +++ b/apps/raycast-extension/src/deeplink.ts @@ -0,0 +1,182 @@ +import { execFileSync } from "node:child_process"; + +export type RecordingMode = "instant" | "studio"; + +export interface StartRecordingPreferences { + defaultMode?: RecordingMode; + defaultScreen?: string; + cameraDeviceId?: string; + micLabel?: string; + captureSystemAudio?: boolean; +} + +export interface StopRecordingPreferences { + copyUrlOnStop?: boolean; + dashboardUrl?: string; +} + +export interface DeepLinkStartPayload { + start_recording: { + capture_mode: { screen: string } | { window: string }; + camera: { DeviceID: string } | null; + mic_label: string | null; + capture_system_audio: boolean; + mode: RecordingMode; + }; +} + +const CAP_ACTION_BASE_URL = "cap-desktop://action"; +const DEFAULT_DASHBOARD_URL = "https://cap.so/dashboard"; +const ACTIVE_SCREEN_JXA_SCRIPT = [ + 'ObjC.import("AppKit");', + "const mouse = $.NSEvent.mouseLocation;", + "const screens = $.NSScreen.screens;", + "const count = screens.count;", + "let chosen = null;", + "for (let i = 0; i < count; i++) {", + " const s = screens.objectAtIndex(i);", + " const f = s.frame;", + " if (mouse.x >= f.origin.x && mouse.x <= f.origin.x + f.size.width && mouse.y >= f.origin.y && mouse.y <= f.origin.y + f.size.height) {", + " chosen = s;", + " break;", + " }", + "}", + "if (!chosen) { chosen = $.NSScreen.mainScreen; }", + 'chosen ? ObjC.unwrap(chosen.localizedName) : "";', +].join(" "); + +export function parseUnambiguousDisplayFromSystemProfiler( + rawJson: string, +): string | null { + try { + const parsed = JSON.parse(rawJson) as { + SPDisplaysDataType?: Array<{ + spdisplays_ndrvs?: Array<{ _name?: string }>; + }>; + }; + const names: string[] = []; + for (const gpu of parsed.SPDisplaysDataType ?? []) { + for (const display of gpu.spdisplays_ndrvs ?? []) { + const name = display._name?.trim(); + if (name) { + names.push(name); + } + } + } + return names.length === 1 ? names[0] : null; + } catch { + return null; + } +} + +function queryActiveScreenViaAppKit(): string | null { + try { + const activeName = execFileSync( + "osascript", + ["-l", "JavaScript", "-e", ACTIVE_SCREEN_JXA_SCRIPT], + { encoding: "utf8", timeout: 2000 }, + ).trim(); + return activeName || null; + } catch { + return null; + } +} + +export function detectActiveMacDisplayName(): string | null { + const activeScreen = queryActiveScreenViaAppKit(); + if (activeScreen) { + return activeScreen; + } + + try { + const output = execFileSync( + "system_profiler", + ["SPDisplaysDataType", "-json"], + { encoding: "utf8", timeout: 3000 }, + ); + return parseUnambiguousDisplayFromSystemProfiler(output); + } catch { + return null; + } +} + +export function resolveTargetScreenName( + prefs: StartRecordingPreferences, + detectDisplay: () => string | null = detectActiveMacDisplayName, +): string { + const configured = prefs.defaultScreen?.trim(); + if (configured) { + return configured; + } + const detected = detectDisplay()?.trim(); + if (detected) { + return detected; + } + throw new Error( + "No target screen name configured or detected. Set Default Screen Name in Raycast preferences.", + ); +} + +export function buildStartRecordingPayload( + prefs: StartRecordingPreferences, + detectDisplay: () => string | null = detectActiveMacDisplayName, +): DeepLinkStartPayload { + const screenName = resolveTargetScreenName(prefs, detectDisplay); + const cameraId = prefs.cameraDeviceId?.trim(); + const micName = prefs.micLabel?.trim(); + + return { + start_recording: { + capture_mode: { screen: screenName }, + camera: cameraId ? { DeviceID: cameraId } : null, + mic_label: micName || null, + capture_system_audio: prefs.captureSystemAudio ?? true, + mode: prefs.defaultMode || "instant", + }, + }; +} + +export function encodeCapActionUrl(actionValue: unknown): string { + const serialized = JSON.stringify(actionValue); + return `${CAP_ACTION_BASE_URL}?value=${encodeURIComponent(serialized)}`; +} + +export function buildStartRecordingDeepLink( + prefs: StartRecordingPreferences = {}, + detectDisplay: () => string | null = detectActiveMacDisplayName, +): string { + return encodeCapActionUrl(buildStartRecordingPayload(prefs, detectDisplay)); +} + +export function buildStopRecordingDeepLink(): string { + return encodeCapActionUrl("stop_recording"); +} + +export function resolveDashboardUrl(customUrl?: string): string { + const trimmed = customUrl?.trim(); + if (!trimmed) { + return DEFAULT_DASHBOARD_URL; + } + return trimmed.replace(/\/+$/, ""); +} + +export function extractShareableCapUrl( + clipboardText: string | undefined, + dashboardBaseUrl?: string, +): string | null { + if (!clipboardText) { + return null; + } + const trimmed = clipboardText.trim(); + const customOrigin = resolveDashboardUrl(dashboardBaseUrl).replace( + /\/dashboard$/, + "", + ); + const isCapLink = + trimmed.startsWith("https://cap.so/s/") || + trimmed.startsWith("https://cap.link/") || + (customOrigin.startsWith("http") && + trimmed.startsWith(`${customOrigin}/s/`)); + + return isCapLink ? trimmed : null; +} diff --git a/apps/raycast-extension/src/open-dashboard.ts b/apps/raycast-extension/src/open-dashboard.ts new file mode 100644 index 00000000000..e5b7e23fd52 --- /dev/null +++ b/apps/raycast-extension/src/open-dashboard.ts @@ -0,0 +1,16 @@ +import { type StopRecordingPreferences, resolveDashboardUrl } from "./deeplink"; +import { + closeMainWindow, + getPreferenceValues, + open, + showHUD, +} from "./raycast-bridge"; + +export default async function Command(): Promise { + const prefs = await getPreferenceValues(); + const targetUrl = resolveDashboardUrl(prefs.dashboardUrl); + + await closeMainWindow(); + await open(targetUrl); + await showHUD("Opened Cap Dashboard"); +} diff --git a/apps/raycast-extension/src/raycast-bridge.ts b/apps/raycast-extension/src/raycast-bridge.ts new file mode 100644 index 00000000000..8e39342e67a --- /dev/null +++ b/apps/raycast-extension/src/raycast-bridge.ts @@ -0,0 +1,81 @@ +import { execFileSync } from "node:child_process"; + +interface RaycastRuntimeModule { + Clipboard: { + readText: () => Promise; + copy: (text: string) => Promise; + }; + closeMainWindow: () => Promise; + getPreferenceValues: () => T; + open: (target: string) => Promise; + showHUD: (title: string) => Promise; +} + +let cachedRuntime: RaycastRuntimeModule | null | undefined; + +async function loadRaycastRuntime(): Promise { + if (cachedRuntime !== undefined) { + return cachedRuntime; + } + try { + const moduleName = "@raycast/api"; + cachedRuntime = (await import(moduleName)) as RaycastRuntimeModule; + return cachedRuntime; + } catch { + cachedRuntime = null; + return null; + } +} + +export async function getPreferenceValues(): Promise { + const runtime = await loadRaycastRuntime(); + if (runtime) { + return runtime.getPreferenceValues(); + } + return {} as T; +} + +export async function closeMainWindow(): Promise { + const runtime = await loadRaycastRuntime(); + if (runtime) { + await runtime.closeMainWindow(); + } +} + +export async function open(target: string): Promise { + const runtime = await loadRaycastRuntime(); + if (runtime) { + await runtime.open(target); + return; + } + execFileSync("open", [target]); +} + +export async function showHUD(title: string): Promise { + const runtime = await loadRaycastRuntime(); + if (runtime) { + await runtime.showHUD(title); + } +} + +export const Clipboard = { + async readText(): Promise { + const runtime = await loadRaycastRuntime(); + if (runtime) { + return runtime.Clipboard.readText(); + } + try { + return execFileSync("pbpaste", { encoding: "utf8" }); + } catch { + return undefined; + } + }, + async copy(text: string): Promise { + const runtime = await loadRaycastRuntime(); + if (runtime) { + await runtime.Clipboard.copy(text); + return; + } + execFileSync("pbcopy", { input: text, encoding: "utf8" }); + }, +}; diff --git a/apps/raycast-extension/src/start-recording.ts b/apps/raycast-extension/src/start-recording.ts new file mode 100644 index 00000000000..44573894fe1 --- /dev/null +++ b/apps/raycast-extension/src/start-recording.ts @@ -0,0 +1,31 @@ +import { + type StartRecordingPreferences, + buildStartRecordingDeepLink, +} from "./deeplink"; +import { + Clipboard, + closeMainWindow, + getPreferenceValues, + open, + showHUD, +} from "./raycast-bridge"; + +export default async function Command(): Promise { + const prefs = await getPreferenceValues(); + try { + const deepLinkUrl = buildStartRecordingDeepLink(prefs); + await closeMainWindow(); + await open(deepLinkUrl); + await showHUD( + `Cap recording started (${prefs.defaultMode || "instant"} mode)`, + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to resolve target screen name"; + await showHUD(message); + } +} + +export { Clipboard }; diff --git a/apps/raycast-extension/src/stop-recording.ts b/apps/raycast-extension/src/stop-recording.ts new file mode 100644 index 00000000000..ab9f8d00502 --- /dev/null +++ b/apps/raycast-extension/src/stop-recording.ts @@ -0,0 +1,60 @@ +import { + type StopRecordingPreferences, + buildStopRecordingDeepLink, + extractShareableCapUrl, +} from "./deeplink"; +import { + Clipboard, + closeMainWindow, + getPreferenceValues, + open, + showHUD, +} from "./raycast-bridge"; + +const STOP_CLIPBOARD_POLL_ATTEMPTS = 6; +const STOP_CLIPBOARD_POLL_DELAY_MS = 250; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForNewShareUrl( + previousClipboard: string | undefined, + dashboardUrl: string | undefined, +): Promise { + for (let attempt = 0; attempt < STOP_CLIPBOARD_POLL_ATTEMPTS; attempt++) { + await sleep(STOP_CLIPBOARD_POLL_DELAY_MS); + const currentText = await Clipboard.readText(); + if (!currentText || currentText === previousClipboard) { + continue; + } + const shareUrl = extractShareableCapUrl(currentText, dashboardUrl); + if (shareUrl) { + return shareUrl; + } + } + return null; +} + +export default async function Command(): Promise { + const prefs = await getPreferenceValues(); + const previousClipboard = await Clipboard.readText(); + const deepLinkUrl = buildStopRecordingDeepLink(); + + await closeMainWindow(); + await open(deepLinkUrl); + + if (prefs.copyUrlOnStop ?? true) { + const shareUrl = await waitForNewShareUrl( + previousClipboard, + prefs.dashboardUrl, + ); + if (shareUrl) { + await Clipboard.copy(shareUrl); + await showHUD(`Cap recording stopped — copied ${shareUrl}`); + return; + } + } + + await showHUD("Cap recording stopped"); +} diff --git a/apps/raycast-extension/tsconfig.json b/apps/raycast-extension/tsconfig.json new file mode 100644 index 00000000000..fddac517480 --- /dev/null +++ b/apps/raycast-extension/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 20", + "include": ["src/**/*"], + "compilerOptions": { + "lib": ["ES2022"], + "module": "commonjs", + "target": "ES2022", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "resolveJsonModule": true, + "outDir": "dist" + } +} diff --git a/bun.lock b/bun.lock index 45323cb08d5..803c18f75ed 100644 --- a/bun.lock +++ b/bun.lock @@ -91,6 +91,17 @@ "@types/bun": "latest", }, }, + "apps/raycast-extension": { + "name": "@cap/raycast-extension", + "dependencies": { + "@raycast/api": "^1.83.1", + }, + "devDependencies": { + "@types/node": "^20.11.5", + "typescript": "^5.8.3", + "vitest": "^3.2.0", + }, + }, "apps/render-farm": { "name": "@cap/render-farm", "devDependencies": { @@ -825,6 +836,8 @@ "@cap/media-server": ["@cap/media-server@workspace:apps/media-server"], + "@cap/raycast-extension": ["@cap/raycast-extension@workspace:apps/raycast-extension"], + "@cap/recorder-core": ["@cap/recorder-core@workspace:packages/recorder-core"], "@cap/render-farm": ["@cap/render-farm@workspace:apps/render-farm"], @@ -1579,6 +1592,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], + "@raycast/api": ["@raycast/api@1.83.1", "", { "dependencies": { "@types/node": "^20.8.10", "@types/react": "^18.3.3", "react": "18.3.1" }, "peerDependencies": { "@types/node": "20.8.10", "@types/react": "18.3.3", "react-devtools": "5.2.0" }, "optionalPeers": ["@types/node", "@types/react", "react-devtools"], "bin": { "ray": "bin/ray" } }, "sha512-24VFghPKVaNHqaxJyUzdpUiKW8qAkJ9/X/q/jq6tHhfzsKiWFQ0a3cjfNyUmDMmJAEsW4dGrxm7WoU6fox+YRg=="], + "@react-email/body": ["@react-email/body@0.0.11", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg=="], "@react-email/button": ["@react-email/button@0.1.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fg4LtgTu5zXxaRSly9cuv6sHVF/hi1lElbRaIA8EPx5coWOBhCto6rCPfawcXpaN2oER7rNHUrcNBkI+lz5F9A=="], @@ -2107,6 +2122,8 @@ "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "20.19.43", "form-data": "4.0.6" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], @@ -4947,6 +4964,10 @@ "@radix-ui/react-use-escape-keydown/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.0.0", "", { "dependencies": { "@babel/runtime": "7.29.7" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg=="], + "@raycast/api/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + + "@raycast/api/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "@react-email/components/@react-email/render": ["@react-email/render@1.1.3", "", { "dependencies": { "html-to-text": "9.0.5", "prettier": "3.9.4", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TjjF1tdTmOqYEIWWg9wMx5q9JbQRbWmnG7owQbSGEHkNfc/c/vBu7hjfrki907lgQEAkYac9KPTyIjOKhvhJCg=="], "@react-email/components/@react-email/tailwind": ["@react-email/tailwind@1.0.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-BH00cZSeFfP9HiDASl+sPHi7Hh77W5nzDgdnxtsVr/m3uQD9g180UwxcE3PhOfx0vRdLzQUU8PtmvvDfbztKQg=="],