Foxel
Menu
On this page

Complete Example: JSON Viewer

This minimal plugin opens .json files in Foxel and displays formatted content or an error. It uses the host React, includes loading and error states, cancels reads on unmount, and needs no backend.

Dependency versions follow the current official plugins and the local Foxel web/package.json; check alignment when upgrading the host. The viewer reads the whole JSON file into browser memory, so use small files.

1. Create the project

Create a standalone project anywhere; it need not be inside the Foxel repository:

bash
mkdir -p com.example.jsonviewer/src com.example.jsonviewer/frontend
cd com.example.jsonviewer

Final layout:

text
com.example.jsonviewer/
├── manifest.json
├── package.json
├── tsconfig.json
├── vite.config.ts
├── pack.py
├── src/
│   ├── contract.ts
│   ├── index.tsx
│   └── styles.css
└── frontend/
    ├── icon.svg
    ├── index.js          # Generated
    └── player.css        # Generated

2. Metadata

Save as manifest.json and replace the author and website. openApp: false enables file mode only. capabilities: [] means no optional host editor or player is required.

json
{
  "foxpkg": "1.0",
  "hostApiVersion": ">=1.0,<2.0",
  "key": "com.example.jsonviewer",
  "name": "JSON 查看器",
  "version": "1.0.0",
  "description": "在 Foxel 中格式化并查看 JSON 文件。",
  "i18n": {
    "zh": {
      "name": "JSON 查看器",
      "description": "在 Foxel 中格式化并查看 JSON 文件。"
    },
    "en": {
      "name": "JSON Viewer",
      "description": "Formats and previews JSON files in Foxel."
    }
  },
  "author": "Your Name",
  "website": "https://example.com",
  "license": "MIT",
  "frontend": {
    "entry": "frontend/index.js",
    "styles": [
      "frontend/player.css"
    ],
    "icon": "frontend/icon.svg",
    "publicDir": "frontend",
    "capabilities": [],
    "openApp": false,
    "supportedExts": [
      "json"
    ],
    "defaultBounds": {
      "width": 900,
      "height": 700
    },
    "useSystemWindow": true
  }
}

3. Build configuration

package.json

json
{
  "name": "com.example.jsonviewer",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "build": "vite build",
    "foxpkg": "vite build && python3 pack.py"
  },
  "peerDependencies": {
    "react": "^19.3.0",
    "react-dom": "^19.3.0"
  },
  "devDependencies": {
    "@types/react": "^19.3.0",
    "@types/react-dom": "^19.3.0",
    "@vitejs/plugin-react": "^6.1.1",
    "react": "^19.3.0",
    "react-dom": "^19.3.0",
    "typescript": "~6.0.3",
    "vite": "^8.3.2"
  }
}

tsconfig.json

json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": [
      "vite/client"
    ]
  },
  "include": [
    "src"
  ]
}

vite.config.ts

Follow the official IIFE / external configuration. Only externalize modules the host exposes; keep the JSX runtime bundled.

typescript
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  publicDir: false,
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
  build: {
    outDir: "frontend",
    emptyOutDir: false,
    sourcemap: false,
    cssCodeSplit: false,
    lib: {
      entry: "src/index.tsx",
      formats: ["iife"],
      name: "FoxelJsonViewer",
      fileName: () => "index.js",
      cssFileName: "player",
    },
    rolldownOptions: {
      external: ["react", "react-dom", "react-dom/client", "antd", "@ant-design/icons"],
      output: {
        globals: {
          react: "window.__FOXEL_EXTERNALS__.React",
          "react-dom": "window.__FOXEL_EXTERNALS__.ReactDOM",
          "react-dom/client": "window.__FOXEL_EXTERNALS__.ReactDOM",
          antd: "window.__FOXEL_EXTERNALS__.antd",
          "@ant-design/icons": "window.__FOXEL_EXTERNALS__.AntdIcons",
        },
      },
    },
  },
});

4. Local type contract

Save as src/contract.ts. This is the subset needed by the example, not a replacement for the full host API. The complete contracts live in Foxel's web/src/plugins/sdk/v1.ts and externals.ts.

typescript
import type * as React from "react";
import type * as ReactDOM from "react-dom/client";

export interface FoxelHostApi {
  close: () => Promise<void>;
  showMessage: (type: "success" | "error" | "info" | "warning", content: string) => void;
  callApi: <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) => Promise<T>;
  getTempLink?: (filePath: string) => Promise<string>;
  getStreamUrl?: (filePath: string) => string;
}

export interface PluginContext {
  filePath: string;
  entry: {
    name: string;
    is_dir: boolean;
    size: number;
    mtime: number;
    type?: string;
    has_thumbnail?: boolean;
  };
  urls: { downloadUrl: string; streamUrl?: string };
  host: FoxelHostApi;
}

export interface PluginAppContext { host: FoxelHostApi }
export type Cleanup = () => void | Promise<void>;
export interface RegisteredPlugin {
  mount: (container: HTMLElement, ctx: PluginContext) => void | Cleanup | Promise<void | Cleanup>;
  mountApp?: (container: HTMLElement, ctx: PluginAppContext) => void | Cleanup | Promise<void | Cleanup>;
  unmount?: (container: HTMLElement) => void | Promise<void>;
  unmountApp?: (container: HTMLElement) => void | Promise<void>;
}

declare global {
  interface Window {
    FoxelRegister?: (plugin: RegisteredPlugin) => void;
    __FOXEL_EXTERNALS__?: {
      React: typeof React;
      ReactDOM: typeof ReactDOM;
    };
  }
}

5. Viewer entry

