Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[FEATURE] Introduce client-side templating engine
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
Showing
3 changed files
with
263 additions
and
0 deletions.
There are no files selected for viewing
112 changes: 112 additions & 0 deletions
112
Build/Sources/TypeScript/backend/Resources/Public/TypeScript/Element/Template.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} |
13 changes: 13 additions & 0 deletions
13
typo3/sysext/backend/Resources/Public/JavaScript/Element/Template.js
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
138 changes: 138 additions & 0 deletions
138
...ntation/Changelog/master/Feature-91810-IntroduceClient-sideTemplatingEngine.rst
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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:`<b>World</b>`). | ||
|
||
|
||
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 |