Spaces:
Runtime error
Runtime error
| /** | |
| * 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 | |
| })); | |