diff options
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 Binary files differnew file mode 100755 index 0000000..fdfb137 --- /dev/null +++ b/scripts/wasm/hb-subset.wasm diff --git a/scripts/wasm/woff2.wasm b/scripts/wasm/woff2.wasm Binary files differnew file mode 100644 index 0000000..7f31f44 --- /dev/null +++ b/scripts/wasm/woff2.wasm diff --git a/scripts/woff2/assets/LiberationSans-Regular-2048.ttf b/scripts/woff2/assets/LiberationSans-Regular-2048.ttf Binary files differnew file mode 100644 index 0000000..ff514cd --- /dev/null +++ b/scripts/woff2/assets/LiberationSans-Regular-2048.ttf diff --git a/scripts/woff2/assets/LiberationSans-Regular.ttf b/scripts/woff2/assets/LiberationSans-Regular.ttf Binary files differnew file mode 100644 index 0000000..006f615 --- /dev/null +++ b/scripts/woff2/assets/LiberationSans-Regular.ttf diff --git a/scripts/woff2/assets/NotoEmoji-Regular-2048.ttf b/scripts/woff2/assets/NotoEmoji-Regular-2048.ttf Binary files differnew file mode 100644 index 0000000..608947b --- /dev/null +++ b/scripts/woff2/assets/NotoEmoji-Regular-2048.ttf diff --git a/scripts/woff2/assets/NotoEmoji-Regular.ttf b/scripts/woff2/assets/NotoEmoji-Regular.ttf Binary files differnew file mode 100644 index 0000000..f68173b --- /dev/null +++ b/scripts/woff2/assets/NotoEmoji-Regular.ttf diff --git a/scripts/woff2/assets/Xiaolai-Regular.ttf b/scripts/woff2/assets/Xiaolai-Regular.ttf Binary files differnew file mode 100644 index 0000000..c8673de --- /dev/null +++ b/scripts/woff2/assets/Xiaolai-Regular.ttf 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" + /> + `, + ); + } + }, + }; +}; |
