UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

317 lines (280 loc) 8.34 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2019-22 Zenesis Ltd, https://www.zenesis.com License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: John Spackman (john.spackman@zenesis.com, @johnspackman) ************************************************************************ */ /** * Controls serializing the VDOM in `qx.html.*` into an HTML string. * * The principal task here is to write the HTML with QxObjectIds, in a form which allows * the DOM that the browser parsed to be connected to the instances of `qx.html.Node` * that are created by the Javascript on the client. * * In other words, the DOM which is created by this HTML will be passed to `qx.html.Element.useNode` * on the client. */ qx.Class.define("qx.html.Serializer", { extend: qx.core.Object, /** * Constructor */ construct() { super(); this.__output = ""; this.__objectStack = []; this.__tagDataStack = []; }, properties: { /** Whether to pretty print (default is whatever qx.cdebug is set to) */ prettyPrint: { init: qx.core.Environment.get("qx.debug"), check: "Boolean", nullable: false } }, members: { /** @type{String} the HTML being built up */ __output: null, /** @type{qx.html.Node[]} the stack of objects being written */ __objectStack: null, /** * For each tag on the stack being emitted, we track the data in an object, nominally called TagData * * @typedef {Object} TagData * @property {Integer} indent how far this node is indented * @property {String} tagName the name of the tag * @property {Dictionary} attributes the attributes to set on the tag * @property {Boolean?} openTagWritten whether the open tag has been written * @property {Boolean?} closeTagWritten whether the close tag has been written */ /** @type{TagData[]} the stack of elements being written */ __tagDataStack: null, /** @type{String?} the current tag name */ __currentTagName: null, /** * Writes to the output * @param {var[]} args array of values to convert to strings and output */ write(...args) { this.__output += args.join(""); }, /** * Called when an open tag needs to be emitted * * @param {String} tagName */ openTag(tagName) { this.__flush(); this.__tagDataStack.push({ indent: this.__tagDataStack.length, tagName: tagName.toLowerCase(), attributes: {} }); }, /** * Called to add plain text into the output * @param {String?} text */ rawTextInBody(text) { if (text !== null && text !== undefined) { this.__flush(); this.write(text); } }, /** * Called to close the current tag */ closeTag() { this.__flush(true); this.__tagDataStack.pop(); }, /** * Adds an attribute to the current tag; cannot be done if body or children have been output * * @param {String} key the attribute name * @param {String?} value teh attribite value, if null the attribute will be deleted */ setAttribute(key, value) { const tagData = this.__peekTagData(); if (tagData.openTagWritten) { throw new Error( "Cannot modify attributes after the opening tag has been written" ); } tagData.attributes[key] = value; }, /** * Looks for the current tag * * @returns {TagData} */ __peekTagData() { return this.__tagDataStack[this.__tagDataStack.length - 1]; }, /** * Flushes the tag into the output. This will prevent further attributes etc from being emitted * and if `closeTag` is true then the tag is closed. Handles self closing tags and indentation * * @param {Boolean} closeTag if we are flushing because the tag is being closed */ __flush(closeTag) { const tagData = this.__peekTagData(); if (!tagData) { return; } const indent = () => { if (this.isPrettyPrint()) { for (let i = 0; i < tagData.indent; i++) { this.write(" "); } } }; if (!tagData.openTagWritten) { indent(); const tmp = ["<" + tagData.tagName]; for (const key in tagData.attributes) { const value = tagData.attributes[key]; if (value !== null && value !== undefined) { tmp.push(`${key}=${value}`); } } this.write(tmp.join(" ")); if (closeTag) { if (qx.html.Serializer.__SELF_CLOSING_TAGS[tagData.tagName]) { this.write("/>"); } else { this.write("></" + tagData.tagName + ">"); } tagData.openTagWritten = true; tagData.closeTagWritten = true; if (this.isPrettyPrint()) { this.write("\n"); } } else { this.write(">"); if (this.isPrettyPrint()) { this.write("\n"); } tagData.openTagWritten = true; } } else if (closeTag && !tagData.closeTagWritten) { indent(); this.write(`</${tagData.tagName}>`); if (this.isPrettyPrint()) { this.write("\n"); } tagData.closeTagWritten = true; } }, /** * Erases all output */ clear() { this.__output = ""; }, /** * Provides the accumulated output * * @returns {String} */ getOutput() { return this.__output; }, /** * Pushes the QxObject onto the stack * * @param {qx.core.Object} obj */ pushQxObject(obj) { this.__objectStack.push(obj); }, /** * Pops the topmost QxObject from the stack */ popQxObject() { this.__objectStack.pop(); }, /** * Peeks the QxObject stack * * @returns {qx.core.Object} */ peekQxObject() { return this.__objectStack[this.__objectStack.length - 1] || null; }, /** * Calculates a Qx Object ID which is either relative to the root most element, * or is relative to it's owner. This tries to be as concise as possible so that * the output HTML is as readable as possible * * The return is null if the object does not have an ID * * @param {qx.html.Element} target * @returns {String?} */ getQxObjectIdFor(target) { if (!target.getQxObjectId()) { return null; } // If we can make the ID relative to it's parent, then just use the shorter version. This is // not strictly necessary because we could use absolute paths everywhere, but it's a lot // easier to read and understand, and consumes less bytes in the output const stackTop = this.peekQxObject(); if (stackTop === target) { const secondTop = this.__objectStack.slice(-2)[0] || null; if (secondTop === target.getQxOwner()) { return target.getQxObjectId(); } } // Calculate the relative path between the stack top and the target object const ids = [target.getQxObjectId()]; const stackFirst = this.__objectStack[0]; let tmp = target; do { const owner = tmp.getQxOwner(); if (this.__objectStack.indexOf(owner) < 0) { break; } else if (owner === stackFirst) { ids.unshift(".."); } else { ids.unshift(tmp.getQxObjectId()); } } while ((tmp = tmp.getQxOwner())); return ids.join("/"); } }, statics: { /** @type{Dictionary<String,Boolean>} list of self closing tags, in lowercase */ __SELF_CLOSING_TAGS: null }, /** * Populates statics */ defer(statics) { statics.__SELF_CLOSING_TAGS = {}; [ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" ].forEach(function (tagName) { statics.__SELF_CLOSING_TAGS[tagName] = true; }); } });