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