diff --git a/closure/goog/goog.js b/closure/goog/goog.js index 6e81b6987..f044ececb 100644 --- a/closure/goog/goog.js +++ b/closure/goog/goog.js @@ -72,9 +72,9 @@ export const global = globalThis; // export const scope = goog.scope; // export const defineClass = goog.defineClass; export const declareModuleId = function(namespace) { - if (window.goog && window.goog.declareModuleId) { - window.goog.declareModuleId.call(this, namespace); - } + // Use globalThis instead of window to find goog, so this can be + // imported in node.js (e.g. when running buildShims gulp task). + globalThis?.goog?.declareModuleId.call(this, namespace); }; diff --git a/scripts/gulpfiles/build_tasks.js b/scripts/gulpfiles/build_tasks.js index 4479c0be7..ed81e7cc1 100644 --- a/scripts/gulpfiles/build_tasks.js +++ b/scripts/gulpfiles/build_tasks.js @@ -15,6 +15,7 @@ gulp.sourcemaps = require('gulp-sourcemaps'); const path = require('path'); const fs = require('fs'); +const fsPromises = require('fs/promises'); const {exec, execSync} = require('child_process'); const closureCompiler = require('google-closure-compiler').gulp(); @@ -24,7 +25,7 @@ const {rimraf} = require('rimraf'); const {BUILD_DIR, DEPS_FILE, RELEASE_DIR, TEST_DEPS_FILE, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} = require('./config'); const {getPackageJson} = require('./helper_tasks'); -const {posixPath} = require('../helpers'); +const {posixPath, quote} = require('../helpers'); //////////////////////////////////////////////////////////// // Build // @@ -106,6 +107,7 @@ const chunks = [ { name: 'blockly', entry: path.join(TSC_OUTPUT_DIR, 'core', 'main.js'), + moduleEntry: path.join(TSC_OUTPUT_DIR, 'core', 'blockly.js'), exports: 'module$build$src$core$blockly', scriptExport: 'Blockly', }, @@ -672,6 +674,57 @@ function buildCompiled() { .pipe(gulp.dest(RELEASE_DIR)); } +/** + * This task builds the shims used by the playgrounds and tests to + * load Blockly in either compressed or uncompressed mode, creating + * build/blockly.loader.mjs, blocks.loader.mjs, javascript.loader.mjs, + * etc. + * + * Prerequisite: getChunkOptions (via buildCompiled, for chunks[].parent). + */ +async function buildShims() { + // Install a package.json file in BUILD_DIR to tell node.js that the + // .js files therein are ESM not CJS, so we can import the + // entrypoints to enumerate their exported names. + // + // N.B.: There is an exception: core/main.js is a goog.module not + // ESM, but fortunately we don't attempt to import or require this + // file from node.js - we only feed it to Closure Compiler, which + // uses the type information in deps.js rather than package.json. + await fsPromises.writeFile( + path.join(BUILD_DIR, 'package.json'), + '{"type": "module"}' + ); + + // Import each entrypoint module, enumerate its exports, and write + // a shim to load the chunk either by importing the entrypoint + // module or by loading the compiled script. + await Promise.all(chunks.map(async (chunk) => { + const modulePath = posixPath(chunk.moduleEntry ?? chunk.entry); + const scriptPath = + path.posix.join(RELEASE_DIR, `${chunk.name}${COMPILED_SUFFIX}.js`); + const shimPath = path.join(BUILD_DIR, `${chunk.name}.loader.mjs`); + const parentImport = + chunk.parent ? `import ${quote(`./${chunk.parent.name}.mjs`)};` : ''; + const exports = await import(`../../${modulePath}`); + + await fsPromises.writeFile(shimPath, + `import {loadChunk} from '../tests/scripts/load.mjs'; +${parentImport} + +export const { +${Object.keys(exports).map((name) => ` ${name},`).join('\n')} +} = await loadChunk( + ${quote(modulePath)}, + ${quote(scriptPath)}, + ${quote(chunk.scriptExport)}, +); +`); + })); +} + + + /** * This task builds Blockly core, blocks and generators together and uses * Closure Compiler's ADVANCED_COMPILATION mode. @@ -728,7 +781,7 @@ exports.cleanBuildDir = cleanBuildDir; exports.langfiles = buildLangfiles; // Build build/msg/*.js from msg/json/*. exports.tsc = buildJavaScript; exports.deps = gulp.series(exports.tsc, buildDeps); -exports.minify = gulp.series(exports.deps, buildCompiled); +exports.minify = gulp.series(exports.deps, buildCompiled, buildShims); exports.build = gulp.parallel(exports.minify, exports.langfiles); // Manually-invokable targets, with prerequisites where required. diff --git a/scripts/helpers.js b/scripts/helpers.js index 8dd013835..8150593dd 100644 --- a/scripts/helpers.js +++ b/scripts/helpers.js @@ -11,25 +11,64 @@ const path = require('path'); -/** - * Escape regular expression pattern - * @param {string} pattern regular expression pattern - * @return {string} escaped regular expression pattern - */ -function escapeRegex(pattern) { - return pattern.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); -} - /** * Replaces OS-specific path with POSIX style path. + * Simplified implementation based on + * https://stackoverflow.com/a/63251716/4969945 + * * @param {string} target target path * @return {string} posix path */ function posixPath(target) { - const osSpecificSep = new RegExp(escapeRegex(path.sep), 'g'); - return target.replace(osSpecificSep, path.posix.sep); + return target.split(path.sep).join(path.posix.sep); +} + +/** + * Convert a string into a string literal. Strictly speaking we + * only need to escape backslash, \r, \n, \u2028 (line separator), + * \u2029 (paragraph separator) and whichever quote character we're + * using, but for simplicity we escape all the control characters. + * + * Based on https://github.com/google/CodeCity/blob/master/server/code.js + * + * @param {string} str The string to convert. + * @return {string} The value s as a eval-able string literal. + */ +function quote(str) { + /* eslint-disable no-control-regex, no-multi-spaces */ + /** Regexp for characters to be escaped in a single-quoted string. */ + const singleRE = /[\x00-\x1f\\\u2028\u2029']/g; + + /** Map of control character replacements. */ + const replacements = { + '\x00': '\\0', + '\x01': '\\x01', + '\x02': '\\x02', + '\x03': '\\x03', + '\x04': '\\x04', + '\x05': '\\x05', + '\x06': '\\x06', + '\x07': '\\x07', + '\x08': '\\b', + '\x09': '\\t', + '\x0a': '\\n', + '\x0b': '\\v', + '\x0c': '\\f', + '\x0d': '\\r', + '\x0e': '\\x0e', + '\x0f': '\\x0f', + '"': '\\"', + "'": "\\'", + '\\': '\\\\', + '\u2028': '\\u2028', + '\u2029': '\\u2029', + }; + /* eslint-enable no-control-regex, no-multi-spaces */ + + return "'" + str.replace(singleRE, (c) => replacements[c]) + "'"; } module.exports = { posixPath, + quote, }; diff --git a/tests/multi_playground.html b/tests/multi_playground.html index 0c235d6a3..f929de8d8 100644 --- a/tests/multi_playground.html +++ b/tests/multi_playground.html @@ -4,14 +4,13 @@ Multi-toolbox Playground - - - - - - - - - - - -

