// Builds the website's FineNotes playground (../play/) from a FineNotes 2.3.0 // checkout. See BUILD.md. Original code for this site, GPL-3.0-or-later. // // node build.mjs --src --lucide [--out ] [--dev] // // The checkout is only read. Its `obsidian` import is pointed at // ./obsidian-shim.ts, and its own esbuild bundles the result. import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); function option(name, fallback) { const at = process.argv.indexOf(`--${name}`); return at > 0 && process.argv[at + 1] ? process.argv[at + 1] : fallback; } const srcRoot = path.resolve(option("src", process.env.FINENOTES_SRC ?? ".")); const lucideRoot = path.resolve(option("lucide", process.env.LUCIDE_DIR ?? here)); const outDir = path.resolve(option("out", path.join(here, "..", "play"))); const dev = process.argv.includes("--dev"); const fail = (message) => { console.error(message); process.exit(1); }; if (!existsSync(path.join(srcRoot, "src", "view", "ink-view.ts"))) fail(`Not a FineNotes checkout: ${srcRoot} (use --src)`); const lucideDir = path.join(lucideRoot, "node_modules", "lucide"); const lucideIndex = path.join(lucideDir, "dist", "esm", "iconsAndAliases.mjs"); if (!existsSync(lucideIndex)) fail(`No node_modules/lucide in ${lucideRoot} (use --lucide)`); const finenotes = JSON.parse(await readFile(path.join(srcRoot, "package.json"), "utf8")); const lucide = JSON.parse(await readFile(path.join(lucideDir, "package.json"), "utf8")); const esbuild = createRequire(path.join(srcRoot, "package.json"))("esbuild"); // --- Icons --------------------------------------------------------------------------------- // Lucide exports each icon as [tag, attributes][] under a PascalCase name (and // its older names as aliases). Keep only the names FineNotes and the glue spell // out somewhere as a string, as inner SVG markup keyed by kebab-case name. const toKebab = (pascal) => pascal.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").toLowerCase(); async function sourceFiles(dir) { const found = []; for (const entry of await readdir(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) found.push(...(await sourceFiles(full))); else if (entry.name.endsWith(".ts")) found.push(full); } return found; } async function lucideTable() { const icons = await import(pathToFileURL(lucideIndex).href); const byName = new Map(); const index = await readFile(lucideIndex, "utf8"); // Each line: export { default as A, default as B } from './icons/.mjs'; for (const line of index.split("\n")) { const file = /from '\.\/icons\/([a-z0-9-]+)\.mjs'/.exec(line)?.[1]; if (!file) continue; const exported = [...line.matchAll(/default as (\w+)/g)].map((m) => m[1]); byName.set(file, exported[0]); for (const alias of exported) { const kebab = toKebab(alias); for (const name of [kebab, kebab.replace(/([a-z])(\d)/g, "$1-$2")]) if (!byName.has(name)) byName.set(name, alias); } } const used = new Set(["x", "check", "settings", "ellipsis", "ellipsis-vertical"]); for (const file of [...(await sourceFiles(path.join(srcRoot, "src"))), ...(await sourceFiles(here))]) { const text = await readFile(file, "utf8"); for (const m of text.matchAll(/["'`](?:lucide-)?([a-z][a-z0-9-]*)["'`]/g)) if (byName.has(m[1])) used.add(m[1]); } const attr = (v) => String(v).replace(/&/g, "&").replace(/"/g, """).replace(/ `<${tag}${Object.entries(attrs).map(([k, v]) => ` ${k}="${attr(v)}"`).join("")}/>`) .join(""); } return table; } const icons = await lucideTable(); // --- Plugins for the bundle ------------------------------------------------------------------ /** Virtual modules: the icon table, and empty stand-ins for what the playground leaves out. */ const VIRTUAL = { "lucide-icons": () => `export default ${JSON.stringify(icons)};`, // The handwriting model (Grape, most of the plugin's 6 MB) and its worker client. "grape-model": () => "export default new Uint8Array(0);", "grape-client": () => "export class HwrWorkerClient { constructor() { throw new Error('Handwriting reading works in the FineNotes plugin'); } }", // PDF export's library (about 400 KB with its fonts); the button shows a notice instead. "pdf-lib": () => [ "const unavailable = () => Promise.reject(new Error('Export as PDF works in the FineNotes plugin'));", "export const PDFDocument = { create: unavailable, load: unavailable };", "export const BlendMode = { Normal: 'Normal', Multiply: 'Multiply' };", "export const degrees = (angle) => ({ type: 'degrees', angle });", "export const rgb = (red, green, blue) => ({ type: 'RGB', red, green, blue });", ].join("\n"), }; const virtualModules = { name: "playground-virtual", setup(build) { const to = (name) => () => ({ path: name, namespace: "playground-virtual" }); build.onResolve({ filter: /^virtual:lucide-icons$/ }, to("lucide-icons")); build.onResolve({ filter: /models[\\/]grape-[\w.-]+\.bin$/ }, to("grape-model")); build.onResolve({ filter: /hwr[\\/]client$/ }, to("grape-client")); build.onResolve({ filter: /^pdf-lib$/ }, to("pdf-lib")); build.onLoad({ filter: /.*/, namespace: "playground-virtual" }, (a) => ({ contents: VIRTUAL[a.path](), loader: "js" })); }, }; /** * FineNotes imports its save worker as `worker:./save-worker`, meaning "that * module, bundled by itself, as a string" (src/build.d.ts). Do the same. */ const workerAsText = { name: "playground-worker-text", setup(build) { build.onResolve({ filter: /^worker:/ }, (a) => ({ path: path.join(a.resolveDir, a.path.replace(/^worker:/, "")), namespace: "playground-worker", })); build.onLoad({ filter: /.*/, namespace: "playground-worker" }, async (a) => { const entry = existsSync(a.path) ? a.path : `${a.path}.ts`; const { outputFiles } = await esbuild.build({ entryPoints: [entry], bundle: true, write: false, format: "iife", target: "es2020", minify: !dev, logLevel: "silent", }); return { contents: `export default ${JSON.stringify(outputFiles[0].text)};`, loader: "js" }; }); }, }; const banner = `/*! * FineNotes playground — https://finenotes.vercel.app/play/ * Contains FineNotes ${finenotes.version}, GPL-3.0-or-later: * https://github.com/Joost-8/FineNotes/tree/${finenotes.version} * The playground's glue (an Obsidian stand-in, the host page and this build) * is original code for this site, GPL-3.0-or-later; its source is in /source/playground/. * Icons: Lucide ${lucide.version} (https://lucide.dev), ISC licence. * Compression: fflate (MIT). Licences: THIRD_PARTY_NOTICES.txt. * Not made by or affiliated with Obsidian. */`; // --- Build ---------------------------------------------------------------------------------- await mkdir(outDir, { recursive: true }); const result = await esbuild.build({ absWorkingDir: srcRoot, entryPoints: [path.join(here, "playground.ts")], outfile: path.join(outDir, "playground.js"), bundle: true, format: "iife", target: "es2020", minify: !dev, sourcemap: dev ? "inline" : false, legalComments: "none", banner: { js: banner }, nodePaths: [path.join(srcRoot, "node_modules")], alias: { obsidian: path.join(here, "obsidian-shim.ts"), "@finenotes": path.join(srcRoot, "src") }, loader: { ".md": "text", ".bin": "binary" }, plugins: [virtualModules, workerAsText], metafile: true, logLevel: "warning", }); // For size checks (esbuild's bundle analyser reads this file). await writeFile(path.join(os.tmpdir(), "finenotes-play-meta.json"), JSON.stringify(result.metafile)); await writeFile( path.join(outDir, "finenotes.css"), `/* FineNotes ${finenotes.version} styles.css (GPL-3.0-or-later): https://github.com/Joost-8/FineNotes/tree/${finenotes.version} */\n` + (await readFile(path.join(srcRoot, "styles.css"), "utf8")), ); await copyFile(path.join(here, "playground.css"), path.join(outDir, "playground.css")); await copyFile(path.join(here, "index.html"), path.join(outDir, "index.html")); await writeFile( path.join(outDir, "THIRD_PARTY_NOTICES.txt"), [ "FineNotes playground: licences", "", `FineNotes ${finenotes.version} and the playground's own code are licensed under the GNU`, "General Public License v3.0 or later.", `FineNotes: https://github.com/Joost-8/FineNotes/tree/${finenotes.version}`, "Playground source: /source/playground/ on this site.", "", `Lucide ${lucide.version} (icons)`, "-------------------", await readFile(path.join(lucideDir, "LICENSE"), "utf8"), "fflate (compression)", "--------------------", await readFile(path.join(srcRoot, "node_modules", "fflate", "LICENSE"), "utf8"), ].join("\n"), ); const bytes = (await readFile(path.join(outDir, "playground.js"))).length; console.log(`playground.js ${(bytes / 1024).toFixed(0)} KB, ${Object.keys(icons).length} icons -> ${outDir}`);