diff --git a/README.md b/README.md index 7c06a25..f75490d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,16 @@ Renders an inline swatch beside every color literal in a thread — hex, `rgb()` Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/color-swatches --yes` +### Open in Moss + +Makes local Markdown links in bb open directly in Moss, with bb's viewer kept as the fallback. + +![A Markdown file link from bb open in Moss](plugins/open-in-moss/docs/screenshot.png) + +[Source](plugins/open-in-moss) · [README](plugins/open-in-moss/README.md) + +Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/open-in-moss --yes` + ### Timeline Comments Attaches durable discussion threads to selected timeline text. Users and agents can reply, edit, resolve or reopen comments, review them together, and add open feedback to the composer for follow-up. diff --git a/package-lock.json b/package-lock.json index 4c29f21..5fdcb8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2978,6 +2978,10 @@ "resolved": "plugins/mesh-gradient", "link": true }, + "node_modules/bb-plugin-open-in-moss": { + "resolved": "plugins/open-in-moss", + "link": true + }, "node_modules/bb-plugin-prompt-shaper": { "resolved": "plugins/improve-prompt", "link": true @@ -5204,6 +5208,31 @@ "bbPluginSdk": ">=0.4.1" } }, + "plugins/open-in-moss": { + "name": "bb-plugin-open-in-moss", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "@get-bb/plugin-sdk": "file:../../tooling/vendor/get-bb-plugin-sdk-0.4.8.tgz", + "@testing-library/react": "^16.3.2", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "better-sqlite3": "^12.10.0", + "cron-parser": "^5.5.0", + "hono": "^4.11.9", + "jsdom": "^29.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "sonner": "^1.7.4", + "typescript": "^5.7.0", + "vitest": "^4.1.8" + }, + "engines": { + "bb": ">=0.0.34", + "bbPluginSdk": "^0.4.1" + } + }, "plugins/theme-preview": { "name": "bb-plugin-theme-preview", "version": "0.1.0", diff --git a/plugins/open-in-moss/README.md b/plugins/open-in-moss/README.md new file mode 100644 index 0000000..cf859ed --- /dev/null +++ b/plugins/open-in-moss/README.md @@ -0,0 +1,26 @@ +# Open in Moss + +Makes local Markdown links in bb open directly in Moss. + +![A Markdown file link from bb open in Moss](docs/screenshot.png) + +## Install + +```sh +bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/open-in-moss --yes +``` + +## Use + +Click any local `.md` or `.markdown` link in bb. It opens in Moss instead of +bb's file viewer. + +Right-click still uses bb's normal menu. If Moss or the local file is +unavailable, bb opens its own viewer and shows a notice. + +## Develop + +```sh +npm install +npm run check --workspace=bb-plugin-open-in-moss +``` diff --git a/plugins/open-in-moss/app.test.tsx b/plugins/open-in-moss/app.test.tsx new file mode 100644 index 0000000..1e6f7fb --- /dev/null +++ b/plugins/open-in-moss/app.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + loadPluginApp, + mountPluginContentScripts, + type MountedPluginContentScripts, +} from "@get-bb/plugin-sdk/testing/app"; +import { toast } from "sonner"; + +vi.mock("sonner", () => ({ + toast: { error: vi.fn() }, +})); + +const app = await loadPluginApp(() => import("./app")); +let mounted: MountedPluginContentScripts; + +function link(href: string): HTMLAnchorElement { + const anchor = document.createElement("a"); + anchor.href = href; + const child = document.createElement("span"); + child.textContent = "Open file"; + anchor.append(child); + document.body.append(anchor); + return anchor; +} + +function click( + target: Element, + init: MouseEventInit = {}, +): MouseEvent { + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + ...init, + }); + target.dispatchEvent(event); + return event; +} + +beforeEach(async () => { + mounted = await mountPluginContentScripts(app, { + pluginId: "open-in-moss", + }); +}); + +afterEach(async () => { + await mounted.lifecycle.dispose(); + document.body.replaceChildren(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Markdown link interception", () => { + it("opens encoded Markdown file links through the plugin route", async () => { + const fetch = vi.fn(async () => ({ ok: true })); + vi.stubGlobal("fetch", fetch); + const anchor = link("file:///Users/brsbl/My%20Notes/spec.md#L12"); + const reachedAnchor = vi.fn(); + anchor.addEventListener("click", reachedAnchor); + + const event = click(anchor.firstElementChild!); + + expect(event.defaultPrevented).toBe(true); + expect(reachedAnchor).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()); + expect(fetch).toHaveBeenCalledWith( + "/api/v1/plugins/open-in-moss/http/open", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: "/Users/brsbl/My Notes/spec.md" }), + }, + ); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("falls back to the original bb click when Moss cannot open the file", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false }))); + const anchor = link("file:///workspace/spec.markdown"); + const bbPreview = vi.fn((event: Event) => event.preventDefault()); + anchor.addEventListener("click", bbPreview); + + click(anchor); + + await vi.waitFor(() => expect(bbPreview).toHaveBeenCalledOnce()); + expect(toast.error).toHaveBeenCalledWith("Moss couldn’t open this file", { + description: "It was opened in bb instead.", + }); + }); + + it("intercepts modified primary clicks so they cannot open bb's viewer", async () => { + const fetch = vi.fn(async () => ({ ok: true })); + vi.stubGlobal("fetch", fetch); + const anchor = link("file:///workspace/spec.md"); + const event = click(anchor, { metaKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()); + }); + + it("leaves non-Markdown, web, and right clicks alone", async () => { + const fetch = vi.fn(async () => ({ ok: true })); + vi.stubGlobal("fetch", fetch); + const cases: Array<[HTMLAnchorElement, MouseEventInit]> = [ + [link("file:///workspace/code.ts"), {}], + [link("https://example.com/readme.md"), {}], + [link("file:///workspace/spec.md"), { button: 2 }], + ]; + + for (const [anchor, init] of cases) { + const reachedAnchor = vi.fn(); + anchor.addEventListener("click", (event) => { + reachedAnchor(); + event.preventDefault(); + }); + click(anchor, init); + expect(reachedAnchor).toHaveBeenCalledOnce(); + } + expect(fetch).not.toHaveBeenCalled(); + }); + + it("removes the interceptor when the plugin is disposed", async () => { + const fetch = vi.fn(async () => ({ ok: true })); + vi.stubGlobal("fetch", fetch); + await mounted.lifecycle.dispose(); + const anchor = link("file:///workspace/spec.md"); + anchor.addEventListener("click", (event) => event.preventDefault()); + + click(anchor); + + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/open-in-moss/app.tsx b/plugins/open-in-moss/app.tsx new file mode 100644 index 0000000..b7464fe --- /dev/null +++ b/plugins/open-in-moss/app.tsx @@ -0,0 +1,101 @@ +import { definePluginApp } from "@get-bb/plugin-sdk/app"; +import { toast } from "sonner"; + +const MARKDOWN_EXTENSION = /\.(?:md|markdown)$/iu; +const fallbackEvents = new WeakSet(); + +interface MarkdownFileLink { + anchor: HTMLAnchorElement; + path: string; +} + +function markdownFileLinkFromClick(event: MouseEvent): MarkdownFileLink | null { + if ( + event.button !== 0 || + event.defaultPrevented + ) { + return null; + } + + const anchor = event + .composedPath() + .find((target): target is HTMLAnchorElement => + target instanceof HTMLAnchorElement, + ); + if (!anchor) return null; + + let url: URL; + try { + url = new URL(anchor.href); + } catch { + return null; + } + if (url.protocol !== "file:" || url.hostname !== "" || url.search !== "") { + return null; + } + + let filePath: string; + try { + filePath = decodeURIComponent(url.pathname); + } catch { + return null; + } + if (!filePath.startsWith("/") || !MARKDOWN_EXTENSION.test(filePath)) { + return null; + } + return { anchor, path: filePath }; +} + +function openInBb(anchor: HTMLAnchorElement): boolean { + if (!anchor.isConnected) return false; + const fallbackEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + }); + fallbackEvents.add(fallbackEvent); + return !anchor.dispatchEvent(fallbackEvent); +} + +async function requestMossOpen( + pluginId: string, + link: MarkdownFileLink, +): Promise { + try { + const response = await fetch( + `/api/v1/plugins/${encodeURIComponent(pluginId)}/http/open`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: link.path }), + }, + ); + if (!response.ok) throw new Error("Moss did not accept the file"); + } catch { + const openedInBb = openInBb(link.anchor); + toast.error("Moss couldn’t open this file", { + description: openedInBb + ? "It was opened in bb instead." + : "Right-click the link to choose another app.", + }); + } +} + +export default definePluginApp((app) => { + app.contentScripts.register({ + id: "open-markdown-links", + mount({ pluginId }) { + const handleClick = (event: MouseEvent) => { + if (fallbackEvents.has(event)) return; + const link = markdownFileLinkFromClick(event); + if (link === null) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + void requestMossOpen(pluginId, link); + }; + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, + }); +}); diff --git a/plugins/open-in-moss/components/ui/hooks/use-compact-viewport.tsx b/plugins/open-in-moss/components/ui/hooks/use-compact-viewport.tsx new file mode 100644 index 0000000..e2adfae --- /dev/null +++ b/plugins/open-in-moss/components/ui/hooks/use-compact-viewport.tsx @@ -0,0 +1,37 @@ +import { + createContext, + createElement, + useContext, + type ReactNode, +} from "react"; + +import { useMediaQuery } from "./use-media-query.js"; + +export const COMPACT_VIEWPORT_QUERY = "(max-width: 767px)"; + +const CompactViewportOverrideContext = createContext(null); + +interface CompactViewportOverrideProviderProps { + children: ReactNode; + isCompactViewport: boolean; +} + +export function CompactViewportOverrideProvider({ + children, + isCompactViewport, +}: CompactViewportOverrideProviderProps) { + return createElement( + CompactViewportOverrideContext.Provider, + { value: isCompactViewport }, + children, + ); +} + +export function useIsCompactViewport(): boolean { + const override = useContext(CompactViewportOverrideContext); + const isCompactViewport = useMediaQuery(COMPACT_VIEWPORT_QUERY); + if (override !== null) { + return override; + } + return isCompactViewport; +} diff --git a/plugins/open-in-moss/components/ui/hooks/use-media-query.ts b/plugins/open-in-moss/components/ui/hooks/use-media-query.ts new file mode 100644 index 0000000..1de6399 --- /dev/null +++ b/plugins/open-in-moss/components/ui/hooks/use-media-query.ts @@ -0,0 +1,72 @@ +import { useSyncExternalStore } from "react"; + +export const DARK_COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"; +export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +// One MediaQueryList per query, shared across every caller. This avoids adding +// a browser listener for each row, tooltip, or overlay that subscribes. +type MediaQueryRef = { + mql: MediaQueryList; + subscribe: (notify: () => void) => () => void; +}; + +const mediaQueryCache = new Map(); + +function createMediaQueryRef(query: string): MediaQueryRef | null { + if (typeof window === "undefined" || !window.matchMedia) return null; + + let ref = mediaQueryCache.get(query); + if (ref) return ref; + + const mql = window.matchMedia(query); + const listeners = new Set<() => void>(); + const onChange = () => { + for (const listener of listeners) listener(); + }; + + ref = { + mql, + subscribe(notify) { + const wasEmpty = listeners.size === 0; + listeners.add(notify); + if (wasEmpty) { + mql.addEventListener("change", onChange); + } + return () => { + listeners.delete(notify); + if (listeners.size === 0) { + mql.removeEventListener("change", onChange); + mediaQueryCache.delete(query); + } + }; + }, + }; + mediaQueryCache.set(query, ref); + return ref; +} + +export function subscribeMediaQuery( + query: string, + notify: () => void, +): () => void { + return createMediaQueryRef(query)?.subscribe(notify) ?? (() => {}); +} + +export function getMediaQuerySnapshot(query: string): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return ( + mediaQueryCache.get(query)?.mql.matches ?? window.matchMedia(query).matches + ); +} + +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + (notify) => subscribeMediaQuery(query, notify), + () => getMediaQuerySnapshot(query), + () => false, + ); +} + +export function usePrefersReducedMotion(): boolean { + return useMediaQuery(REDUCED_MOTION_QUERY); +} diff --git a/plugins/open-in-moss/dist/app.css b/plugins/open-in-moss/dist/app.css new file mode 100644 index 0000000..0c7527f --- /dev/null +++ b/plugins/open-in-moss/dist/app.css @@ -0,0 +1,133 @@ +/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, utilities; +@layer theme { + :root, :host { + --font-sans: var(--font-sans); + --font-serif: var(--font-serif); + --font-mono: var(--font-mono); + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); + --shadow: var(--shadow); + --shadow-lift: var(--shadow-lift); + } +} +@property --tw-animation-delay { + syntax: "*"; + inherits: false; + initial-value: 0s; +} +@property --tw-animation-direction { + syntax: "*"; + inherits: false; + initial-value: normal; +} +@property --tw-animation-duration { + syntax: "*"; + inherits: false; +} +@property --tw-animation-fill-mode { + syntax: "*"; + inherits: false; + initial-value: none; +} +@property --tw-animation-iteration-count { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-enter-blur { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-enter-opacity { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-enter-rotate { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-enter-scale { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-enter-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-enter-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-blur { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-opacity { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-exit-rotate { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-scale { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-exit-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-exit-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@layer utilities { + @scope ([data-bb-plugin="open-in-moss"], [data-bb-plugin-root]:not([data-bb-plugin])) { + .absolute { + position: absolute; + } + } +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-animation-delay: 0s; + --tw-animation-direction: normal; + --tw-animation-duration: initial; + --tw-animation-fill-mode: none; + --tw-animation-iteration-count: 1; + --tw-enter-blur: 0; + --tw-enter-opacity: 1; + --tw-enter-rotate: 0; + --tw-enter-scale: 1; + --tw-enter-translate-x: 0; + --tw-enter-translate-y: 0; + --tw-exit-blur: 0; + --tw-exit-opacity: 1; + --tw-exit-rotate: 0; + --tw-exit-scale: 1; + --tw-exit-translate-x: 0; + --tw-exit-translate-y: 0; + } + } +} diff --git a/plugins/open-in-moss/dist/app.js b/plugins/open-in-moss/dist/app.js new file mode 100644 index 0000000..f802f30 --- /dev/null +++ b/plugins/open-in-moss/dist/app.js @@ -0,0 +1,116 @@ +// bb-plugin-runtime-shim:@get-bb/plugin-sdk/app +var runtime = globalThis.__bbPluginRuntime; +if (runtime == null || runtime.pluginSdkApp == null) { + throw new Error('Cannot load "@get-bb/plugin-sdk/app": this bundle must be loaded by the BB app, which provides the shared plugin runtime (globalThis.__bbPluginRuntime).'); +} +var mod = runtime.pluginSdkApp; +var { + Markdown, + ThreadChat, + definePluginApp, + experimental_NewThreadComposer, + experimental_useSidebarThreadActions, + experimental_useSidebarThreadPullRequest, + experimental_useSidebarThreadSplit, + experimental_useSidebarThreads, + useBbContext, + useBbNavigate, + useComposer, + useComposerView, + useRealtime, + useRealtimeConnectionState, + useRpc, + useSettings +} = mod; + +// bb-plugin-runtime-shim:sonner +var runtime2 = globalThis.__bbPluginRuntime; +if (runtime2 == null || runtime2.sonner == null) { + throw new Error('Cannot load "sonner": this bundle must be loaded by the BB app, which provides the shared plugin runtime (globalThis.__bbPluginRuntime).'); +} +var mod2 = runtime2.sonner; +var { + Toaster, + toast, + useSonner +} = mod2; + +// app.tsx +var MARKDOWN_EXTENSION = /\.(?:md|markdown)$/iu; +var fallbackEvents = /* @__PURE__ */ new WeakSet(); +function markdownFileLinkFromClick(event) { + if (event.button !== 0 || event.defaultPrevented) { + return null; + } + const anchor = event.composedPath().find( + (target) => target instanceof HTMLAnchorElement + ); + if (!anchor) return null; + let url; + try { + url = new URL(anchor.href); + } catch { + return null; + } + if (url.protocol !== "file:" || url.hostname !== "" || url.search !== "") { + return null; + } + let filePath; + try { + filePath = decodeURIComponent(url.pathname); + } catch { + return null; + } + if (!filePath.startsWith("/") || !MARKDOWN_EXTENSION.test(filePath)) { + return null; + } + return { anchor, path: filePath }; +} +function openInBb(anchor) { + if (!anchor.isConnected) return false; + const fallbackEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0 + }); + fallbackEvents.add(fallbackEvent); + return !anchor.dispatchEvent(fallbackEvent); +} +async function requestMossOpen(pluginId, link) { + try { + const response = await fetch( + `/api/v1/plugins/${encodeURIComponent(pluginId)}/http/open`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: link.path }) + } + ); + if (!response.ok) throw new Error("Moss did not accept the file"); + } catch { + const openedInBb = openInBb(link.anchor); + toast.error("Moss couldn\u2019t open this file", { + description: openedInBb ? "It was opened in bb instead." : "Right-click the link to choose another app." + }); + } +} +var app_default = definePluginApp((app) => { + app.contentScripts.register({ + id: "open-markdown-links", + mount({ pluginId }) { + const handleClick = (event) => { + if (fallbackEvents.has(event)) return; + const link = markdownFileLinkFromClick(event); + if (link === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + void requestMossOpen(pluginId, link); + }; + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + } + }); +}); +export { + app_default as default +}; diff --git a/plugins/open-in-moss/dist/app.meta.json b/plugins/open-in-moss/dist/app.meta.json new file mode 100644 index 0000000..5d0d278 --- /dev/null +++ b/plugins/open-in-moss/dist/app.meta.json @@ -0,0 +1,11 @@ +{ + "sdkMajor": 0, + "sdkVersion": "0.4.8", + "artifactFormatVersion": 1, + "pluginId": "open-in-moss", + "pluginVersion": "0.1.0", + "builtWith": { + "bbVersion": "0.39.0", + "pluginSdkVersion": "0.4.8" + } +} diff --git a/plugins/open-in-moss/dist/server.js b/plugins/open-in-moss/dist/server.js new file mode 100644 index 0000000..1248c7c --- /dev/null +++ b/plugins/open-in-moss/dist/server.js @@ -0,0 +1,174 @@ +import { createRequire as __createRequire } from "node:module"; +import { dirname as __pathDirname } from "node:path"; +import { fileURLToPath as __fileURLToPath } from "node:url"; +const require = __createRequire(import.meta.url); +var __filename = __fileURLToPath(import.meta.url); +var __dirname = __pathDirname(__filename); + +// server.ts +import { execFile } from "node:child_process"; +import { realpath, stat } from "node:fs/promises"; +import { extname, isAbsolute } from "node:path"; +var OpenInMossError = class extends Error { + constructor(code, message) { + super(message); + this.code = code; + this.name = "OpenInMossError"; + } + code; +}; +function isMarkdownPath(filePath) { + const extension = extname(filePath).toLowerCase(); + return extension === ".md" || extension === ".markdown"; +} +function launchMoss(filePath) { + return new Promise((resolve, reject) => { + execFile( + "/usr/bin/open", + ["-a", "Moss", filePath], + { timeout: 15e3 }, + (error) => { + if (error) reject(error); + else resolve(); + } + ); + }); +} +var systemDependencies = { + platform: process.platform, + realpath, + stat, + open: launchMoss +}; +async function openMarkdownInMoss(filePath, dependencies) { + if (dependencies.platform !== "darwin") { + throw new OpenInMossError( + "unsupported_platform", + "Opening Markdown in Moss is available only on macOS." + ); + } + if (!isAbsolute(filePath) || filePath.includes("\0")) { + throw new OpenInMossError( + "invalid_path", + "The Markdown link does not contain a valid absolute file path." + ); + } + if (!isMarkdownPath(filePath)) { + throw new OpenInMossError( + "not_markdown", + "Only .md and .markdown files can be opened in Moss." + ); + } + let canonicalPath; + try { + canonicalPath = await dependencies.realpath(filePath); + } catch { + throw new OpenInMossError( + "not_found", + "That Markdown file is no longer available." + ); + } + if (!isMarkdownPath(canonicalPath)) { + throw new OpenInMossError( + "not_markdown", + "The linked file does not resolve to a Markdown file." + ); + } + let fileStat; + try { + fileStat = await dependencies.stat(canonicalPath); + } catch { + throw new OpenInMossError( + "not_found", + "That Markdown file is no longer available." + ); + } + if (!fileStat.isFile()) { + throw new OpenInMossError( + "not_regular_file", + "That Markdown link does not point to a regular file." + ); + } + try { + await dependencies.open(canonicalPath); + } catch { + throw new OpenInMossError( + "open_failed", + "Moss could not open that Markdown file." + ); + } + return canonicalPath; +} +function errorResponse(context, error) { + const body = { ok: false, error: { code: error.code, message: error.message } }; + switch (error.code) { + case "invalid_path": + case "not_markdown": + return context.json(body, 400); + case "not_found": + return context.json(body, 404); + case "not_regular_file": + return context.json(body, 422); + case "unsupported_platform": + return context.json(body, 409); + case "open_failed": + return context.json(body, 502); + } +} +function createOpenInMossPlugin(dependencies = systemDependencies) { + return async function plugin(bb) { + bb.http.route( + "POST", + "/open", + async (context) => { + let body; + try { + body = await context.req.json(); + } catch { + return context.json( + { + ok: false, + error: { + code: "invalid_path", + message: "The request must contain a Markdown file path." + } + }, + 400 + ); + } + const filePath = typeof body === "object" && body !== null && typeof Reflect.get(body, "path") === "string" ? Reflect.get(body, "path") : null; + if (filePath === null) { + return context.json( + { + ok: false, + error: { + code: "invalid_path", + message: "The request must contain a Markdown file path." + } + }, + 400 + ); + } + try { + const openedPath = await openMarkdownInMoss(filePath, dependencies); + return context.json({ ok: true, opened: true, path: openedPath }); + } catch (error) { + if (error instanceof OpenInMossError) { + bb.log.warn(`Open in Moss failed (${error.code}): ${error.message}`); + return errorResponse(context, error); + } + throw error; + } + }, + { auth: "local" } + ); + bb.log.info("Markdown file links will open in Moss"); + }; +} +var server_default = createOpenInMossPlugin(); +export { + createOpenInMossPlugin, + server_default as default, + openMarkdownInMoss +}; +//# sourceMappingURL=server.js.map diff --git a/plugins/open-in-moss/dist/server.js.map b/plugins/open-in-moss/dist/server.js.map new file mode 100644 index 0000000..9d7f859 --- /dev/null +++ b/plugins/open-in-moss/dist/server.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../server.ts"], + "sourcesContent": ["import { execFile } from \"node:child_process\";\nimport { realpath, stat } from \"node:fs/promises\";\nimport { extname, isAbsolute } from \"node:path\";\nimport type { BbPluginApi } from \"@get-bb/plugin-sdk\";\n\ntype FileStat = { isFile(): boolean };\n\nexport interface OpenInMossDependencies {\n platform: string;\n realpath(filePath: string): Promise;\n stat(filePath: string): Promise;\n open(filePath: string): Promise;\n}\n\ntype OpenErrorCode =\n | \"invalid_path\"\n | \"not_found\"\n | \"not_markdown\"\n | \"not_regular_file\"\n | \"open_failed\"\n | \"unsupported_platform\";\n\nclass OpenInMossError extends Error {\n constructor(\n readonly code: OpenErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"OpenInMossError\";\n }\n}\n\nfunction isMarkdownPath(filePath: string): boolean {\n const extension = extname(filePath).toLowerCase();\n return extension === \".md\" || extension === \".markdown\";\n}\n\nfunction launchMoss(filePath: string): Promise {\n return new Promise((resolve, reject) => {\n execFile(\n \"/usr/bin/open\",\n [\"-a\", \"Moss\", filePath],\n { timeout: 15_000 },\n (error) => {\n if (error) reject(error);\n else resolve();\n },\n );\n });\n}\n\nconst systemDependencies: OpenInMossDependencies = {\n platform: process.platform,\n realpath,\n stat,\n open: launchMoss,\n};\n\nexport async function openMarkdownInMoss(\n filePath: string,\n dependencies: OpenInMossDependencies,\n): Promise {\n if (dependencies.platform !== \"darwin\") {\n throw new OpenInMossError(\n \"unsupported_platform\",\n \"Opening Markdown in Moss is available only on macOS.\",\n );\n }\n if (!isAbsolute(filePath) || filePath.includes(\"\\0\")) {\n throw new OpenInMossError(\n \"invalid_path\",\n \"The Markdown link does not contain a valid absolute file path.\",\n );\n }\n if (!isMarkdownPath(filePath)) {\n throw new OpenInMossError(\n \"not_markdown\",\n \"Only .md and .markdown files can be opened in Moss.\",\n );\n }\n\n let canonicalPath: string;\n try {\n canonicalPath = await dependencies.realpath(filePath);\n } catch {\n throw new OpenInMossError(\n \"not_found\",\n \"That Markdown file is no longer available.\",\n );\n }\n if (!isMarkdownPath(canonicalPath)) {\n throw new OpenInMossError(\n \"not_markdown\",\n \"The linked file does not resolve to a Markdown file.\",\n );\n }\n\n let fileStat: FileStat;\n try {\n fileStat = await dependencies.stat(canonicalPath);\n } catch {\n throw new OpenInMossError(\n \"not_found\",\n \"That Markdown file is no longer available.\",\n );\n }\n if (!fileStat.isFile()) {\n throw new OpenInMossError(\n \"not_regular_file\",\n \"That Markdown link does not point to a regular file.\",\n );\n }\n\n try {\n await dependencies.open(canonicalPath);\n } catch {\n throw new OpenInMossError(\n \"open_failed\",\n \"Moss could not open that Markdown file.\",\n );\n }\n return canonicalPath;\n}\n\nfunction errorResponse(\n context: Parameters[2]>[0],\n error: OpenInMossError,\n): Response {\n const body = { ok: false, error: { code: error.code, message: error.message } };\n switch (error.code) {\n case \"invalid_path\":\n case \"not_markdown\":\n return context.json(body, 400);\n case \"not_found\":\n return context.json(body, 404);\n case \"not_regular_file\":\n return context.json(body, 422);\n case \"unsupported_platform\":\n return context.json(body, 409);\n case \"open_failed\":\n return context.json(body, 502);\n }\n}\n\nexport function createOpenInMossPlugin(\n dependencies: OpenInMossDependencies = systemDependencies,\n) {\n return async function plugin(bb: BbPluginApi) {\n bb.http.route(\n \"POST\",\n \"/open\",\n async (context) => {\n let body: unknown;\n try {\n body = await context.req.json();\n } catch {\n return context.json(\n {\n ok: false,\n error: {\n code: \"invalid_path\",\n message: \"The request must contain a Markdown file path.\",\n },\n },\n 400,\n );\n }\n\n const filePath =\n typeof body === \"object\" &&\n body !== null &&\n typeof Reflect.get(body, \"path\") === \"string\"\n ? (Reflect.get(body, \"path\") as string)\n : null;\n if (filePath === null) {\n return context.json(\n {\n ok: false,\n error: {\n code: \"invalid_path\",\n message: \"The request must contain a Markdown file path.\",\n },\n },\n 400,\n );\n }\n\n try {\n const openedPath = await openMarkdownInMoss(filePath, dependencies);\n return context.json({ ok: true, opened: true, path: openedPath });\n } catch (error) {\n if (error instanceof OpenInMossError) {\n bb.log.warn(`Open in Moss failed (${error.code}): ${error.message}`);\n return errorResponse(context, error);\n }\n throw error;\n }\n },\n { auth: \"local\" },\n );\n\n bb.log.info(\"Markdown file links will open in Moss\");\n };\n}\n\nexport default createOpenInMossPlugin();\n"], + "mappings": ";;;;;;;;AAAA,SAAS,gBAAgB;AACzB,SAAS,UAAU,YAAY;AAC/B,SAAS,SAAS,kBAAkB;AAoBpC,IAAM,kBAAN,cAA8B,MAAM;AAAA,EAClC,YACW,MACT,SACA;AACA,UAAM,OAAO;AAHJ;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EALW;AAMb;AAEA,SAAS,eAAe,UAA2B;AACjD,QAAM,YAAY,QAAQ,QAAQ,EAAE,YAAY;AAChD,SAAO,cAAc,SAAS,cAAc;AAC9C;AAEA,SAAS,WAAW,UAAiC;AACnD,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC;AAAA,MACE;AAAA,MACA,CAAC,MAAM,QAAQ,QAAQ;AAAA,MACvB,EAAE,SAAS,KAAO;AAAA,MAClB,CAAC,UAAU;AACT,YAAI,MAAO,QAAO,KAAK;AAAA,YAClB,SAAQ;AAAA,MACf;AAAA,IACF;AAAA,EACF,CAAC;AACH;AAEA,IAAM,qBAA6C;AAAA,EACjD,UAAU,QAAQ;AAAA,EAClB;AAAA,EACA;AAAA,EACA,MAAM;AACR;AAEA,eAAsB,mBACpB,UACA,cACiB;AACjB,MAAI,aAAa,aAAa,UAAU;AACtC,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,WAAW,QAAQ,KAAK,SAAS,SAAS,IAAI,GAAG;AACpD,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,eAAe,QAAQ,GAAG;AAC7B,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,oBAAgB,MAAM,aAAa,SAAS,QAAQ;AAAA,EACtD,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,eAAe,aAAa,GAAG;AAClC,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,eAAW,MAAM,aAAa,KAAK,aAAa;AAAA,EAClD,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,SAAS,OAAO,GAAG;AACtB,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,MAAI;AACF,UAAM,aAAa,KAAK,aAAa;AAAA,EACvC,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,cACP,SACA,OACU;AACV,QAAM,OAAO,EAAE,IAAI,OAAO,OAAO,EAAE,MAAM,MAAM,MAAM,SAAS,MAAM,QAAQ,EAAE;AAC9E,UAAQ,MAAM,MAAM;AAAA,IAClB,KAAK;AAAA,IACL,KAAK;AACH,aAAO,QAAQ,KAAK,MAAM,GAAG;AAAA,IAC/B,KAAK;AACH,aAAO,QAAQ,KAAK,MAAM,GAAG;AAAA,IAC/B,KAAK;AACH,aAAO,QAAQ,KAAK,MAAM,GAAG;AAAA,IAC/B,KAAK;AACH,aAAO,QAAQ,KAAK,MAAM,GAAG;AAAA,IAC/B,KAAK;AACH,aAAO,QAAQ,KAAK,MAAM,GAAG;AAAA,EACjC;AACF;AAEO,SAAS,uBACd,eAAuC,oBACvC;AACA,SAAO,eAAe,OAAO,IAAiB;AAC5C,OAAG,KAAK;AAAA,MACN;AAAA,MACA;AAAA,MACA,OAAO,YAAY;AACjB,YAAI;AACJ,YAAI;AACF,iBAAO,MAAM,QAAQ,IAAI,KAAc;AAAA,QACzC,QAAQ;AACN,iBAAO,QAAQ;AAAA,YACb;AAAA,cACE,IAAI;AAAA,cACJ,OAAO;AAAA,gBACL,MAAM;AAAA,gBACN,SAAS;AAAA,cACX;AAAA,YACF;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAEA,cAAM,WACJ,OAAO,SAAS,YAChB,SAAS,QACT,OAAO,QAAQ,IAAI,MAAM,MAAM,MAAM,WAChC,QAAQ,IAAI,MAAM,MAAM,IACzB;AACN,YAAI,aAAa,MAAM;AACrB,iBAAO,QAAQ;AAAA,YACb;AAAA,cACE,IAAI;AAAA,cACJ,OAAO;AAAA,gBACL,MAAM;AAAA,gBACN,SAAS;AAAA,cACX;AAAA,YACF;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAEA,YAAI;AACF,gBAAM,aAAa,MAAM,mBAAmB,UAAU,YAAY;AAClE,iBAAO,QAAQ,KAAK,EAAE,IAAI,MAAM,QAAQ,MAAM,MAAM,WAAW,CAAC;AAAA,QAClE,SAAS,OAAO;AACd,cAAI,iBAAiB,iBAAiB;AACpC,eAAG,IAAI,KAAK,wBAAwB,MAAM,IAAI,MAAM,MAAM,OAAO,EAAE;AACnE,mBAAO,cAAc,SAAS,KAAK;AAAA,UACrC;AACA,gBAAM;AAAA,QACR;AAAA,MACF;AAAA,MACA,EAAE,MAAM,QAAQ;AAAA,IAClB;AAEA,OAAG,IAAI,KAAK,uCAAuC;AAAA,EACrD;AACF;AAEA,IAAO,iBAAQ,uBAAuB;", + "names": [] +} diff --git a/plugins/open-in-moss/dist/server.meta.json b/plugins/open-in-moss/dist/server.meta.json new file mode 100644 index 0000000..5d0d278 --- /dev/null +++ b/plugins/open-in-moss/dist/server.meta.json @@ -0,0 +1,11 @@ +{ + "sdkMajor": 0, + "sdkVersion": "0.4.8", + "artifactFormatVersion": 1, + "pluginId": "open-in-moss", + "pluginVersion": "0.1.0", + "builtWith": { + "bbVersion": "0.39.0", + "pluginSdkVersion": "0.4.8" + } +} diff --git a/plugins/open-in-moss/docs/screenshot.png b/plugins/open-in-moss/docs/screenshot.png new file mode 100644 index 0000000..35a2c70 Binary files /dev/null and b/plugins/open-in-moss/docs/screenshot.png differ diff --git a/plugins/open-in-moss/package.json b/plugins/open-in-moss/package.json new file mode 100644 index 0000000..07c9bc9 --- /dev/null +++ b/plugins/open-in-moss/package.json @@ -0,0 +1,50 @@ +{ + "name": "bb-plugin-open-in-moss", + "version": "0.1.0", + "description": "Open Markdown file links in Moss instead of BB's built-in file viewer.", + "type": "module", + "private": true, + "license": "UNLICENSED", + "files": ["dist", "docs", "README.md"], + "engines": { + "bb": ">=0.0.34", + "bbPluginSdk": "^0.4.1" + }, + "bb": { + "name": "Open in Moss", + "description": "Open Markdown file links in Moss instead of BB's file viewer.", + "branding": { + "icon": "ExternalLink" + }, + "server": "./server.ts", + "app": "./app.tsx", + "skills": [] + }, + "keywords": [ + "bb-plugin", + "markdown", + "moss" + ], + "scripts": { + "build": "node ../../tooling/build-plugin.mjs", + "check": "npm run typecheck && npm run build && npm test", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@get-bb/plugin-sdk": "file:../../tooling/vendor/get-bb-plugin-sdk-0.4.8.tgz", + "@testing-library/react": "^16.3.2", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "better-sqlite3": "^12.10.0", + "cron-parser": "^5.5.0", + "hono": "^4.11.9", + "jsdom": "^29.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "sonner": "^1.7.4", + "typescript": "^5.7.0", + "vitest": "^4.1.8" + } +} diff --git a/plugins/open-in-moss/server.test.ts b/plugins/open-in-moss/server.test.ts new file mode 100644 index 0000000..a900941 --- /dev/null +++ b/plugins/open-in-moss/server.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +import { + createOpenInMossPlugin, + type OpenInMossDependencies, +} from "./server"; + +function dependencies( + overrides: Partial = {}, +): OpenInMossDependencies { + return { + platform: "darwin", + realpath: async (filePath) => filePath, + stat: async () => ({ isFile: () => true }), + open: async () => {}, + ...overrides, + }; +} + +async function loadPlugin(deps: OpenInMossDependencies) { + const host = createFakePluginHost({ pluginId: "open-in-moss" }); + await createOpenInMossPlugin(deps)(host.bb); + return host; +} + +async function post( + deps: OpenInMossDependencies, + body: unknown, +): Promise { + const { harness } = await loadPlugin(deps); + return harness.fetchHttp("POST", "/open", { + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /open", () => { + it("resolves and opens a Markdown file in Moss", async () => { + const open = vi.fn(async () => {}); + const realpath = vi.fn(async () => "/real/notes/spec.md"); + const response = await post( + dependencies({ open, realpath }), + { path: "/workspace/spec.md" }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, + opened: true, + path: "/real/notes/spec.md", + }); + expect(realpath).toHaveBeenCalledWith("/workspace/spec.md"); + expect(open).toHaveBeenCalledWith("/real/notes/spec.md"); + }); + + it("rejects malformed, relative, and non-Markdown paths", async () => { + for (const body of [ + null, + {}, + { path: "notes/spec.md" }, + { path: "/workspace/spec.ts" }, + ]) { + const response = await post(dependencies(), body); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ ok: false }); + } + }); + + it("rejects missing files and non-files without launching Moss", async () => { + const missingOpen = vi.fn(async () => {}); + const missing = await post( + dependencies({ + open: missingOpen, + realpath: async () => { + throw new Error("ENOENT"); + }, + }), + { path: "/workspace/gone.md" }, + ); + expect(missing.status).toBe(404); + expect(missingOpen).not.toHaveBeenCalled(); + + const directoryOpen = vi.fn(async () => {}); + const directory = await post( + dependencies({ + open: directoryOpen, + stat: async () => ({ isFile: () => false }), + }), + { path: "/workspace/folder.md" }, + ); + expect(directory.status).toBe(422); + expect(directoryOpen).not.toHaveBeenCalled(); + }); + + it("reports unsupported platforms and launch failures", async () => { + const unsupported = await post( + dependencies({ platform: "linux" }), + { path: "/workspace/spec.md" }, + ); + expect(unsupported.status).toBe(409); + + const failed = await post( + dependencies({ + open: async () => { + throw new Error("Moss is missing"); + }, + }), + { path: "/workspace/spec.md" }, + ); + expect(failed.status).toBe(502); + expect(await failed.json()).toEqual({ + ok: false, + error: { + code: "open_failed", + message: "Moss could not open that Markdown file.", + }, + }); + }); +}); diff --git a/plugins/open-in-moss/server.ts b/plugins/open-in-moss/server.ts new file mode 100644 index 0000000..e2e46ac --- /dev/null +++ b/plugins/open-in-moss/server.ts @@ -0,0 +1,206 @@ +import { execFile } from "node:child_process"; +import { realpath, stat } from "node:fs/promises"; +import { extname, isAbsolute } from "node:path"; +import type { BbPluginApi } from "@get-bb/plugin-sdk"; + +type FileStat = { isFile(): boolean }; + +export interface OpenInMossDependencies { + platform: string; + realpath(filePath: string): Promise; + stat(filePath: string): Promise; + open(filePath: string): Promise; +} + +type OpenErrorCode = + | "invalid_path" + | "not_found" + | "not_markdown" + | "not_regular_file" + | "open_failed" + | "unsupported_platform"; + +class OpenInMossError extends Error { + constructor( + readonly code: OpenErrorCode, + message: string, + ) { + super(message); + this.name = "OpenInMossError"; + } +} + +function isMarkdownPath(filePath: string): boolean { + const extension = extname(filePath).toLowerCase(); + return extension === ".md" || extension === ".markdown"; +} + +function launchMoss(filePath: string): Promise { + return new Promise((resolve, reject) => { + execFile( + "/usr/bin/open", + ["-a", "Moss", filePath], + { timeout: 15_000 }, + (error) => { + if (error) reject(error); + else resolve(); + }, + ); + }); +} + +const systemDependencies: OpenInMossDependencies = { + platform: process.platform, + realpath, + stat, + open: launchMoss, +}; + +export async function openMarkdownInMoss( + filePath: string, + dependencies: OpenInMossDependencies, +): Promise { + if (dependencies.platform !== "darwin") { + throw new OpenInMossError( + "unsupported_platform", + "Opening Markdown in Moss is available only on macOS.", + ); + } + if (!isAbsolute(filePath) || filePath.includes("\0")) { + throw new OpenInMossError( + "invalid_path", + "The Markdown link does not contain a valid absolute file path.", + ); + } + if (!isMarkdownPath(filePath)) { + throw new OpenInMossError( + "not_markdown", + "Only .md and .markdown files can be opened in Moss.", + ); + } + + let canonicalPath: string; + try { + canonicalPath = await dependencies.realpath(filePath); + } catch { + throw new OpenInMossError( + "not_found", + "That Markdown file is no longer available.", + ); + } + if (!isMarkdownPath(canonicalPath)) { + throw new OpenInMossError( + "not_markdown", + "The linked file does not resolve to a Markdown file.", + ); + } + + let fileStat: FileStat; + try { + fileStat = await dependencies.stat(canonicalPath); + } catch { + throw new OpenInMossError( + "not_found", + "That Markdown file is no longer available.", + ); + } + if (!fileStat.isFile()) { + throw new OpenInMossError( + "not_regular_file", + "That Markdown link does not point to a regular file.", + ); + } + + try { + await dependencies.open(canonicalPath); + } catch { + throw new OpenInMossError( + "open_failed", + "Moss could not open that Markdown file.", + ); + } + return canonicalPath; +} + +function errorResponse( + context: Parameters[2]>[0], + error: OpenInMossError, +): Response { + const body = { ok: false, error: { code: error.code, message: error.message } }; + switch (error.code) { + case "invalid_path": + case "not_markdown": + return context.json(body, 400); + case "not_found": + return context.json(body, 404); + case "not_regular_file": + return context.json(body, 422); + case "unsupported_platform": + return context.json(body, 409); + case "open_failed": + return context.json(body, 502); + } +} + +export function createOpenInMossPlugin( + dependencies: OpenInMossDependencies = systemDependencies, +) { + return async function plugin(bb: BbPluginApi) { + bb.http.route( + "POST", + "/open", + async (context) => { + let body: unknown; + try { + body = await context.req.json(); + } catch { + return context.json( + { + ok: false, + error: { + code: "invalid_path", + message: "The request must contain a Markdown file path.", + }, + }, + 400, + ); + } + + const filePath = + typeof body === "object" && + body !== null && + typeof Reflect.get(body, "path") === "string" + ? (Reflect.get(body, "path") as string) + : null; + if (filePath === null) { + return context.json( + { + ok: false, + error: { + code: "invalid_path", + message: "The request must contain a Markdown file path.", + }, + }, + 400, + ); + } + + try { + const openedPath = await openMarkdownInMoss(filePath, dependencies); + return context.json({ ok: true, opened: true, path: openedPath }); + } catch (error) { + if (error instanceof OpenInMossError) { + bb.log.warn(`Open in Moss failed (${error.code}): ${error.message}`); + return errorResponse(context, error); + } + throw error; + } + }, + { auth: "local" }, + ); + + bb.log.info("Markdown file links will open in Moss"); + }; +} + +export default createOpenInMossPlugin(); diff --git a/plugins/open-in-moss/tsconfig.json b/plugins/open-in-moss/tsconfig.json new file mode 100644 index 0000000..8d33bd6 --- /dev/null +++ b/plugins/open-in-moss/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": [ + "ES2022", + "DOM" + ], + "types": [ + "node" + ], + "paths": { + "@/*": [ + "./*" + ] + }, + "noEmit": true, + "skipLibCheck": false + }, + "include": [ + "server.ts", + "app.tsx", + "*.test.ts", + "*.test.tsx", + "vitest.config.ts" + ] +} diff --git a/plugins/open-in-moss/vitest.config.ts b/plugins/open-in-moss/vitest.config.ts new file mode 100644 index 0000000..c28115c --- /dev/null +++ b/plugins/open-in-moss/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.test.{ts,tsx}"], + exclude: ["dist/**", "node_modules/**"], + }, +});