Save as src/index.tsx. Use the temporary download URL already supplied in context, without assembling paths or handling login tokens. Render content as a React text node.

tsx
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import type { PluginContext } from "./contract";
import "./styles.css";

type ViewState =
  | { status: "loading" }
  | { status: "ready"; text: string }
  | { status: "error"; message: string };

function JsonViewer({ ctx }: { ctx: PluginContext }) {
  const [state, setState] = useState<ViewState>({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });
    void (async () => {
      try {
        const response = await fetch(ctx.urls.downloadUrl, { signal: controller.signal });
        if (!response.ok) throw new Error(`Read failed: ${response.status}`);
        const text = await response.text();
        const value: unknown = JSON.parse(text.replace(/^\uFEFF/, ""));
        if (!controller.signal.aborted) {
          setState({ status: "ready", text: JSON.stringify(value, null, 2) });
        }
      } catch (error) {
        if (!controller.signal.aborted) {
          setState({
            status: "error",
            message: error instanceof Error ? error.message : String(error),
          });
        }
      }
    })();
    return () => controller.abort();
  }, [ctx.urls.downloadUrl]);

  return (
    <main className="jsonviewer">
      <header><strong>{ctx.entry.name}</strong></header>
      {state.status === "loading" && <p role="status">Loading JSON…</p>}
      {state.status === "error" && <p role="alert">{state.message}</p>}
      {state.status === "ready" && <pre>{state.text}</pre>}
    </main>
  );
}

const register = window.FoxelRegister;
if (!register || !window.__FOXEL_EXTERNALS__) {
  throw new Error("Open this plugin inside Foxel");
}
register({
  mount(container, ctx) {
    const root = createRoot(container);
    root.render(<JsonViewer ctx={ctx} />);
    return () => root.unmount();
  },
});

6. Styles and icon

src/styles.css

The example uses a fixed light canvas for readable text. Add theme synchronization later using the frontend guide and official plugins.

css
.jsonviewer {
  box-sizing: border-box;
  height: 100%;
  overflow: auto;
  padding: 16px;
  background: #ffffff;
  color: #1f2937;
  font: 14px/1.6 system-ui, sans-serif;
}
.jsonviewer header { margin-bottom: 12px; }
.jsonviewer pre { margin: 0; white-space: pre; tab-size: 2; }
.jsonviewer [role="alert"] { color: #b91c1c; }

frontend/icon.svg

xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
  <rect width="64" height="64" rx="12" fill="#2563eb"/>
  <text x="32" y="43" text-anchor="middle" font-size="34" fill="white">{ }</text>
</svg>

7. Packer

Save as pack.py at the project root. This is the standalone packer from build and package; it does not depend on Foxel Python modules.

python
import json
import re
import zipfile
from pathlib import Path, PurePosixPath

ROOT = Path(__file__).resolve().parent
manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8"))
key = manifest["key"]
if not re.fullmatch(r"[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+", key):
    raise SystemExit("Invalid plugin key")
if manifest.get("foxpkg") != "1.0":
    raise SystemExit("Unsupported foxpkg format")

files = {"manifest.json"}
for folder in ("frontend", "backend", "assets"):
    for path in (ROOT / folder).rglob("*"):
        if path.is_symlink():
            raise SystemExit(f"Symlink is not allowed: {path}")
        if path.is_file() and "__pycache__" not in path.parts:
            files.add(path.relative_to(ROOT).as_posix())

frontend = manifest.get("frontend") or {}
required = [frontend.get("entry"), *(frontend.get("styles") or []),
            *(frontend.get("assets") or [])]
icon = frontend.get("icon")
if icon and not icon.startswith(("/", "http://", "https://")):
    required.append(icon)
backend = manifest.get("backend") or {}
for group in ("routes", "processors"):
    required.extend(item["module"] for item in backend.get(group) or [])

for name in files | {value for value in required if value}:
    path = PurePosixPath(name)
    if path.is_absolute() or ".." in path.parts or "\\" in name:
        raise SystemExit(f"Invalid archive path: {name}")
for name in required:
    if name and name not in files:
        raise SystemExit(f"Missing package file: {name}")

out = ROOT / "dist" / f"{key}.foxpkg"
out.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
    for name in sorted(files):
        archive.write(ROOT / name, name)
with zipfile.ZipFile(out) as archive:
    bad = archive.testzip()
    if bad:
        raise SystemExit(f"Corrupt archive member: {bad}")
print(out)

8. Build and install

bash
bun install
bun run typecheck
bun run foxpkg

Output: dist/com.example.jsonviewer.foxpkg. Do not add an enclosing project directory to the archive. Run the host-side package validation from build and package if desired, then upload through Foxel's plugin page.

Create example.json:

json
{"hello":"Foxel","items":[1,2,3]}

Upload it to a mount you can read and choose JSON Viewer in “Open with”. You should see the filename and JSON formatted with two-space indentation. No standalone app entry is expected because this is a file-only plugin.

9. Verify and extend

  • Valid JSON displays formatted content, including arrays, strings, and null.
  • Invalid JSON displays a parse error.
  • Failed reads display HTTP or network errors instead of remaining in a loading state.
  • Closing during loading unmounts React and cancels the request.
  • After changes, rebuild and reinstall, then close old windows and reopen.

This example does not save files or provide a JSON tree, search, or backend validation API. Add an explicit save action with the frontend VFS API for editing. For server validation or formatting tasks, add Python files and declarations following backend development, then repackage.

Explore the official Foxel-Studio plugins: Markdown demonstrates capabilities, Monaco demonstrates editing and saving, and Music / Image demonstrate complex interfaces and resource cleanup.