Skip to content
Snippets Groups Projects
Commit c5b8e7a9 authored by Oliver Hader's avatar Oliver Hader Committed by Benni Mack
Browse files

[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: default avatarcore-ci <typo3@b13.com>
Tested-by: default avatarMarkus Klein <markus.klein@typo3.org>
Tested-by: default avatarBenni Mack <benni@typo3.org>
Reviewed-by: default avatarMarkus Klein <markus.klein@typo3.org>
Reviewed-by: default avatarBenni Mack <benni@typo3.org>
parent 6501c328
Branches
Tags
No related merge requests found
......@@ -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;
......
<?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);
}
}
......@@ -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
......
......@@ -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
......
......@@ -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);
}
});
}
})();
......@@ -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>',
......
......@@ -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();
......
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment