Skip to content

Commit

Permalink
[FEATURE] Introduce client-side templating engine
Browse files Browse the repository at this point in the history
Introduce a slim and simplistic client-side templating engine to
avoid using custom template processing with e.g. jQuery and keep
results robust agains cross-site scripting.

This module has been inspired by `lit-html`, uses some of their
vocabulary and behavior, but actually does not have all of their
great features - see https://lit-element.polymer-project.org/

Resolves: #91810
Releases: master
Change-Id: I4402f64625cb5526d246c315312a7977b68e88ac
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/65047
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Oliver Bartsch <bo@cedev.de>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Oliver Bartsch <bo@cedev.de>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
  • Loading branch information
ohader committed Dec 10, 2020
1 parent f564ae7 commit 6558a6b
Show file tree
Hide file tree
Showing 3 changed files with 263 additions and 0 deletions.
@@ -0,0 +1,112 @@
/*
* 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!
*/

import SecurityUtility = require('TYPO3/CMS/Core/SecurityUtility');

/**
* Module: TYPO3/CMS/Backend/Element/Template
*
* @example
* const value = 'Hello World';
* const template = html`<div>${value}</div>`;
* console.log(template.content);
*/
export class Template {
private readonly securityUtility: SecurityUtility;
private readonly strings: TemplateStringsArray;
private readonly values: any[];
private readonly unsafe: boolean;

private closures: Map<string, Function>;

public constructor(unsafe: boolean, strings: TemplateStringsArray, ...values: any[]) {
this.securityUtility = new SecurityUtility();
this.unsafe = unsafe;
this.strings = strings;
this.values = values;
this.closures = new Map<string, Function>();
}

public getHtml(parentScope: Template = null): string {
if (parentScope === null) {
parentScope = this;
}
return this.strings
.map((string: string, index: number) => {
if (this.values[index] === undefined) {
return string;
}
return string + this.getValue(this.values[index], parentScope);
})
.join('');
}

public getElement(): HTMLTemplateElement {
const template = document.createElement('template');
template.innerHTML = this.getHtml();
return template;
}

public mountTo(renderRoot: HTMLElement | ShadowRoot, clear: boolean = false): void {
if (clear) {
renderRoot.innerHTML = '';
}
const fragment = this.getElement().content;
const target = fragment.cloneNode(true) as DocumentFragment;
const closurePattern = new RegExp('^@closure:(.+)$');
target.querySelectorAll('[\\@click]').forEach((element: HTMLElement) => {
const pointer = element.getAttribute('@click');
const matches = closurePattern.exec(pointer);
const closure = this.closures.get(matches[1]);
if (matches === null || closure === null) {
return;
}
element.removeAttribute('@click');
element.addEventListener('click', (evt: Event) => closure.call(null, evt));
});
renderRoot.appendChild(target)
}

private getValue(value: any, parentScope: Template): string {
if (value instanceof Array) {
return value
.map((item: any) => this.getValue(item, parentScope))
.filter((item: string) => item !== '')
.join('');
}
if (value instanceof Function) {
const identifier = this.securityUtility.getRandomHexValue(20);
parentScope.closures.set(identifier, value);
return '@closure:' + identifier;
}
// @todo `value instanceof Template` is removed somewhere during minification
if (value instanceof Template || value instanceof Object && value.constructor === this.constructor) {
return value.getHtml(parentScope);
}
if (value instanceof Object) {
return JSON.stringify(value);
}
if (this.unsafe) {
return (value + '').trim();
}
return this.securityUtility.encodeHtml(value).trim();
}
}

export const html = (strings: TemplateStringsArray, ...values: any[]): Template => {
return new Template(false, strings, ...values);
}

