diff --git a/src/addons/addons.js b/src/addons/addons.js index 1dd6f1a64b0..84e105879ca 100644 --- a/src/addons/addons.js +++ b/src/addons/addons.js @@ -20,7 +20,6 @@ const addons = [ 'clones', 'mouse-pos', 'color-picker', - 'remove-sprite-confirm', 'block-count', 'onion-skinning', 'paint-snap', @@ -84,9 +83,7 @@ const addons = [ 'editor-stepping' ]; -const newAddons = [ - 'expanded-backpack' -]; +const newAddons = []; // eslint-disable-next-line import/no-commonjs module.exports = { diff --git a/src/addons/api.js b/src/addons/api.js index 814bd6a0dee..bc00b6cc529 100644 --- a/src/addons/api.js +++ b/src/addons/api.js @@ -587,9 +587,9 @@ class Tab extends EventTargetShim { items.forEach((item, i) => { if (i !== 0 && item.separator) { const itemElt = blocklyContextMenu.children[i]; - itemElt.style.paddingTop = '2px'; - itemElt.classList.add('sa-blockly-menu-item-border'); + itemElt.style.padding = '0'; itemElt.style.borderTop = '1px solid var(--ui-black-transparent)'; + itemElt.classList.add('sa-blockly-menu-item-border'); } }); }; diff --git a/src/addons/pull.js b/src/addons/pull.js index a3b223477db..80911d582ae 100644 --- a/src/addons/pull.js +++ b/src/addons/pull.js @@ -1,5 +1,5 @@ /** - * Copyright (C) 2021 Thomas Weber + * Copyright (C) 2021-2026 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 @@ -19,12 +19,15 @@ /* eslint-disable no-console */ /* global __dirname */ -const fs = require('fs'); -const childProcess = require('child_process'); -const rimraf = require('rimraf'); -const pathUtil = require('path'); +const fs = require('node:fs'); +const childProcess = require('node:child_process'); +const pathUtil = require('node:path'); const {addons, newAddons} = require('./addons.js'); +/** + * @param {string} dir Directory path + * @returns {string[]} All files in the directory + */ const walk = dir => { const children = fs.readdirSync(dir); const files = []; @@ -43,26 +46,52 @@ const walk = dir => { return files; }; -const clone = obj => JSON.parse(JSON.stringify(obj)); +/** + * @param {string} path Possible symlink + * @returns {boolean} True if a symlink + */ +const isSymbolicLink = path => { + try { + const stat = fs.lstatSync(path); + return stat.isSymbolicLink(); + } catch (e) { + return false; + } +}; const repoPath = pathUtil.resolve(__dirname, 'ScratchAddons'); -if (!process.argv.includes('-')) { - rimraf.sync(repoPath); +if (!process.argv.includes('-') && !isSymbolicLink(repoPath)) { + fs.rmSync(repoPath, { + recursive: true, + force: true + }); 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}); + fs.rmSync(path, { + recursive: true, + force: true + }); + 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') +fs.rmSync(generatedPath, { + recursive: true, + force: true +}); +fs.mkdirSync(generatedPath, { + recursive: true +}); + +const commitHash = childProcess + .execSync('git rev-parse --short HEAD', { + cwd: fs.realpathSync(repoPath) + }) .toString() .trim(); @@ -73,7 +102,7 @@ class GeneratedImports { } add (src, namespace) { - // On Windows, convert \ to / in paths. + // Convert Windows \ to / in paths. src = src.replace(/\\/g, '/'); namespace = namespace.replace(/[^\w\d_]/g, '_'); @@ -81,7 +110,7 @@ class GeneratedImports { 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 + // All identifiers start with _ as otherwise debugger and 2d_color_picker would be invalid let importName = `_${namespace}`; if (count !== 1) { importName += `${count}`; @@ -96,68 +125,99 @@ class GeneratedImports { } } -const matchAll = (str, regex) => { - const matches = []; - let match; - while ((match = regex.exec(str)) !== null) { - matches.push(match); - } - return matches; +/** + * @param {string} relativePath Path relative to /libraries + */ +const includeLibrary = relativePath => { + const oldLibraryPath = pathUtil.resolve(__dirname, 'ScratchAddons', 'libraries', relativePath); + const newLibraryPath = pathUtil.resolve(__dirname, 'libraries', relativePath); + const libraryContents = fs.readFileSync(oldLibraryPath, 'utf-8'); + const newLibraryDirName = pathUtil.dirname(newLibraryPath); + fs.mkdirSync(newLibraryDirName, { + recursive: true + }); + fs.writeFileSync(newLibraryPath, libraryContents); }; -const includeImportedLibraries = contents => { +/** + * @param {string} js JS source + */ +const includeLibrariesJS = js => { // Parse things like: // import { normalizeHex, getHexRegex } from "../../libraries/normalize-color.js"; + // ^^^^^^^^^^^^^^^^^^ capture group 1 // import RateLimiter from "../../libraries/rate-limiter.js"; + // ^^^^^^^^^^^^^^^ capture group 1 // import "../../libraries/thirdparty/cs/chart.min.js"; - const matches = matchAll( - contents, + // ^^^^^^^^^^^^^^^^^^^^^^^^^^ capture group 1 + const matches = js.matchAll( /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); + includeLibrary(match[1]); } }; -const includePolyfills = contents => { - if (contents.includes('EventTarget')) { - contents = `import EventTarget from "../../event-target.js"; /* inserted by pull.js */\n\n${contents}`; +/** + * @param {string} css CSS source + */ +const includeLibrariesCSS = css => { + // Parse things like: + // @import url("../../libraries/common/cs/react-tooltip.css"); + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ capture group 1 + const matches = css.matchAll( + /@import url\("\.\.\/\.\.\/libraries\/([\w\d./-]+)"\)/g + ); + for (const match of matches) { + includeLibrary(match[1]); + } +}; + +/** + * @param {string} js Original JS + * @returns {string} Modified JS + */ +const includePolyfills = js => { + if (js.includes('EventTarget')) { + js = `import EventTarget from "../../event-target.js"; /* inserted by pull.js */\n\n${js}`; } - return contents; + return js; }; -const detectUnimplementedAPIs = (addonId, contents) => { - if (contents.includes('data-addon-id')) { +/** + * @param {string} addonId Addon ID + * @param {string} js JS source code + */ +const warnOnUnimplementedJS = (addonId, js) => { + /* eslint-disable max-len */ + + if (js.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 un-rewritten addon.self.dir. It or this script should be modified so that it will be rewritten.`); + if (js.includes('addon.self.dir')) { + console.warn(`Warning: ${addonId} contains un-rewritten addon.self.dir. 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 un-rewritten addon.self.lib. It should use modern ES6 import statements.`); + if (js.includes('import.meta.dir')) { + console.warn(`Warning: ${addonId} contains un-rewritten import.meta.dir. This script should be modified so that it will be rewritten.`); } + + /* eslint-enable max-len */ }; -const rewriteAssetImports = contents => { +/** + * @param {string} js Original JS + * @returns {string} Modified JS + */ +const rewriteAssetImports = js => { // Rewrite addon.self.dir concatenation to call runtime function. // Rewrite things like: // el.src = addon.self.dir + "/" + name + ".svg"; // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ match // ^^^^^^^^^^^^^^^^^^^ capture group 1 - contents = contents.replace( + js = js.replace( /addon\.self\.(?:dir|lib) *\+ *([^;,\n]+)/g, (_fullText, name) => `addon.self.getResource(${name}) /* rewritten by pull.js */` ); @@ -165,15 +225,29 @@ const rewriteAssetImports = contents => { // Rewrite things like: // `${addon.self.dir}/${name}.svg` // ^^^^^^^^^^^^ capture group 1 - contents = contents.replace( + js = js.replace( /`\${addon\.self\.(?:dir|lib)}([^`]+)`/g, (_fullText, name) => `addon.self.getResource(\`${name}\`) /* rewritten by pull.js */` ); - return contents; + // Rewrite things like: + // src: import.meta.url + "/../../../images/cs/close-s3.svg", + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ match + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ capture group 1 + // We ignore the first ../ because that's just deleting the file's own name to get to the dir + js = js.replace( + /import\.meta\.url ?\+ ?"\/\.\.\/([\w.-/-]+)"/g, + (_fullText, name) => `addon.self.getResource("${name}") /* rewritten by pull.js */` + ); + + return js; }; -const normalizeManifest = (id, manifest) => { +/** + * @param {string} addonId Addon ID + * @param {*} manifest Modified in-place + */ +const normalizeManifest = (addonId, manifest) => { const KEEP_TAGS = [ 'recommended', 'theme', @@ -181,38 +255,40 @@ const normalizeManifest = (id, manifest) => { 'danger' ]; manifest.tags = manifest.tags.filter(i => KEEP_TAGS.includes(i)); - if (newAddons.includes(id)) { + if (newAddons.includes(addonId)) { manifest.tags.push('new'); } + // Properties we never display delete manifest.versionAdded; delete manifest.latestUpdate; delete manifest.libraries; delete manifest.injectAsStyleElt; delete manifest.updateUserstylesOnSettingsChange; delete manifest.presetPreview; + delete manifest.relatedAddons; - // All addons have dynamic enable + // All addons have dynamic enable, so the property is redundant delete manifest.dynamicEnable; - const filterUserscripts = scripts => scripts + // Only include userscripts/styles that would run on project pages + const filterUserscriptsOrStyles = 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); + manifest.userscripts = filterUserscriptsOrStyles(manifest.userscripts); } if (manifest.userstyles) { - manifest.userstyles = filterUserscripts(manifest.userstyles); + manifest.userstyles = filterUserscriptsOrStyles(manifest.userstyles); } if (manifest.credits) { for (const user of manifest.credits) { if (user.link && !user.link.startsWith('https://scratch.mit.edu/')) { - console.warn(`Warning: ${id} contains unsafe credit link: ${user.link}`); + console.warn(`Warning: ${addonId} contains unsafe credit link: ${user.link}`); } delete user.note; @@ -221,34 +297,52 @@ const normalizeManifest = (id, manifest) => { } }; -const generateManifestEntry = (id, manifest) => { - const trimmedManifest = clone(manifest); +/** + * @param {string} addonId Addon ID + * @param {*} manifest Addon manifest + * @returns {string} JS for the manifest + */ +const generateManifestEntry = (addonId, manifest) => { + const trimmedManifest = structuredClone(manifest); delete trimmedManifest.enabledByDefaultMobile; delete trimmedManifest.permissions; let result = '/* generated by pull.js */\n'; result += `const manifest = ${JSON.stringify(trimmedManifest, null, 2)};\n`; + + // Various special overrides to the JSON ... + 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') { + + if (addonId === 'mediarecorder') { result += 'import {mediaRecorderSupported} from "../../environment";\n'; result += 'if (!mediaRecorderSupported) manifest.unsupported = true;\n'; } - if (id === 'tw-disable-cloud-variables') { + + if (addonId === '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) => { +/** + * @param {string} addonId Addon ID + * @param {*} manifest Addon manifest + * @param {string[]} assets Names of non-code assets that should also be imported + * @returns {string} JS entry + */ +const generateRuntimeEntry = (addonId, manifest, assets) => { const importSection = new GeneratedImports(); let exportSection = 'export const resources = {\n'; @@ -276,8 +370,17 @@ const generateRuntimeEntry = (id, manifest, assets) => { return result; }; +/** + * @type {Record} + */ const addonIdToManifest = {}; -const processAddon = (id, oldDirectory, newDirectory) => { + +/** + * @param {string} addonId Addon ID + * @param {string} oldDirectory ScratchAddons source directory + * @param {string} newDirectory scratch-gui destination directory + */ +const processAddon = (addonId, oldDirectory, newDirectory) => { const files = walk(oldDirectory); const ASSET_EXTENSIONS = [ @@ -291,19 +394,21 @@ const processAddon = (id, oldDirectory, newDirectory) => { let contents = fs.readFileSync(oldPath); const newPath = pathUtil.join(newDirectory, file); - fs.mkdirSync(pathUtil.dirname(newPath), {recursive: true}); + 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; + normalizeManifest(addonId, parsedManifest); + addonIdToManifest[addonId] = parsedManifest; const settingsEntryPath = pathUtil.join(newDirectory, '_manifest_entry.js'); - fs.writeFileSync(settingsEntryPath, generateManifestEntry(id, parsedManifest)); + fs.writeFileSync(settingsEntryPath, generateManifestEntry(addonId, parsedManifest)); const runtimeEntryPath = pathUtil.join(newDirectory, '_runtime_entry.js'); - fs.writeFileSync(runtimeEntryPath, generateRuntimeEntry(id, parsedManifest, assets)); + fs.writeFileSync(runtimeEntryPath, generateRuntimeEntry(addonId, parsedManifest, assets)); continue; } @@ -311,12 +416,15 @@ const processAddon = (id, oldDirectory, newDirectory) => { contents = contents.toString('utf-8'); if (file.endsWith('.js')) { - includeImportedLibraries(contents); + includeLibrariesJS(contents); contents = includePolyfills(contents); contents = rewriteAssetImports(contents); + warnOnUnimplementedJS(addonId, contents); } - detectUnimplementedAPIs(id, contents); + if (file.endsWith('.css')) { + includeLibrariesCSS(contents); + } } fs.writeFileSync(newPath, contents); @@ -350,6 +458,10 @@ const SKIP_MESSAGES = [ 'custom-menu-bar/@settings-name-my-stuff' ]; +/** + * @param {string} localeRoot ScratchAddons locale root + * @returns {{settings: *, runtime: *, upstreamMessageIds: Set}} Generated locales + */ const parseMessageDirectory = localeRoot => { const unstructure = string => { if (typeof string === 'object') {