diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2e08cd52bc..161327e01c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -63,7 +63,7 @@ jobs: sed -e 's/^\[warn] \(.*\)$/::warning file=\1,line=1::File was not formatted with prettier. Usually, commenting !format will fix this./' \ )) - type-warnings: + typecheck: runs-on: ubuntu-latest steps: - name: Checkout @@ -77,8 +77,8 @@ jobs: cache: npm - name: Install dependencies run: npm ci - - name: Generate type warnings - run: node development/ci-generate-type-warnings.js + - name: Check types + run: node development/ci-type-check.js env: PR_NUMBER: "${{ github.event.number }}" GH_REPO: "${{ github.repository }}" diff --git a/development/builder.js b/development/builder.js index 181e8a8a7d..7d4894d034 100644 --- a/development/builder.js +++ b/development/builder.js @@ -12,6 +12,7 @@ import parseMetadata from "./parse-extension-metadata.js"; import parseTranslations from "./parse-extension-translations.js"; import renderTemplate from "./render-template.js"; import renderDocs from "./render-docs.js"; +import transpileTypeScript from "./transpile-typescript.js"; import { mkdirp, recursiveReadDirectory } from "./fs-utils.js"; import { fetchAllDependencies, @@ -176,8 +177,27 @@ class ExtensionFile extends BuildFile { this.mode = mode; } + getType() { + return ".js"; + } + + /** + * @returns {{jsCode: string; sourceMap: string | null}} + */ + async transpile() { + const sourceMap = this.mode === "development"; + const source = await fsPromises.readFile(this.sourcePath, "utf-8"); + if (this.sourcePath.endsWith(".ts")) { + return transpileTypeScript(source, this.slug, !!sourceMap); + } + return { + jsCode: source, + sourceMap: null, + }; + } + async read() { - let data = await fsPromises.readFile(this.sourcePath, "utf-8"); + let data = (await this.transpile()).jsCode; if (this.mode !== "development") { const dependenciesJS = rewriteExternalToInline(data); @@ -186,6 +206,11 @@ class ExtensionFile extends BuildFile { let prefixJS = ""; let suffixJS = ""; + if (this.sourcePath.endsWith(".ts")) { + prefixJS += + "/* transpiled from TypeScript - see repository for original version with types */"; + } + const translations = filterTranslationsByPrefix( this.allTranslations, `${this.slug}@` @@ -286,10 +311,10 @@ class ExtensionFile extends BuildFile { } } - validateImports(js); + validateImports((await this.transpile()).jsCode); } - getStrings() { + async getStrings() { if (!this.featured) { return null; } @@ -315,7 +340,7 @@ class ExtensionFile extends BuildFile { }, }; - const jsCode = fs.readFileSync(this.sourcePath, "utf-8"); + const jsCode = (await this.transpile()).jsCode; const unprefixedRuntimeStrings = parseTranslations(jsCode); const runtimeStrings = Object.fromEntries( Object.entries(unprefixedRuntimeStrings).map(([key, value]) => [ @@ -331,9 +356,26 @@ class ExtensionFile extends BuildFile { } async getDependencies() { - return parseExtensionDependencies( - await fsPromises.readFile(this.sourcePath, "utf-8") - ); + return parseExtensionDependencies((await this.transpile()).jsCode); + } +} + +class ExtensionSourceMapFile extends BuildFile { + /** + * @param {ExtensionFile} extensionFile + */ + constructor(extensionFile) { + super(extensionFile.sourcePath); + /** @type {ExtensionFile} */ + this.extensionFile = extensionFile; + } + + getType() { + return ".json"; + } + + async read() { + return (await this.extensionFile.transpile()).sourceMap; } } @@ -675,13 +717,13 @@ class Build { /** * @returns {Record>} */ - generateL10N() { + async generateL10N() { const allStrings = {}; for (const [filePath, file] of Object.entries(this.files)) { let fileStrings; try { - fileStrings = file.getStrings(); + fileStrings = await file.getStrings(); } catch (error) { console.error(error); throw new Error( @@ -717,7 +759,7 @@ class Build { async exportL10N(root) { await mkdirp(root); - const groups = this.generateL10N(); + const groups = await this.generateL10N(); for (const [name, strings] of Object.entries(groups)) { const filename = pathUtil.join(root, `exported-${name}.json`); await fsPromises.writeFile(filename, JSON.stringify(strings, null, 2)); @@ -799,20 +841,34 @@ class Builder { for (const [filename, absolutePath] of await recursiveReadDirectory( this.extensionsRoot )) { - if (!filename.endsWith(".js")) { + const isJavaScript = filename.endsWith(".js"); + const isTypeScript = + filename.endsWith(".ts") && !filename.endsWith(".d.ts"); + if (!isJavaScript && !isTypeScript) { + // Not an extension. continue; } + const extensionSlug = filename.split(".")[0]; const featured = featuredExtensionSlugs.includes(extensionSlug); - const file = new ExtensionFile( + + const extensionFile = new ExtensionFile( absolutePath, extensionSlug, featured, translations["extension-runtime"], this.mode ); - extensionFiles[extensionSlug] = file; - build.files[`/${filename}`] = file; + extensionFiles[extensionSlug] = extensionFile; + build.files[`/${extensionSlug}.js`] = extensionFile; + + // Sourcemaps are only accurate in development builds as production builds will insert strings + // and cached dependencies. + if (isTypeScript && this.mode === "development") { + build.files[`/${extensionSlug}.js.map`] = new ExtensionSourceMapFile( + extensionFile + ); + } } /** @type {Record} */ diff --git a/development/ci-generate-type-warnings.js b/development/ci-type-check.js similarity index 67% rename from development/ci-generate-type-warnings.js rename to development/ci-type-check.js index 26652165a5..ec85908f07 100644 --- a/development/ci-generate-type-warnings.js +++ b/development/ci-type-check.js @@ -3,9 +3,8 @@ import ts from "typescript"; import { createAnnotation, getChangedFiles, isCI } from "./ci-interop.js"; /** - * @fileoverview Generates CI annotations for type warnings in files modified by the - * pull request. Standard TypeScript CLI will include all files and its output is - * detected as errors, so not usable for us. + * @fileoverview Generates CI annotations for type checking. + * JavaScript files are warn-only; TypeScript are hard errors. */ const check = async () => { @@ -16,7 +15,7 @@ const check = async () => { pathUtil.join(rootDir, f) ); - console.log(`${changedFiles.size} changed files:`); + console.log(`${changedFiles.length} changed files:`); console.log(Array.from(changedFiles).sort().join("\n")); console.log(""); @@ -39,12 +38,25 @@ const check = async () => { ]; let numWarnings = 0; + let numErrors = 0; for (const diagnostic of diagnostics) { - if (!changedFilesAbsolute.includes(diagnostic.file.fileName)) { + const isBlocker = diagnostic.file.fileName.endsWith(".ts"); + + if ( + !isBlocker && + !changedFilesAbsolute.includes(diagnostic.file.fileName) + ) { + // Warning in a file not touched by the PR: ignore continue; } + if (isBlocker) { + numErrors++; + } else { + numWarnings++; + } + const startPosition = ts.getLineAndCharacterOfPosition( diagnostic.file, diagnostic.start @@ -60,12 +72,12 @@ const check = async () => { 0 ); - numWarnings++; createAnnotation({ - type: "warning", + type: isBlocker ? "error" : "warning", file: diagnostic.file.fileName, - title: "Type warning - may indicate a bug - ignore if no bug", - onlyIfChanged: true, + title: isBlocker + ? "Type error - must be fixed" + : "Type warning - may indicate a bug - ignore if no bug", message: flattened, line: startPosition.line + 1, col: startPosition.character + 1, @@ -74,12 +86,17 @@ const check = async () => { }); } + console.log(`Errors: ${numErrors}`); console.log(`Warnings in changed files: ${numWarnings}`); - console.log(`Warnings in all files: ${diagnostics.length}`); + console.log(`Total errors+warnings in all files: ${diagnostics.length}`); + + return numErrors === 0; }; if (isCI()) { - check(); + check().then((success) => { + process.exit(success ? 0 : 1); + }); } else { console.error( "This script is only intended to be used in CI. For development, use normal TypeScript CLI instead." diff --git a/development/transpile-typescript.js b/development/transpile-typescript.js new file mode 100644 index 0000000000..ffb17fed84 --- /dev/null +++ b/development/transpile-typescript.js @@ -0,0 +1,60 @@ +import * as pathUtil from "node:path"; +import ts from "typescript"; + +/** + * @fileoverview Transpiles TypeScript to JavaScript. + */ + +const tsconfigPath = pathUtil.join(import.meta.dirname, "../tsconfig.json"); + +/** + * @type {import("typescript").CompilerOptions | null} + */ +let cachedBaseOptions = null; + +/** + * @returns {import("typescript").CompilerOptions} + */ +const getBaseCompilerOptions = () => { + if (!cachedBaseOptions) { + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (configFile.error) { + throw new Error( + ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n") + ); + } + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + pathUtil.dirname(tsconfigPath) + ); + cachedBaseOptions = parsed.options; + } + return cachedBaseOptions; +}; + +/** + * Transpile a TypeScript extension to JavaScript. Does not enforce any sort of type checking. + * @param {string} tsCode TypeScript code + * @param {string} slug extension slug + * @param {boolean} sourceMap true to generate source map + * @returns {{jsCode: string, sourcemap: string|null}} + */ +const compileTypeScript = (tsCode, slug, sourceMap) => { + const result = ts.transpileModule(tsCode, { + // used in the generated source map + fileName: `${slug}.ts`, + compilerOptions: { + ...getBaseCompilerOptions(), + // Our normal `tsc` is checking only - but here we want output + noEmit: false, + sourceMap, + }, + }); + return { + jsCode: result.outputText, + sourceMap: result.sourceMapText ?? null, + }; +}; + +export default compileTypeScript; diff --git a/development/upload-translations.js b/development/upload-translations.js index 34266a3647..7be0072967 100644 --- a/development/upload-translations.js +++ b/development/upload-translations.js @@ -55,7 +55,7 @@ const run = async () => { const build = await builder.build(); console.log("Generating strings..."); - const l10n = build.generateL10N(); + const l10n = await build.generateL10N(); console.log("Uploading runtime strings..."); await uploadRuntimeStrings(l10n["extension-runtime"]); diff --git a/extensions/cursor.js b/extensions/cursor.ts similarity index 92% rename from extensions/cursor.js rename to extensions/cursor.ts index 55c927b173..e8304287ec 100644 --- a/extensions/cursor.js +++ b/extensions/cursor.ts @@ -5,7 +5,7 @@ // By: GarboMuffin // License: MIT AND MPL-2.0 -(function (Scratch) { +(function (Scratch: typeof globalThis.Scratch) { "use strict"; if (!Scratch.extensions.unsandboxed) { @@ -13,17 +13,13 @@ } const lazilyCreatedCanvas = () => { - /** @type {HTMLCanvasElement} */ - let canvas = null; - /** @type {CanvasRenderingContext2D} */ - let ctx = null; - /** - * @param {number} width - * @param {number} height - * @returns {[HTMLCanvasElement, CanvasRenderingContext2D]} - */ - return (width, height) => { - if (!canvas) { + let canvas: HTMLCanvasElement | null = null; + let ctx: CanvasRenderingContext2D | null = null; + return ( + width: number, + height: number + ): [HTMLCanvasElement, CanvasRenderingContext2D] => { + if (!canvas || !ctx) { canvas = document.createElement("canvas"); ctx = canvas.getContext("2d"); if (!ctx) { @@ -38,15 +34,17 @@ }; const getRawSkinCanvas = lazilyCreatedCanvas(); + const isSVGSkin = (skin: RenderWebGL.Skin): skin is RenderWebGL.SVGSkin => + !!(skin as RenderWebGL.SVGSkin)._svgImage; + /** - * @param {RenderWebGL.Skin} skin - * @returns {string} A data: URI for the skin. + * @param skin + * @returns A data: URI for the skin. */ - const encodeSkinToURL = (skin) => { - const svgSkin = /** @type {RenderWebGL.SVGSkin} */ (skin); - if (svgSkin._svgImage) { + const encodeSkinToURL = (skin: RenderWebGL.Skin): string => { + if (isSVGSkin(skin)) { // This is an SVG skin - return svgSkin._svgImage.src; + return skin._svgImage.src; } // It's probably a bitmap skin. @@ -57,7 +55,9 @@ if (silhouette.unlazy) { silhouette.unlazy(); } - const colorData = silhouette._colorData; + // The renderer types _colorData with a generic ArrayBufferLike backing, but the + // ImageData constructor specifically wants an ArrayBuffer-backed array. + const colorData = silhouette._colorData as Uint8ClampedArray; const width = silhouette._width; const height = silhouette._height; const imageData = new ImageData( @@ -70,13 +70,11 @@ return canvas.toDataURL(); }; - /** - * @param {VM.Costume} costume - * @param {number} maxWidth - * @param {number} maxHeight - * @returns {{uri: string, width: number, height: number}} - */ - const costumeToCursor = (costume, maxWidth, maxHeight) => { + const costumeToCursor = ( + costume: VM.Costume, + maxWidth: number, + maxHeight: number + ): { uri: string; width: number; height: number } => { const skin = Scratch.vm.renderer._allSkins[costume.skinId]; const imageURI = encodeSkinToURL(skin); @@ -113,13 +111,10 @@ }; }; - /** @type {string} */ let nativeCursor = "default"; - /** @type {null|string} */ - let customCursorImageName = null; + let customCursorImageName: null | string = null; const canvas = Scratch.renderer.canvas; - /** @type {string} */ let currentCanvasCursor = nativeCursor; const updateCanvasCursor = () => { if (canvas.style.cursor !== currentCanvasCursor) { @@ -135,19 +130,16 @@ /** * Parse strings like "60x12" or "77,1" - * @param {string} string - * @returns {[number, number]} */ - const parseTuple = (string) => { + const parseTuple = (string: string): [number, number] => { const [a, b] = ("" + string).split(/[ ,x]/); return [+a || 0, +b || 0]; }; /** - * @param {string} size eg. "48x84" - * @returns {string} + * @param size eg. "48x84" */ - const formatUnreliableSize = (size) => + const formatUnreliableSize = (size: string): string => Scratch.translate( { default: "{size} (unreliable)", @@ -196,7 +188,7 @@ "nwse-resize", ]; - class MouseCursor { + class MouseCursor implements Scratch.Extension { constructor() { Scratch.vm.runtime.on("RUNTIME_DISPOSED", () => { this.setCur({ @@ -560,7 +552,7 @@ }; } - setCur(args) { + setCur(args: { cur: unknown }) { const newCursor = Scratch.Cast.toString(args.cur); // Prevent setting cursor to "url(...), default" from causing fetch. if (ALL_ALLOWED_CURSORS.includes(newCursor)) { @@ -571,10 +563,13 @@ } } - setCursorImage(args, util) { - const [maxWidth, maxHeight] = parseTuple(args.size).map((i) => - Math.max(0, i) - ); + setCursorImage( + args: { position: unknown; size: unknown }, + util: VM.BlockUtility + ) { + const [maxWidth, maxHeight] = parseTuple( + Scratch.Cast.toString(args.size) + ).map((i) => Math.max(0, i)); const currentCostume = util.target.getCostumes()[util.target.currentCostume]; @@ -589,9 +584,9 @@ } if (encodedCostume) { - const [percentX, percentY] = parseTuple(args.position).map( - (i) => Math.max(0, Math.min(100, i)) / 100 - ); + const [percentX, percentY] = parseTuple( + Scratch.Cast.toString(args.position) + ).map((i) => Math.max(0, Math.min(100, i)) / 100); const x = percentX * encodedCostume.width; const y = percentY * encodedCostume.height; diff --git a/extensions/gamepad.js b/extensions/gamepad.ts similarity index 87% rename from extensions/gamepad.js rename to extensions/gamepad.ts index e8f06ae2cd..c2b4806cb0 100644 --- a/extensions/gamepad.js +++ b/extensions/gamepad.ts @@ -7,7 +7,7 @@ // Some parts of this scripts are based on or designed to be compatible-ish with: // https://arpruss.github.io/gamepad.js (MIT Licensed) -(function (Scratch) { +(function (Scratch: typeof globalThis.Scratch) { "use strict"; // For joysticks @@ -17,20 +17,18 @@ // For triggers. Drift isn't so big of an issue with these. const BUTTON_DEADZONE = 0.05; - /** - * @typedef InternalGamepadState - * @property {string} id - * @property {Gamepad} realGamepad - * @property {number} timestamp - * @property {number[]} axisDirections - * @property {number[]} axisMagnitudes - * @property {number[]} axisValues - * @property {number[]} buttonValues - * @property {boolean[]} buttonPressed - */ + interface InternalGamepadState { + id: string; + realGamepad: Gamepad; + timestamp: number; + axisDirections: number[]; + axisMagnitudes: number[]; + axisValues: number[]; + buttonValues: number[]; + buttonPressed: boolean[]; + } - /** @type {Array} */ - let gamepadState = []; + let gamepadState: Array = []; const updateState = () => { // In Firefox, the objects returned by getGamepads() change in the background, but in Chrome @@ -46,8 +44,7 @@ return null; } - /** @type {InternalGamepadState} */ - const result = { + const result: InternalGamepadState = { id: gamepad.id, realGamepad: gamepad, timestamp: gamepad.timestamp, @@ -104,12 +101,11 @@ }); /** - * @param {unknown} index 1-indexed index or 'any' - * @returns {InternalGamepadState[]} + * @param index 1-indexed index or 'any' */ - const getGamepads = (index) => { + const getGamepads = (index: unknown): InternalGamepadState[] => { if (index === "any") { - return gamepadState.filter((i) => i); + return gamepadState.filter((i): i is InternalGamepadState => !!i); } const gamepad = gamepadState[Scratch.Cast.toNumber(index) - 1]; if (gamepad) { @@ -119,11 +115,13 @@ }; /** - * @param {InternalGamepadState} gamepad - * @param {unknown} buttonIndex 1-indexed index or 'any' - * @returns {boolean} false if button does not exist + * @param buttonIndex 1-indexed index or 'any' + * @returns false if button does not exist */ - const isButtonPressed = (gamepad, buttonIndex) => { + const isButtonPressed = ( + gamepad: InternalGamepadState, + buttonIndex: unknown + ): boolean => { if (buttonIndex === "any") { return gamepad.buttonPressed.some((i) => i); } @@ -131,47 +129,49 @@ }; /** - * @param {InternalGamepadState} gamepad - * @param {unknown} buttonIndex 1-indexed index - * @returns {number} 0 if button does not exist + * @param buttonIndex 1-indexed index + * @returns 0 if button does not exist */ - const getButtonValue = (gamepad, buttonIndex) => { + const getButtonValue = ( + gamepad: InternalGamepadState, + buttonIndex: unknown + ): number => { const value = gamepad.buttonValues[Scratch.Cast.toNumber(buttonIndex) - 1]; return value || 0; }; /** - * @param {InternalGamepadState} gamepad - * @param {unknown} axisIndex 1-indexed index - * @returns {number} 0 if axis does not exist + * @param axisIndex 1-indexed index + * @returns 0 if axis does not exist */ - const getAxisValue = (gamepad, axisIndex) => { + const getAxisValue = ( + gamepad: InternalGamepadState, + axisIndex: unknown + ): number => { const axisValue = gamepad.axisValues[Scratch.Cast.toNumber(axisIndex) - 1]; return axisValue || 0; }; - /** - * @param {InternalGamepadState} gamepad - * @param {unknown} startIndex - */ - const getAxisPairMagnitude = (gamepad, startIndex) => { + const getAxisPairMagnitude = ( + gamepad: InternalGamepadState, + startIndex: unknown + ) => { const magnitude = gamepad.axisMagnitudes[Scratch.Cast.toNumber(startIndex) - 1]; return magnitude || 0; }; - /** - * @param {InternalGamepadState} gamepad - * @param {unknown} startIndex - */ - const getAxisPairDirection = (gamepad, startIndex) => { + const getAxisPairDirection = ( + gamepad: InternalGamepadState, + startIndex: unknown + ) => { const direction = gamepad.axisDirections[Scratch.Cast.toNumber(startIndex) - 1]; return direction || 0; }; - class GamepadExtension { - getInfo() { + class GamepadExtension implements Scratch.Extension { + getInfo(): Scratch.Info { return { id: "Gamepad", name: Scratch.translate("Gamepad"), @@ -513,11 +513,11 @@ }; } - gamepadConnected({ pad }) { + gamepadConnected({ pad }: { pad: unknown }) { return getGamepads(pad).length > 0; } - buttonDown({ b, i }) { + buttonDown({ b, i }: { b: unknown; i: unknown }) { for (const gamepad of getGamepads(i)) { if (isButtonPressed(gamepad, b)) { return true; @@ -526,7 +526,7 @@ return false; } - buttonValue({ b, i }) { + buttonValue({ b, i }: { b: unknown; i: unknown }) { let greatestButton = 0; for (const gamepad of getGamepads(i)) { const value = getButtonValue(gamepad, b); @@ -537,7 +537,7 @@ return greatestButton; } - axisValue({ b, i }) { + axisValue({ b, i }: { b: unknown; i: unknown }) { let greatestAxis = 0; for (const gamepad of getGamepads(i)) { const axis = getAxisValue(gamepad, b); @@ -548,7 +548,7 @@ return greatestAxis; } - axisDirection({ axis, pad }) { + axisDirection({ axis, pad }: { axis: unknown; pad: unknown }) { let greatestMagnitude = 0; // by default sprites have direction 90 degrees, so that's a reasonable default let direction = 90; @@ -571,11 +571,14 @@ return direction; } - axisMagnitude({ axis, pad }) { + axisMagnitude({ axis, pad }: { axis: unknown; pad: unknown }) { let greatestMagnitude = 0; for (const gamepad of getGamepads(pad)) { const horizontalAxis = getAxisValue(gamepad, axis); - const verticalAxis = getAxisValue(gamepad, +axis + 1); + const verticalAxis = getAxisValue( + gamepad, + Scratch.Cast.toNumber(axis) + 1 + ); const magnitude = Math.sqrt(horizontalAxis ** 2 + verticalAxis ** 2); if (magnitude > greatestMagnitude) { greatestMagnitude = magnitude; @@ -584,23 +587,21 @@ return greatestMagnitude; } - rumble({ s, w, t, i }) { + rumble({ s, w, t, i }: { s: unknown; w: unknown; t: unknown; i: unknown }) { const gamepads = getGamepads(i); for (const { realGamepad } of gamepads) { - // @ts-ignore if (realGamepad.vibrationActuator) { - // @ts-ignore realGamepad.vibrationActuator.playEffect("dual-rumble", { startDelay: 0, - duration: t * 1000, - weakMagnitude: w, - strongMagnitude: s, + duration: Scratch.Cast.toNumber(t) * 1000, + weakMagnitude: Scratch.Cast.toNumber(w), + strongMagnitude: Scratch.Cast.toNumber(s), }); } } } - setAxisDeadzone({ DEADZONE }) { + setAxisDeadzone({ DEADZONE }: { DEADZONE: unknown }) { axisDeadzone = Scratch.Cast.toNumber(DEADZONE); updateState(); } diff --git a/extensions/iframe.js b/extensions/iframe.ts similarity index 91% rename from extensions/iframe.js rename to extensions/iframe.ts index 3d4d281d45..a1db92ae09 100644 --- a/extensions/iframe.js +++ b/extensions/iframe.ts @@ -5,12 +5,11 @@ // Context: "iframe" is an HTML element that lets websites embed other websites. // License: MIT AND MPL-2.0 -(function (Scratch) { +(function (Scratch: typeof globalThis.Scratch) { "use strict"; - /** @type {HTMLIFrameElement|null} */ - let iframe = null; - let overlay = null; + let iframe: HTMLIFrameElement | null = null; + let overlay: RenderWebGL.Overlay | null = null; const featurePolicy = { accelerometer: "'none'", @@ -54,10 +53,8 @@ let height = -1; // negative means default let interactive = true; let resizeBehavior = "scale"; - /** @type {string|number|boolean} */ - let latestMessage = ""; - /** @type {string|number|boolean} */ - let latestParentMessage = ""; + let latestMessage: string | number | boolean = ""; + let latestParentMessage: string | number | boolean = ""; const updateFrameAttributes = () => { if (!iframe) { @@ -97,7 +94,7 @@ const getOverlayMode = () => resizeBehavior === "scale" ? "scale-centered" : "manual"; - const createFrame = (src) => { + const createFrame = (src: string) => { iframe = document.createElement("iframe"); iframe.style.width = "100%"; iframe.style.height = "100%"; @@ -126,18 +123,14 @@ } }; - /** @param {unknown} data */ - const normalizeMessage = (data) => + const normalizeMessage = (data: unknown): string | number | boolean => typeof data === "string" || typeof data === "number" || typeof data === "boolean" ? data : JSON.stringify(data); - /** - * @returns {Window|null} - */ - const getParentWindow = () => { + const getParentWindow = (): Window | null => { // if no parent, window.parent is us. which is not useful if (window.parent !== window) { return window.parent; @@ -162,8 +155,8 @@ Scratch.vm.runtime.on("RUNTIME_DISPOSED", closeFrame); - class IframeExtension { - getInfo() { + class IframeExtension implements Scratch.Extension { + getInfo(): Scratch.Info { return { name: Scratch.translate("Iframe"), id: "iframe", @@ -370,14 +363,15 @@ }; } - async display({ URL }) { + async display({ URL }: { URL: unknown }) { closeFrame(); - if (await Scratch.canEmbed(URL)) { - createFrame(Scratch.Cast.toString(URL)); + const url = Scratch.Cast.toString(URL); + if (await Scratch.canEmbed(url)) { + createFrame(url); } } - async displayHTML({ HTML }) { + async displayHTML({ HTML }: { HTML: unknown }) { closeFrame(); const url = `data:text/html;,${encodeURIComponent( Scratch.Cast.toString(HTML) @@ -403,7 +397,7 @@ closeFrame(); } - get({ MENU }) { + get({ MENU }: { MENU: unknown }) { MENU = Scratch.Cast.toString(MENU); if (MENU === "url") { if (iframe) return iframe.getAttribute("src"); @@ -427,32 +421,32 @@ } } - setX({ X }) { + setX({ X }: { X: unknown }) { x = Scratch.Cast.toNumber(X); updateFrameAttributes(); } - setY({ Y }) { + setY({ Y }: { Y: unknown }) { y = Scratch.Cast.toNumber(Y); updateFrameAttributes(); } - setWidth({ WIDTH }) { + setWidth({ WIDTH }: { WIDTH: unknown }) { width = Scratch.Cast.toNumber(WIDTH); updateFrameAttributes(); } - setHeight({ HEIGHT }) { + setHeight({ HEIGHT }: { HEIGHT: unknown }) { height = Scratch.Cast.toNumber(HEIGHT); updateFrameAttributes(); } - setInteractive({ INTERACTIVE }) { + setInteractive({ INTERACTIVE }: { INTERACTIVE: unknown }) { interactive = Scratch.Cast.toBoolean(INTERACTIVE); updateFrameAttributes(); } - setResize({ RESIZE }) { + setResize({ RESIZE }: { RESIZE: unknown }) { if (RESIZE === "scale" || RESIZE === "viewport") { resizeBehavior = RESIZE; if (overlay) { @@ -463,13 +457,13 @@ } } - sendMessage({ MESSAGE }) { + sendMessage({ MESSAGE }: { MESSAGE: unknown }) { if (iframe && iframe.contentWindow) { iframe.contentWindow.postMessage(MESSAGE, "*"); } } - sendMessageParent({ MESSAGE }) { + sendMessageParent({ MESSAGE }: { MESSAGE: unknown }) { const parentWindow = getParentWindow(); if (parentWindow) { parentWindow.postMessage(MESSAGE, "*"); diff --git a/extensions/runtime-options.js b/extensions/runtime-options.ts similarity index 90% rename from extensions/runtime-options.js rename to extensions/runtime-options.ts index 3ce5a68c6a..5f079f7d1f 100644 --- a/extensions/runtime-options.js +++ b/extensions/runtime-options.ts @@ -4,7 +4,7 @@ // By: GarboMuffin // License: MIT AND MPL-2.0 -(function (Scratch) { +(function (Scratch: typeof globalThis.Scratch) { "use strict"; if (!Scratch.extensions.unsandboxed) { @@ -23,18 +23,12 @@ const STAGE_SIZE = "stage size"; const USERNAME = "username"; - /** @param {string} what */ - const emitChanged = (what) => + const emitChanged = (what: string) => Scratch.vm.runtime.startHats("runtimeoptions_whenChange", { WHAT: what, }); - /** - * @template T - * @param {T} obj - * @returns {T} - */ - const shallowCopy = (obj) => Object.assign({}, obj); + const shallowCopy = (obj: T): T => Object.assign({}, obj); let previousRuntimeOptions = shallowCopy(Scratch.vm.runtime.runtimeOptions); @@ -60,7 +54,10 @@ Scratch.vm.on("STAGE_SIZE_CHANGED", () => emitChanged(STAGE_SIZE)); const originalPostData = Scratch.vm.runtime.ioDevices.userData.postData; - Scratch.vm.runtime.ioDevices.userData.postData = function (data) { + Scratch.vm.runtime.ioDevices.userData.postData = function ( + this: VM.UserData, + data: VM.UserDataData + ) { const newUsername = data.username !== this._username; originalPostData.call(this, data); if (newUsername) { @@ -68,8 +65,8 @@ } }; - class RuntimeOptions { - getInfo() { + class RuntimeOptions implements Scratch.Extension { + getInfo(): Scratch.Info { return { id: "runtimeoptions", name: Scratch.translate("Runtime Options"), @@ -334,7 +331,7 @@ }; } - getEnabled({ thing }) { + getEnabled({ thing }: { thing: unknown }) { if (thing === TURBO_MODE) { return Scratch.vm.runtime.turboMode; } else if (thing === INTERPOLATION) { @@ -349,23 +346,23 @@ return false; } - setEnabled({ thing, enabled }) { - enabled = Scratch.Cast.toBoolean(enabled); + setEnabled({ thing, enabled }: { thing: unknown; enabled: unknown }) { + const isEnabled = Scratch.Cast.toBoolean(enabled); if (thing === TURBO_MODE) { - Scratch.vm.setTurboMode(enabled); + Scratch.vm.setTurboMode(isEnabled); } else if (thing === INTERPOLATION) { - Scratch.vm.setInterpolation(enabled); + Scratch.vm.setInterpolation(isEnabled); } else if (thing === REMOVE_FENCING) { Scratch.vm.setRuntimeOptions({ - fencing: !enabled, + fencing: !isEnabled, }); } else if (thing === REMOVE_MISC_LIMITS) { Scratch.vm.setRuntimeOptions({ - miscLimits: !enabled, + miscLimits: !isEnabled, }); } else if (thing === HIGH_QUALITY_PEN) { - Scratch.renderer.setUseHighQualityRender(enabled); + Scratch.renderer.setUseHighQualityRender(isEnabled); } } @@ -373,22 +370,20 @@ return Scratch.vm.runtime.frameLoop.framerate; } - setFramerate({ fps }) { - fps = Scratch.Cast.toNumber(fps); - Scratch.vm.setFramerate(fps); + setFramerate({ fps }: { fps: unknown }) { + Scratch.vm.setFramerate(Scratch.Cast.toNumber(fps)); } getCloneLimit() { return Scratch.vm.runtime.runtimeOptions.maxClones; } - setCloneLimit({ limit }) { - limit = Scratch.Cast.toNumber(limit); + setCloneLimit({ limit }: { limit: unknown }) { Scratch.vm.setRuntimeOptions({ - maxClones: limit, + maxClones: Scratch.Cast.toNumber(limit), }); } - getDimension({ dimension }) { + getDimension({ dimension }: { dimension: unknown }) { if (dimension === "width") { return Scratch.vm.runtime.stageWidth; } else if (dimension === "height") { @@ -397,13 +392,14 @@ return 0; } - setDimensions({ width, height }) { - width = Scratch.Cast.toNumber(width); - height = Scratch.Cast.toNumber(height); - Scratch.vm.setStageSize(width, height); + setDimensions({ width, height }: { width: unknown; height: unknown }) { + Scratch.vm.setStageSize( + Scratch.Cast.toNumber(width), + Scratch.Cast.toNumber(height) + ); } - setUsername({ username }) { + setUsername({ username }: { username: unknown }) { Scratch.vm.postIOData("userData", { username: Scratch.Cast.toString(username), }); diff --git a/package-lock.json b/package-lock.json index 88c4cb657f..e9126aaac4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -255,8 +255,7 @@ }, "node_modules/@turbowarp/types": { "version": "0.0.14", - "resolved": "git+ssh://git@github.com/TurboWarp/types-tw.git#938c3ab84e4dd859f6f0646cdf7ed5dd1a94f622", - "integrity": "sha512-oDu7BzT6YriGTaX7k8UmPuTiC/Cr2dmhwoWRRCMvzaOSDNZ4E45DM5B9uO477eQFcSSiUzW+ELhphD4+rNFKFQ==", + "resolved": "git+ssh://git@github.com/TurboWarp/types-tw.git#783efabc66fc60e637d800658c1e54035dc83044", "license": "Apache-2.0" }, "node_modules/@types/estree": { diff --git a/tsconfig.json b/tsconfig.json index 07ec64f2ec..47540a528b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ "noEmit": true, "allowJs": true, "checkJs": true, + "removeComments": false, + "sourceMap": true, + "inlineSources": true, "paths": { // See https://github.com/turboWarp/types#using-from-npm "scratch-vm": ["./node_modules/@turbowarp/types/index.d.ts"],