dorfteich/packages/plugins/excalidraw/build.mjs
Claude Fable 5 c164a031e4
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 4m19s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
#136 Excalidraw-Block-Plugin
Neues Referenz-Block-Plugin „Excalidraw" (handgezeichnete Whiteboard-
Skizzen), analog zum draw.io-Plugin. Anders als draw.io (vendored Webapp)
ist Excalidraw eine React-npm-Lib: esbuild bündelt Controller + React +
Excalidraw in plugin.js, die Font-/Locale-/Data-Assets werden aus
node_modules in den ZIP-Root kopiert und zur Laufzeit über
EXCALIDRAW_ASSET_PATH (Plugin-Asset-Basis) geladen — nichts spricht mit
excalidraw.com, die Sandbox-CSP pinnt jede Anfrage auf self.

- manifest.json: kind=code, Block-Extension-Point diagram,
  permissions blockData+ui, fallback "[Excalidraw]".
- src/plugin.tsx: Render-Modus zeigt gespeichertes SVG; Edit-Modus zeigt
  Snapshot + Bearbeiten-Knopf (leerer Block öffnet direkt); Vollbild via
  host.ui.enterFullscreen mountet <Excalidraw> (React), „Speichern &
  Beenden" exportiert per exportToSvg, persistiert {scene, svg} über
  host.blockData.setData → Fallback-Renderer bedient Lese-/Public-Ansicht
  + Exporte ohne Backend-Änderung.
- build.mjs: esbuild (jsx automatic, css→text, production-conditions) +
  fflate-ZIP. Build erzeugt excalidraw-1.0.0.zip: 15,5 MiB zip /
  22,3 MiB unpacked (Limits 64/256 MiB — passt).
- i18n de+en, globals.d.ts (CSS-Modul-Deklaration).

pnpm-Override @floating-ui/react-dom@2.1.2: Excalidraw 0.18.1 zieht sonst
@floating-ui/dom@^1.8.0, das (noch) nicht im Registry ist und `pnpm
install` repo-weit bricht (dokumentiert in pnpm-workspace.yaml).

VERIFIZIERT: typecheck/lint, Manifest-Validierung (SDK), Build+ZIP-Größe.
NICHT lokal verifiziert (braucht Preview/Test-Stage): Laufzeit —
Excalidraw-Rendering + Speichern unter Sandbox-CSP, Font-Laden vom
Asset-Pfad. Prod-Installation macht Stefan als Site-Admin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 04:18:04 +02:00

116 lines
4.4 KiB
JavaScript

// Builds the installable Excalidraw plugin. Unlike draw.io (which vendors a
// standalone webapp) Excalidraw is an npm React component: esbuild bundles the
// controller + React + Excalidraw into plugin.js, and its font/worker assets
// are copied from node_modules into `excalidraw-assets/` so the runtime loads
// them from the plugin's own asset path (EXCALIDRAW_ASSET_PATH) — nothing ever
// talks to excalidraw.com, and the sandbox CSP pins every request to self.
import { createRequire } from 'node:module';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { zipSync } from 'fflate';
const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const require = createRequire(import.meta.url);
// --- 1. Locate Excalidraw's prebuilt font/worker assets --------------------
// The package's `exports` map hides ./package.json, so resolve the main entry
// and walk up to the package root instead.
function packageRootOf(specifier) {
let dir = dirname(require.resolve(specifier));
while (dir !== dirname(dir)) {
const pj = join(dir, 'package.json');
if (existsSync(pj)) {
try {
if (JSON.parse(readFileSync(pj, 'utf8')).name === specifier) return dir;
} catch {
// keep walking up
}
}
dir = dirname(dir);
}
throw new Error(`package root not found for ${specifier}`);
}
const excalidrawPkg = packageRootOf('@excalidraw/excalidraw');
const prodDir = join(excalidrawPkg, 'dist', 'prod');
if (!existsSync(prodDir)) {
throw new Error(`Excalidraw prod build not found at ${prodDir}. Check the dist layout.`);
}
// Runtime assets loaded relative to EXCALIDRAW_ASSET_PATH (set to the plugin's
// asset base in plugin.tsx): fonts render the sketch, locales translate the
// editor UI, data holds font metadata. Copied to the ZIP root so the sandbox
// CSP (self only) can serve them.
const ASSET_SUBDIRS = ['fonts', 'locales', 'data'];
/** Recursively collect files below `dir` (absolute), keyed by path relative to
* it, each prefixed with `prefix`. */
function collect(dir, prefix) {
const files = {};
for (const name of readdirSync(dir)) {
const abs = join(dir, name);
const rel = `${prefix}${name}`;
if (statSync(abs).isDirectory()) Object.assign(files, collect(abs, `${rel}/`));
else files[rel] = readFileSync(abs);
}
return files;
}
const assetFiles = {};
for (const sub of ASSET_SUBDIRS) {
const dir = join(prodDir, sub);
if (existsSync(dir)) Object.assign(assetFiles, collect(dir, `${sub}/`));
}
// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------
mkdirSync(join(root, 'dist'), { recursive: true });
await build({
entryPoints: [join(root, 'src/plugin.tsx')],
bundle: true,
format: 'esm',
platform: 'browser',
// Excalidraw's `exports` gate index.js / index.css behind `production` /
// `development` conditions (no `default`); select the production build.
conditions: ['production'],
outfile: join(root, 'dist/plugin.js'),
minify: true,
jsx: 'automatic',
loader: { '.css': 'text', '.woff2': 'dataurl', '.ttf': 'dataurl', '.svg': 'text' },
define: {
'process.env.NODE_ENV': '"production"',
'process.env.IS_PREACT': '"false"',
},
});
// --- 3. Pack the ZIP --------------------------------------------------------
const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
};
for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
}
// Asset paths already carry their subdir prefix (fonts/…, locales/…, data/…)
// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH.
Object.assign(files, assetFiles);
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
rmSync(target, { force: true });
writeFileSync(target, zipSync(files, { level: 6 }));
const unpacked = Object.values(files).reduce((sum, bytes) => sum + bytes.length, 0);
console.log(
`wrote ${relative(process.cwd(), target)} ` +
`(zip ${(statSync(target).size / 1024 / 1024).toFixed(1)} MiB, ` +
`unpacked ${(unpacked / 1024 / 1024).toFixed(1)} MiB, ` +
`${Object.keys(files).length} files)`,
);