chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 11:57:19 +08:00
commit eff4f2e836
1244 changed files with 326523 additions and 0 deletions
+37
View File
@@ -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",
);
+40
View File
@@ -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();
};
+61
View File
@@ -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);
}
});
});
+54
View File
@@ -0,0 +1,54 @@
const path = require("path");
const { build } = require("esbuild");
// contains all dependencies bundled inside
const getConfig = (outdir) => ({
outdir,
bundle: true,
format: "esm",
entryPoints: ["src/index.ts"],
entryNames: "[name]",
assetNames: "[dir]/[name]",
alias: {
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: [
"@excalidraw/common",
"@excalidraw/element",
"@excalidraw/math",
"@excalidraw/fractional-indexing",
],
});
function buildDev(config) {
return build({
...config,
sourcemap: true,
define: {
"import.meta.env": JSON.stringify({ DEV: true }),
},
});
}
function buildProd(config) {
return build({
...config,
minify: true,
define: {
"import.meta.env": JSON.stringify({ PROD: true }),
},
});
}
const createESMRawBuild = async () => {
// development unminified build with source maps
await buildDev(getConfig("dist/dev"));
// production minified build without sourcemaps
await buildProd(getConfig("dist/prod"));
};
(async () => {
await createESMRawBuild();
})();
+21
View File
@@ -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);
});
+129
View File
@@ -0,0 +1,129 @@
const path = require("path");
const fs = require("fs");
const { pathToFileURL } = require("url");
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,
},
};
// Resolve a relative path from the source file's directory
const resolveRelativePath = (importPath, sourceFile) => {
const sourceDir = path.dirname(sourceFile);
const extensions = [".scss", ".css", ""];
for (const ext of extensions) {
const fullPath = path.resolve(sourceDir, importPath + ext);
if (fs.existsSync(fullPath)) {
return fullPath;
}
// Try with underscore prefix for partials
const partialPath = path.join(
path.dirname(fullPath),
`_${path.basename(fullPath)}`,
);
if (fs.existsSync(partialPath)) {
return partialPath;
}
}
return null;
};
// Precompile function to convert relative paths to absolute paths
const precompile = (source, sourcePath) => {
// Match @use and @forward statements with relative paths
const importRegex = /(@use|@forward)\s+["'](\.[^"']+)["']/g;
return source.replace(importRegex, (match, directive, importPath) => {
const resolvedPath = resolveRelativePath(importPath, sourcePath);
if (resolvedPath) {
// Convert to file:// URL format for sass
const fileUrl = pathToFileURL(resolvedPath).href;
return `${directive} "${fileUrl}"`;
}
return match;
});
};
// excludes all external dependencies and bundles only the source code
const getConfig = (outdir) => ({
outdir,
bundle: true,
splitting: true,
format: "esm",
packages: "external",
plugins: [
sassPlugin({
precompile,
}),
],
target: "es2020",
assetNames: "[dir]/[name]",
chunkNames: "[dir]/[name]-[hash]",
alias: {
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: [
"@excalidraw/common",
"@excalidraw/element",
"@excalidraw/math",
"@excalidraw/fractional-indexing",
],
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();
})();
+66
View File
@@ -0,0 +1,66 @@
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: ["src/index.ts"],
entryNames: "[name]",
assetNames: "[dir]/[name]",
alias: {
"@excalidraw/common": path.resolve(__dirname, "../packages/common/src"),
"@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
"@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
"@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
"@excalidraw/fractional-indexing": path.resolve(
__dirname,
"../packages/fractional-indexing/src",
),
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
});
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
await buildDev(getConfig("dist/dev"));
// production minified build without sourcemaps
await buildProd(getConfig("dist/prod"));
};
(async () => {
await createESMRawBuild();
})();
+75
View File
@@ -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);
}
+215
View File
@@ -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.");
+245
View File
@@ -0,0 +1,245 @@
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const updateChangelog = require("./updateChangelog");
// skipping utils for now, as it has independent release process
const PACKAGES = [
"common",
"fractional-indexing",
"math",
"element",
"excalidraw",
];
const PACKAGES_DIR = path.resolve(__dirname, "../packages");
/**
* Returns the arguments for the release script.
*
* Usage examples:
* - yarn release --help -> prints this help message
* - yarn release -> publishes `@excalidraw` packages with "test" tag and "-[hash]" version suffix
* - yarn release --tag=test -> same as above
* - yarn release --tag=next -> publishes `@excalidraw` packages with "next" tag and version "-[hash]" suffix
* - yarn release --tag=next --non-interactive -> skips interactive prompts (runs on CI/CD), otherwise same as above
* - yarn release --tag=latest --version=0.19.0 -> publishes `@excalidraw` packages with "latest" tag and version "0.19.0" & prepares changelog for the release
*
* @returns [tag, version, nonInteractive]
*/
const getArguments = () => {
let tag = "test";
let version = "";
let nonInteractive = false;
for (const argument of process.argv.slice(2)) {
if (/--help/.test(argument)) {
console.info(`Available arguments:
--tag=<tag> -> (optional) "test" (default), "next" for auto release, "latest" for stable release
--version=<version> -> (optional) for "next" and "test", (required) for "latest" i.e. "0.19.0"
--non-interactive -> (optional) disables interactive prompts`);
console.info(`\nUsage examples:
- yarn release -> publishes \`@excalidraw\` packages with "test" tag and "-[hash]" version suffix
- yarn release --tag=test -> same as above
- yarn release --tag=next -> publishes \`@excalidraw\` packages with "next" tag and version "-[hash]" suffix
- yarn release --tag=next --non-interactive -> skips interactive prompts (runs on CI/CD), otherwise same as above
- yarn release --tag=latest --version=0.19.0 -> publishes \`@excalidraw\` packages with "latest" tag and version "0.19.0" & prepares changelog for the release`);
process.exit(0);
}
if (/--tag=/.test(argument)) {
tag = argument.split("=")[1];
}
if (/--version=/.test(argument)) {
version = argument.split("=")[1];
}
if (/--non-interactive/.test(argument)) {
nonInteractive = true;
}
}
if (tag !== "latest" && tag !== "next" && tag !== "test") {
console.error(`Unsupported tag "${tag}", use "latest", "next" or "test".`);
process.exit(1);
}
if (tag === "latest" && !version) {
console.error("Pass the version to make the latest stable release!");
process.exit(1);
}
if (!version) {
// set the next version based on the excalidraw package version + commit hash
const excalidrawPackageVersion = require(getPackageJsonPath(
"excalidraw",
)).version;
const hash = getShortCommitHash();
if (!excalidrawPackageVersion.includes(hash)) {
version = `${excalidrawPackageVersion}-${hash}`;
} else {
// ensuring idempotency
version = excalidrawPackageVersion;
}
}
console.info(`Running with tag "${tag}" and version "${version}"...`);
return [tag, version, nonInteractive];
};
const validatePackageName = (packageName) => {
if (!PACKAGES.includes(packageName)) {
console.error(`Package "${packageName}" not found!`);
process.exit(1);
}
};
const getPackageJsonPath = (packageName) => {
validatePackageName(packageName);
return path.resolve(PACKAGES_DIR, packageName, "package.json");
};
const updatePackageJsons = (nextVersion) => {
const packageJsons = new Map();
for (const packageName of PACKAGES) {
const pkg = require(getPackageJsonPath(packageName));
pkg.version = nextVersion;
if (pkg.dependencies) {
for (const dependencyName of PACKAGES) {
if (!pkg.dependencies[`@excalidraw/${dependencyName}`]) {
continue;
}
pkg.dependencies[`@excalidraw/${dependencyName}`] = nextVersion;
}
}
packageJsons.set(packageName, `${JSON.stringify(pkg, null, 2)}\n`);
}
// modify once, to avoid inconsistent state
for (const packageName of PACKAGES) {
const content = packageJsons.get(packageName);
fs.writeFileSync(getPackageJsonPath(packageName), content, "utf-8");
}
};
const getShortCommitHash = () => {
return execSync("git rev-parse --short HEAD").toString().trim();
};
const askToCommit = (tag, nextVersion) => {
if (tag !== "latest") {
return Promise.resolve();
}
return new Promise((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(
"Would you like to commit these changes to git? (Y/n): ",
(answer) => {
rl.close();
if (answer.toLowerCase() === "y") {
execSync(`git add -u`);
execSync(
`git commit -m "chore: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
);
} else {
console.warn(
"Skipping commit. Don't forget to commit manually later!",
);
}
resolve();
},
);
});
};
const buildPackages = () => {
console.info("Running yarn install...");
execSync(`yarn --frozen-lockfile`, { stdio: "inherit" });
console.info("Removing existing build artifacts...");
execSync(`yarn rm:build`, { stdio: "inherit" });
for (const packageName of PACKAGES) {
console.info(`Building "@excalidraw/${packageName}"...`);
execSync(`yarn run build:esm`, {
cwd: path.resolve(PACKAGES_DIR, packageName),
stdio: "inherit",
});
}
};
const askToPublish = (tag, version) => {
return new Promise((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(
"Would you like to publish these changes to npm? (Y/n): ",
(answer) => {
rl.close();
if (answer.toLowerCase() === "y") {
publishPackages(tag, version);
} else {
console.info("Skipping publish.");
}
resolve();
},
);
});
};
const publishPackages = (tag, version) => {
for (const packageName of PACKAGES) {
execSync(`yarn publish --tag ${tag}`, {
cwd: path.resolve(PACKAGES_DIR, packageName),
stdio: "inherit",
});
console.info(
`Published "@excalidraw/${packageName}@${tag}" with version "${version}"! 🎉`,
);
}
};
/** main */
(async () => {
const [tag, version, nonInteractive] = getArguments();
buildPackages();
if (tag === "latest") {
await updateChangelog(version);
}
updatePackageJsons(version);
if (nonInteractive) {
publishPackages(tag, version);
} else {
await askToCommit(tag, version);
await askToPublish(tag, version);
}
})();
+106
View File
@@ -0,0 +1,106 @@
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"`;
const { stdout } = await exec(
`git log --format=format:"%H" --grep=${commitMessage}`,
);
// take commit hash from latest release
return stdout.split(/\r?\n/)[0];
} 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;
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+252
View File
@@ -0,0 +1,252 @@
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const { Font } = require("fonteditor-core");
const wawoff = require("wawoff2");
const which = require("which");
/**
* 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(``);
}
});
},
};
};
+112
View File
@@ -0,0 +1,112 @@
// define `EXCALIDRAW_ASSET_PATH` as a SSOT
const OSS_FONTS_CDN = "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/oss/";
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>
// point into our CDN in prod, fallback to root (excalidraw.com) domain in case of issues
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/Assistant/Assistant-SemiBold.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"
/>
`,
);
}
},
};
};