import esbuild from "esbuild"; import process from "process"; import { builtinModules as builtins } from "node:module"; import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, unlinkSync, watch as fsWatch } from "fs"; import { join } from "path"; import esbuildSvelte from "esbuild-svelte"; import { sveltePreprocess } from "svelte-preprocess"; const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ `; const prod = (process.argv[2] === "production"); // Clean up build artifacts that aren"t needed (main.css, main.js, font files) const CLEANUP_BUILD_ARTIFACTS = true; // Set to false if you need to debug these files // Function to copy directory recursively function copyDir(src, dest) { if (!existsSync(dest)) { mkdirSync(dest, { recursive: true }); } const files = readdirSync(src); for (const file of files) { const srcPath = join(src, file); const destPath = join(dest, file); if (statSync(srcPath).isDirectory()) { copyDir(srcPath, destPath); } else { copyFileSync(srcPath, destPath); console.log(`📁 Copied: ${srcPath} → ${destPath}`); } } } // Neutralises `new Function(...)` literals in the bundled output. // // All such literals come from dependencies, never from our own source, and every // one is dead code in our usage: // - diff2html/@profoundlogic/hogan: 2x template compilers reachable only via // Hogan.compile(), which diff2html invokes only for runtime `rawTemplates`. // We use diff2html's precompiled defaults, so these never run. // // We rewrite them to a throwing stub so the bundle contains no `new Function(` // literal, which clears the Obsidian scanner's "Dynamic Code Execution" warning. The // build FAILS if the count drifts from EXPECTED_NEW_FUNCTION_COUNT, so a dependency // change can never silently reintroduce a live (or un-neutralised) dynamic-eval path. // // 2 = Hogan. const EXPECTED_NEW_FUNCTION_COUNT = 2; const NEW_FUNCTION_STUB = "VKBlockedDynamicFn"; function neutraliseDynamicEval(outfile) { if (!existsSync(outfile)) return; let contents = readFileSync(outfile, "utf-8"); const matches = contents.match(/new Function\s*\(/g) || []; if (matches.length !== EXPECTED_NEW_FUNCTION_COUNT) { throw new Error( `neutraliseDynamicEval: expected ${EXPECTED_NEW_FUNCTION_COUNT} ` + `\`new Function(\` literals in ${outfile} but found ${matches.length}. ` + `A dependency changed — re-audit which library introduced/removed the ` + `call and confirm it is unreachable before updating EXPECTED_NEW_FUNCTION_COUNT.`, ); } // Throwing stub: callable as `new VKBlockedDynamicFn(...)` with any args. const stubDecl = `function ${NEW_FUNCTION_STUB}(){throw new Error("Dynamic code execution is disabled in this build.")}`; contents = contents.replace(/new Function\s*\(/g, `new ${NEW_FUNCTION_STUB}(`); // Insert the stub right after the generated banner comment (a hoisted function // declaration is in scope for the whole module regardless of position). const bannerEnd = contents.indexOf("*/"); if (bannerEnd !== -1) { const insertAt = contents.indexOf("\n", bannerEnd) + 1; contents = contents.slice(0, insertAt) + stubDecl + "\n" + contents.slice(insertAt); } else { contents = `${stubDecl}\n${contents}`; } writeFileSync(outfile, contents); console.log( `🛡️ Neutralised ${matches.length} \`new Function(\` literal(s) in ${outfile}`, ); } // Runs the dynamic-eval neutralisation on every build and rebuild, for both dev and // production, so the dev bundle matches what ships and the post-build step is // exercised continuously rather than only at release time. Registered last so it // transforms the finalised main.js. const dynamicEvalPlugin = { name: "neutralise-dynamic-eval", setup(build) { build.onEnd(() => { neutraliseDynamicEval(build.initialOptions.outfile); }); }, }; // Plugin to merge CSS files into styles.css and cleanup build artifacts const cssMergerPlugin = { name: "css-merger", setup(build) { build.onEnd(() => { // esbuild outputs CSS as main.css (based on outfile: main.js) const generatedCss = "main.css"; const customCssDir = "Styles"; const outputCss = "styles.css"; let mergedCss = ""; // Read generated CSS from dependencies if it exists if (existsSync(generatedCss)) { mergedCss += readFileSync(generatedCss, "utf-8"); mergedCss += "\n\n/* Custom Styles */\n\n"; } // Append custom CSS files from styles directory if (existsSync(customCssDir)) { const cssFiles = readdirSync(customCssDir) .filter(file => file.endsWith(".css")) .sort(); for (const cssFile of cssFiles) { const cssPath = join(customCssDir, cssFile); mergedCss += readFileSync(cssPath, "utf-8"); mergedCss += "\n\n"; console.log(`📦 Merged: ${cssPath}`); } } // Write merged CSS to styles.css writeFileSync(outputCss, mergedCss); console.log(`✅ Generated: ${outputCss}`); // Clean up build artifacts if enabled if (CLEANUP_BUILD_ARTIFACTS) { // Remove main.css (intermediate CSS file) if (existsSync(generatedCss)) { unlinkSync(generatedCss); console.log(`🗑️ Removed: ${generatedCss}`); } // Remove KaTeX font files let anyRemoved = false; const fontExtensions = [".woff", ".woff2", ".ttf"]; const files = readdirSync("."); for (const file of files) { const ext = file.substring(file.lastIndexOf(".")); if (fontExtensions.includes(ext)) { unlinkSync(file); anyRemoved = true; } } if (anyRemoved) { console.log("🗑️ Removed KaTeX Font Files"); } } }); } }; const buildOptions = { plugins: [ esbuildSvelte({ compilerOptions: { css: "injected" }, preprocess: sveltePreprocess(), }), cssMergerPlugin, dynamicEvalPlugin, ], banner: { js: banner, }, entryPoints: ["main.ts"], bundle: true, define: { "process.env.NODE_ENV": JSON.stringify(prod ? "production" : "development"), }, external: [ "obsidian", "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ...builtins], format: "cjs", target: "es2022", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, outfile: "main.js", minify: prod, loader: { ".css": "css", ".svg": "text", ".png": "dataurl", ".ttf": "file", ".woff": "file", ".woff2": "file", }, }; if (prod) { await esbuild.build(buildOptions); console.log("✅ Production build complete!"); } else { const ctx = await esbuild.context(buildOptions); await ctx.watch(); // Watch Styles directory for CSS changes if (existsSync("Styles")) { fsWatch("Styles", { recursive: true }, (_eventType, filename) => { if (filename && filename.endsWith(".css")) { console.log(`🔄 CSS file changed: ${filename} - Rebuilding...`); ctx.rebuild(); } }); } }