mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
feat(mcp-server): bundle a trimmed draw.io into dist/drawio at build time
scripts/fetch-drawio.mjs downloads the pinned draw.war into a cache (DRAWIO_WAR_CACHE, or DRAWIO_WAR for a local file), checks its SHA-256 and extracts the files named in drawio-files.txt plus a LICENSE with the Apache-2.0 text. scripts/check-drawio-files.mjs drives the embedded editor in headless Chromium from a full copy, records every requested file and checks or rewrites (--update) the list.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Files copied from draw.war into dist/drawio by scripts/fetch-drawio.mjs
|
||||
# (the preview page embeds this copy from its own origin). `*` matches within
|
||||
# a path segment, `**` across segments.
|
||||
#
|
||||
# Always included: the language files, the image icon libraries (loaded one
|
||||
# icon at a time, as diagrams use them), the stylesheets, and the licenses of
|
||||
# the icon sets.
|
||||
img/LICENSE
|
||||
img/lib/**
|
||||
mxgraph/css/**
|
||||
resources/dia*.txt
|
||||
shapes/LICENSE
|
||||
stencils/LICENSE
|
||||
styles/**
|
||||
|
||||
# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update
|
||||
images/droptarget.png
|
||||
images/github-logo.svg
|
||||
images/logo-flat-small.png
|
||||
images/sidebar-general.png
|
||||
images/spin.gif
|
||||
img/clipart/Gear_128x128.png
|
||||
index.html
|
||||
js/PostConfig.js
|
||||
js/PreConfig.js
|
||||
js/app.min.js
|
||||
js/bootstrap.js
|
||||
js/extensions.min.js
|
||||
js/main.js
|
||||
js/shapes-14-6-5.min.js
|
||||
js/stencils.min.js
|
||||
math4/es5/drawio-mathjax.min.js
|
||||
mxgraph/images/maximize.gif
|
||||
shortcuts.svg
|
||||
@@ -8,7 +8,7 @@
|
||||
"next-ai-drawio-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-assets.mjs",
|
||||
"build": "tsc && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs",
|
||||
"check-package": "node scripts/check-package.mjs",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
/**
|
||||
* Guard for drawio-files.txt: opens the embedded editor from a FULL draw.io
|
||||
* copy in headless Chromium, uses it the way the preview page does (every
|
||||
* theme the user can switch to, diagrams with shapes from the icon libraries,
|
||||
* the format panel, the shape sidebar, the menus, the page tabs, the export
|
||||
* and edit dialogs, exports) and records every file the editor requests.
|
||||
*
|
||||
* node scripts/check-drawio-files.mjs [--drawio <dir>] [--update]
|
||||
*
|
||||
* The full copy is --drawio, else the web app's public/drawio. Without
|
||||
* --update it fails when the editor requested a file the list does not
|
||||
* have; with --update it rewrites the requested section of the list.
|
||||
* Needs Playwright from the repository root (npm ci at the root).
|
||||
*/
|
||||
import http from "node:http"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import {
|
||||
formatFileList,
|
||||
parseFileList,
|
||||
readFileList,
|
||||
} from "./drawio-file-list.mjs"
|
||||
|
||||
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const LIST = path.join(PKG, "drawio-files.txt")
|
||||
const args = process.argv.slice(2)
|
||||
const update = args.includes("--update")
|
||||
const drawioDir = path.resolve(
|
||||
args.includes("--drawio")
|
||||
? args[args.indexOf("--drawio") + 1]
|
||||
: path.join(PKG, "../../public/drawio"),
|
||||
)
|
||||
if (!existsSync(path.join(drawioDir, "index.html"))) {
|
||||
console.error(
|
||||
`No draw.io copy at ${drawioDir}. Run "npm run dev" once at the repository root (it downloads one into public/drawio), or pass --drawio <dir>.`,
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
const playwrightEntry = path.join(
|
||||
PKG,
|
||||
"../../node_modules/playwright/index.mjs",
|
||||
)
|
||||
if (!existsSync(playwrightEntry)) {
|
||||
console.error("Playwright is missing: run npm ci at the repository root.")
|
||||
process.exit(2)
|
||||
}
|
||||
const { chromium } = await import(pathToFileURL(playwrightEntry).href)
|
||||
|
||||
// The themes a user can switch to under Extras > Theme (themes=1)
|
||||
const THEMES = ["kennedy", "min", "atlas", "dark", "sketch", "simple"]
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".json": "application/json",
|
||||
".xml": "text/xml",
|
||||
".txt": "text/plain",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
|
||||
// The page that embeds the editor, with the preview page's iframe query
|
||||
const parentPage = (ui) => `<!doctype html><html><body style="margin:0">
|
||||
<iframe id="f" style="width:1280px;height:860px;border:0" src="/drawio/index.html?embed=1&proto=json&configure=1&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=0&ui=${ui}"></iframe>
|
||||
<script>
|
||||
const f = document.getElementById('f')
|
||||
window.exports = 0
|
||||
window.addEventListener('message', (e) => {
|
||||
let m; try { m = JSON.parse(e.data) } catch { return }
|
||||
if (m.event === 'configure') f.contentWindow.postMessage(JSON.stringify({ action: 'configure', config: {} }), '*')
|
||||
if (m.event === 'init') window.ready = true
|
||||
if (m.event === 'export') window.exports++
|
||||
})
|
||||
window.send = (msg) => f.contentWindow.postMessage(JSON.stringify(msg), '*')
|
||||
</script></body></html>`
|
||||
|
||||
// Every path the editor asked for, in order; and the ones the full copy lacks
|
||||
const requested = []
|
||||
const notFound = new Set()
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost")
|
||||
if (url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(parentPage(url.searchParams.get("ui") || "kennedy"))
|
||||
return
|
||||
}
|
||||
const rel = path.posix.normalize(
|
||||
decodeURIComponent(url.pathname.replace(/^\/drawio\//, "")),
|
||||
)
|
||||
const file = path.join(drawioDir, rel)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(file)
|
||||
} catch {
|
||||
notFound.add(rel)
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (!requested.includes(rel)) requested.push(rel)
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[path.extname(file)] || "application/octet-stream",
|
||||
"Content-Length": stat.size,
|
||||
})
|
||||
res.end(readFileSync(file))
|
||||
})
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r))
|
||||
const port = server.address().port
|
||||
|
||||
const mb = (n) => (n / 1048576).toFixed(2)
|
||||
let seen = 0
|
||||
function report(step) {
|
||||
const added = requested.slice(seen)
|
||||
seen = requested.length
|
||||
if (added.length === 0) return
|
||||
const bytes = added.reduce(
|
||||
(n, f) => n + statSync(path.join(drawioDir, f)).size,
|
||||
0,
|
||||
)
|
||||
const names =
|
||||
added.slice(0, 5).join(", ") + (added.length > 5 ? ", ..." : "")
|
||||
console.log(
|
||||
` ${step}: +${added.length} files, +${mb(bytes)} MB [${names}]`,
|
||||
)
|
||||
}
|
||||
|
||||
// Diagrams: a plain shape, one shape from each icon library, and the styles
|
||||
// that make draw.io load code on demand (hand-drawn look, math)
|
||||
const cell = (id, style, x) =>
|
||||
`<mxCell id="${id}" value="${id}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="60" height="60" as="geometry"/></mxCell>`
|
||||
const doc = (cells, attrs = "") =>
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel ${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
const LIBS = {
|
||||
plain: "rounded=1;whiteSpace=wrap;html=1;",
|
||||
aws4: "shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.a1_instance;fillColor=#ED7100;strokeColor=#ffffff;",
|
||||
gcp2: "shape=mxgraph.gcp2.a7_power;fillColor=#4285F4;strokeColor=none;",
|
||||
kubernetes:
|
||||
"shape=mxgraph.kubernetes.icon;prIcon=api;fillColor=#326CE5;strokeColor=none;",
|
||||
cisco19:
|
||||
"shape=mxgraph.cisco19.rect;prIcon=aci;fillColor=#00bceb;strokeColor=none;",
|
||||
flowchart:
|
||||
"shape=mxgraph.flowchart.annotation_1;fillColor=#d5e8d4;strokeColor=#82b366;",
|
||||
webicons:
|
||||
"shape=mxgraph.webicons.adfty;fillColor=#3b5998;strokeColor=none;",
|
||||
bpmn: "shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;",
|
||||
networks:
|
||||
"shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;",
|
||||
azure2_image:
|
||||
"image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;",
|
||||
sketch: "rounded=1;whiteSpace=wrap;html=1;sketch=1;",
|
||||
}
|
||||
const MATH_DOC = doc(
|
||||
cell("math", "rounded=1;whiteSpace=wrap;html=1;", 40).replace(
|
||||
'value="math"',
|
||||
'value="$$x^2$$"',
|
||||
),
|
||||
'math="1"',
|
||||
)
|
||||
|
||||
// Dialogs and toggles, by draw.io action name (Actions.js); each is closed
|
||||
// again right after it opened. Left out on purpose, to keep the package
|
||||
// small: Insert > Template (5.6 MB of templates and previews) and the
|
||||
// Insert > Advanced tools that load code when used (Mermaid, PlantUML).
|
||||
const ACTIONS = [
|
||||
"editData",
|
||||
"editDiagram",
|
||||
"editStyle",
|
||||
"editGeometry",
|
||||
"editTooltip",
|
||||
"editLink",
|
||||
"insertImage",
|
||||
"exportPng",
|
||||
"exportSvg",
|
||||
"exportJpg",
|
||||
"exportWebp",
|
||||
"exportPdf",
|
||||
"exportXml",
|
||||
"exportHtml",
|
||||
"exportVsdx",
|
||||
"embedHtml",
|
||||
"embedImage",
|
||||
"embedSvg",
|
||||
"embedIframe",
|
||||
"about",
|
||||
"pageSetup",
|
||||
"print",
|
||||
"findReplace",
|
||||
"tags",
|
||||
"layers",
|
||||
"outline",
|
||||
"shapes",
|
||||
"scratchpad",
|
||||
"plugins",
|
||||
"properties",
|
||||
"fromText",
|
||||
"csv",
|
||||
"runLayout",
|
||||
"insertFreehand",
|
||||
"comments",
|
||||
"customZoom",
|
||||
"renamePage",
|
||||
"format",
|
||||
"format",
|
||||
"toggleShapes",
|
||||
"toggleShapes",
|
||||
"darkMode",
|
||||
"lightMode",
|
||||
"autoMode",
|
||||
"highContrast",
|
||||
"highContrast",
|
||||
"mathematicalTypesetting",
|
||||
"mathematicalTypesetting",
|
||||
"keyboardShortcuts",
|
||||
]
|
||||
// Enough for the other themes, whose dialogs are the same code
|
||||
const LIGHT_ACTIONS = ["editData", "exportPng", "shapes", "format", "format"]
|
||||
|
||||
const wait = (page, ms) => page.waitForTimeout(ms)
|
||||
|
||||
/**
|
||||
* Open the editor in one theme and use it. The default theme gets the full
|
||||
* tour; the others the parts that differ by theme (chrome, menus, panels).
|
||||
*/
|
||||
async function drive(browser, ui, full) {
|
||||
console.log(`theme ${ui}${full ? "" : " (light)"}`)
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1300, height: 900 },
|
||||
})
|
||||
page.on("popup", (p) => p.close().catch(() => {}))
|
||||
await page.goto(`http://localhost:${port}/?ui=${ui}`)
|
||||
await page.waitForFunction(() => window.ready === true, null, {
|
||||
timeout: 60000,
|
||||
})
|
||||
await wait(page, 2000)
|
||||
report("editor ready")
|
||||
|
||||
const load = (xml) =>
|
||||
page.evaluate(
|
||||
(x) => window.send({ action: "load", xml: x, autosave: 1 }),
|
||||
xml,
|
||||
)
|
||||
let x = 40
|
||||
for (const [name, style] of Object.entries(LIBS)) {
|
||||
await load(doc(cell(name, style, (x += 100))))
|
||||
await wait(page, full ? 1500 : 500)
|
||||
report(`load ${name}`)
|
||||
if (!full) break
|
||||
}
|
||||
if (full) {
|
||||
await load(MATH_DOC)
|
||||
await wait(page, 2500)
|
||||
report("load math")
|
||||
}
|
||||
|
||||
// The editor instance: in embed mode draw.io calls a plugin callback
|
||||
// registered after startup right away
|
||||
const frame = page.frames().find((f) => f.url().includes("/drawio/"))
|
||||
await frame.evaluate(() => {
|
||||
window.Draw.loadPlugin((u) => {
|
||||
window.__ui = u
|
||||
})
|
||||
window.__ui.editor.graph.selectAll()
|
||||
})
|
||||
const closeAll = async () => {
|
||||
await frame.evaluate(() => {
|
||||
const ui = window.__ui
|
||||
while (ui.dialogs && ui.dialogs.length > 0) ui.hideDialog()
|
||||
ui.hideCurrentMenu?.()
|
||||
})
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, 100)
|
||||
}
|
||||
|
||||
let opened = 0
|
||||
const actions = full ? ACTIONS : LIGHT_ACTIONS
|
||||
for (const name of actions) {
|
||||
const result = await frame.evaluate((n) => {
|
||||
const ui = window.__ui
|
||||
const action = ui.actions.get(n)
|
||||
if (!action) return "missing"
|
||||
try {
|
||||
action.funct()
|
||||
} catch (e) {
|
||||
return `failed: ${e.message}`
|
||||
}
|
||||
return ui.dialog || ui.dialogs?.length ? "dialog" : "ok"
|
||||
}, name)
|
||||
if (result === "dialog") opened++
|
||||
if (result !== "ok" && result !== "dialog") {
|
||||
console.log(` action ${name}: ${result}`)
|
||||
}
|
||||
await wait(page, 400)
|
||||
report(`action ${name}`)
|
||||
await closeAll()
|
||||
}
|
||||
console.log(` ${opened} of ${actions.length} actions opened a dialog`)
|
||||
|
||||
// Menus: the visible menubar items, the toolbar buttons that open a menu
|
||||
// in the other themes, and every menu rendered with its submenus
|
||||
const menubarItems = frame.locator(".geMenubar .geItem:visible")
|
||||
const menubarCount = await menubarItems.count()
|
||||
for (let i = 0; i < menubarCount; i++) {
|
||||
await menubarItems
|
||||
.nth(i)
|
||||
.click({ timeout: 2000 })
|
||||
.catch(() => {})
|
||||
await wait(page, 300)
|
||||
await closeAll()
|
||||
}
|
||||
let toolbarCount = 0
|
||||
for (const title of [
|
||||
"Diagram",
|
||||
"Insert",
|
||||
"Shapes",
|
||||
"Pages",
|
||||
"View",
|
||||
"Table",
|
||||
"Layout",
|
||||
]) {
|
||||
const button = frame.locator(`a[title="${title}"]:visible`).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
toolbarCount++
|
||||
await button.click({ timeout: 2000 }).catch(() => {})
|
||||
await wait(page, 300)
|
||||
await closeAll()
|
||||
}
|
||||
const failed = await frame.evaluate(() => {
|
||||
const ui = window.__ui
|
||||
const failed = []
|
||||
for (const name of Object.keys(ui.menus.menus)) {
|
||||
const menu = new window.mxPopupMenu()
|
||||
menu.init()
|
||||
document.body.appendChild(menu.div)
|
||||
try {
|
||||
ui.menus.get(name).funct(menu, null)
|
||||
} catch {
|
||||
failed.push(name)
|
||||
}
|
||||
menu.destroy()
|
||||
}
|
||||
return failed
|
||||
})
|
||||
await wait(page, 500)
|
||||
console.log(
|
||||
` menus: ${menubarCount} menubar items, ${toolbarCount} toolbar menus clicked` +
|
||||
(failed.length ? `; not rendered: ${failed.join(", ")}` : ""),
|
||||
)
|
||||
report("menus")
|
||||
|
||||
// Format panel tabs, with a shape selected
|
||||
await frame.evaluate(() => window.__ui.editor.graph.selectAll())
|
||||
for (const tab of ["Style", "Text", "Arrange", "Diagram"]) {
|
||||
const el = frame
|
||||
.locator(`.geFormatContainer div:text-is("${tab}")`)
|
||||
.first()
|
||||
if ((await el.count()) > 0)
|
||||
await el.click({ timeout: 2000 }).catch(() => {})
|
||||
await wait(page, 200)
|
||||
}
|
||||
report("format panel tabs")
|
||||
|
||||
// Shape sidebar: open every palette, then search
|
||||
await frame.evaluate(() => {
|
||||
const ui = window.__ui
|
||||
for (const title of document.querySelectorAll(
|
||||
".geSidebarContainer .geTitle",
|
||||
)) {
|
||||
title.click()
|
||||
}
|
||||
ui.sidebar.searchShapes?.("server")
|
||||
})
|
||||
await wait(page, full ? 2500 : 1000)
|
||||
report("sidebar palettes and search")
|
||||
|
||||
// Pages: insert, switch, duplicate, remove, and the tab bar
|
||||
for (const name of [
|
||||
"insertPage",
|
||||
"nextPage",
|
||||
"previousPage",
|
||||
"duplicatePage",
|
||||
"removePage",
|
||||
]) {
|
||||
await frame.evaluate((n) => window.__ui.actions.get(n)?.funct(), name)
|
||||
await wait(page, 150)
|
||||
await closeAll()
|
||||
}
|
||||
for (const title of ["Insert Page", "Pages"]) {
|
||||
const el = frame.locator(`a[title="${title}"]:visible`).first()
|
||||
if ((await el.count()) > 0)
|
||||
await el.click({ timeout: 2000 }).catch(() => {})
|
||||
await wait(page, 300)
|
||||
await closeAll()
|
||||
}
|
||||
report("page tabs")
|
||||
|
||||
if (full) {
|
||||
for (const format of ["png", "svg", "xmlsvg", "xml"]) {
|
||||
await page.evaluate(
|
||||
(f) => window.send({ action: "export", format: f, scale: 2 }),
|
||||
format,
|
||||
)
|
||||
await wait(page, 1500)
|
||||
report(`export ${format}`)
|
||||
}
|
||||
const exports = await page.evaluate(() => window.exports)
|
||||
if (exports < 4) console.log(` only ${exports} of 4 exports answered`)
|
||||
}
|
||||
await page.close()
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
for (const ui of THEMES) await drive(browser, ui, ui === THEMES[0])
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
}
|
||||
|
||||
if (notFound.size > 0) {
|
||||
console.log(
|
||||
`\nNot in the full copy either (ignored): ${[...notFound].join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Compare with the list
|
||||
const list = readFileList(LIST)
|
||||
const missing = requested.filter((f) => !list.matches(f)).sort()
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) yield* walk(full)
|
||||
else yield path.relative(drawioDir, full).split(path.sep).join("/")
|
||||
}
|
||||
}
|
||||
const summarize = (fileList) => {
|
||||
let count = 0
|
||||
let bytes = 0
|
||||
for (const rel of walk(drawioDir)) {
|
||||
if (!fileList.matches(rel)) continue
|
||||
count++
|
||||
bytes += statSync(path.join(drawioDir, rel)).size
|
||||
}
|
||||
return `${count} files, ${mb(bytes)} MB unpacked`
|
||||
}
|
||||
|
||||
if (update) {
|
||||
// Only the head decides what is already covered by "always include"
|
||||
const always = parseFileList(list.head)
|
||||
const kept = requested.filter((f) => !always.matches(f))
|
||||
writeFileSync(LIST, formatFileList(list.head, kept))
|
||||
console.log(`\nWrote ${LIST}: ${summarize(readFileList(LIST))}`)
|
||||
} else if (missing.length > 0) {
|
||||
console.error(
|
||||
`\nThe editor requested ${missing.length} files that drawio-files.txt does not list:\n ${missing.join("\n ")}\nRun with --update to add them.`,
|
||||
)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log(`\ndrawio-files.txt is complete: ${summarize(list)}`)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* drawio-files.txt: the files copied from draw.war into dist/drawio.
|
||||
*
|
||||
* One path per line; `*` matches within a path segment and `**` across
|
||||
* segments; `#` starts a comment. Everything above the marker line is kept
|
||||
* as it is ("always include"); the paths below it are what the embedded
|
||||
* editor requested in a guard run, rewritten by check-drawio-files.mjs
|
||||
* --update.
|
||||
*/
|
||||
import fs from "node:fs"
|
||||
|
||||
export const MARKER =
|
||||
"# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update"
|
||||
|
||||
function toRegExp(pattern) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
const source = escaped
|
||||
.replace(/\*\*/g, "\0")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\0/g, ".*")
|
||||
return new RegExp(`^${source}$`)
|
||||
}
|
||||
|
||||
const entries = (text) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"))
|
||||
|
||||
export function parseFileList(text) {
|
||||
const at = text.indexOf(MARKER)
|
||||
if (at < 0) throw new Error(`drawio-files.txt has no "${MARKER}" line`)
|
||||
const head = text.slice(0, at + MARKER.length)
|
||||
const always = entries(head)
|
||||
const requested = entries(text.slice(at + MARKER.length))
|
||||
|
||||
const exact = new Set()
|
||||
const patterns = []
|
||||
for (const entry of [...always, ...requested]) {
|
||||
if (entry.includes("*")) {
|
||||
patterns.push({ text: entry, re: toRegExp(entry), hit: false })
|
||||
} else {
|
||||
exact.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
head,
|
||||
always,
|
||||
requested,
|
||||
/** Whether the list names this file (remembers which patterns hit) */
|
||||
matches(name) {
|
||||
if (exact.has(name)) return true
|
||||
let found = false
|
||||
for (const p of patterns) {
|
||||
if (p.re.test(name)) p.hit = found = true
|
||||
}
|
||||
return found
|
||||
},
|
||||
/** Entries that matched none of `seen` after matches() ran on them */
|
||||
unmatched(seen) {
|
||||
return [
|
||||
...[...exact].filter((name) => !seen.has(name)),
|
||||
...patterns.filter((p) => !p.hit).map((p) => p.text),
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function readFileList(file) {
|
||||
return parseFileList(fs.readFileSync(file, "utf8"))
|
||||
}
|
||||
|
||||
/** The list text with a new requested section (sorted, unique) */
|
||||
export function formatFileList(head, requested) {
|
||||
const body = [...new Set(requested)].sort().join("\n")
|
||||
return `${head}\n${body}\n`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build step: put a trimmed copy of draw.io into dist/drawio, so the preview
|
||||
* page can embed the editor from its own origin (same-origin editor access,
|
||||
* and no internet needed at runtime).
|
||||
*
|
||||
* Downloads the pinned draw.war (see src/drawio-version.json) into a cache
|
||||
* directory (DRAWIO_WAR_CACHE, default <tmpdir>/next-ai-drawio), checks its
|
||||
* SHA-256, and extracts only the files listed in drawio-files.txt. Set
|
||||
* DRAWIO_WAR=<path> to use a local war without downloading. The list is kept
|
||||
* up to date by scripts/check-drawio-files.mjs.
|
||||
*/
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import {
|
||||
readDrawioVersion,
|
||||
readZipEntries,
|
||||
sha256,
|
||||
} from "../../../scripts/drawio-zip.mjs"
|
||||
import { readFileList } from "./drawio-file-list.mjs"
|
||||
|
||||
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const DEST = path.join(PKG, "dist/drawio")
|
||||
const { version, sha256: expectedSha, downloadUrl } = readDrawioVersion()
|
||||
|
||||
function log(message) {
|
||||
console.log(`[fetch-drawio] ${message}`)
|
||||
}
|
||||
|
||||
/** The draw.war as a buffer, from DRAWIO_WAR, the cache, or GitHub */
|
||||
async function readWar() {
|
||||
const verified = (buf, source) => {
|
||||
const actual = sha256(buf)
|
||||
if (actual !== expectedSha) {
|
||||
throw new Error(
|
||||
`${source} is not draw.io ${version} (SHA-256 ${actual})`,
|
||||
)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
if (process.env.DRAWIO_WAR) {
|
||||
log(`Using ${process.env.DRAWIO_WAR}`)
|
||||
return verified(fs.readFileSync(process.env.DRAWIO_WAR), "DRAWIO_WAR")
|
||||
}
|
||||
|
||||
const cacheDir =
|
||||
process.env.DRAWIO_WAR_CACHE || path.join(os.tmpdir(), "next-ai-drawio")
|
||||
const cached = path.join(cacheDir, `draw-${version}.war`)
|
||||
if (fs.existsSync(cached)) {
|
||||
try {
|
||||
return verified(fs.readFileSync(cached), cached)
|
||||
} catch (error) {
|
||||
log(`${error.message}; downloading again`)
|
||||
fs.rmSync(cached, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
log(`Downloading draw.io ${version} ...`)
|
||||
const res = await fetch(downloadUrl)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${downloadUrl}`)
|
||||
const buf = verified(Buffer.from(await res.arrayBuffer()), downloadUrl)
|
||||
fs.mkdirSync(cacheDir, { recursive: true })
|
||||
const tmp = `${cached}.tmp-${process.pid}`
|
||||
fs.writeFileSync(tmp, buf)
|
||||
fs.renameSync(tmp, cached)
|
||||
log(`Cached at ${cached}`)
|
||||
return buf
|
||||
}
|
||||
|
||||
/** Apache-2.0 text with a notice naming draw.io, for dist/drawio/LICENSE */
|
||||
function licenseText(warLicense) {
|
||||
const notice =
|
||||
`The files in this directory are an unmodified subset of draw.io ${version}\n` +
|
||||
"(https://github.com/jgraph/drawio), Copyright (c) JGraph Ltd, redistributed\n" +
|
||||
"under the Apache License, Version 2.0 reproduced below. The icon sets under\n" +
|
||||
"img/lib and the stencils built into the scripts have additional terms, see\n" +
|
||||
"img/LICENSE, shapes/LICENSE and stencils/LICENSE.\n\n"
|
||||
if (warLicense) return notice + warLicense
|
||||
// The standard terms, without this project's own copyright notice
|
||||
const apache = fs.readFileSync(path.join(PKG, "../../LICENSE"), "utf8")
|
||||
const end = "END OF TERMS AND CONDITIONS"
|
||||
return notice + apache.slice(0, apache.indexOf(end) + end.length) + "\n"
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = readFileList(path.join(PKG, "drawio-files.txt"))
|
||||
const buf = await readWar()
|
||||
|
||||
const tmp = `${DEST}.tmp-${process.pid}`
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
const written = new Set()
|
||||
let bytes = 0
|
||||
let warLicense = null
|
||||
for (const { name, data } of readZipEntries(buf)) {
|
||||
// Server-side parts of the war; never shipped, whatever the list says
|
||||
if (/^(WEB-INF|META-INF)\//.test(name)) continue
|
||||
if (name === "LICENSE") warLicense = data.toString("utf8")
|
||||
if (!list.matches(name)) continue
|
||||
const target = path.join(tmp, name)
|
||||
if (!target.startsWith(tmp + path.sep)) {
|
||||
throw new Error(`Unsafe path in archive: ${name}`)
|
||||
}
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(target, data)
|
||||
written.add(name)
|
||||
bytes += data.length
|
||||
}
|
||||
|
||||
const missing = list.unmatched(written)
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`drawio-files.txt names files that draw.io ${version} does not have:\n ${missing.join("\n ")}\n` +
|
||||
"Run scripts/check-drawio-files.mjs --update to refresh the list.",
|
||||
)
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(tmp, "LICENSE"), licenseText(warLicense))
|
||||
fs.writeFileSync(path.join(tmp, ".version"), `${version}\n`)
|
||||
fs.rmSync(DEST, { recursive: true, force: true })
|
||||
fs.renameSync(tmp, DEST)
|
||||
log(
|
||||
`Installed ${written.size} files (${(bytes / 1048576).toFixed(1)} MB) into dist/drawio`,
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[fetch-drawio] ${error.stack || error}`)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user