dorfteich/packages/plugins/excalidraw/build.mjs
Claude Fable 5 cc9c70287c
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m51s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Successful in 9m28s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m57s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m7s
CI / Import/export fidelity gate (push) Successful in 57s
Restore drill / Restore the latest backup into a scratch stack (push) Failing after 17s
Ship third-party license texts in plugin ZIPs (#345)
The drawio, excalidraw, and mermaid plugin packages redistribute
third-party material (the draw.io webapp, the Excalidraw editor and its
fonts, mermaid and its dependency tree) without the license texts their
licenses require. Every affected ZIP now carries a licenses/ directory:

- licenses/THIRD-PARTY-NOTICES.txt is generated from the esbuild
  metafile (packages/plugins/third-party-licenses.mjs), so the notice
  list is derived from what actually lands in plugin.js and cannot
  drift the way a hand-maintained list would.
- drawio additionally extracts the upstream LICENSE from the pinned
  release tarball (Apache-2.0 requires the text with redistribution);
  the extraction guard also heals vendor/ caches from before this
  change. The CI fast path (no vendor fetch, no ZIP) is unchanged.
- excalidraw additionally commits curated texts (MIT for Excalidraw,
  per-font OFL-1.1/MIT with each font's own copyright statement, plus
  a FONT-NOTICES.md attribution table), because neither the npm
  package nor upstream ships any license files for them.

The api-side package validator accepts additional ZIP entries, so
installed plugins are unaffected beyond the new files.

Closes #345

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-16 19:00:26 +02:00

142 lines
5.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';
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)`,
);