// 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'; import { thirdPartyNotices } from '../third-party-licenses.mjs'; 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 }); const buildResult = 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"', }, metafile: true, }); // --- 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); // License texts for the redistributed material (issue #345): bundled npm // packages come from the metafile; the shipped fonts have no license files // upstream at all, so the texts are curated in licenses/ (see FONT-NOTICES.md) // — @excalidraw/excalidraw ships no LICENSE file either, hence the committed // MIT text instead of the metafile fallback line. for (const name of readdirSync(join(root, 'licenses'))) { files[`licenses/${name}`] = readFileSync(join(root, 'licenses', name)); } files['licenses/THIRD-PARTY-NOTICES.txt'] = Buffer.from( thirdPartyNotices(buildResult.metafile, [ { title: 'Excalidraw (bundled into plugin.js)', license: 'MIT', note: 'Full license text in licenses/excalidraw-MIT.txt.', }, { title: 'Fonts (shipped under fonts/)', license: 'OFL-1.1 and MIT, per family', note: 'Attribution table in licenses/FONT-NOTICES.md; per-font license texts alongside it.', }, ]), ); 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)`, );