soiz1's picture
Upload 2891 files
6bcb42f verified
/**
* Copyright (C) 2021 Thomas Weber
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* eslint-disable import/no-commonjs */
/* eslint-disable import/no-nodejs-modules */
/* eslint-disable no-console */
/* global __dirname */
const fs = require('fs');
const childProcess = require('child_process');
const rimraf = require('rimraf');
const pathUtil = require('path');
const {addons, newAddons} = require('./addons.js');
const walk = dir => {
const children = fs.readdirSync(dir);
const files = [];
for (const child of children) {
const path = pathUtil.join(dir, child);
const stat = fs.statSync(path);
if (stat.isDirectory()) {
const childChildren = walk(path);
for (const childChild of childChildren) {
files.push(pathUtil.join(child, childChild));
}
} else {
files.push(child);
}
}
return files;
};
const clone = obj => JSON.parse(JSON.stringify(obj));
const repoPath = pathUtil.resolve(__dirname, 'ScratchAddons');
if (!process.argv.includes('-')) {
rimraf.sync(repoPath);
childProcess.execSync(`git clone --depth=1 --branch=tw https://github.com/TurboWarp/addons ${repoPath}`);
}
for (const folder of ['addons', 'addons-l10n', 'addons-l10n-settings', 'libraries']) {
const path = pathUtil.resolve(__dirname, folder);
rimraf.sync(path);
fs.mkdirSync(path, {recursive: true});
}
const generatedPath = pathUtil.resolve(__dirname, 'generated');
rimraf.sync(generatedPath);
fs.mkdirSync(generatedPath, {recursive: true});
process.chdir(repoPath);
const commitHash = childProcess.execSync('git rev-parse --short HEAD')
.toString()
.trim();
class GeneratedImports {
constructor () {
this.source = '';
this.namespaces = new Map();
}
add (src, namespace) {
// On Windows, convert \ to / in paths.
src = src.replace(/\\/g, '/');
namespace = namespace.replace(/[^\w\d_]/g, '_');
const count = this.namespaces.get(namespace) || 1;
this.namespaces.set(namespace, count + 1);
// All identifiers should start with _ so things like debugger and 2d-color-picker will be valid identifiers
let importName = `_${namespace}`;
if (count !== 1) {
importName += `${count}`;
}
this.source += `import ${importName} from ${JSON.stringify(src)};\n`;
return importName;
}
toString () {
return this.source;
}
}
const matchAll = (str, regex) => {
const matches = [];
let match;
while ((match = regex.exec(str)) !== null) {
matches.push(match);
}
return matches;
};
const includeImportedLibraries = contents => {
// Parse things like:
// import { normalizeHex, getHexRegex } from "../../libraries/normalize-color.js";
// import RateLimiter from "../../libraries/rate-limiter.js";
const matches = matchAll(
contents,
/import +(?:{.*}|.*) +from +["']\.\.\/\.\.\/libraries\/([\w\d_\/-]+(?:\.esm)?\.js)["'];/g
);
for (const match of matches) {
const libraryFile = match[1];
const oldLibraryPath = pathUtil.resolve(__dirname, 'ScratchAddons', 'libraries', libraryFile);
const newLibraryPath = pathUtil.resolve(__dirname, 'libraries', libraryFile);
const libraryContents = fs.readFileSync(oldLibraryPath, 'utf-8');
const newLibraryDirName = pathUtil.dirname(newLibraryPath);
fs.mkdirSync(newLibraryDirName, {
recursive: true
});
fs.writeFileSync(newLibraryPath, libraryContents);
}
};
const includePolyfills = contents => {
if (contents.includes('EventTarget')) {
contents = `import EventTarget from "../../event-target.js"; /* inserted by pull.js */\n\n${contents}`;
}
return contents;
};
const detectUnimplementedAPIs = (addonId, contents) => {
if (contents.includes('data-addon-id')) {
console.warn(`Warning: ${addonId} seems to use data-addon-id. It should use [data-addons*=...] instead.`);
}
if (contents.includes('addon.self.dir')) {
// eslint-disable-next-line max-len
console.warn(`Warning: ${addonId} contains unwritten addon.self.dir. It or this script should be modified so that it will be rewritten.`);
}
if (contents.includes('addon.self.lib')) {
// eslint-disable-next-line max-len
console.warn(`Warning: ${addonId} contains unwritten addon.self.lib. It should use modern ES6 import statements.`);
}
};
const rewriteAssetImports = contents => {
// Reroute addon.self.dir concatenation to call runtime function.
// Parse things like:
// el.src = addon.self.dir + "/" + name + ".svg";
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ match
// ^^^^^^^^^^^^^^^^^^^ capture group 1
contents = contents.replace(
/addon\.self\.(?:dir|lib) *\+ *([^;,\n]+)/g,
(_fullText, name) => `addon.self.getResource(${name}) /* rewritten by pull.js */`
);
return contents;
};
const normalizeManifest = (id, manifest) => {
const KEEP_TAGS = [
'recommended',
'theme',
'beta',
'danger'
];
manifest.tags = manifest.tags.filter(i => KEEP_TAGS.includes(i));
if (newAddons.includes(id)) {
manifest.tags.push('new');
}
delete manifest.versionAdded;
delete manifest.latestUpdate;
delete manifest.libraries;
delete manifest.injectAsStyleElt;
delete manifest.updateUserstylesOnSettingsChange;
// All addons have dynamic enable
delete manifest.dynamicEnable;
const filterUserscripts = scripts => scripts
.filter(({matches}) => matches.includes('projects') || matches.includes('https://scratch.mit.edu/projects/*'))
.map(obj => ({
url: obj.url,
if: obj.if
}));
if (manifest.userscripts) {
manifest.userscripts = filterUserscripts(manifest.userscripts);
}
if (manifest.userstyles) {
manifest.userstyles = filterUserscripts(manifest.userstyles);
}
if (manifest.credits) {
for (const {link} of manifest.credits) {
if (link && !link.startsWith('https://scratch.mit.edu/')) {
console.warn(`Warning: ${id} contains unsafe credit link: ${link}`);
}
}
}
};
const generateManifestEntry = (id, manifest) => {
const trimmedManifest = clone(manifest);
delete trimmedManifest.enabledByDefaultMobile;
delete trimmedManifest.permissions;
let result = '/* generated by pull.js */\n';
result += `const manifest = ${JSON.stringify(trimmedManifest, null, 2)};\n`;
if (typeof manifest.enabledByDefaultMobile === 'boolean') {
result += 'import {isMobile} from "../../environment";\n';
result += `if (isMobile) manifest.enabledByDefault = ${manifest.enabledByDefaultMobile};\n`;
}
if (manifest.permissions && manifest.permissions.includes('clipboardWrite')) {
result += 'import {clipboardSupported} from "../../environment";\n';
result += 'if (!clipboardSupported) manifest.unsupported = true;\n';
}
if (id === 'mediarecorder') {
result += 'import {mediaRecorderSupported} from "../../environment";\n';
result += 'if (!mediaRecorderSupported) manifest.unsupported = true;\n';
}
if (id === 'tw-disable-cloud-variables') {
result += 'import {isScratchDesktop} from "../../../lib/isScratchDesktop";\n';
result += 'if (isScratchDesktop()) manifest.unsupported = true;\n';
}
result += 'export default manifest;\n';
return result;
};
const generateRuntimeEntry = (id, manifest, assets) => {
const importSection = new GeneratedImports();
let exportSection = 'export const resources = {\n';
for (const userscript of manifest.userscripts || []) {
const src = userscript.url;
const importName = importSection.add(`./${src}`, 'js');
exportSection += ` ${JSON.stringify(src)}: ${importName},\n`;
}
for (const userstyle of manifest.userstyles || []) {
const src = userstyle.url;
const importName = importSection.add(`!css-loader!./${src}`, 'css');
exportSection += ` ${JSON.stringify(src)}: ${importName},\n`;
}
for (const assetName of assets) {
const importName = importSection.add(`!url-loader!./${assetName}`, 'asset');
exportSection += ` ${JSON.stringify(assetName)}: ${importName},\n`;
}
exportSection += '};\n';
let result = '/* generated by pull.js */\n';
result += importSection.toString();
result += exportSection;
return result;
};
const addonIdToManifest = {};
const processAddon = (id, oldDirectory, newDirectory) => {
const files = walk(oldDirectory);
const ASSET_EXTENSIONS = [
'.svg',
'.png'
];
const assets = files.filter(file => ASSET_EXTENSIONS.some(extension => file.endsWith(extension)));
for (const file of files) {
const oldPath = pathUtil.join(oldDirectory, file);
let contents = fs.readFileSync(oldPath);
const newPath = pathUtil.join(newDirectory, file);
fs.mkdirSync(pathUtil.dirname(newPath), {recursive: true});
if (file === 'addon.json') {
contents = contents.toString('utf-8');
const parsedManifest = JSON.parse(contents);
normalizeManifest(id, parsedManifest);
addonIdToManifest[id] = parsedManifest;
const settingsEntryPath = pathUtil.join(newDirectory, '_manifest_entry.js');
fs.writeFileSync(settingsEntryPath, generateManifestEntry(id, parsedManifest));
const runtimeEntryPath = pathUtil.join(newDirectory, '_runtime_entry.js');
fs.writeFileSync(runtimeEntryPath, generateRuntimeEntry(id, parsedManifest, assets));
continue;
}
if (file.endsWith('.js') || file.endsWith('.css')) {
contents = contents.toString('utf-8');
if (file.endsWith('.js')) {
includeImportedLibraries(contents);
contents = includePolyfills(contents);
contents = rewriteAssetImports(contents);
}
detectUnimplementedAPIs(id, contents);
}
fs.writeFileSync(newPath, contents);
}
};
const SKIP_MESSAGES = [
'debugger/@description',
'debugger/@settings-name-log_max_list_length',
'debugger/log-msg-list-append-too-long',
'debugger/log-msg-list-insert-too-long',
'debugger/@settings-name-log_invalid_cloud_data',
'debugger/log-cloud-data-nan',
'debugger/log-cloud-data-too-long',
'debugger/tab-performance',
'debugger/performance-framerate-title',
'debugger/performance-framerate-graph-tooltip',
'debugger/performance-clonecount-title',
'debugger/performance-clonecount-graph-tooltip',
'editor-devtools/extension-description-not-for-addon',
'mediarecorder/added-by',
'editor-theme3/@settings-name-sa-color',
'editor-theme3/@settings-name-forums',
'block-switching/@settings-name-sa'
];
const parseMessageDirectory = localeRoot => {
const settings = {};
const runtime = {};
const upstreamMessageIds = new Set();
for (const addon of addons) {
const path = pathUtil.join(localeRoot, `${addon}.json`);
try {
const contents = fs.readFileSync(path, 'utf-8');
const parsed = JSON.parse(contents);
for (const id of Object.keys(parsed).sort()) {
upstreamMessageIds.add(id);
if (SKIP_MESSAGES.includes(id)) {
continue;
}
const value = parsed[id];
if (id.includes('/@')) {
settings[id] = value;
} else {
runtime[id] = value;
}
}
} catch (e) {
// Ignore errors caused by file not existing.
if (e.code !== 'ENOENT') {
throw e;
}
}
}
return {
settings,
runtime,
upstreamMessageIds
};
};
const generateEntries = (items, callback) => {
let exportSection = 'export default {\n';
const importSection = new GeneratedImports();
for (const i of items) {
const {src, name, type} = callback(i);
if (type === 'lazy-import') {
// eslint-disable-next-line max-len
exportSection += ` ${JSON.stringify(i)}: () => import(/* webpackChunkName: ${JSON.stringify(name)} */ ${JSON.stringify(src)}),\n`;
} else if (type === 'lazy-require') {
exportSection += ` ${JSON.stringify(i)}: () => require(${JSON.stringify(src)}),\n`;
} else if (type === 'eager-import') {
const importName = importSection.add(src, i);
exportSection += ` ${JSON.stringify(i)}: ${importName},\n`;
} else {
throw new Error(`Unknown type: ${type}`);
}
}
exportSection += '};\n';
let result = '/* generated by pull.js */\n';
result += importSection.toString();
result += exportSection;
return result;
};
const generateL10nEntries = locales => generateEntries(
locales.filter(i => i !== 'en'),
locale => ({
name: `addon-l10n-${locale}`,
src: `../addons-l10n/${locale}.json`,
type: 'lazy-import'
})
);
const generateL10nSettingsEntries = locales => generateEntries(
locales.filter(i => i !== 'en'),
locale => ({
src: `../addons-l10n-settings/${locale}.json`,
type: 'lazy-require'
})
);
const generateRuntimeEntries = () => generateEntries(
addons,
id => {
const manifest = addonIdToManifest[id];
return {
src: `../addons/${id}/_runtime_entry.js`,
// Include default addons in a single bundle
name: manifest.enabledByDefault ? 'addon-default-entry' : `addon-entry-${id}`,
// Include default addons useful outside of the editor in the original bundle, no request required
type: (manifest.enabledByDefault && !manifest.editorOnly) ? 'lazy-require' : 'lazy-import'
};
}
);
const generateManifestEntries = () => generateEntries(
addons,
id => ({
src: `../addons/${id}/_manifest_entry.js`,
type: 'eager-import'
})
);
for (const addon of addons) {
const oldDirectory = pathUtil.resolve(__dirname, 'ScratchAddons', 'addons', addon);
const newDirectory = pathUtil.resolve(__dirname, 'addons', addon);
processAddon(addon, oldDirectory, newDirectory);
}
const l10nFiles = fs.readdirSync(pathUtil.resolve(__dirname, 'ScratchAddons', 'addons-l10n'));
const languages = [];
const allUpstreamMessageIds = new Set();
for (const file of l10nFiles) {
const oldDirectory = pathUtil.resolve(__dirname, 'ScratchAddons', 'addons-l10n', file);
// Ignore README
if (!fs.statSync(oldDirectory).isDirectory()) {
continue;
}
// Convert pt-br to just pt
const fixedName = file === 'pt-br' ? 'pt' : file;
languages.push(fixedName);
const runtimePath = pathUtil.resolve(__dirname, 'addons-l10n', `${fixedName}.json`);
const settingsPath = pathUtil.resolve(__dirname, 'addons-l10n-settings', `${fixedName}.json`);
const {settings, runtime, upstreamMessageIds} = parseMessageDirectory(oldDirectory);
for (const id of upstreamMessageIds) {
allUpstreamMessageIds.add(id);
}
fs.writeFileSync(runtimePath, JSON.stringify(runtime, null, 4));
if (fixedName !== 'en') {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
}
}
for (const id of SKIP_MESSAGES) {
if (!allUpstreamMessageIds.has(id)) {
console.warn(`Warning: Translation ${id} is in SKIP_MESSAGES but does not exist`);
}
}
fs.writeFileSync(pathUtil.resolve(generatedPath, 'l10n-entries.js'), generateL10nEntries(languages));
fs.writeFileSync(pathUtil.resolve(generatedPath, 'l10n-settings-entries.js'), generateL10nSettingsEntries(languages));
fs.writeFileSync(pathUtil.resolve(generatedPath, 'addon-entries.js'), generateRuntimeEntries(languages));
fs.writeFileSync(pathUtil.resolve(generatedPath, 'addon-manifests.js'), generateManifestEntries(languages));
const upstreamMetaPath = pathUtil.resolve(generatedPath, 'upstream-meta.json');
fs.writeFileSync(upstreamMetaPath, JSON.stringify({
commit: commitHash
}));