Shared Procedures Playground

- -
-
- - diff --git a/tests/scripts/load.mjs b/tests/scripts/load.mjs new file mode 100644 index 000000000..728a4fd2c --- /dev/null +++ b/tests/scripts/load.mjs @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Helper functions for loading Blockly in tests. + * + * Used by playgrounds and test harnesses, both directly and via the + * shims generated by the buildShims function in + * scripts/gulpfiles/build_tasks.js. + * + */ + +if (typeof window !== 'object') { + // Not running in a browser. Maybe we wish to support this? + // blockly_uncompressed formerly supported node.js, though it + // appears that the code had not been working for some time (at + // least since PR #5718 back in December 2021. For now just throw + // an error. + throw new Error('Bootstrapping without window is not supported.'); +} + +/** + * URL of the blockly repository. This is needed for a few reasons: + * + * - We need an absolute path instead of relative path because the + * advanced_playground and the regular playground are in + * different folders. + * - We need to get the root directory for blockly because it is + * different for github.io, appspot and local. + * + * The formulation given here will work so long as top-level page is loaded from + * somewhere in tests/. + */ +export const ROOT = window.location.href.replace(/\/tests\/.*$/, '/'); + +/** + * Decide whether to use compressed mode or not. + * + * Honours a "?compressed=" query parameter if present; otherwise uses + * uncompressed for when loading from local machine and compressed + * otherwise. See issue #5557 for additional background. + * + * @return {boolean} True if should load in compressed mode. + */ +function compressed() { + const param = location.search.match(/compressed=([^&]+)/)?.[1]; + if (param) { + if (['y', 'yes', 'true', '1'].includes(param)) return true; + if (['n', 'no', 'false', '0'].includes(param)) return false; + console.error(`Unrecognised compressed parameter "${param}"`); + } + + const LOCALHOSTS = ['localhost', '127.0.0.1', '[::1]']; + const isLocalhost = LOCALHOSTS.includes(location.hostname); + const isFileUrl = location.origin === 'file://'; + return !(isLocalhost || isFileUrl); +} + +/** @type {boolean} Load in compressed mode. */ +export const COMPRESSED = compressed(); + +/** + * Load a chunk, either by importing its ESM entrypoint or loading the + * compressed chunk script. + * + * When loading in uncompressed mode, if scriptExports is a simple + * variable name (e.g. 'Blockly') then globalThis[scriptExports] will + * be set to the the chunk's Module object. This attempts to provide + * backward compatibility with loading the compressed chunk as a + * script, where this is done by the compressed chunk's UMD wrapper. + * The compatibility is not complete, however: since Module objects + * are immutable, it is not possible to set (e.g.) + * Blockly.libraryBlocks. + * + * The intention is to allow the chunk to be accessed either via + * the returned Module / exports object (preferred) or via the global + * scope (needed by dev-tools) regardless of whether it was loaded + * compressed or uncompressed. + * + * Paths should be relative to the repository root. + * + * @param {string} modulePath Path to the chunk's ESM entry point. + * @param {string} scriptPath Path to the chunk's _compressed.js file. + * @param {string} scriptExports The global variable name (or + * dotted-identifier path relative from a global variable) in + * which the compressed chunk's UMD wrapper will save the module's + * exports object. Should be the same as the correspodning + * chunks[].scriptExport property in + * scripts/gulpfiles/build_tasks.js. + * @return {Module} The module's Module or exports object. + */ +export async function loadChunk(modulePath, scriptPath, scriptExports) { + if (COMPRESSED) { + await loadScript(`${ROOT}${scriptPath}`); + return get(scriptExports); + } else { + const exports = await import(`../../${modulePath}`); + if (!scriptExports.includes('.')) globalThis[scriptExports] = exports; + return exports; + } +} + +/** + * Load a given URL using a