diff --git a/apps/obsidian/AGENTS.md b/apps/obsidian/AGENTS.md index cc297c415..8002c1b2c 100644 --- a/apps/obsidian/AGENTS.md +++ b/apps/obsidian/AGENTS.md @@ -88,6 +88,8 @@ for (let leaf of app.workspace.getActiveLeavesOfType(MY_VIEW_TYPE)) { ### Mobile compatibility +- The plugin ships with `isDesktopOnly: false`, so it must load on mobile +- Node builtins and `electron` are deliberately not listed in the esbuild `external` array in `scripts/compile.ts`. Adding them back would let a dependency's `require("fs")` survive into the bundle and throw at runtime on mobile; leaving them out makes the build fail instead. Replace the dependency rather than re-adding the external - Node.js and Electron APIs (`fs`, `crypto`, `os`) are unavailable on mobile - If the plugin targets mobile, use web API equivalents: `SubtleCrypto` instead of `crypto`, `navigator.clipboard` for clipboard access - Regex lookbehind assertions are not supported on some mobile — avoid them if possible diff --git a/apps/obsidian/manifest.json b/apps/obsidian/manifest.json index 142ec82d9..9d7e4c067 100644 --- a/apps/obsidian/manifest.json +++ b/apps/obsidian/manifest.json @@ -6,5 +6,5 @@ "description": "Add semantic structure to your notes with the Discourse Graph protocol.", "author": "Discourse Graphs", "authorUrl": "https://discoursegraphs.com", - "isDesktopOnly": true + "isDesktopOnly": false } diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index d7b167bc9..9dd700a9a 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,7 +10,9 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts", - "check-types": "tsc --noEmit --skipLibCheck" + "check-types": "tsc --noEmit --skipLibCheck", + "test": "pnpm test:unit", + "test:unit": "vitest run --config vitest.config.mts" }, "keywords": [], "author": "", @@ -19,12 +21,10 @@ "@octokit/core": "^6.1.2", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", - "@types/mime-types": "3.0.1", "@types/node": "catalog:", "@types/react": "catalog:obsidian", "@types/react-dom": "catalog:obsidian", "autoprefixer": "^10.4.21", - "builtin-modules": "3.3.0", "dotenv": "^16.4.5", "esbuild": "0.17.3", "eslint": "catalog:", @@ -35,7 +35,8 @@ "tsx": "^4.19.2", "typescript": "5.5.4", "uuidv7": "1.1.0", - "zod": "^3.24.1" + "zod": "^3.24.1", + "vitest": "catalog:" }, "dependencies": { "@codemirror/view": "^6.38.8", @@ -43,8 +44,6 @@ "@repo/utils": "workspace:*", "@supabase/supabase-js": "catalog:", "date-fns": "^4.1.0", - "gray-matter": "^4.0.3", - "mime-types": "^3.0.1", "nanoid": "^4.0.2", "react": "catalog:obsidian", "react-dom": "catalog:obsidian", diff --git a/apps/obsidian/scripts/compile.ts b/apps/obsidian/scripts/compile.ts index 84326b12d..d81cc9c93 100644 --- a/apps/obsidian/scripts/compile.ts +++ b/apps/obsidian/scripts/compile.ts @@ -2,7 +2,6 @@ import esbuild from "esbuild"; import fs from "fs"; import path from "path"; import { z } from "zod"; -import builtins from "builtin-modules"; import dotenv from "dotenv"; import postcss from "postcss"; import tailwindcss from "tailwindcss"; @@ -43,9 +42,12 @@ export const args = { format: "cjs", root: ".", mirror: process.env.OBSIDIAN_PLUGIN_PATH, + // Node builtins and "electron" are deliberately NOT external. Marking them + // external lets a dependency's `require("fs")` survive into the bundle, which + // throws at runtime on Obsidian mobile. Leaving them out makes esbuild fail + // the build instead, so a mobile-breaking dependency cannot land unnoticed. external: [ "obsidian", - "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", @@ -58,7 +60,6 @@ export const args = { "@lezer/highlight", "@lezer/lr", "tslib=window.TSLib", - ...builtins, ], } as CliOpts; @@ -115,6 +116,7 @@ export const compile = ({ outdir, bundle: true, format, + platform: "browser", sourcemap: isProd ? undefined : "inline", minify: isProd, entryNames: out, diff --git a/apps/obsidian/scripts/publish.ts b/apps/obsidian/scripts/publish.ts index 7f5dae16a..923ee5e44 100644 --- a/apps/obsidian/scripts/publish.ts +++ b/apps/obsidian/scripts/publish.ts @@ -375,6 +375,55 @@ const updateManifest = (tempDir: string, version: string): void => { fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); log(`Updated manifest version to ${version}`); + log(` isDesktopOnly: ${manifest.isDesktopOnly}`); +}; + +// Everything published is read from the current working directory, so running +// this from the wrong checkout silently ships that checkout's manifest. Printing +// the branch alongside the flags that change who can install the plugin makes +// that mistake visible before anything is pushed. +const logReleaseProvenance = async (obsidianDir: string): Promise => { + const manifestPath = path.join(obsidianDir, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + throw new Error(`manifest.json not found in ${obsidianDir}`); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + + let branch = "unknown"; + try { + const { stdout } = await execPromise("git rev-parse --abbrev-ref HEAD", { + cwd: obsidianDir, + }); + branch = stdout.trim(); + } catch { + // A detached HEAD or a missing git dir should not block a publish + } + + log("Publishing from:"); + log(` directory: ${obsidianDir}`); + log(` branch: ${branch}`); + log(` isDesktopOnly: ${manifest.isDesktopOnly}`); + log(` minAppVersion: ${manifest.minAppVersion}`); +}; + +// The mirror-repo copy takes manifest.json from dist/ while the release assets +// take it from source, so a stale dist/ would publish two different manifests. +const assertBuiltManifestMatchesSource = (obsidianDir: string): void => { + const sourcePath = path.join(obsidianDir, "manifest.json"); + const builtPath = path.join(obsidianDir, "dist", "manifest.json"); + if (!fs.existsSync(builtPath)) { + throw new Error("dist/manifest.json not found — build the plugin first"); + } + + const source = fs.readFileSync(sourcePath, "utf8").trim(); + const built = fs.readFileSync(builtPath, "utf8").trim(); + if (source !== built) { + throw new Error( + "manifest.json and dist/manifest.json disagree. The build is stale, so " + + "the mirrored repo and the release assets would ship different " + + "manifests. Rebuild before publishing.", + ); + } }; const copyBuildFiles = (buildDir: string, tempDir: string): void => { @@ -739,6 +788,8 @@ const publish = async (config: PublishConfig): Promise => { log(`Publishing Obsidian plugin v${version} (${releaseType} release)`); await buildPlugin(obsidianDir); + assertBuiltManifestMatchesSource(obsidianDir); + await logReleaseProvenance(obsidianDir); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 7afd2759d..1e461c968 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -516,8 +516,8 @@ const NodeSearch = ({ sortKey={sortKey} /> -
-
+
+
{candidateState.status === "loading" && (
Loading discourse nodes…
)} @@ -566,12 +566,14 @@ export class NodeSearchModal extends Modal { onOpen() { const { contentEl, modalEl } = this; // The default modal is too narrow for a result list beside a preview pane. - // Responsive layout is an explicit non-goal, so this is a desktop-only size. + // Below `sm` the two panes stack instead, so the width is only claimed once + // there is room for the side-by-side layout. modalEl.addClasses([ "dg-node-search-modal", "h-[600px]", "max-h-[80vh]", - "w-[900px]", + "w-full", + "sm:w-[900px]", "max-w-[90vw]", ]); contentEl.addClasses(["flex", "h-full", "flex-col", "overflow-hidden"]); diff --git a/apps/obsidian/src/utils/__tests__/mimeType.test.ts b/apps/obsidian/src/utils/__tests__/mimeType.test.ts new file mode 100644 index 000000000..a5164a1eb --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/mimeType.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_MIME_TYPE, getMimeTypeForPath } from "~/utils/mimeType"; + +describe("getMimeTypeForPath", () => { + it("resolves common attachment types", () => { + expect(getMimeTypeForPath("assets/diagram.png")).toBe("image/png"); + expect(getMimeTypeForPath("notes/paper.pdf")).toBe("application/pdf"); + expect(getMimeTypeForPath("clip.mp4")).toBe("video/mp4"); + }); + + it("is case insensitive", () => { + expect(getMimeTypeForPath("Photo.JPG")).toBe("image/jpeg"); + }); + + it("prefixes text formats with text/ so callers can skip them", () => { + expect(getMimeTypeForPath("note.md").startsWith("text/")).toBe(true); + expect(getMimeTypeForPath("data.csv").startsWith("text/")).toBe(true); + }); + + it("uses the last extension of a multi-dot name", () => { + expect(getMimeTypeForPath("archive.tar.png")).toBe("image/png"); + }); + + it("falls back for unknown and extensionless paths", () => { + expect(getMimeTypeForPath("notes/README")).toBe(DEFAULT_MIME_TYPE); + expect(getMimeTypeForPath("thing.unknownext")).toBe(DEFAULT_MIME_TYPE); + }); + + it("does not treat a dotfile as an extension", () => { + expect(getMimeTypeForPath(".gitignore")).toBe(DEFAULT_MIME_TYPE); + }); + + it("ignores dots in parent directories", () => { + expect(getMimeTypeForPath("my.folder/file")).toBe(DEFAULT_MIME_TYPE); + }); +}); diff --git a/apps/obsidian/src/utils/__tests__/splitFrontmatter.test.ts b/apps/obsidian/src/utils/__tests__/splitFrontmatter.test.ts new file mode 100644 index 000000000..b6bf8edd5 --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/splitFrontmatter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { splitFrontmatter } from "~/utils/splitFrontmatter"; + +describe("splitFrontmatter", () => { + it("splits a standard block", () => { + expect(splitFrontmatter("---\na: 1\nb: two\n---\nbody here")).toEqual({ + yaml: "a: 1\nb: two", + body: "body here", + }); + }); + + it("handles CRLF line endings", () => { + expect(splitFrontmatter("---\r\na: 1\r\n---\r\nbody")).toEqual({ + yaml: "a: 1", + body: "body", + }); + }); + + it("treats an empty block as present but empty", () => { + expect(splitFrontmatter("---\n---\nbody")).toEqual({ + yaml: "", + body: "body", + }); + }); + + it("returns no frontmatter when the file does not start with a fence", () => { + const content = "intro text\n---\na: 1\n---\nbody"; + expect(splitFrontmatter(content)).toEqual({ yaml: null, body: content }); + }); + + it("leaves a horizontal rule in the body alone", () => { + expect(splitFrontmatter("---\na: 1\n---\nbody\n---\nafter rule")).toEqual({ + yaml: "a: 1", + body: "body\n---\nafter rule", + }); + }); + + it("handles a file that is only frontmatter", () => { + expect(splitFrontmatter("---\na: 1\n---")).toEqual({ + yaml: "a: 1", + body: "", + }); + }); + + it("returns no frontmatter for content with no fence at all", () => { + expect(splitFrontmatter("just a note")).toEqual({ + yaml: null, + body: "just a note", + }); + }); + + it("keeps multi-line YAML values intact", () => { + expect(splitFrontmatter("---\ntags:\n - a\n - b\n---\nbody")).toEqual({ + yaml: "tags:\n - a\n - b", + body: "body", + }); + }); +}); diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index bde0f108d..d7ce5da7a 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -1,12 +1,12 @@ import type { Json } from "@repo/database/dbTypes"; -import matter from "gray-matter"; -import { App, Notice, TFile } from "obsidian"; +import { App, Notice, TFile, parseYaml } from "obsidian"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import { listGroupSharedNodes } from "@repo/database/lib/sharedNodes"; import type DiscourseGraphPlugin from "~/index"; import { getLoggedInClient, getSupabaseContext } from "./supabaseContext"; import type { DiscourseNode, ImportableNode } from "~/types"; import { QueryEngine } from "~/services/QueryEngine"; +import { splitFrontmatter } from "~/utils/splitFrontmatter"; import { getImportedNodesInfo, getLocalNodeKeyToEndpointId, @@ -725,13 +725,18 @@ const updateMarkdownAssetLinks = ({ }, ); - // Match markdown links (non-image): [text](path) — internal paths resolved like wikilinks, href kept URL-encoded - const markdownLinkRegex = /(? { - if (!linkPath) return match; - linkPath = linkPath + (match, ...groups: string[]) => { + const [imagePrefix, linkText, rawLinkPath] = groups; + // An `!` prefix makes this an image embed, handled by the next pass + if (imagePrefix) return match; + if (!rawLinkPath) return match; + const linkPath = rawLinkPath .split("/") .map((segment) => { try { @@ -1028,14 +1033,19 @@ type ParsedFrontmatter = { [key: string]: unknown; }; +// Unparseable frontmatter is treated as absent rather than thrown, matching how +// Obsidian itself tolerates bad YAML. `gray-matter` threw here instead. const parseFrontmatter = ( content: string, ): { frontmatter: ParsedFrontmatter; body: string } => { - const { data, content: body } = matter(content); - return { - frontmatter: (data ?? {}) as ParsedFrontmatter, - body: body ?? "", - }; + const { yaml, body } = splitFrontmatter(content); + if (yaml === null) return { frontmatter: {}, body }; + try { + const data = parseYaml(yaml) as ParsedFrontmatter | null; + return { frontmatter: data ?? {}, body }; + } catch { + return { frontmatter: {}, body: content }; + } }; /** diff --git a/apps/obsidian/src/utils/mimeType.ts b/apps/obsidian/src/utils/mimeType.ts new file mode 100644 index 000000000..a016915ee --- /dev/null +++ b/apps/obsidian/src/utils/mimeType.ts @@ -0,0 +1,59 @@ +// Replaces the `mime-types` package, which requires Node's `path` module and so +// cannot load on Obsidian mobile. Only covers extensions Obsidian accepts as +// vault attachments; anything else falls back to a generic binary type. +const MIME_TYPES_BY_EXTENSION: Record = { + // Images + avif: "image/avif", + bmp: "image/bmp", + gif: "image/gif", + ico: "image/vnd.microsoft.icon", + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + tif: "image/tiff", + tiff: "image/tiff", + webp: "image/webp", + + // Audio + aac: "audio/aac", + flac: "audio/flac", + m4a: "audio/mp4", + mp3: "audio/mpeg", + oga: "audio/ogg", + ogg: "audio/ogg", + wav: "audio/wav", + webm: "video/webm", + + // Video + mkv: "video/x-matroska", + mov: "video/quicktime", + mp4: "video/mp4", + ogv: "video/ogg", + + // Documents + pdf: "application/pdf", + + // Text — callers rely on the `text/` prefix to skip these + canvas: "application/json", + css: "text/css", + csv: "text/csv", + html: "text/html", + js: "text/javascript", + json: "application/json", + md: "text/markdown", + txt: "text/plain", + xml: "text/xml", + yaml: "text/yaml", + yml: "text/yaml", +}; + +export const DEFAULT_MIME_TYPE = "application/octet-stream"; + +export const getMimeTypeForPath = (filePath: string): string => { + const fileName = filePath.split("/").pop() ?? ""; + const lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex <= 0) return DEFAULT_MIME_TYPE; + const extension = fileName.slice(lastDotIndex + 1).toLowerCase(); + return MIME_TYPES_BY_EXTENSION[extension] ?? DEFAULT_MIME_TYPE; +}; diff --git a/apps/obsidian/src/utils/nativeJsonFileDialogs.ts b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts deleted file mode 100644 index 9a784c61e..000000000 --- a/apps/obsidian/src/utils/nativeJsonFileDialogs.ts +++ /dev/null @@ -1,121 +0,0 @@ -type SaveDialogResult = { - canceled: boolean; - filePath?: string; -}; - -type OpenDialogResult = { - canceled: boolean; - filePaths: string[]; -}; - -type ElectronDialog = { - showSaveDialog: (options: { - title: string; - defaultPath: string; - filters: Array<{ name: string; extensions: string[] }>; - }) => Promise; - showOpenDialog: (options: { - title: string; - properties: string[]; - filters: Array<{ name: string; extensions: string[] }>; - }) => Promise; -}; - -type ElectronLike = { - dialog?: ElectronDialog; - remote?: { - dialog?: ElectronDialog; - }; -}; - -type FsPromisesLike = { - readFile: (path: string, encoding: string) => Promise; - writeFile: (path: string, data: string, encoding: string) => Promise; -}; - -type ElectronWindow = Window & { - require: (name: string) => unknown; -}; - -export class NativeFileDialogCancelledError extends Error { - constructor() { - super("File dialog cancelled"); - this.name = "NativeFileDialogCancelledError"; - } -} - -const getElectronWindow = (): ElectronWindow => { - if (typeof window === "undefined" || !("require" in window)) { - throw new Error( - "Schema export/import requires Obsidian desktop (Electron).", - ); - } - return window as ElectronWindow; -}; - -const getFsPromises = (electronWindow: ElectronWindow): FsPromisesLike => { - const fsPromises = electronWindow.require("fs/promises"); - if ( - typeof fsPromises !== "object" || - fsPromises === null || - !("readFile" in fsPromises) || - !("writeFile" in fsPromises) - ) { - throw new Error("Unable to access filesystem read/write APIs."); - } - return fsPromises as FsPromisesLike; -}; - -const getElectronDialog = (electronWindow: ElectronWindow): ElectronDialog => { - const electron = electronWindow.require("electron") as ElectronLike; - const dialog = electron.dialog ?? electron.remote?.dialog; - if (!dialog?.showSaveDialog || !dialog.showOpenDialog) { - throw new Error("Unable to access Electron file dialogs."); - } - return dialog; -}; - -export const saveJsonToUserLocation = async ({ - title, - fileName, - content, -}: { - title: string; - fileName: string; - content: string; -}): Promise => { - const electronWindow = getElectronWindow(); - const dialog = getElectronDialog(electronWindow); - const result = await dialog.showSaveDialog({ - title, - defaultPath: fileName, - filters: [{ name: "JSON files", extensions: ["json"] }], - }); - if (result.canceled || !result.filePath) { - throw new NativeFileDialogCancelledError(); - } - const fsPromises = getFsPromises(electronWindow); - await fsPromises.writeFile(result.filePath, content, "utf8"); - return result.filePath; -}; - -export const openJsonFromUserLocation = async ({ - title, -}: { - title: string; -}): Promise<{ content: string; sourcePath: string }> => { - const electronWindow = getElectronWindow(); - const dialog = getElectronDialog(electronWindow); - const result = await dialog.showOpenDialog({ - title, - properties: ["openFile"], - filters: [{ name: "JSON files", extensions: ["json"] }], - }); - if (result.canceled || !result.filePaths[0]) { - throw new NativeFileDialogCancelledError(); - } - const fsPromises = getFsPromises(electronWindow); - const sourcePath = result.filePaths[0]; - const content = await fsPromises.readFile(sourcePath, "utf8"); - return { content, sourcePath }; -}; diff --git a/apps/obsidian/src/utils/splitFrontmatter.ts b/apps/obsidian/src/utils/splitFrontmatter.ts new file mode 100644 index 000000000..f47487079 --- /dev/null +++ b/apps/obsidian/src/utils/splitFrontmatter.ts @@ -0,0 +1,20 @@ +// Replaces the frontmatter splitting `gray-matter` did. That package requires +// Node's `fs` at module load, so it cannot run on Obsidian mobile. Kept free of +// any `obsidian` import so it stays a pure, directly testable function; callers +// pass the returned YAML to Obsidian's own `parseYaml`. +const FRONTMATTER_BLOCK = + /^---[ \t]*\r?\n([\s\S]*?)\r?\n?^---[ \t]*(?:\r?\n|$)/m; + +export type SplitFrontmatter = { + /** Raw YAML source, or null when the content has no frontmatter block. */ + yaml: string | null; + body: string; +}; + +export const splitFrontmatter = (content: string): SplitFrontmatter => { + const match = FRONTMATTER_BLOCK.exec(content); + // The block only counts as frontmatter at the very start of the file; + // a `---` further down is a horizontal rule. + if (!match || match.index !== 0) return { yaml: null, body: content }; + return { yaml: match[1] ?? "", body: content.slice(match[0].length) }; +}; diff --git a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts index 8d5de8f0f..3ff623c06 100644 --- a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts +++ b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts @@ -1,6 +1,6 @@ import { Notice, TFile } from "obsidian"; import { addFile } from "@repo/database/lib/files"; -import mime from "mime-types"; +import { getMimeTypeForPath } from "~/utils/mimeType"; import { ensureNodeInstanceId } from "~/utils/nodeInstanceId"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import type { Json } from "@repo/database/dbTypes"; @@ -715,7 +715,7 @@ export const syncPublishedNodeAssets = async ({ ) as Record; for (const attachment of attachments) { - const mimetype = mime.lookup(attachment.path) || "application/octet-stream"; + const mimetype = getMimeTypeForPath(attachment.path); if (mimetype.startsWith("text/")) continue; // Do not use standard upload for large files if (attachment.stat.size >= 6 * 1024 * 1024) { diff --git a/apps/obsidian/vitest.config.mts b/apps/obsidian/vitest.config.mts new file mode 100644 index 000000000..8df0cd40a --- /dev/null +++ b/apps/obsidian/vitest.config.mts @@ -0,0 +1,17 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + }, + resolve: { + alias: { + "~": path.resolve(dirname, "src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d2a748ce..d1f8d0677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,12 +136,6 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 - mime-types: - specifier: ^3.0.1 - version: 3.0.2 nanoid: specifier: ^4.0.2 version: 4.0.2 @@ -167,9 +161,6 @@ importers: '@repo/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config - '@types/mime-types': - specifier: 3.0.1 - version: 3.0.1 '@types/node': specifier: 'catalog:' version: 22.20.0 @@ -182,9 +173,6 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) - builtin-modules: - specifier: 3.3.0 - version: 3.3.0 dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -215,6 +203,9 @@ importers: uuidv7: specifier: 1.1.0 version: 1.1.0 + vitest: + specifier: 'catalog:' + version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) zod: specifier: ^3.24.1 version: 3.25.76 @@ -5056,9 +5047,6 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - '@types/mime-types@3.0.1': - resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==} - '@types/minimatch@6.0.0': resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. @@ -5778,10 +5766,6 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - builtin-modules@3.3.0: - resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} - engines: {node: '>=6'} - bytes@3.1.0: resolution: {integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==} engines: {node: '>= 0.8'} @@ -16592,8 +16576,6 @@ snapshots: '@types/mdx@2.0.13': {} - '@types/mime-types@3.0.1': {} - '@types/minimatch@6.0.0': dependencies: minimatch: 10.2.6 @@ -17120,6 +17102,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.11.1(@types/node@22.20.0)(typescript@5.5.4) + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) + '@vitest/mocker@4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.6 @@ -17613,8 +17604,6 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - builtin-modules@3.3.0: {} - bytes@3.1.0: {} bytes@3.1.2: {} @@ -24335,6 +24324,36 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 3.2.0 + '@opentelemetry/api': 1.9.0 + '@types/node': 22.20.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - msw + vitest@4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.6