UNPKG

closure-builder

Version:

Simple Closure, Soy and JavaScript Build system

369 lines (339 loc) 14.5 kB
/* * @fileoverview Helper utilities for incremental dom code generation in Soy. * Copyright 2016 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as googSoy from 'goog:goog.soy'; // from //javascript/closure/soy import SanitizedContent from 'goog:goog.soy.data.SanitizedContent'; // from //javascript/closure/soy:data import SanitizedContentKind from 'goog:goog.soy.data.SanitizedContentKind'; // from //javascript/closure/soy:data import SanitizedHtml from 'goog:goog.soy.data.SanitizedHtml'; // from //javascript/closure/soy:data import SanitizedHtmlAttribute from 'goog:goog.soy.data.SanitizedHtmlAttribute'; // from //javascript/closure/soy:data import SanitizedJs from 'goog:goog.soy.data.SanitizedJs'; // from //javascript/closure/soy:data import SanitizedUri from 'goog:goog.soy.data.SanitizedUri'; // from //javascript/closure/soy:data import UnsanitizedText from 'goog:goog.soy.data.UnsanitizedText'; // from //javascript/closure/soy:data import * as googString from 'goog:goog.string'; // from //javascript/closure/string import * as soy from 'goog:soy'; // from //javascript/template/soy:soy_usegoog_js import {isAttribute} from 'goog:soy.checks'; // from //javascript/template/soy:checks import {ordainSanitizedHtml} from 'goog:soydata.VERY_UNSAFE'; // from //javascript/template/soy:soy_usegoog_js import * as incrementaldom from 'incrementaldom'; // from //third_party/javascript/incremental_dom:incrementaldom import {IncrementalDomRenderer, isProposedKeySuffixOfCurrentKey} from './api_idom'; import {IdomFunction, PatchFunction, SoyElement} from './element_lib_idom'; import {getSoyUntyped} from './global'; type TextualValue = UnsanitizedText|SanitizedUri|SanitizedJs|string|number|boolean; // Declare properties that need to be applied not as attributes but as // actual DOM properties. const {attributes, getKey, isDataInitialized} = incrementaldom; const defaultIdomRenderer = new IncrementalDomRenderer(); type IdomTemplate<A, B> = (idom: IncrementalDomRenderer, params: A, ijData: B) => void; type SoyTemplate<A, B> = (params: A, ijData: B) => string|SanitizedContent; type LetFunction = (idom: IncrementalDomRenderer) => void; type Template<A, B> = IdomTemplate<A, B>|SoyTemplate<A, B>; // tslint:disable-next-line:no-any attributes['checked'] = (el: Element, name: string, value: any) => { // We don't use !!value because: // 1. If value is '' (this is the case where a user uses <div checked />), // the checked value should be true, but '' is falsy. // 2. If value is 'false', the checked value should be false, but // 'false' is truthy. el.setAttribute('checked', value); (el as HTMLInputElement).checked = !(value === false || value === 'false' || value === undefined); }; // tslint:disable-next-line:no-any attributes['value'] = (el: Element, name: string, value: any) => { (el as HTMLInputElement).value = value; el.setAttribute('value', value); }; // Soy uses the {key} command syntax, rather than HTML attributes, to // indicate element keys. incrementaldom.setKeyAttributeName(null); /** * Returns the template object stored in the currentPointer element if it is the * correct type. Otherwise, returns null. */ function tryGetElement<T extends SoyElement<{}, {}>>( incrementaldom: IncrementalDomRenderer, elementClassCtor: new () => T, firstElementKey: string) { let currentPointer = incrementaldom.currentPointer(); while (currentPointer != null) { const el = getSoyUntyped(currentPointer); if (el instanceof elementClassCtor && isDataInitialized(currentPointer)) { const currentPointerKey = getKey(currentPointer) as string; const currentPointerKeyArr = JSON.parse(currentPointerKey); if (isProposedKeySuffixOfCurrentKey( incrementaldom.getCurrentKeyStack().concat(firstElementKey), currentPointerKeyArr)) { return el; } } currentPointer = currentPointer.nextSibling; } return null; } // tslint:disable-next-line:no-any Attaching arbitrary attributes to function. function makeHtml(idomFn: any): IdomFunction { idomFn.toString = (renderer: IncrementalDomRenderer = defaultIdomRenderer) => htmlToString(idomFn, renderer); idomFn.toBoolean = () => toBoolean(idomFn); idomFn.contentKind = SanitizedContentKind.HTML; return idomFn as IdomFunction; } // tslint:disable-next-line:no-any Attaching arbitrary attributes to function. function makeAttributes(idomFn: any): IdomFunction { idomFn.toString = () => attributesToString(idomFn); idomFn.toBoolean = () => toBoolean(idomFn); idomFn.contentKind = SanitizedContentKind.ATTRIBUTES; return idomFn as IdomFunction; } /** * TODO(tomnguyen): Issue a warning in these cases so that users know that * expensive behavior is happening. */ function htmlToString( fn: LetFunction, renderer: IncrementalDomRenderer = defaultIdomRenderer) { const el = document.createElement('div'); incrementaldom.patch(el, () => fn(renderer)); return el.innerHTML; } function attributesFactory(fn: PatchFunction): PatchFunction { return () => { incrementaldom.elementOpenStart('div'); fn(defaultIdomRenderer); incrementaldom.elementOpenEnd(); incrementaldom.elementClose('div'); }; } /** * TODO(tomnguyen): Issue a warning in these cases so that users know that * expensive behavior is happening. */ function attributesToString(fn: PatchFunction): string { const elFn = attributesFactory(fn); const el = document.createElement('div'); incrementaldom.patchOuter(el, elFn); const s: string[] = []; for (let i = 0; i < el.attributes.length; i++) { s.push(`${el.attributes[i].name}=${el.attributes[i].value}`); } // The sort is important because attribute order varies per browser. return s.sort().join(' '); } function toBoolean(fn: IdomFunction) { return fn.toString().length > 0; } /** * Calls an expression in case of a function or outputs it as text content. */ function renderDynamicContent( incrementaldom: IncrementalDomRenderer, expr: unknown) { // TODO(lukes): check content kind == html if (typeof expr === 'function') { // The Soy compiler will validate the content kind of the parameter. expr(incrementaldom); } else { incrementaldom.text(String(expr)); } } /** * Matches an HTML attribute name value pair. * Name is in group 1. Value, if present, is in one of group (2,3,4) * depending on how it's quoted. * * This RegExp was derived from visual inspection of * html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state * and following states. */ const htmlAttributeRegExp: RegExp = /([^\t\n\f\r />=]+)[\t\n\f\r ]*(?:=[\t\n\f\r ]*(?:"([^"]*)"?|'([^']*)'?|([^\t\n\f\r >]*)))?/g; function splitAttributes(attributes: string) { const nameValuePairs: string[][] = []; String(attributes).replace(htmlAttributeRegExp, (_, name, dq, sq, uq) => { nameValuePairs.push( [name, googString.unescapeEntities(dq || sq || uq || '')]); return ' '; }); return nameValuePairs; } /** * Calls an expression in case of a function or outputs it as text content. */ function callDynamicAttributes<A, B>( incrementaldom: IncrementalDomRenderer, // tslint:disable-next-line:no-any expr: Template<A, B>, data: A, ij: B) { // tslint:disable-next-line:no-any Attaching arbitrary attributes to function. const type = (expr as any as IdomFunction).contentKind; if (type === SanitizedContentKind.ATTRIBUTES) { (expr as IdomTemplate<A, B>)(incrementaldom, data, ij); } else { let val: string|SanitizedHtmlAttribute; if (type === SanitizedContentKind.HTML) { // This effectively negates the value of splitting a string. However, // This can be removed if Soy decides to treat attribute printing // and attribute names differently. val = soy.$$filterHtmlAttributes(htmlToString( () => (expr as IdomTemplate<A, B>)(defaultIdomRenderer, data, ij))); } else { val = (expr as SoyTemplate<A, B>)(data, ij) as SanitizedHtmlAttribute; } printDynamicAttr(incrementaldom, val); } } /** * Prints an expression whose type is not statically known to be of type * "attributes". The expression is tested at runtime and evaluated depending * on what type it is. For example, if a string is printed in a context * that expects attributes, the string is evaluated dynamically to compute * attributes. */ function printDynamicAttr( incrementaldom: IncrementalDomRenderer, expr: SanitizedHtmlAttribute|string|boolean|IdomFunction) { if (goog.isFunction(expr) && (expr as IdomFunction).contentKind === SanitizedContentKind.ATTRIBUTES) { // tslint:disable-next-line:no-any (expr as any as LetFunction)(incrementaldom); return; } const attributes = splitAttributes(expr.toString()); const isExprAttribute = isAttribute(expr); for (const attribute of attributes) { const attrName = isExprAttribute ? attribute[0] : soy.$$filterHtmlAttributes(attribute[0]); if (attrName === 'zSoyz') { incrementaldom.attr(attrName, ''); } else { incrementaldom.attr(String(attrName), String(attribute[1])); } } } /** * Calls an expression in case of a function or outputs it as text content. */ function callDynamicHTML<A, B>( incrementaldom: IncrementalDomRenderer, expr: Template<A, B>, data: A, ij: B) { // tslint:disable-next-line:no-any Attaching arbitrary attributes to function. const type = (expr as any as IdomFunction).contentKind; if (type === SanitizedContentKind.HTML) { (expr as IdomTemplate<A, B>)(incrementaldom, data, ij); } else if (type === SanitizedContentKind.ATTRIBUTES) { const val = attributesToString( () => (expr as IdomTemplate<A, B>)(defaultIdomRenderer, data, ij)); incrementaldom.text(val); } else { const val = (expr as SoyTemplate<A, B>)(data, ij); incrementaldom.text(String(val)); } } function callDynamicCss<A, B>( // tslint:disable-next-line:no-any Attaching attributes to function. incrementaldom: IncrementalDomRenderer, expr: (a: A, b: B) => any, data: A, ij: B) { const val = callDynamicText<A, B>(expr, data, ij, soy.$$filterCssValue); incrementaldom.text(String(val)); } function callDynamicJs<A, B>( // tslint:disable-next-line:no-any Attaching attributes to function. incrementaldom: IncrementalDomRenderer, expr: (a: A, b: B) => any, data: A, ij: B) { const val = callDynamicText<A, B>(expr, data, ij, soy.$$escapeJsValue); incrementaldom.text(String(val)); } /** * Calls an expression and coerces it to a string for cases where an IDOM * function needs to be concatted to a string. */ function callDynamicText<A, B>( // tslint:disable-next-line:no-any expr: Template<A, B>, data: A, ij: B, escFn?: (i: string) => string) { const transformFn = escFn ? escFn : (a: string) => a; // tslint:disable-next-line:no-any Attaching arbitrary attributes to function. const type = (expr as any as IdomFunction).contentKind; let val: string|SanitizedContent; if (type === SanitizedContentKind.HTML) { val = transformFn(htmlToString( () => (expr as IdomTemplate<A, B>)(defaultIdomRenderer, data, ij))); } else if (type === SanitizedContentKind.ATTRIBUTES) { val = transformFn(attributesToString( () => (expr as IdomTemplate<A, B>)(defaultIdomRenderer, data, ij))); } else { val = (expr as SoyTemplate<A, B>)(data, ij); } return val; } declare global { interface Element { __innerHTML: string; } } /** * Prints an expression depending on its type. */ function print( incrementaldom: IncrementalDomRenderer, expr: unknown, isSanitizedContent?: boolean|undefined) { if (expr instanceof SanitizedHtml || isSanitizedContent) { const content = String(expr); // If the string has no < or &, it's definitely not HTML. Otherwise // proceed with caution. if (content.indexOf('<') < 0 && content.indexOf('&') < 0) { incrementaldom.text(content); } else { // For HTML content we need to insert a custom element where we can place // the content without incremental dom modifying it. const el = incrementaldom.elementOpen('html-blob'); if (el && el.__innerHTML !== content) { googSoy.renderHtml(el, ordainSanitizedHtml(content)); el.__innerHTML = content; } incrementaldom.skip(); incrementaldom.elementClose('html-blob'); } } else { renderDynamicContent(incrementaldom, expr); } } function visitHtmlCommentNode( incrementaldom: IncrementalDomRenderer, val: string) { const currNode = incrementaldom.currentElement(); if (!currNode) { return; } if (currNode.nextSibling != null && currNode.nextSibling.nodeType === Node.COMMENT_NODE) { currNode.nextSibling.textContent = val; // This is the case where we are creating new DOM from an empty element. } else { currNode.appendChild(document.createComment(val)); } incrementaldom.skipNode(); } export { SoyElement as $SoyElement, print as $$print, htmlToString as $$htmlToString, makeHtml as $$makeHtml, makeAttributes as $$makeAttributes, callDynamicJs as $$callDynamicJs, callDynamicCss as $$callDynamicCss, callDynamicHTML as $$callDynamicHTML, callDynamicAttributes as $$callDynamicAttributes, callDynamicText as $$callDynamicText, tryGetElement as $$tryGetElement, printDynamicAttr as $$printDynamicAttr, visitHtmlCommentNode as $$visitHtmlCommentNode, };