diff --git a/Build/types/TYPO3/index.d.ts b/Build/types/TYPO3/index.d.ts index 615d35c80678a4bb5984d0bd7c48677f6146b3f8..c3e77596006d844f1a4da74617b78d8156dbb260 100644 --- a/Build/types/TYPO3/index.d.ts +++ b/Build/types/TYPO3/index.d.ts @@ -29,6 +29,14 @@ declare namespace TYPO3 { export const lang: { [key: string]: string }; export const configuration: any; export namespace CMS { + export namespace Core { + export class JavaScriptHandler { + public processItems(data: string|any[], isParsed?: boolean): void; + public globalAssignment(data: string|any, isParsed?: boolean): void; + public javaScriptModuleInstruction(data: string|any, isParsed?: boolean): void; + } + } + export namespace Backend { export class FormEngineValidation { public readonly errorClass: string; @@ -95,6 +103,12 @@ declare namespace TYPO3 { /** * Current AMD/RequireJS modules are returning *instances* of ad-hoc *classes*, make that known to TypeScript */ + +declare module 'TYPO3/CMS/Core/JavaScriptHandler' { + const _exported: TYPO3.CMS.Core.JavaScriptHandler; + export = _exported; +} + declare module 'TYPO3/CMS/Backend/FormEngineValidation' { const _exported: TYPO3.CMS.Backend.FormEngineValidation; export = _exported; diff --git a/typo3/sysext/core/Classes/Page/JavaScriptItems.php b/typo3/sysext/core/Classes/Page/JavaScriptItems.php new file mode 100644 index 0000000000000000000000000000000000000000..3821ba70b206c7f4a432a1671987591fbd5ce5de --- /dev/null +++ b/typo3/sysext/core/Classes/Page/JavaScriptItems.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Page; + +class JavaScriptItems implements \JsonSerializable +{ + /** + * @var list<array> + */ + protected array $globalAssignments = []; + + /** + * @var list<JavaScriptModuleInstruction> + */ + protected array $javaScriptModuleInstructions = []; + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + public function addGlobalAssignment(array $payload): void + { + if (empty($payload)) { + return; + } + $this->globalAssignments[] = $payload; + } + + public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void + { + $this->javaScriptModuleInstructions[] = $instruction; + } + + /** + * @return list<array{type: string, payload: mixed}> + * @internal + */ + public function toArray(): array + { + if ($this->isEmpty()) { + return []; + } + $items = []; + foreach ($this->globalAssignments as $item) { + $items[] = [ + 'type' => 'globalAssignment', + 'payload' => $item, + ]; + } + foreach ($this->javaScriptModuleInstructions as $item) { + $items[] = [ + 'type' => 'javaScriptModuleInstruction', + 'payload' => $item, + ]; + } + return $items; + } + + public function isEmpty(): bool + { + return $this->globalAssignments === [] + && empty($this->javaScriptModuleInstructions); + } +} diff --git a/typo3/sysext/core/Classes/Page/JavaScriptModuleInstruction.php b/typo3/sysext/core/Classes/Page/JavaScriptModuleInstruction.php index b9adbc48ade6b4693af5896b5305890202258b05..417aed861e0f38f3fc35996d3d4ab9b69ad6fe14 100644 --- a/typo3/sysext/core/Classes/Page/JavaScriptModuleInstruction.php +++ b/typo3/sysext/core/Classes/Page/JavaScriptModuleInstruction.php @@ -34,6 +34,7 @@ class JavaScriptModuleInstruction implements \JsonSerializable /** * @param string $name RequireJS module name + * @param ?string $exportName (optional) name used internally to export the module * @return static */ public static function forRequireJS(string $name, string $exportName = null): self diff --git a/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php b/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php index f6ec3a85db65d02f42ca8e69ad6021bdc968ed1e..6784dd2d9fb376a629fbb5548a1ef9b3127257f2 100644 --- a/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php +++ b/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php @@ -23,18 +23,9 @@ use TYPO3\CMS\Core\Utility\PathUtility; class JavaScriptRenderer { protected string $handlerUri; + protected JavaScriptItems $items; protected ?RequireJS $requireJS = null; - /** - * @var list<array> - */ - protected array $globalAssignments = []; - - /** - * @var list<JavaScriptModuleInstruction> - */ - protected array $javaScriptModuleInstructions = []; - public static function create(string $uri = null): self { $uri ??= PathUtility::getAbsoluteWebPath( @@ -46,6 +37,7 @@ class JavaScriptRenderer public function __construct(string $handlerUri) { $this->handlerUri = $handlerUri; + $this->items = GeneralUtility::makeInstance(JavaScriptItems::class); } public function loadRequireJS(RequireJS $requireJS): void @@ -55,15 +47,12 @@ class JavaScriptRenderer public function addGlobalAssignment(array $payload): void { - if (empty($payload)) { - return; - } - $this->globalAssignments[] = $payload; + $this->items->addGlobalAssignment($payload); } public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void { - $this->javaScriptModuleInstructions[] = $instruction; + $this->items->addJavaScriptModuleInstruction($instruction); } /** @@ -78,21 +67,12 @@ class JavaScriptRenderer $items = []; if ($this->requireJS !== null) { $items[] = [ - 'type' => 'loadRequireJS', + 'type' => 'loadRequireJs', 'payload' => $this->requireJS, ]; } - foreach ($this->globalAssignments as $item) { - $items[] = [ - 'type' => 'globalAssignment', - 'payload' => $item, - ]; - } - foreach ($this->javaScriptModuleInstructions as $item) { - $items[] = [ - 'type' => 'javaScriptModuleInstruction', - 'payload' => $item, - ]; + foreach ($this->items->toArray() as $item) { + $items[] = $item; } return $items; } @@ -104,15 +84,13 @@ class JavaScriptRenderer } return $this->createScriptElement([ 'src' => $this->handlerUri, - 'data-process-type' => 'processItems', + 'data-process-text-content' => 'processItems', ], $this->jsonEncode($this->toArray())); } protected function isEmpty(): bool { - return $this->requireJS === null - && $this->globalAssignments === [] - && empty($this->javaScriptModuleInstructions); + return $this->requireJS === null && $this->items->isEmpty(); } protected function createScriptElement(array $attributes, string $textContent = ''): string diff --git a/typo3/sysext/core/Resources/Public/JavaScript/JavaScriptHandler.js b/typo3/sysext/core/Resources/Public/JavaScript/JavaScriptHandler.js index a712ad6c3ab78bbac2ee9f8375bb89efebd0f211..fb1c7a755d3bb04967d38962919019e66205318c 100644 --- a/typo3/sysext/core/Resources/Public/JavaScript/JavaScriptHandler.js +++ b/typo3/sysext/core/Resources/Public/JavaScript/JavaScriptHandler.js @@ -12,147 +12,184 @@ */ /** * This handler is used as client-side counterpart of `\TYPO3\CMS\Core\Page\JavaScriptRenderer`. + * It either can be used standalone or as requireJS module internally. + * + * @module TYPO3/CMS/Core/JavaScriptHandler + * @internal Use in TYPO3 core only, API can change at any time! */ (function() { + "use strict"; + // @todo Handle document.currentScript.async if (!document.currentScript) { return false; } + const FLAG_LOAD_REQUIRE_JS = 1; const deniedProperties = ['__proto__', 'prototype', 'constructor']; - const supportedItemTypes = ['assign', 'invoke', 'instance']; + const allowedRequireJsItemTypes = ['assign', 'invoke', 'instance']; + const allowedRequireJsNames = ['globalAssignment', 'javaScriptModuleInstruction']; + const allowedDirectNames = ['processTextContent', 'loadRequireJs', 'processItems', 'globalAssignment', 'javaScriptModuleInstruction']; const scriptElement = document.currentScript; - const handlers = { - /** - * @param {string} type sub-handler type (processItems, loadRequireJs, globalAssignment, javaScriptModuleInstruction) - */ - processType: (type) => { - // extracts JSON payload from `/* [JSON] */` content - invokeHandler(type, scriptElement.textContent.replace(/^\s*\/\*\s*|\s*\*\/\s*/g, '')); - }, + + class JavaScriptHandler { /** - * Processes multiple items and delegates to sub-handlers (processItems, loadRequireJs, globalAssignment, javaScriptModuleInstruction) - * @param {string} data JSON data + * @param {any} json + * @param {string} json.name module name + * @param {string} json.exportName? name used internally to export the module + * @param {array<{type: string, assignments?: object, method?: string, args: array}>} json.items */ - processItems: (data) => { - const json = JSON.parse(data); - if (!isArrayInstance(json)) { + static loadRequireJsModule(json) { + // `name` is required + if (!json.name) { + throw new Error('RequireJS module name is required'); + } + if (!json.items) { + require([json.name]); return; } - json.forEach((item) => invokeHandler(item.type, item.payload, true)); - }, + const exportName = json.exportName; + const resolveSubjectRef = (__esModule) => { + return typeof exportName === 'string' ? __esModule[exportName] : __esModule; + } + const items = json.items + .filter((item) => allowedRequireJsItemTypes.includes(item.type)) + .map((item) => { + if (item.type === 'assign') { + return (__esModule) => { + const subjectRef = resolveSubjectRef(__esModule); + JavaScriptHandler.mergeRecursive(subjectRef, item.assignments); + }; + } else if (item.type === 'invoke') { + return (__esModule) => { + const subjectRef = resolveSubjectRef(__esModule); + subjectRef[item.method].apply(subjectRef, item.args); + }; + } else if (item.type === 'instance') { + return (__esModule) => { + // this `null` is `thisArg` scope of `Function.bind`, + // which will be reset when invoking `new` + const args = [null].concat(item.args); + const subjectRef = resolveSubjectRef(__esModule); + new (subjectRef.bind.apply(subjectRef, args)); + } + } + }); + require( + [json.name], + (subjectRef) => items.forEach((item) => item.call(null, subjectRef)) + ); + } + + static isObjectInstance(item) { + return item instanceof Object && !(item instanceof Array); + } + + static isArrayInstance(item) { + return item instanceof Array; + } + + static mergeRecursive(target, source) { + Object.keys(source).forEach((property) => { + if (deniedProperties.indexOf(property) !== -1) { + throw new Error('Property ' + property + ' is not allowed'); + } + if (!JavaScriptHandler.isObjectInstance(source[property]) || typeof target[property] === 'undefined') { + Object.assign(target, {[property]:source[property]}); + } else { + JavaScriptHandler.mergeRecursive(target[property], source[property]); + } + }); + } + + constructor(invokableNames) { + this.invokableNames = invokableNames; + } + + invoke(name, data, isParsed = false) { + if (!this.invokableNames.includes(name) || typeof this[name] !== 'function') { + throw new Error('Unknown handler name "' + name + '"'); + } + this[name].call(this, data, Boolean(isParsed)); + } + + /** + * @param {string} type of sub-handler (processItems, loadRequireJs, globalAssignment, javaScriptModuleInstruction) + */ + processTextContent(type) { + // extracts JSON payload from `/* [JSON] */` content + this.invoke(type, scriptElement.textContent.replace(/^\s*\/\*\s*|\s*\*\/\s*/g, '')); + } + /** * Initializes require.js configuration - require.js sources must be loaded already. - * @param {string} data JSON data + * @param {string|any} data JSON data * @param {boolean} isParsed whether data has been parsed already */ - loadRequireJS: (data, isParsed) => { + loadRequireJs(data, isParsed = false) { const payload = isParsed ? data : JSON.parse(data); - if (!isObjectInstance(payload)) { - return; + if (!JavaScriptHandler.isObjectInstance(payload)) { + throw new Error('Expected payload object'); } require.config(payload.config); - }, + } + + /** + * Processes multiple items and delegates to sub-handlers + * (processItems, loadRequireJs, globalAssignment, javaScriptModuleInstruction) + * @param {string|any[]} data JSON data + * @param {boolean} isParsed whether data has been parsed already + */ + processItems(data, isParsed = false) { + const payload = isParsed ? data : JSON.parse(data); + if (!JavaScriptHandler.isArrayInstance(payload)) { + throw new Error('Expected payload array'); + } + payload.forEach((item) => this.invoke(item.type, item.payload, true)); + } + /** * Assigns (filtered) variables to `window` object globally. - * @param {string} data JSON data + * @param {string|any} data JSON data * @param {boolean} isParsed whether data has been parsed already */ - globalAssignment: (data, isParsed) => { + globalAssignment(data, isParsed = false) { const payload = isParsed ? data : JSON.parse(data); - if (!isObjectInstance(payload)) { - return; + if (!JavaScriptHandler.isObjectInstance(payload)) { + throw new Error('Expected payload object'); } - mergeRecursive(window, payload); - }, + JavaScriptHandler.mergeRecursive(window, payload); + } + /** * Loads and invokes a requires.js module (AMD). - * @param {string} data JSON data + * @param {string|any} data JSON data * @param {boolean} isParsed whether data has been parsed already */ - javaScriptModuleInstruction: (data, isParsed) => { + javaScriptModuleInstruction(data, isParsed = false) { const payload = isParsed ? data : JSON.parse(data); if ((payload.flags & FLAG_LOAD_REQUIRE_JS) === FLAG_LOAD_REQUIRE_JS) { - loadRequireJsModule(payload); + JavaScriptHandler.loadRequireJsModule(payload); } } - }; - - function loadRequireJsModule(json) { - // `name` is required - if (!json.name) { - return; - } - if (!json.items) { - require([json.name]); - return; - } - const exportName = json.exportName; - const resolveSubjectRef = (__esModule) => { - return typeof exportName === 'string' ? __esModule[exportName] : __esModule; - } - const items = json.items - .filter((item) => supportedItemTypes.includes(item.type)) - .map((item) => { - if (item.type === 'assign') { - return (__esModule) => { - const subjectRef = resolveSubjectRef(__esModule); - mergeRecursive(subjectRef, item.assignments); - }; - } else if (item.type === 'invoke') { - return (__esModule) => { - const subjectRef = resolveSubjectRef(__esModule); - subjectRef[item.method].apply(subjectRef, item.args); - }; - } else if (item.type === 'instance') { - return (__esModule) => { - // this `null` is `thisArg` scope of `Function.bind`, - // which will be reset when invoking `new` - const args = [null].concat(item.args); - const subjectRef = resolveSubjectRef(__esModule); - new (subjectRef.bind.apply(subjectRef, args)); - } - } - }); - require( - [json.name], - (subjectRef) => items.forEach((item) => item.call(null, subjectRef)) - ); } - function isObjectInstance(item) { - return item instanceof Object && !(item instanceof Array); - } - function isArrayInstance(item) { - return item instanceof Array; - } - function mergeRecursive(target, source) { - Object.keys(source).forEach((property) => { - if (deniedProperties.indexOf(property) !== -1) { - throw new Error('Property ' + property + ' is not allowed'); - } - if (!isObjectInstance(source[property]) || typeof target[property] === 'undefined') { - Object.assign(target, {[property]:source[property]}); - } else { - mergeRecursive(target[property], source[property]); - } + // called using requireJS + if (scriptElement.dataset.requirecontext && scriptElement.dataset.requiremodule) { + const handler = new JavaScriptHandler(allowedRequireJsNames); + define(['require','exports'], () => { + return handler; }); - } - - function invokeHandler(name, data, isParsed) { - if (typeof handlers[name] === 'undefined') { - return; - } - handlers[name].call(null, data, Boolean(isParsed)); - } - - // start processing dataset declarations - Object.keys(scriptElement.dataset) - .forEach((name) => { + // called directly using `<script>` element + } else { + const handler = new JavaScriptHandler(allowedDirectNames); + // start processing dataset declarations + Object.keys(scriptElement.dataset).forEach((name) => { try { - invokeHandler(name, scriptElement.dataset[name]); + handler.invoke(name, scriptElement.dataset[name]); } catch (e) { console.error(e); } }); + } })(); diff --git a/typo3/sysext/core/Tests/Functional/Page/JavaScriptRendererTest.php b/typo3/sysext/core/Tests/Functional/Page/JavaScriptRendererTest.php index dc3760e6c747aa86910ab8050e950e5bcd5f8192..09736194bd3305adcc6a87d911bab29f5a3a3ee3 100644 --- a/typo3/sysext/core/Tests/Functional/Page/JavaScriptRendererTest.php +++ b/typo3/sysext/core/Tests/Functional/Page/JavaScriptRendererTest.php @@ -37,7 +37,7 @@ class JavaScriptRendererTest extends FunctionalTestCase ); $subject->addGlobalAssignment(['section*/' => ['key*/' => 'value*/']]); self::assertSame( - '<script src="anything.js" data-process-type="processItems">' + '<script src="anything.js" data-process-text-content="processItems">' . '/* [{"type":"globalAssignment","payload":{"section*\/":{"key*\/":"value*\/"}}},' . '{"type":"javaScriptModuleInstruction","payload":{"name":"TYPO3\/CMS\/Test*\/","exportName":null,' . '"flags":1,"items":[{"type":"invoke","method":"test*\/","args":["arg*\/"]}]}}] */</script>', diff --git a/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php b/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php index 51e0687d94ea28c4d612f2a3d0e77aece8b5b888..7e28cbd4ef6d471c36c730d26ad387b65175e019 100644 --- a/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php +++ b/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php @@ -230,7 +230,7 @@ class PageRendererTest extends FunctionalTestCase if ($requestType === SystemEnvironmentBuilder::REQUESTTYPE_FE) { $expectedInlineAssignmentsPrefix = 'var TYPO3 = Object.assign(TYPO3 || {}, Object.fromEntries(Object.entries({"settings":'; } else { - $expectedInlineAssignmentsPrefix = '<script src="typo3/sysext/core/Resources/Public/JavaScript/JavaScriptHandler.js" data-process-type="processItems">/* [{"type":"globalAssignment","payload":{"TYPO3":{"settings":'; + $expectedInlineAssignmentsPrefix = '<script src="typo3/sysext/core/Resources/Public/JavaScript/JavaScriptHandler.js" data-process-text-content="processItems">/* [{"type":"globalAssignment","payload":{"TYPO3":{"settings":'; } $renderedString = $subject->render();