From c5b8e7a979b72be6f5fbb733232e23e5816df54e Mon Sep 17 00:00:00 2001
From: Oliver Hader <oliver@typo3.org>
Date: Wed, 10 Nov 2021 21:22:35 +0100
Subject: [PATCH] [TASK] Transform JavaScriptHandler.js to be hybrid IIFE and
 AMD

With this change JavaScriptHandler.js is not only an immediately
invoked function expression (IIFE) like before but is extended to
be used as asynchronous module definition (AMD) as well. This way
it can be used in internal TypeScript implementations as well.

Resolves: #95953
Releases: master
Change-Id: I7aa37ba707298eaa8ea151358c985a2e857cfb0c
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/72153
Tested-by: core-ci <typo3@b13.com>
Tested-by: Markus Klein <markus.klein@typo3.org>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Markus Klein <markus.klein@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 Build/types/TYPO3/index.d.ts                  |  14 +
 .../core/Classes/Page/JavaScriptItems.php     |  80 ++++++
 .../Page/JavaScriptModuleInstruction.php      |   1 +
 .../core/Classes/Page/JavaScriptRenderer.php  |  40 +--
 .../Public/JavaScript/JavaScriptHandler.js    | 239 ++++++++++--------
 .../Page/JavaScriptRendererTest.php           |   2 +-
 .../Functional/Page/PageRendererTest.php      |   2 +-
 7 files changed, 244 insertions(+), 134 deletions(-)
 create mode 100644 typo3/sysext/core/Classes/Page/JavaScriptItems.php

diff --git a/Build/types/TYPO3/index.d.ts b/Build/types/TYPO3/index.d.ts
index 615d35c80678..c3e77596006d 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 000000000000..3821ba70b206
--- /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 b9adbc48ade6..417aed861e0f 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 f6ec3a85db65..6784dd2d9fb3 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 a712ad6c3ab7..fb1c7a755d3b 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 dc3760e6c747..09736194bd33 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 51e0687d94ea..7e28cbd4ef6d 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();
-- 
GitLab