export const unsafe = (strings: TemplateStringsArray, ...values: any[]): Template => {
return new Template(true, strings, ...values);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

@@ -0,0 +1,138 @@
.. include:: ../../Includes.txt

=========================================================
Feature: #91810 - Introduce client-side templating engine
=========================================================

See :issue:`91810`

Description
===========

To avoid custom jQuery template building a new slim client-side templating
engine is introduced. The functionality has been inspired by `lit-html`_ -
however it is actually not the same. As long as RequireJS and AMD-based
JavaScript modules are in place `lit-html` cannot be used directly, since
it requires native ES6-module support.

This templating engine is very simplistic and does not yet support virtual
DOM, any kind of data-binding or mutation/change detection mechanism. However
it does support conditions, iterations and simple default events in templates.

.. _lit-html: https://lit-html.polymer-project.org/


Impact
======

Individual client-side templates can be processed in JavaScript directly
using moder web technologies like template-strings_ and template-elements_.

.. _template-strings: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
.. _template-elements: https://developer.mozilla.org/de/docs/Web/HTML/Element/template

Rendering is handled by AMD-module `TYPO3/CMS/Backend/Element/Template`:

* :js:`constructor(unsafe: boolean, strings: TemplateStringsArray, ...values: any[])`
is most probably invoked by template tag functions `html` and `unsafe` only
* :js:`getHtml(parentScope: Template = null): string`
renders and returns inner HTML
* :js:`getElement(): HTMLTemplateElement`
renders and returns HTML `template` element
* :js:`mountTo(renderRoot: HTMLElement | ShadowRoot, clear: boolean = false): void`
renders and mounts result to existing HTML element
+ :js:`renderRoot` can be a regular HTML element or root node of a Shadow DOM
+ :js:`clear` instructs to clear all exiting child elements in :js:`renderRoot`


Invocation usually happens using static template tag functions:

* :js:`html = (strings: TemplateStringsArray, ...values: any[]): Template`
processes templates and ensures values are encoded for HTML
* :js:`unsafe = (strings: TemplateStringsArray, ...values: any[]): Template`
processes templates and skips encoding values for HTML - when using this
function, user submitted values need be encoded manually to avoid XSS

Examples
========

Variable assignment
-------------------

.. code-block:: ts
import {Template, html, unsafe} from 'TYPO3/CMS/Backend/Element/Template';
const value = 'World';
const target = document.getElementById('target');
const template = html`<div>Hello ${value}!</div>`;
template.mountTo(target, true);
.. code-block:: html

<div>Hello World!</div>

Unsafe tags would have been encoded (e.g. :html:`<b>World</b>`
as :html:`&lt;b&gt;World&lt;/b&gt;`).


Condition and iteration
-----------------------

.. code-block:: ts
import {Template, html, unsafe} from 'TYPO3/CMS/Backend/Element/Template';
const items = ['a', 'b', 'c']
const addClass = true;
const target = document.getElementById('target');
const template = html`
<ul ${addClass ? 'class="list"' : ''}>
${items.map((item: string, index: number): string => {
return html`<li>#${index+1}: ${item}</li>`
})}
</ul>
`;
template.mountTo(target, true);
.. code-block:: html

<ul class="list">
<li>#1: a</li>
<li>#2: b</li>
<li>#3: c</li>
</ul>

The :js:`${...}` literal used in template tags can basically contain any
JavaScript instruction - as long as their result can be casted to `string`
again or is of type `TYPO3/CMS/Backend/Element/Template`. This allows to
make use of custom conditions as well as iterations:

* condition: :js:`${condition ? thenReturn : elseReturn}`
* iteration: :js:`${array.map((item) => { return item; })}`


Events
------

Currently only `click` events are supported using :html:`@click="${handler}"`.

.. code-block:: ts
import {Template, html, unsafe} from 'TYPO3/CMS/Backend/Element/Template';
const value = 'World';
const target = document.getElementById('target');
const template = html`
<div @click="${(evt: Event): void => { console.log(value); })}">
Hello ${value}!
</div>
`;
template.mountTo(target, true);
The result won't look much different than the first example - however the
custom attribute :html:`@click` will be transformed into an according event
listener bound to the element where it has been declared.


.. index:: Backend, JavaScript, ext:backend

0 comments on commit 6558a6b

Please sign in to comment.