From c195ed79710baf9292cdf9a6179cbc9d7abc10b6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 3 Sep 2026 12:17:21 -0400 Subject: [PATCH 1/3] Make the Obsidian plugin loadable on mobile Removes every Node/Electron dependency from the bundle so the plugin can run on Obsidian mobile, and flips `isDesktopOnly` to false. The bundle previously emitted three bare Node requires (`fs` from gray-matter, `path` from mime-types, `buffer` from js-yaml via gray-matter), each of which throws at load time on mobile: - Replace gray-matter with a pure `splitFrontmatter` helper plus Obsidian's own `parseYaml`. - Replace mime-types with a local extension-to-MIME map. - Delete `nativeJsonFileDialogs.ts`, which wrapped Electron's native file dialogs. It had no callers and was absent from the bundle, so this is not a behavior change. - Rewrite the markdown-link regex to capture a leading `!` instead of using a lookbehind, which older mobile WebViews do not support. Node builtins and `electron` are also dropped from the esbuild `external` array. Marking them external is what allowed those requires to survive into the bundle in the first place; without it, esbuild fails the build instead of shipping something that breaks on device. The node search modal stacks its two panes below the `sm` breakpoint rather than claiming a fixed 900px width. Touch affordances are deliberately not addressed here: the hover-only node tag tooltip, wikilink drag-to-canvas, and the canvas right-click context menu still have no touch equivalent. Co-Authored-By: Claude Opus 5 --- apps/obsidian/AGENTS.md | 2 + apps/obsidian/manifest.json | 2 +- apps/obsidian/package.json | 11 +- apps/obsidian/scripts/compile.ts | 8 +- .../src/components/NodeSearchModal.tsx | 10 +- .../src/utils/__tests__/mimeType.test.ts | 36 ++++++ .../utils/__tests__/splitFrontmatter.test.ts | 58 +++++++++ apps/obsidian/src/utils/importNodes.ts | 28 ++-- apps/obsidian/src/utils/mimeType.ts | 59 +++++++++ .../src/utils/nativeJsonFileDialogs.ts | 121 ------------------ apps/obsidian/src/utils/splitFrontmatter.ts | 20 +++ .../src/utils/syncDgNodesToSupabase.ts | 4 +- apps/obsidian/vitest.config.mts | 17 +++ pnpm-lock.yaml | 65 ++++++---- 14 files changed, 271 insertions(+), 170 deletions(-) create mode 100644 apps/obsidian/src/utils/__tests__/mimeType.test.ts create mode 100644 apps/obsidian/src/utils/__tests__/splitFrontmatter.test.ts create mode 100644 apps/obsidian/src/utils/mimeType.ts delete mode 100644 apps/obsidian/src/utils/nativeJsonFileDialogs.ts create mode 100644 apps/obsidian/src/utils/splitFrontmatter.ts create mode 100644 apps/obsidian/vitest.config.mts 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/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..a447d1202 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,11 +725,14 @@ const updateMarkdownAssetLinks = ({ }, ); - // Match markdown links (non-image): [text](path) — internal paths resolved like wikilinks, href kept URL-encoded - const markdownLinkRegex = /(? { + (match, imagePrefix: string, linkText: string, linkPath: string) => { + if (imagePrefix) return match; if (!linkPath) return match; linkPath = linkPath .split("/") @@ -1028,14 +1031,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 From 83e173a1c3d573eb5f0f1df346b96a9db7849595 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 3 Sep 2026 12:23:13 -0400 Subject: [PATCH 2/3] Use a rest parameter in the markdown link replacer Capturing the leading `!` pushed the replace callback to four parameters, over the max-params limit. ESLint reports this as a warning, which passes locally but fails the reviewdog lint check on added lines. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/importNodes.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index a447d1202..d7ce5da7a 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -731,10 +731,12 @@ const updateMarkdownAssetLinks = ({ const markdownLinkRegex = /(!?)\[([^\]]*)\]\(([^)]+)\)/g; updatedContent = updatedContent.replace( markdownLinkRegex, - (match, imagePrefix: string, linkText: string, linkPath: string) => { + (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 (!linkPath) return match; - linkPath = linkPath + if (!rawLinkPath) return match; + const linkPath = rawLinkPath .split("/") .map((segment) => { try { From 2289875403059ba307c3a046ceb96c768ffc96e9 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 3 Sep 2026 12:38:37 -0400 Subject: [PATCH 3/3] Surface the manifest a publish is about to ship The publish script reads everything from the current working directory, so running it from a different checkout silently ships that checkout's manifest. With worktrees in play that is easy to do and there was no signal until after the release existed. Log the directory, branch, isDesktopOnly and minAppVersion before anything is pushed, and fail when manifest.json and dist/manifest.json disagree. The mirrored repo takes its manifest from dist/ while the release assets take theirs from source, so a stale build would publish two different manifests. Co-Authored-By: Claude Opus 5 --- apps/obsidian/scripts/publish.ts | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) 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 });