summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:36 -0400
committerkj_sh6042026-03-15 16:19:36 -0400
commit72ece7c00b091011617fccf719df7f602cf4f7c7 (patch)
tree75a085594679b4282faac3b3646d589bf5a67ea5
parentc142734224f6263180e4cbe6fabec591a27972a1 (diff)
refactor: scripts/
-rw-r--r--scripts/autorelease.js70
-rw-r--r--scripts/build-locales-coverage.js37
-rwxr-xr-xscripts/build-node.js40
-rwxr-xr-xscripts/build-version.js61
-rw-r--r--scripts/buildDocs.js21
-rw-r--r--scripts/buildMath.js52
-rw-r--r--scripts/buildPackage.js79
-rw-r--r--scripts/buildUtils.js58
-rw-r--r--scripts/buildWasm.js75
-rw-r--r--scripts/locales-coverage-description.js215
-rw-r--r--scripts/prerelease.js37
-rw-r--r--scripts/release.js28
-rw-r--r--scripts/updateChangelog.js104
-rwxr-xr-xscripts/wasm/hb-subset.wasmbin0 -> 590665 bytes
-rw-r--r--scripts/wasm/woff2.wasmbin0 -> 727190 bytes
-rw-r--r--scripts/woff2/assets/LiberationSans-Regular-2048.ttfbin0 -> 898548 bytes
-rw-r--r--scripts/woff2/assets/LiberationSans-Regular.ttfbin0 -> 864800 bytes
-rw-r--r--scripts/woff2/assets/NotoEmoji-Regular-2048.ttfbin0 -> 896640 bytes
-rw-r--r--scripts/woff2/assets/NotoEmoji-Regular.ttfbin0 -> 862680 bytes
-rw-r--r--scripts/woff2/assets/Xiaolai-Regular.ttfbin0 -> 21745444 bytes
-rw-r--r--scripts/woff2/woff2-esbuild-plugins.js251
-rw-r--r--scripts/woff2/woff2-vite-plugins.js105
22 files changed, 1233 insertions, 0 deletions
diff --git a/scripts/autorelease.js b/scripts/autorelease.js
new file mode 100644
index 0000000..f506cf1
--- /dev/null
+++ b/scripts/autorelease.js
@@ -0,0 +1,70 @@
+const fs = require("fs");
+const { exec, execSync } = require("child_process");
+const core = require("@actions/core");
+
+const excalidrawDir = `${__dirname}/../packages/excalidraw`;
+const excalidrawPackage = `${excalidrawDir}/package.json`;
+const pkg = require(excalidrawPackage);
+const isPreview = process.argv.slice(2)[0] === "preview";
+
+const getShortCommitHash = () => {
+ return execSync("git rev-parse --short HEAD").toString().trim();
+};
+
+const publish = () => {
+ const tag = isPreview ? "preview" : "next";
+
+ try {
+ execSync(`yarn --frozen-lockfile`);
+ execSync(`yarn run build:esm`, { cwd: excalidrawDir });
+ execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
+ console.info(`Published ${pkg.name}@${tag}🎉`);
+ core.setOutput(
+ "result",
+ `**Preview version has been shipped** :rocket:
+ You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
+ );
+ } catch (error) {
+ core.setOutput("result", "package couldn't be published :warning:!");
+ console.error(error);
+ process.exit(1);
+ }
+};
+// get files changed between prev and head commit
+exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
+ if (error || stderr) {
+ console.error(error);
+ core.setOutput("result", ":warning: Package couldn't be published!");
+ process.exit(1);
+ }
+ const changedFiles = stdout.trim().split("\n");
+
+ const excalidrawPackageFiles = changedFiles.filter((file) => {
+ return (
+ file.indexOf("packages/excalidraw") >= 0 ||
+ file.indexOf("buildPackage.js") > 0
+ );
+ });
+ if (!excalidrawPackageFiles.length) {
+ console.info("Skipping release as no valid diff found");
+ core.setOutput("result", "Skipping release as no valid diff found");
+ process.exit(0);
+ }
+
+ // update package.json
+ let version = `${pkg.version}-${getShortCommitHash()}`;
+
+ // update readme
+
+ if (isPreview) {
+ // use pullNumber-commithash as the version for preview
+ const pullRequestNumber = process.argv.slice(3)[0];
+ version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
+ }
+ pkg.version = version;
+
+ fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
+
+ console.info("Publish in progress...");
+ publish();
+});
diff --git a/scripts/build-locales-coverage.js b/scripts/build-locales-coverage.js
new file mode 100644
index 0000000..bab7f2f
--- /dev/null
+++ b/scripts/build-locales-coverage.js
@@ -0,0 +1,37 @@
+const { readdirSync, writeFileSync } = require("fs");
+const files = readdirSync(`${__dirname}/../packages/excalidraw/locales`);
+
+const flatten = (object = {}, result = {}, extraKey = "") => {
+ for (const key in object) {
+ if (typeof object[key] !== "object") {
+ result[extraKey + key] = object[key];
+ } else {
+ flatten(object[key], result, `${extraKey}${key}.`);
+ }
+ }
+ return result;
+};
+
+const locales = files.filter(
+ (file) => file !== "README.md" && file !== "percentages.json",
+);
+
+const percentages = {};
+
+for (let index = 0; index < locales.length; index++) {
+ const currentLocale = locales[index];
+ const data = flatten(
+ require(`${__dirname}/../packages/excalidraw/locales/${currentLocale}`),
+ );
+
+ const allKeys = Object.keys(data);
+ const translatedKeys = allKeys.filter((item) => data[item] !== "");
+ const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
+ percentages[currentLocale.replace(".json", "")] = percentage;
+}
+
+writeFileSync(
+ `${__dirname}/../packages/excalidraw/locales/percentages.json`,
+ `${JSON.stringify(percentages, null, 2)}\n`,
+ "utf8",
+);
diff --git a/scripts/build-node.js b/scripts/build-node.js
new file mode 100755
index 0000000..b9bed01
--- /dev/null
+++ b/scripts/build-node.js
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+
+// In order to use this, you need to install Cairo on your machine. See
+// instructions here: https://github.com/Automattic/node-canvas#compiling
+
+// In order to run:
+// npm install canvas # please do not check it in
+// yarn build-node
+// node build/static/js/build-node.js
+// open test.png
+
+const rewire = require("rewire");
+const defaults = rewire("react-scripts/scripts/build.js");
+const config = defaults.__get__("config");
+
+// Disable multiple chunks
+config.optimization.runtimeChunk = false;
+config.optimization.splitChunks = {
+ cacheGroups: {
+ default: false,
+ },
+};
+// Set the filename to be deterministic
+config.output.filename = "static/js/build-node.js";
+// Don't choke on node-specific requires
+config.target = "node";
+// Set the node entrypoint
+config.entry = "../packages/excalidraw/index-node";
+// By default, webpack is going to replace the require of the canvas.node file
+// to just a string with the path of the canvas.node file. We need to tell
+// webpack to avoid rewriting that dependency.
+config.externals = (context, request, callback) => {
+ if (/\.node$/.test(request)) {
+ return callback(
+ null,
+ "commonjs ../../../node_modules/canvas/build/Release/canvas.node",
+ );
+ }
+ callback();
+};
diff --git a/scripts/build-version.js b/scripts/build-version.js
new file mode 100755
index 0000000..0d77f7d
--- /dev/null
+++ b/scripts/build-version.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+
+const fs = require("fs");
+const path = require("path");
+const versionFile = path.join("build", "version.json");
+const indexFile = path.join("build", "index.html");
+
+const versionDate = (date) => date.toISOString().replace(".000", "");
+
+const commitHash = () => {
+ try {
+ return require("child_process")
+ .execSync("git rev-parse --short HEAD")
+ .toString()
+ .trim();
+ } catch {
+ return "none";
+ }
+};
+
+const commitDate = (hash) => {
+ try {
+ const unix = require("child_process")
+ .execSync(`git show -s --format=%ct ${hash}`)
+ .toString()
+ .trim();
+ const date = new Date(parseInt(unix) * 1000);
+ return versionDate(date);
+ } catch {
+ return versionDate(new Date());
+ }
+};
+
+const getFullVersion = () => {
+ const hash = commitHash();
+ return `${commitDate(hash)}-${hash}`;
+};
+
+const data = JSON.stringify(
+ {
+ version: getFullVersion(),
+ },
+ undefined,
+ 2,
+);
+
+fs.writeFileSync(versionFile, data);
+
+// https://stackoverflow.com/a/14181136/8418
+fs.readFile(indexFile, "utf8", (error, data) => {
+ if (error) {
+ return console.error(error);
+ }
+ const result = data.replace(/{version}/g, getFullVersion());
+
+ fs.writeFile(indexFile, result, "utf8", (error) => {
+ if (error) {
+ return console.error(error);
+ }
+ });
+});
diff --git a/scripts/buildDocs.js b/scripts/buildDocs.js
new file mode 100644
index 0000000..7aca90a
--- /dev/null
+++ b/scripts/buildDocs.js
@@ -0,0 +1,21 @@
+const { exec } = require("child_process");
+
+// get files changed between prev and head commit
+exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
+ if (error || stderr) {
+ console.error(error);
+ process.exit(1);
+ }
+ const changedFiles = stdout.trim().split("\n");
+
+ const docFiles = changedFiles.filter((file) => {
+ return file.indexOf("docs") >= 0;
+ });
+
+ if (!docFiles.length) {
+ console.info("Skipping building docs as no valid diff found");
+ process.exit(0);
+ }
+ // Exit code 1 to build the docs in ignoredBuildStep
+ process.exit(1);
+});
diff --git a/scripts/buildMath.js b/scripts/buildMath.js
new file mode 100644
index 0000000..fd2f820
--- /dev/null
+++ b/scripts/buildMath.js
@@ -0,0 +1,52 @@
+const path = require("path");
+const { build } = require("esbuild");
+const { sassPlugin } = require("esbuild-sass-plugin");
+
+// contains all dependencies bundled inside
+const getConfig = (outdir) => ({
+ outdir,
+ bundle: true,
+ format: "esm",
+ entryPoints: ["index.ts"],
+ entryNames: "[name]",
+ assetNames: "[dir]/[name]",
+ alias: {
+ "@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
+ "@excalidraw/utils": path.resolve(__dirname, "../packages/utils"),
+ "@excalidraw/math": path.resolve(__dirname, "../packages/math"),
+ },
+});
+
+function buildDev(config) {
+ return build({
+ ...config,
+ plugins: [sassPlugin()],
+ sourcemap: true,
+ define: {
+ "import.meta.env": JSON.stringify({ DEV: true }),
+ },
+ });
+}
+
+function buildProd(config) {
+ return build({
+ ...config,
+ plugins: [sassPlugin()],
+ minify: true,
+ define: {
+ "import.meta.env": JSON.stringify({ PROD: true }),
+ },
+ });
+}
+
+const createESMRawBuild = async () => {
+ // development unminified build with source maps
+ buildDev(getConfig("dist/dev"));
+
+ // production minified build without sourcemaps
+ buildProd(getConfig("dist/prod"));
+};
+
+(async () => {
+ await createESMRawBuild();
+})();
diff --git a/scripts/buildPackage.js b/scripts/buildPackage.js
new file mode 100644
index 0000000..653af50
--- /dev/null
+++ b/scripts/buildPackage.js
@@ -0,0 +1,79 @@
+const path = require("path");
+const { build } = require("esbuild");
+const { sassPlugin } = require("esbuild-sass-plugin");
+const { parseEnvVariables } = require("../packages/excalidraw/env.cjs");
+
+const ENV_VARS = {
+ development: {
+ ...parseEnvVariables(`${__dirname}/../.env.development`),
+ DEV: true,
+ },
+ production: {
+ ...parseEnvVariables(`${__dirname}/../.env.production`),
+ PROD: true,
+ },
+};
+
+// excludes all external dependencies and bundles only the source code
+const getConfig = (outdir) => ({
+ outdir,
+ bundle: true,
+ splitting: true,
+ format: "esm",
+ packages: "external",
+ plugins: [sassPlugin()],
+ target: "es2020",
+ assetNames: "[dir]/[name]",
+ chunkNames: "[dir]/[name]-[hash]",
+ alias: {
+ "@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
+ "@excalidraw/utils": path.resolve(__dirname, "../packages/utils"),
+ "@excalidraw/math": path.resolve(__dirname, "../packages/math"),
+ },
+ loader: {
+ ".woff2": "file",
+ },
+});
+
+function buildDev(config) {
+ return build({
+ ...config,
+ sourcemap: true,
+ define: {
+ "import.meta.env": JSON.stringify(ENV_VARS.development),
+ },
+ });
+}
+
+function buildProd(config) {
+ return build({
+ ...config,
+ minify: true,
+ define: {
+ "import.meta.env": JSON.stringify(ENV_VARS.production),
+ },
+ });
+}
+
+const createESMRawBuild = async () => {
+ const chunksConfig = {
+ entryPoints: ["index.tsx", "**/*.chunk.ts"],
+ entryNames: "[name]",
+ };
+
+ // development unminified build with source maps
+ await buildDev({
+ ...getConfig("dist/dev"),
+ ...chunksConfig,
+ });
+
+ // production minified buld without sourcemaps
+ await buildProd({
+ ...getConfig("dist/prod"),
+ ...chunksConfig,
+ });
+};
+
+(async () => {
+ await createESMRawBuild();
+})();
diff --git a/scripts/buildUtils.js b/scripts/buildUtils.js
new file mode 100644
index 0000000..65b9473
--- /dev/null
+++ b/scripts/buildUtils.js
@@ -0,0 +1,58 @@
+const path = require("path");
+const { build } = require("esbuild");
+const { sassPlugin } = require("esbuild-sass-plugin");
+const { woff2ServerPlugin } = require("./woff2/woff2-esbuild-plugins");
+
+// contains all dependencies bundled inside
+const getConfig = (outdir) => ({
+ outdir,
+ bundle: true,
+ format: "esm",
+ entryPoints: ["index.ts"],
+ entryNames: "[name]",
+ assetNames: "[dir]/[name]",
+ alias: {
+ "@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
+ "@excalidraw/utils": path.resolve(__dirname, "../packages/utils"),
+ "@excalidraw/math": path.resolve(__dirname, "../packages/math"),
+ },
+});
+
+function buildDev(config) {
+ return build({
+ ...config,
+ sourcemap: true,
+ plugins: [sassPlugin(), woff2ServerPlugin()],
+ define: {
+ "import.meta.env": JSON.stringify({ DEV: true }),
+ },
+ });
+}
+
+function buildProd(config) {
+ return build({
+ ...config,
+ minify: true,
+ plugins: [
+ sassPlugin(),
+ woff2ServerPlugin({
+ outdir: `${config.outdir}/assets`,
+ }),
+ ],
+ define: {
+ "import.meta.env": JSON.stringify({ PROD: true }),
+ },
+ });
+}
+
+const createESMRawBuild = async () => {
+ // development unminified build with source maps
+ buildDev(getConfig("dist/dev"));
+
+ // production minified build without sourcemaps
+ buildProd(getConfig("dist/prod"));
+};
+
+(async () => {
+ await createESMRawBuild();
+})();
diff --git a/scripts/buildWasm.js b/scripts/buildWasm.js
new file mode 100644
index 0000000..062e733
--- /dev/null
+++ b/scripts/buildWasm.js
@@ -0,0 +1,75 @@
+/**
+ * This script is used to convert the wasm modules into js modules, with the binary converted into base64 encoded strings.
+ */
+const fs = require("fs");
+const path = require("path");
+
+const wasmModules = [
+ {
+ pkg: `../node_modules/fonteditor-core`,
+ src: `./wasm/woff2.wasm`,
+ dest: `../packages/excalidraw/fonts/wasm/woff2-wasm.ts`,
+ },
+ {
+ pkg: `../node_modules/harfbuzzjs`,
+ src: `./wasm/hb-subset.wasm`,
+ dest: `../packages/excalidraw/fonts/wasm/hb-subset-wasm.ts`,
+ },
+];
+
+for (const { pkg, src, dest } of wasmModules) {
+ const packagePath = path.resolve(__dirname, pkg, "package.json");
+ const licensePath = path.resolve(__dirname, pkg, "LICENSE");
+ const sourcePath = path.resolve(__dirname, src);
+ const destPath = path.resolve(__dirname, dest);
+
+ const {
+ name,
+ version,
+ author,
+ license,
+ authors,
+ licenses,
+ } = require(packagePath);
+
+ const licenseContent = fs.readFileSync(licensePath, "utf-8") || "";
+ const base64 = fs.readFileSync(sourcePath, "base64");
+ const content = `// GENERATED CODE -- DO NOT EDIT!
+/* eslint-disable */
+// @ts-nocheck
+
+/**
+* The following wasm module is generated with \`scripts/buildWasm.js\` and encoded as base64.
+*
+* The source of this content is taken from the package "${name}", which contains the following metadata:
+*
+* @author ${author || JSON.stringify(authors)}
+* @license ${license || JSON.stringify(licenses)}
+* @version ${version}
+
+${licenseContent}
+*/
+
+// faster atob alternative - https://github.com/evanw/esbuild/issues/1534#issuecomment-902738399
+const __toBinary = /* @__PURE__ */ (() => {
+ const table = new Uint8Array(128);
+ for (let i = 0; i < 64; i++)
+ {table[i < 26 ? i + 65 : i < 52 ? i + 71 : i < 62 ? i - 4 : i * 4 - 205] = i;}
+ return (base64) => {
+ const n = base64.length; const bytes = new Uint8Array((n - (base64[n - 1] == "=") - (base64[n - 2] == "=")) * 3 / 4 | 0);
+ for (let i2 = 0, j = 0; i2 < n; ) {
+ const c0 = table[base64.charCodeAt(i2++)]; const c1 = table[base64.charCodeAt(i2++)];
+ const c2 = table[base64.charCodeAt(i2++)]; const c3 = table[base64.charCodeAt(i2++)];
+ bytes[j++] = c0 << 2 | c1 >> 4;
+ bytes[j++] = c1 << 4 | c2 >> 2;
+ bytes[j++] = c2 << 6 | c3;
+ }
+ return bytes;
+ };
+})();
+
+export default __toBinary(\`${base64}\`);
+`;
+
+ fs.writeFileSync(destPath, content);
+}
diff --git a/scripts/locales-coverage-description.js b/scripts/locales-coverage-description.js
new file mode 100644
index 0000000..2b437cc
--- /dev/null
+++ b/scripts/locales-coverage-description.js
@@ -0,0 +1,215 @@
+const fs = require("fs");
+
+const THRESSHOLD = 85;
+
+// we're using BCP 47 language tags as keys
+// e.g. https://gist.github.com/typpo/b2b828a35e683b9bf8db91b5404f1bd1
+
+const crowdinMap = {
+ "ar-SA": "en-ar",
+ "bg-BG": "en-bg",
+ "bn-BD": "en-bn",
+ "ca-ES": "en-ca",
+ "da-DK": "en-da",
+ "de-DE": "en-de",
+ "el-GR": "en-el",
+ "es-ES": "en-es",
+ "eu-ES": "en-eu",
+ "fa-IR": "en-fa",
+ "fi-FI": "en-fi",
+ "fr-FR": "en-fr",
+ "gl-ES": "en-gl",
+ "he-IL": "en-he",
+ "hi-IN": "en-hi",
+ "hu-HU": "en-hu",
+ "id-ID": "en-id",
+ "it-IT": "en-it",
+ "ja-JP": "en-ja",
+ "kab-KAB": "en-kab",
+ "ko-KR": "en-ko",
+ "ku-TR": "en-ku",
+ "my-MM": "en-my",
+ "nb-NO": "en-nb",
+ "nl-NL": "en-nl",
+ "nn-NO": "en-nnno",
+ "oc-FR": "en-oc",
+ "pa-IN": "en-pain",
+ "pl-PL": "en-pl",
+ "pt-BR": "en-ptbr",
+ "pt-PT": "en-pt",
+ "ro-RO": "en-ro",
+ "ru-RU": "en-ru",
+ "si-LK": "en-silk",
+ "sk-SK": "en-sk",
+ "sl-SI": "en-sl",
+ "sv-SE": "en-sv",
+ "ta-IN": "en-ta",
+ "tr-TR": "en-tr",
+ "uk-UA": "en-uk",
+ "zh-CN": "en-zhcn",
+ "zh-HK": "en-zhhk",
+ "zh-TW": "en-zhtw",
+ "lt-LT": "en-lt",
+ "lv-LV": "en-lv",
+ "cs-CZ": "en-cs",
+ "kk-KZ": "en-kk",
+ "vi-VN": "en-vi",
+ "mr-IN": "en-mr",
+ "th-TH": "en-th",
+};
+
+const flags = {
+ "ar-SA": "🇸🇦",
+ "bg-BG": "🇧🇬",
+ "bn-BD": "🇧🇩",
+ "ca-ES": "🏳",
+ "cs-CZ": "🇨🇿",
+ "da-DK": "🇩🇰",
+ "de-DE": "🇩🇪",
+ "el-GR": "🇬🇷",
+ "es-ES": "🇪🇸",
+ "fa-IR": "🇮🇷",
+ "fi-FI": "🇫🇮",
+ "fr-FR": "🇫🇷",
+ "gl-ES": "🇪🇸",
+ "he-IL": "🇮🇱",
+ "hi-IN": "🇮🇳",
+ "hu-HU": "🇭🇺",
+ "id-ID": "🇮🇩",
+ "it-IT": "🇮🇹",
+ "ja-JP": "🇯🇵",
+ "kab-KAB": "🏳",
+ "kk-KZ": "🇰🇿",
+ "ko-KR": "🇰🇷",
+ "ku-TR": "🏳",
+ "lt-LT": "🇱🇹",
+ "lv-LV": "🇱🇻",
+ "my-MM": "🇲🇲",
+ "nb-NO": "🇳🇴",
+ "nl-NL": "🇳🇱",
+ "nn-NO": "🇳🇴",
+ "oc-FR": "🏳",
+ "pa-IN": "🇮🇳",
+ "pl-PL": "🇵🇱",
+ "pt-BR": "🇧🇷",
+ "pt-PT": "🇵🇹",
+ "ro-RO": "🇷🇴",
+ "ru-RU": "🇷🇺",
+ "si-LK": "🇱🇰",
+ "sk-SK": "🇸🇰",
+ "sl-SI": "🇸🇮",
+ "sv-SE": "🇸🇪",
+ "ta-IN": "🇮🇳",
+ "tr-TR": "🇹🇷",
+ "uk-UA": "🇺🇦",
+ "zh-CN": "🇨🇳",
+ "zh-HK": "🇭🇰",
+ "zh-TW": "🇹🇼",
+ "eu-ES": "🇪🇦",
+ "vi-VN": "🇻🇳",
+ "mr-IN": "🇮🇳",
+ "th-TH": "🇹🇭",
+};
+
+const languages = {
+ "ar-SA": "العربية",
+ "bg-BG": "Български",
+ "bn-BD": "Bengali",
+ "ca-ES": "Català",
+ "cs-CZ": "Česky",
+ "da-DK": "Dansk",
+ "de-DE": "Deutsch",
+ "el-GR": "Ελληνικά",
+ "es-ES": "Español",
+ "eu-ES": "Euskara",
+ "fa-IR": "فارسی",
+ "fi-FI": "Suomi",
+ "fr-FR": "Français",
+ "gl-ES": "Galego",
+ "he-IL": "עברית",
+ "hi-IN": "हिन्दी",
+ "hu-HU": "Magyar",
+ "id-ID": "Bahasa Indonesia",
+ "it-IT": "Italiano",
+ "ja-JP": "日本語",
+ "kab-KAB": "Taqbaylit",
+ "kk-KZ": "Қазақ тілі",
+ "ko-KR": "한국어",
+ "ku-TR": "Kurdî",
+ "lt-LT": "Lietuvių",
+ "lv-LV": "Latviešu",
+ "my-MM": "Burmese",
+ "nb-NO": "Norsk bokmål",
+ "nl-NL": "Nederlands",
+ "nn-NO": "Norsk nynorsk",
+ "oc-FR": "Occitan",
+ "pa-IN": "ਪੰਜਾਬੀ",
+ "pl-PL": "Polski",
+ "pt-BR": "Português Brasileiro",
+ "pt-PT": "Português",
+ "ro-RO": "Română",
+ "ru-RU": "Русский",
+ "si-LK": "සිංහල",
+ "sk-SK": "Slovenčina",
+ "sl-SI": "Slovenščina",
+ "sv-SE": "Svenska",
+ "ta-IN": "Tamil",
+ "tr-TR": "Türkçe",
+ "uk-UA": "Українська",
+ "zh-CN": "简体中文",
+ "zh-HK": "繁體中文 (香港)",
+ "zh-TW": "繁體中文",
+ "vi-VN": "Tiếng Việt",
+ "mr-IN": "मराठी",
+ "th-TH": "ภาษาไทย",
+};
+
+const percentages = fs.readFileSync(
+ `${__dirname}/../packages/excalidraw/locales/percentages.json`,
+);
+const rowData = JSON.parse(percentages);
+
+const coverages = Object.entries(rowData)
+ .sort(([, a], [, b]) => b - a)
+ .reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
+
+const boldIf = (text, condition) => (condition ? `**${text}**` : text);
+
+const printHeader = () => {
+ let result = "| | Flag | Locale | % |\n";
+ result += "| :--: | :--: | -- | :--: |";
+ return result;
+};
+
+const printRow = (id, locale, coverage) => {
+ const isOver = coverage >= THRESSHOLD;
+ let result = `| ${isOver ? id : "..."} | `;
+ result += `${locale in flags ? flags[locale] : ""} | `;
+ const language = locale in languages ? languages[locale] : locale;
+ if (locale in crowdinMap && crowdinMap[locale]) {
+ result += `[${boldIf(
+ language,
+ isOver,
+ )}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
+ } else {
+ result += `${boldIf(language, isOver)} | `;
+ }
+ result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
+ return result;
+};
+
+console.info(
+ `Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
+);
+console.info("\n\r");
+console.info(printHeader());
+let index = 1;
+for (const coverage in coverages) {
+ if (coverage === "en") {
+ continue;
+ }
+ console.info(printRow(index, coverage, coverages[coverage]));
+ index++;
+}
+console.info("\n\r");
+console.info("\\* Languages in **bold** are going to appear on production.");
diff --git a/scripts/prerelease.js b/scripts/prerelease.js
new file mode 100644
index 0000000..3b8080d
--- /dev/null
+++ b/scripts/prerelease.js
@@ -0,0 +1,37 @@
+const fs = require("fs");
+const util = require("util");
+const exec = util.promisify(require("child_process").exec);
+const updateChangelog = require("./updateChangelog");
+
+const excalidrawDir = `${__dirname}/../packages/excalidraw/`;
+const excalidrawPackage = `${excalidrawDir}/package.json`;
+
+const updatePackageVersion = (nextVersion) => {
+ const pkg = require(excalidrawPackage);
+ pkg.version = nextVersion;
+ const content = `${JSON.stringify(pkg, null, 2)}\n`;
+ fs.writeFileSync(excalidrawPackage, content, "utf-8");
+};
+
+const prerelease = async (nextVersion) => {
+ try {
+ await updateChangelog(nextVersion);
+ updatePackageVersion(nextVersion);
+ await exec(`git add -u`);
+ await exec(
+ `git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
+ );
+
+ console.info("Done!");
+ } catch (error) {
+ console.error(error);
+ process.exit(1);
+ }
+};
+
+const nextVersion = process.argv.slice(2)[0];
+if (!nextVersion) {
+ console.error("Pass the next version to release!");
+ process.exit(1);
+}
+prerelease(nextVersion);
diff --git a/scripts/release.js b/scripts/release.js
new file mode 100644
index 0000000..21f9f25
--- /dev/null
+++ b/scripts/release.js
@@ -0,0 +1,28 @@
+const { execSync } = require("child_process");
+
+const excalidrawDir = `${__dirname}/../packages/excalidraw`;
+const excalidrawPackage = `${excalidrawDir}/package.json`;
+const pkg = require(excalidrawPackage);
+
+const publish = () => {
+ try {
+ console.info("Installing the dependencies in root folder...");
+ execSync(`yarn --frozen-lockfile`);
+ console.info("Installing the dependencies in excalidraw directory...");
+ execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
+ console.info("Building ESM Package...");
+ execSync(`yarn run build:esm`, { cwd: excalidrawDir });
+ console.info("Publishing the package...");
+ execSync(`yarn --cwd ${excalidrawDir} publish`);
+ } catch (error) {
+ console.error(error);
+ process.exit(1);
+ }
+};
+
+const release = () => {
+ publish();
+ console.info(`Published ${pkg.version}!`);
+};
+
+release();
diff --git a/scripts/updateChangelog.js b/scripts/updateChangelog.js
new file mode 100644
index 0000000..b9291b7
--- /dev/null
+++ b/scripts/updateChangelog.js
@@ -0,0 +1,104 @@
+const fs = require("fs");
+const util = require("util");
+const exec = util.promisify(require("child_process").exec);
+
+const excalidrawDir = `${__dirname}/../packages/excalidraw`;
+const excalidrawPackage = `${excalidrawDir}/package.json`;
+const pkg = require(excalidrawPackage);
+const lastVersion = pkg.version;
+const existingChangeLog = fs.readFileSync(
+ `${excalidrawDir}/CHANGELOG.md`,
+ "utf8",
+);
+
+const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
+const headerForType = {
+ feat: "Features",
+ fix: "Fixes",
+ style: "Styles",
+ refactor: " Refactor",
+ perf: "Performance",
+ build: "Build",
+};
+const badCommits = [];
+const getCommitHashForLastVersion = async () => {
+ try {
+ const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
+ const { stdout } = await exec(
+ `git log --format=format:"%H" --grep=${commitMessage}`,
+ );
+ return stdout;
+ } catch (error) {
+ console.error(error);
+ }
+};
+
+const getLibraryCommitsSinceLastRelease = async () => {
+ const commitHash = await getCommitHashForLastVersion();
+ const { stdout } = await exec(
+ `git log --pretty=format:%s ${commitHash}...master`,
+ );
+ const commitsSinceLastRelease = stdout.split("\n");
+ const commitList = {};
+ supportedTypes.forEach((type) => {
+ commitList[type] = [];
+ });
+
+ commitsSinceLastRelease.forEach((commit) => {
+ const indexOfColon = commit.indexOf(":");
+ const type = commit.slice(0, indexOfColon);
+ if (!supportedTypes.includes(type)) {
+ return;
+ }
+ const messageWithoutType = commit.slice(indexOfColon + 1).trim();
+ const messageWithCapitalizeFirst =
+ messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
+ const prMatch = commit.match(/\(#([0-9]*)\)/);
+ if (prMatch) {
+ const prNumber = prMatch[1];
+
+ // return if the changelog already contains the pr number which would happen for package updates
+ if (existingChangeLog.includes(prNumber)) {
+ return;
+ }
+ const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
+ const messageWithPRLink = messageWithCapitalizeFirst.replace(
+ /\(#[0-9]*\)/,
+ prMarkdown,
+ );
+ commitList[type].push(messageWithPRLink);
+ } else {
+ badCommits.push(commit);
+ commitList[type].push(messageWithCapitalizeFirst);
+ }
+ });
+ console.info("Bad commits:", badCommits);
+ return commitList;
+};
+
+const updateChangelog = async (nextVersion) => {
+ const commitList = await getLibraryCommitsSinceLastRelease();
+ let changelogForLibrary =
+ "## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
+ supportedTypes.forEach((type) => {
+ if (commitList[type].length) {
+ changelogForLibrary += `### ${headerForType[type]}\n\n`;
+ const commits = commitList[type];
+ commits.forEach((commit) => {
+ changelogForLibrary += `- ${commit}\n\n`;
+ });
+ }
+ });
+ changelogForLibrary += "---\n";
+ const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
+ let updatedContent =
+ existingChangeLog.slice(0, lastVersionIndex) +
+ changelogForLibrary +
+ existingChangeLog.slice(lastVersionIndex);
+ const currentDate = new Date().toISOString().slice(0, 10);
+ const newVersion = `## ${nextVersion} (${currentDate})`;
+ updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
+ fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
+};
+
+module.exports = updateChangelog;
diff --git a/scripts/wasm/hb-subset.wasm b/scripts/wasm/hb-subset.wasm
new file mode 100755
index 0000000..fdfb137
--- /dev/null
+++ b/scripts/wasm/hb-subset.wasm
Binary files differ
diff --git a/scripts/wasm/woff2.wasm b/scripts/wasm/woff2.wasm
new file mode 100644
index 0000000..7f31f44
--- /dev/null
+++ b/scripts/wasm/woff2.wasm
Binary files differ
diff --git a/scripts/woff2/assets/LiberationSans-Regular-2048.ttf b/scripts/woff2/assets/LiberationSans-Regular-2048.ttf
new file mode 100644
index 0000000..ff514cd
--- /dev/null
+++ b/scripts/woff2/assets/LiberationSans-Regular-2048.ttf
Binary files differ
diff --git a/scripts/woff2/assets/LiberationSans-Regular.ttf b/scripts/woff2/assets/LiberationSans-Regular.ttf
new file mode 100644
index 0000000..006f615
--- /dev/null
+++ b/scripts/woff2/assets/LiberationSans-Regular.ttf
Binary files differ
diff --git a/scripts/woff2/assets/NotoEmoji-Regular-2048.ttf b/scripts/woff2/assets/NotoEmoji-Regular-2048.ttf
new file mode 100644
index 0000000..608947b
--- /dev/null
+++ b/scripts/woff2/assets/NotoEmoji-Regular-2048.ttf
Binary files differ
diff --git a/scripts/woff2/assets/NotoEmoji-Regular.ttf b/scripts/woff2/assets/NotoEmoji-Regular.ttf
new file mode 100644
index 0000000..f68173b
--- /dev/null
+++ b/scripts/woff2/assets/NotoEmoji-Regular.ttf
Binary files differ
diff --git a/scripts/woff2/assets/Xiaolai-Regular.ttf b/scripts/woff2/assets/Xiaolai-Regular.ttf
new file mode 100644
index 0000000..c8673de
--- /dev/null
+++ b/scripts/woff2/assets/Xiaolai-Regular.ttf
Binary files differ
diff --git a/scripts/woff2/woff2-esbuild-plugins.js b/scripts/woff2/woff2-esbuild-plugins.js
new file mode 100644
index 0000000..19ebafc
--- /dev/null
+++ b/scripts/woff2/woff2-esbuild-plugins.js
@@ -0,0 +1,251 @@
+const fs = require("fs");
+const path = require("path");
+const { execSync } = require("child_process");
+const which = require("which");
+const wawoff = require("wawoff2");
+const { Font } = require("fonteditor-core");
+
+/**
+ * Custom esbuild plugin to:
+ * 1. inline all woff2 (url and relative imports) as base64 for server-side use cases (no need for additional font fetch; works in both esm and commonjs)
+ * 2. convert all the imported fonts (including those from cdn) at build time into .ttf (since Resvg does not support woff2, neither inlined dataurls - https://github.com/RazrFalcon/resvg/issues/541)
+ * - merging multiple woff2 into one ttf (for same families with different unicode ranges)
+ * - deduplicating glyphs due to the merge process
+ * - merging fallback font for each
+ * - printing out font metrics
+ *
+ * @returns {import("esbuild").Plugin}
+ */
+module.exports.woff2ServerPlugin = (options = {}) => {
+ return {
+ name: "woff2ServerPlugin",
+ setup(build) {
+ const fonts = new Map();
+
+ build.onResolve({ filter: /\.woff2$/ }, (args) => {
+ const resolvedPath = path.resolve(args.resolveDir, args.path);
+
+ return {
+ path: resolvedPath,
+ namespace: "woff2ServerPlugin",
+ };
+ });
+
+ build.onLoad(
+ { filter: /.*/, namespace: "woff2ServerPlugin" },
+ async (args) => {
+ let woff2Buffer;
+
+ if (path.isAbsolute(args.path)) {
+ // read local woff2 as a buffer (WARN: `readFileSync` does not work!)
+ woff2Buffer = await fs.promises.readFile(args.path);
+ } else {
+ throw new Error(`Font path has to be absolute! "${args.path}"`);
+ }
+
+ // google's brotli decompression into snft
+ const snftBuffer = new Uint8Array(
+ await wawoff.decompress(woff2Buffer),
+ ).buffer;
+
+ // load font and store per fontfamily & subfamily cache
+ let font;
+
+ try {
+ font = Font.create(snftBuffer, {
+ type: "ttf",
+ hinting: true,
+ kerning: true,
+ });
+ } catch {
+ // if loading as ttf fails, try to load as otf
+ font = Font.create(snftBuffer, {
+ type: "otf",
+ hinting: true,
+ kerning: true,
+ });
+ }
+
+ const fontFamily = font.data.name.fontFamily;
+ const subFamily = font.data.name.fontSubFamily;
+
+ if (!fonts.get(fontFamily)) {
+ fonts.set(fontFamily, {});
+ }
+
+ if (!fonts.get(fontFamily)[subFamily]) {
+ fonts.get(fontFamily)[subFamily] = [];
+ }
+
+ // store the snftbuffer per subfamily
+ fonts.get(fontFamily)[subFamily].push(font);
+
+ // inline the woff2 as base64 for server-side use cases
+ // NOTE: "file" loader is broken in commonjs and "dataurl" loader does not produce correct ur
+ return {
+ contents: `data:font/woff2;base64,${woff2Buffer.toString(
+ "base64",
+ )}`,
+ loader: "text",
+ };
+ },
+ );
+
+ build.onEnd(async () => {
+ const { outdir } = options;
+
+ if (!outdir) {
+ return;
+ }
+ const outputDir = path.resolve(outdir);
+
+ const isFontToolsInstalled = await which("fonttools", {
+ nothrow: true,
+ });
+ if (!isFontToolsInstalled) {
+ console.error(
+ `Skipped TTF generation: install "fonttools" first in order to generate TTF fonts!\nhttps://github.com/fonttools/fonttools`,
+ );
+ return;
+ }
+
+ const xiaolaiPath = path.resolve(
+ __dirname,
+ "./assets/Xiaolai-Regular.ttf",
+ );
+ const emojiPath = path.resolve(
+ __dirname,
+ "./assets/NotoEmoji-Regular.ttf",
+ );
+
+ // need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
+ const emojiPath_2048 = path.resolve(
+ __dirname,
+ "./assets/NotoEmoji-Regular-2048.ttf",
+ );
+
+ const liberationPath = path.resolve(
+ __dirname,
+ "./assets/LiberationSans-Regular.ttf",
+ );
+
+ // need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
+ const liberationPath_2048 = path.resolve(
+ __dirname,
+ "./assets/LiberationSans-Regular-2048.ttf",
+ );
+
+ const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
+ type: "ttf",
+ });
+ const emojiFont = Font.create(fs.readFileSync(emojiPath), {
+ type: "ttf",
+ });
+
+ const liberationFont = Font.create(fs.readFileSync(liberationPath), {
+ type: "ttf",
+ });
+
+ const sortedFonts = Array.from(fonts.entries()).sort(
+ ([family1], [family2]) => (family1 > family2 ? 1 : -1),
+ );
+
+ // for now we are interested in the regular families only
+ for (const [family, { Regular }] of sortedFonts) {
+ if (family.includes("Xiaolai")) {
+ // don't generate ttf for Xiaolai, as we have it hardcoded as one ttf
+ continue;
+ }
+
+ const baseFont = Regular[0];
+ const tempPaths = Regular.map((_, index) =>
+ path.resolve(outputDir, `temp_${family}_${index}.ttf`),
+ );
+
+ for (const [index, font] of Regular.entries()) {
+ // tempFileNames
+ if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir, { recursive: true });
+ }
+
+ // write down the buffer
+ fs.writeFileSync(tempPaths[index], font.write({ type: "ttf" }));
+ }
+
+ const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
+
+ const fallbackFontsPaths = [];
+ const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
+
+ if (shouldIncludeXiaolaiFallback) {
+ fallbackFontsPaths.push(xiaolaiPath);
+ }
+
+ // add liberation as fallback to all fonts, so that unknown characters are rendered similarly to how browser renders them (Helvetica, Arial, etc.)
+ if (baseFont.data.head.unitsPerEm === 2048) {
+ fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048);
+ } else {
+ fallbackFontsPaths.push(emojiPath, liberationPath);
+ }
+
+ // drop Vertical related metrics, otherwise it does not allow us to merge the fonts
+ // vhea (Vertical Header Table)
+ // vmtx (Vertical Metrics Table)
+ execSync(
+ `pyftmerge --drop-tables=vhea,vmtx --output-file="${mergedFontPath}" "${tempPaths.join(
+ '" "',
+ )}" "${fallbackFontsPaths.join('" "')}"`,
+ );
+
+ // cleanup
+ for (const path of tempPaths) {
+ fs.rmSync(path);
+ }
+
+ // yeah, we need to read the font again (:
+ const mergedFont = Font.create(fs.readFileSync(mergedFontPath), {
+ type: "ttf",
+ kerning: true,
+ hinting: true,
+ });
+
+ const getNameField = (field) => {
+ const base = baseFont.data.name[field];
+ const xiaolai = xiaolaiFont.data.name[field];
+ const emoji = emojiFont.data.name[field];
+ const liberation = liberationFont.data.name[field];
+ // liberation font
+
+ return shouldIncludeXiaolaiFallback
+ ? `${base} & ${xiaolai} & ${emoji} & ${liberation}`
+ : `${base} & ${emoji} & ${liberation}`;
+ };
+
+ mergedFont.set({
+ ...mergedFont.data,
+ name: {
+ ...mergedFont.data.name,
+ copyright: getNameField("copyright"),
+ licence: getNameField("licence"),
+ },
+ });
+
+ fs.rmSync(mergedFontPath);
+ fs.writeFileSync(mergedFontPath, mergedFont.write({ type: "ttf" }));
+
+ const { ascent, descent } = baseFont.data.hhea;
+ console.info(`Generated "${family}"`);
+ if (Regular.length > 1) {
+ console.info(
+ `- by merging ${Regular.length} woff2 fonts and related fallback fonts`,
+ );
+ }
+ console.info(
+ `- with metrics ${baseFont.data.head.unitsPerEm}, ${ascent}, ${descent}`,
+ );
+ console.info(``);
+ }
+ });
+ },
+ };
+};
diff --git a/scripts/woff2/woff2-vite-plugins.js b/scripts/woff2/woff2-vite-plugins.js
new file mode 100644
index 0000000..c2ed881
--- /dev/null
+++ b/scripts/woff2/woff2-vite-plugins.js
@@ -0,0 +1,105 @@
+// define `EXCALIDRAW_ASSET_PATH` as a SSOT
+const OSS_FONTS_CDN = "/";
+const OSS_FONTS_FALLBACK = "/";
+
+/**
+ * Custom vite plugin for auto-prefixing `EXCALIDRAW_ASSET_PATH` woff2 fonts in `excalidraw-app`.
+ *
+ * @returns {import("vite").PluginOption}
+ */
+module.exports.woff2BrowserPlugin = () => {
+ let isDev;
+
+ return {
+ name: "woff2BrowserPlugin",
+ enforce: "pre",
+ config(_, { command }) {
+ isDev = command === "serve";
+ },
+ transform(code, id) {
+ // using copy / replace as fonts defined in the `.css` don't have to be manually copied over (vite/rollup does this automatically),
+ // but at the same time can't be easily prefixed with the `EXCALIDRAW_ASSET_PATH` only for the `excalidraw-app`
+ if (!isDev && id.endsWith("/excalidraw/fonts/fonts.css")) {
+ return `/* WARN: The following content is generated during excalidraw-app build */
+
+ @font-face {
+ font-family: "Assistant";
+ src: url(${OSS_FONTS_CDN}fonts/Assistant/Assistant-Regular.woff2)
+ format("woff2"),
+ url(./Assistant-Regular.woff2) format("woff2");
+ font-weight: 400;
+ style: normal;
+ display: swap;
+ }
+
+ @font-face {
+ font-family: "Assistant";
+ src: url(${OSS_FONTS_CDN}fonts/Assistant/Assistant-Medium.woff2)
+ format("woff2"),
+ url(./Assistant-Medium.woff2) format("woff2");
+ font-weight: 500;
+ style: normal;
+ display: swap;
+ }
+
+ @font-face {
+ font-family: "Assistant";
+ src: url(${OSS_FONTS_CDN}fonts/Assistant/Assistant-SemiBold.woff2)
+ format("woff2"),
+ url(./Assistant-SemiBold.woff2) format("woff2");
+ font-weight: 600;
+ style: normal;
+ display: swap;
+ }
+
+ @font-face {
+ font-family: "Assistant";
+ src: url(${OSS_FONTS_CDN}fonts/Assistant/Assistant-Bold.woff2)
+ format("woff2"),
+ url(./Assistant-Bold.woff2) format("woff2");
+ font-weight: 700;
+ style: normal;
+ display: swap;
+ }`;
+ }
+
+ if (!isDev && id.endsWith("excalidraw-app/index.html")) {
+ return code.replace(
+ "<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->",
+ `<script>
+ // use app-relative asset paths in prod
+ window.EXCALIDRAW_ASSET_PATH = [
+ "${OSS_FONTS_CDN}",
+ "${OSS_FONTS_FALLBACK}",
+ ];
+ </script>
+
+ <!-- Preload all default fonts to avoid swap on init -->
+ <link
+ rel="preload"
+ href="${OSS_FONTS_CDN}fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2"
+ as="font"
+ type="font/woff2"
+ crossorigin="anonymous"
+ />
+ <!-- For Nunito only preload the latin range, which should be good enough for now -->
+ <link
+ rel="preload"
+ href="${OSS_FONTS_CDN}fonts/Nunito/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
+ as="font"
+ type="font/woff2"
+ crossorigin="anonymous"
+ />
+ <link
+ rel="preload"
+ href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2"
+ as="font"
+ type="font/woff2"
+ crossorigin="anonymous"
+ />
+ `,
+ );
+ }
+ },
+ };
+};