mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 12:39:52 +08:00
feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild
The shell (packages/mcp-server/shell) renders DrawioFrame inside the shared providers without the chat: its config comes from window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the theme is kept under an mcp: localStorage key, and system fonts stand in for the web fonts. scripts/build-shell.mjs bundles it into dist/shell with the root's esbuild and Tailwind; check-package caps shell.js at 1.5 MB and shell.css at 300 KB.
This commit is contained in:
@@ -16,6 +16,8 @@ packages/*/dist
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
packages/*/playwright-report/
|
||||
packages/*/test-results/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"**",
|
||||
"!public",
|
||||
"!packages/mcp-server/src/preview",
|
||||
"!packages/mcp-server/shell/index.html",
|
||||
"!lib/model-catalog.json"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -699,5 +699,9 @@
|
||||
"recent": "Recent diagrams",
|
||||
"drawYourself": "Start with a blank canvas",
|
||||
"openFileHint": "Keep working on a diagram you have"
|
||||
},
|
||||
"shell": {
|
||||
"waiting": "Waiting for the MCP server…",
|
||||
"toggleTheme": "Switch between light and dark"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,5 +699,9 @@
|
||||
"recent": "最近の図",
|
||||
"drawYourself": "白紙のキャンバスから始める",
|
||||
"openFileHint": "手元の図の続きを編集"
|
||||
},
|
||||
"shell": {
|
||||
"waiting": "MCP サーバーを待っています…",
|
||||
"toggleTheme": "ライトとダークを切り替え"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,5 +699,9 @@
|
||||
"recent": "最近的圖紙",
|
||||
"drawYourself": "從空白畫布開始",
|
||||
"openFileHint": "接著編輯已有的圖"
|
||||
},
|
||||
"shell": {
|
||||
"waiting": "正在等待 MCP 伺服器…",
|
||||
"toggleTheme": "切換淺色或深色"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,5 +699,9 @@
|
||||
"recent": "最近的图纸",
|
||||
"drawYourself": "从空白画布开始",
|
||||
"openFileHint": "接着编辑已有的图"
|
||||
},
|
||||
"shell": {
|
||||
"waiting": "正在等待 MCP 服务器…",
|
||||
"toggleTheme": "切换浅色或深色"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
"next-ai-drawio-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs",
|
||||
"build": "tsc && tsc -p shell && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs && node scripts/build-shell.mjs",
|
||||
"check-drawio": "node scripts/check-drawio-files.mjs",
|
||||
"check-package": "node scripts/check-package.mjs",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Build the canvas shell (shell/, the web app's canvas without the chat)
|
||||
// into dist/shell: shell.js with esbuild, shell.css with Tailwind, and the
|
||||
// page template. Needs the repository checked out with its root
|
||||
// devDependencies installed (esbuild, Tailwind, React and the shared
|
||||
// components come from there), like copy-assets.mjs needs ../../docs.
|
||||
import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const root = join(pkg, "../..")
|
||||
const shell = join(pkg, "shell")
|
||||
const out = join(pkg, "dist/shell")
|
||||
|
||||
// The root's copies: the shell's React must be the one the shared
|
||||
// components import, and the MCP core's pako and saxes should not be
|
||||
// bundled twice (packages/mcp-server has its own node_modules)
|
||||
const rootRequire = createRequire(join(root, "package.json"))
|
||||
const rootPackage = (name) =>
|
||||
dirname(rootRequire.resolve(`${name}/package.json`))
|
||||
const { build } = await import(rootRequire.resolve("esbuild"))
|
||||
const postcss = (await import(rootRequire.resolve("postcss"))).default
|
||||
const tailwindcss = (await import(rootRequire.resolve("@tailwindcss/postcss")))
|
||||
.default
|
||||
|
||||
mkdirSync(out, { recursive: true })
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [join(shell, "main.tsx")],
|
||||
outfile: join(out, "shell.js"),
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
platform: "browser",
|
||||
jsx: "automatic",
|
||||
legalComments: "none",
|
||||
metafile: true,
|
||||
absWorkingDir: root,
|
||||
alias: {
|
||||
"@": root,
|
||||
pako: rootPackage("pako"),
|
||||
saxes: rootPackage("saxes"),
|
||||
},
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
// The shell has no base path and takes its draw.io from the config
|
||||
// the server writes into the page, not from the web app's build
|
||||
// settings
|
||||
"process.env.NEXT_PUBLIC_BASE_PATH": '""',
|
||||
"process.env.NEXT_PUBLIC_DRAWIO_BASE_URL": '""',
|
||||
},
|
||||
})
|
||||
const js = readFileSync(join(out, "shell.js"), "utf8")
|
||||
// A reference that the defines above missed would throw in the browser
|
||||
// ("process is not defined")
|
||||
const leftover = js.match(/process\.env\.[A-Z_]+/)
|
||||
if (leftover) {
|
||||
throw new Error(`shell.js still reads ${leftover[0]}; define it above`)
|
||||
}
|
||||
|
||||
const cssFile = join(shell, "shell.css")
|
||||
const css = await postcss([
|
||||
// Tailwind scans the shell directory on its own; shell.css names the
|
||||
// shared component directories with @source
|
||||
tailwindcss({ base: shell, optimize: { minify: true } }),
|
||||
]).process(readFileSync(cssFile, "utf8"), {
|
||||
from: cssFile,
|
||||
to: join(out, "shell.css"),
|
||||
})
|
||||
writeFileSync(join(out, "shell.css"), css.css)
|
||||
|
||||
cpSync(join(shell, "index.html"), join(out, "index.html"))
|
||||
|
||||
const kb = (n) => `${(n / 1024).toFixed(0)} KB`
|
||||
const bundled = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
|
||||
console.log(
|
||||
`shell: shell.js ${kb(bundled.bytes)}, shell.css ${kb(Buffer.byteLength(css.css))} -> ${out}`,
|
||||
)
|
||||
@@ -12,7 +12,16 @@ const REQUIRED = [
|
||||
"dist/drawio/index.html",
|
||||
"dist/drawio/js/app.min.js",
|
||||
"dist/drawio/LICENSE",
|
||||
"dist/shell/index.html",
|
||||
"dist/shell/shell.js",
|
||||
"dist/shell/shell.css",
|
||||
]
|
||||
// The canvas shell (708 KB and 50 KB when it was added); a jump means a
|
||||
// dependency of the shared components grew the bundle
|
||||
const MAX_FILE_BYTES = {
|
||||
"dist/shell/shell.js": 1.5 * 1024 * 1024,
|
||||
"dist/shell/shell.css": 300 * 1024,
|
||||
}
|
||||
// The bundled draw.io is most of the package (15.1 MB packed with draw.io
|
||||
// v32.0.2); a jump means the file list grew more than intended
|
||||
const MAX_TARBALL_BYTES = 20 * 1024 * 1024
|
||||
@@ -29,6 +38,16 @@ if (missing.length > 0) {
|
||||
process.exit(1)
|
||||
}
|
||||
const mb = (n) => (n / 1048576).toFixed(1)
|
||||
const kb = (n) => (n / 1024).toFixed(0)
|
||||
for (const [path, limit] of Object.entries(MAX_FILE_BYTES)) {
|
||||
const size = pack.files.find((f) => f.path === path).size
|
||||
if (size > limit) {
|
||||
console.error(
|
||||
`${path} is ${kb(size)} KB, over the ${kb(limit)} KB limit`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (pack.size > MAX_TARBALL_BYTES) {
|
||||
console.error(
|
||||
`npm package is ${mb(pack.size)} MB packed, over the ${mb(MAX_TARBALL_BYTES)} MB limit`,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||
import { VersionStrip } from "@/components/canvas/version-strip"
|
||||
import {
|
||||
VersionsProvider,
|
||||
type VersionsSource,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { DrawioSource } from "@/lib/drawio/drawio-config"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
import { setDark } from "./theme"
|
||||
|
||||
// No versions until the shell reads the server's History (a later step)
|
||||
const NO_VERSIONS: VersionsSource = {
|
||||
versions: [],
|
||||
onCanvasId: null,
|
||||
undoneId: null,
|
||||
isBusy: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
restore: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
}
|
||||
|
||||
/**
|
||||
* The MCP preview: the web app's canvas without the chat. A slim bar names
|
||||
* the session and the sync state; the versions strip and Compare come from
|
||||
* the shared canvas components.
|
||||
*/
|
||||
export function CanvasShell({ config }: { config: ShellConfig }) {
|
||||
const dict = useDictionary()
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
const drawioSource = useMemo<DrawioSource>(
|
||||
() => ({
|
||||
editorUrl: config.drawioBaseUrl,
|
||||
ui: config.drawioUi || undefined,
|
||||
lang: config.drawioLang || undefined,
|
||||
}),
|
||||
[config],
|
||||
)
|
||||
|
||||
return (
|
||||
<VersionsProvider value={NO_VERSIONS}>
|
||||
<div className="flex h-dvh flex-col bg-background text-foreground">
|
||||
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-surface-0 px-3.5">
|
||||
<span className="text-[13px] font-semibold tracking-[-0.01em]">
|
||||
Next AI Draw.io
|
||||
</span>
|
||||
{config.sessionId && (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground tabular-nums"
|
||||
title={config.sessionId}
|
||||
data-testid="shell-session"
|
||||
>
|
||||
{config.sessionId.slice(-8)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
data-testid="shell-status"
|
||||
>
|
||||
{dict.shell.waiting}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDark(!isDark)}
|
||||
aria-label={dict.shell.toggleTheme}
|
||||
title={dict.shell.toggleTheme}
|
||||
className="ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
data-testid="theme-toggle"
|
||||
>
|
||||
{isDark ? <Sun /> : <Moon />}
|
||||
</button>
|
||||
</header>
|
||||
<VersionStrip minVersions={1} />
|
||||
<div className="relative flex-1">
|
||||
<CanvasStage
|
||||
className="inset-0"
|
||||
drawioSource={drawioSource}
|
||||
/>
|
||||
</div>
|
||||
<CompareDialog />
|
||||
</div>
|
||||
</VersionsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||
import en from "@/lib/i18n/dictionaries/en.json"
|
||||
import ja from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zh from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHant from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
|
||||
// The web app loads one dictionary on the server (lib/i18n/dictionaries.ts
|
||||
// is server-only); the shell ships all four and picks in the browser
|
||||
const DICTIONARIES: Record<Locale, Dictionary> = {
|
||||
en,
|
||||
zh,
|
||||
ja,
|
||||
"zh-Hant": zhHant,
|
||||
}
|
||||
|
||||
/** Our locale for a language tag ("zh-TW", "ja-JP", "en-US"), or null */
|
||||
export function matchLocale(tag: string): Locale | null {
|
||||
const lower = tag.trim().toLowerCase()
|
||||
if (!lower) return null
|
||||
if (/^zh-(hant|tw|hk|mo)\b/.test(lower)) return "zh-Hant"
|
||||
const base = lower.split("-")[0]
|
||||
if (base === "zh") return "zh"
|
||||
if (base === "ja") return "ja"
|
||||
if (base === "en") return "en"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's language: the server's DRAWIO_LANG when it is one of ours,
|
||||
* else the browser's first language we have, else English.
|
||||
*/
|
||||
export function pickLocale(
|
||||
configLang: string,
|
||||
browserLangs: readonly string[],
|
||||
): Locale {
|
||||
for (const tag of [configLang, ...browserLangs]) {
|
||||
const locale = matchLocale(tag)
|
||||
if (locale) return locale
|
||||
}
|
||||
return i18n.defaultLocale
|
||||
}
|
||||
|
||||
export function getShellDictionary(locale: Locale): Dictionary {
|
||||
return DICTIONARIES[locale]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Next AI Draw.io</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/shell/shell.css">
|
||||
<script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script>
|
||||
<script>
|
||||
// Dark class before the first paint (no white flash). The same rule
|
||||
// as shell/theme.ts: the choice stored under "mcp:dark-mode", else
|
||||
// the server's DRAWIO_DARK, else the system
|
||||
try {
|
||||
const config = window.__MCP_CONFIG__ || {}
|
||||
const stored = localStorage.getItem("mcp:dark-mode")
|
||||
const dark =
|
||||
stored === "true" ||
|
||||
(stored === null &&
|
||||
(config.drawioDark === "dark" ||
|
||||
(config.drawioDark !== "light" &&
|
||||
matchMedia("(prefers-color-scheme: dark)").matches)))
|
||||
if (dark) document.documentElement.classList.add("dark")
|
||||
} catch {}
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/shell/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
// Entry of the MCP's canvas shell, bundled by scripts/build-shell.mjs into
|
||||
// dist/shell/shell.js and served at /shell/ by http-server.ts
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
import { CanvasShell } from "./canvas-shell"
|
||||
import { getShellDictionary, pickLocale } from "./dictionary"
|
||||
import { pageShellConfig } from "./runtime-config"
|
||||
import { initTheme } from "./theme"
|
||||
|
||||
const config = pageShellConfig()
|
||||
const locale = pickLocale(config.lang, navigator.languages)
|
||||
// The stylesheet picks the CJK fonts by the document's language
|
||||
document.documentElement.lang = locale
|
||||
initTheme(config.drawioDark)
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("The shell page has no #root element")
|
||||
createRoot(root).render(
|
||||
<DictionaryProvider dictionary={getShellDictionary(locale)}>
|
||||
<LocaleProvider locale={locale}>
|
||||
<DiagramProvider>
|
||||
<CanvasShell config={config} />
|
||||
</DiagramProvider>
|
||||
</LocaleProvider>
|
||||
</DictionaryProvider>,
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* What the MCP server writes into the shell page as window.__MCP_CONFIG__
|
||||
* (http-server.ts fills the {{CONFIG_JSON}} placeholder of index.html).
|
||||
*/
|
||||
export interface ShellConfig {
|
||||
sessionId: string
|
||||
/** Every /api request carries it in the X-Drawio-Token header */
|
||||
token: string
|
||||
/** Prefix of the server's API paths */
|
||||
apiBase: string
|
||||
/** The editor page: the bundled copy's index.html on this origin, or an
|
||||
* external draw.io */
|
||||
drawioBaseUrl: string
|
||||
/** draw.io theme fixed by DRAWIO_UI; "" leaves the pick to draw.io */
|
||||
drawioUi: string
|
||||
/** Editor language fixed by DRAWIO_LANG, in draw.io's naming; "" leaves
|
||||
* the pick to draw.io */
|
||||
drawioLang: string
|
||||
/** DRAWIO_DARK: the theme when the user has not chosen one here */
|
||||
drawioDark: "dark" | "light" | "auto"
|
||||
/** DRAWIO_LANG as given, for the shell's own texts */
|
||||
lang: string
|
||||
}
|
||||
|
||||
const DEFAULTS: ShellConfig = {
|
||||
sessionId: "",
|
||||
token: "",
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "/drawio/index.html",
|
||||
drawioUi: "",
|
||||
drawioLang: "",
|
||||
drawioDark: "auto",
|
||||
lang: "",
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's config with every field present: a missing or malformed one
|
||||
* (another server version) takes its default.
|
||||
*/
|
||||
export function readShellConfig(source: unknown): ShellConfig {
|
||||
const given =
|
||||
source && typeof source === "object"
|
||||
? (source as Record<string, unknown>)
|
||||
: {}
|
||||
const config = { ...DEFAULTS }
|
||||
for (const key of Object.keys(DEFAULTS) as (keyof ShellConfig)[]) {
|
||||
const value = given[key]
|
||||
if (key === "drawioDark") {
|
||||
if (value === "dark" || value === "light" || value === "auto") {
|
||||
config.drawioDark = value
|
||||
}
|
||||
} else if (typeof value === "string") {
|
||||
config[key] = value
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/** The config the server wrote into this page */
|
||||
export function pageShellConfig(): ShellConfig {
|
||||
return readShellConfig(
|
||||
(window as { __MCP_CONFIG__?: unknown }).__MCP_CONFIG__,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Stylesheet of the MCP's canvas shell: the web app's theme and Tailwind,
|
||||
* with the utility classes of the shared components the shell bundles.
|
||||
* Built by scripts/build-shell.mjs; Tailwind scans this directory on its
|
||||
* own, and the shared components through the @source lines.
|
||||
*/
|
||||
@import "../../../app/globals.css";
|
||||
@source "../../../components/canvas";
|
||||
@source "../../../components/ui";
|
||||
@source "../../../components/ai-elements";
|
||||
|
||||
/* System fonts stand in for the web fonts the Next.js layout loads */
|
||||
:root {
|
||||
--font-latin:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
|
||||
/**
|
||||
* localStorage key of the user's theme choice here: "true" dark, "false"
|
||||
* light, none: the server's DRAWIO_DARK, else the system. Its own prefix,
|
||||
* so a web app dev server on the same port keeps its own settings.
|
||||
* index.html repeats the key in its inline script (run before this bundle).
|
||||
*/
|
||||
export const THEME_STORAGE_KEY = "mcp:dark-mode"
|
||||
|
||||
/** Whether the shell is dark, from the choice stored here, the server's
|
||||
* setting and the system */
|
||||
export function isDarkPreferred(
|
||||
stored: string | null,
|
||||
drawioDark: ShellConfig["drawioDark"],
|
||||
systemDark: boolean,
|
||||
): boolean {
|
||||
if (stored === "true" || stored === "false") return stored === "true"
|
||||
if (drawioDark === "auto") return systemDark
|
||||
return drawioDark === "dark"
|
||||
}
|
||||
|
||||
function read(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(THEME_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// The canvas components read the settings store's isDark (and the theme
|
||||
// class, set before the first paint by index.html)
|
||||
function apply(isDark: boolean) {
|
||||
document.documentElement.classList.toggle("dark", isDark)
|
||||
useSettingsStore.setState({
|
||||
hydrated: true,
|
||||
isDark,
|
||||
theme: isDark ? "dark" : "light",
|
||||
})
|
||||
}
|
||||
|
||||
/** Take the theme the inline script chose, and follow the system while
|
||||
* nothing else decides */
|
||||
export function initTheme(drawioDark: ShellConfig["drawioDark"]) {
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
apply(isDarkPreferred(read(), drawioDark, query.matches))
|
||||
query.addEventListener("change", (event) => {
|
||||
apply(isDarkPreferred(read(), drawioDark, event.matches))
|
||||
})
|
||||
}
|
||||
|
||||
/** The user's choice, kept for the next visit */
|
||||
export function setDark(isDark: boolean) {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, String(isDark))
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode, quota)
|
||||
}
|
||||
apply(isDark)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"incremental": false,
|
||||
"plugins": []
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"]
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const SCANNED = [
|
||||
"lib/diagram-diff.ts",
|
||||
"stores/canvas-store.ts",
|
||||
"stores/versions-store.ts",
|
||||
// The MCP's browser shell, built from these modules by esbuild
|
||||
"packages/mcp-server/shell",
|
||||
]
|
||||
|
||||
const FORBIDDEN = [
|
||||
@@ -92,6 +94,7 @@ describe("canvas import boundary", () => {
|
||||
it("scans the canvas modules", () => {
|
||||
expect(files).toContain("components/canvas/canvas-stage.tsx")
|
||||
expect(files).toContain("lib/drawio/editor-bridge.ts")
|
||||
expect(files).toContain("packages/mcp-server/shell/main.tsx")
|
||||
})
|
||||
|
||||
it.each(files)("%s imports no chat or Next-only module", (file) => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The MCP's canvas shell takes its settings from the config the server
|
||||
* writes into the page, and picks its language and theme in the browser.
|
||||
*/
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { i18n } from "@/lib/i18n/config"
|
||||
import {
|
||||
getShellDictionary,
|
||||
matchLocale,
|
||||
pickLocale,
|
||||
} from "@/packages/mcp-server/shell/dictionary"
|
||||
import { readShellConfig } from "@/packages/mcp-server/shell/runtime-config"
|
||||
import {
|
||||
isDarkPreferred,
|
||||
THEME_STORAGE_KEY,
|
||||
} from "@/packages/mcp-server/shell/theme"
|
||||
|
||||
const shellDir = join(process.cwd(), "packages/mcp-server/shell")
|
||||
|
||||
describe("shell runtime config", () => {
|
||||
it("takes the server's values", () => {
|
||||
const config = readShellConfig({
|
||||
sessionId: "mcp-abc",
|
||||
token: "0123",
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "https://embed.diagrams.net/",
|
||||
drawioUi: "sketch",
|
||||
drawioLang: "ja",
|
||||
drawioDark: "dark",
|
||||
lang: "ja",
|
||||
})
|
||||
expect(config.sessionId).toBe("mcp-abc")
|
||||
expect(config.drawioBaseUrl).toBe("https://embed.diagrams.net/")
|
||||
expect(config.drawioUi).toBe("sketch")
|
||||
expect(config.drawioDark).toBe("dark")
|
||||
})
|
||||
|
||||
it("fills in defaults for what is missing or malformed", () => {
|
||||
expect(readShellConfig(undefined)).toEqual({
|
||||
sessionId: "",
|
||||
token: "",
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "/drawio/index.html",
|
||||
drawioUi: "",
|
||||
drawioLang: "",
|
||||
drawioDark: "auto",
|
||||
lang: "",
|
||||
})
|
||||
const odd = readShellConfig({
|
||||
sessionId: 42,
|
||||
drawioDark: "sometimes",
|
||||
token: "t",
|
||||
})
|
||||
expect(odd.sessionId).toBe("")
|
||||
expect(odd.drawioDark).toBe("auto")
|
||||
expect(odd.token).toBe("t")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell language", () => {
|
||||
it("maps language tags to the dictionaries we have", () => {
|
||||
expect(matchLocale("en-US")).toBe("en")
|
||||
expect(matchLocale("zh")).toBe("zh")
|
||||
expect(matchLocale("zh-CN")).toBe("zh")
|
||||
expect(matchLocale("zh-Hans-CN")).toBe("zh")
|
||||
expect(matchLocale("zh-TW")).toBe("zh-Hant")
|
||||
expect(matchLocale("zh-Hant")).toBe("zh-Hant")
|
||||
expect(matchLocale("ZH-HK")).toBe("zh-Hant")
|
||||
expect(matchLocale("ja-JP")).toBe("ja")
|
||||
expect(matchLocale("de")).toBeNull()
|
||||
expect(matchLocale("")).toBeNull()
|
||||
})
|
||||
|
||||
it("prefers the server's language, then the browser's, then English", () => {
|
||||
expect(pickLocale("zh-tw", ["en-US"])).toBe("zh-Hant")
|
||||
expect(pickLocale("", ["de-DE", "ja-JP", "en"])).toBe("ja")
|
||||
expect(pickLocale("de", ["fr"])).toBe("en")
|
||||
expect(pickLocale("", [])).toBe("en")
|
||||
})
|
||||
|
||||
it("has every dictionary, with the shell's own texts", () => {
|
||||
for (const locale of i18n.locales) {
|
||||
const dict = getShellDictionary(locale)
|
||||
expect(Object.keys(dict.shell).sort(), locale).toEqual([
|
||||
"toggleTheme",
|
||||
"waiting",
|
||||
])
|
||||
expect(dict.shell.waiting, locale).toBeTruthy()
|
||||
expect(dict.workspace.canvasLoading, locale).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell theme", () => {
|
||||
it("follows the stored choice, then the server, then the system", () => {
|
||||
expect(isDarkPreferred("true", "light", false)).toBe(true)
|
||||
expect(isDarkPreferred("false", "dark", true)).toBe(false)
|
||||
expect(isDarkPreferred(null, "dark", false)).toBe(true)
|
||||
expect(isDarkPreferred(null, "light", true)).toBe(false)
|
||||
expect(isDarkPreferred(null, "auto", true)).toBe(true)
|
||||
expect(isDarkPreferred(null, "auto", false)).toBe(false)
|
||||
expect(isDarkPreferred("maybe", "auto", true)).toBe(true)
|
||||
})
|
||||
|
||||
it("is applied before the first paint by the page's own script", () => {
|
||||
const html = readFileSync(join(shellDir, "index.html"), "utf8")
|
||||
expect(THEME_STORAGE_KEY.startsWith("mcp:")).toBe(true)
|
||||
expect(html).toContain(`localStorage.getItem("${THEME_STORAGE_KEY}")`)
|
||||
expect(html).toContain("window.__MCP_CONFIG__ = {{CONFIG_JSON}};")
|
||||
expect(html).toContain('src="/shell/shell.js"')
|
||||
expect(html).toContain('href="/shell/shell.css"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user