UNPKG

mancha

Version:

Javscript HTML rendering engine

387 lines 17.1 kB
import { dirname, ellipsize, nodeToString, setProperty, traverse } from "./dome.js"; import { Iterator } from "./iterator.js"; import { RendererPlugins } from "./plugins.js"; import { setupQueryParamBindings } from "./query.js"; import { SignalStore } from "./store.js"; /** * Represents an abstract class for rendering and manipulating HTML content. * Extends the `ReactiveProxyStore` class. * * @template T - The type of the store state. Defaults to `StoreState`. */ export class IRenderer extends SignalStore { _debugLevel = "off"; dirpath = ""; /** Performance data collected during rendering. Reset on each mount(). */ _perfData = { lifecycle: {}, effects: new Map() }; /** Debug level ordering for comparison. */ static DEBUG_LEVELS = ["off", "lifecycle", "effects", "verbose"]; _skipNodes = new Set(); _customElements = new Map(); /** * Queue for retrying failed element.value assignments. * * Some DOM elements (notably <select>) silently fail when setting .value if * the required child elements don't exist yet. For example, setting * select.value = "banana" does nothing if no <option value="banana"> exists. * * This happens when :bind on a parent element runs before :for on child * elements creates those children (due to BFS traversal order). * * The fix: after setting .value, check if it actually worked. If not, queue * a retry callback. These callbacks are executed at the end of renderNode() * after all child elements have been created. */ _pendingValueRetries = []; /** * Sets the debug level for the current instance. * * @param flag - Boolean for backwards compat (true -> 'lifecycle') or a DebugLevel. * @returns The current instance of the class. */ debug(flag) { if (typeof flag === "boolean") { this._debugLevel = flag ? "lifecycle" : "off"; } else { this._debugLevel = flag; } return this; } /** * Returns whether debugging is enabled (any level except 'off'). */ get debugging() { return this._debugLevel !== "off"; } /** * Checks if the current debug level is at least the specified level. */ shouldLog(level) { return (IRenderer.DEBUG_LEVELS.indexOf(this._debugLevel) >= IRenderer.DEBUG_LEVELS.indexOf(level)); } /** * Resets performance data. Called at the start of mount(). */ resetPerfData() { this._perfData = { lifecycle: {}, effects: new Map() }; } /** * Generates a DOM path for an element (e.g., "html>body>div>ul>li:nth-child(2)"). */ getNodePath(elem) { const parts = []; let current = elem; while (current?.tagName) { const tag = current.tagName.toLowerCase(); const parent = current.parentElement; if (parent) { const siblings = Array.from(parent.children).filter((c) => c.tagName.toLowerCase() === tag); if (siblings.length > 1) { const index = siblings.indexOf(current) + 1; parts.unshift(`${tag}:nth-child(${index})`); } else { parts.unshift(tag); } } else { parts.unshift(tag); } current = parent; } return parts.join(">"); } /** * Builds an effect identifier from metadata. */ buildEffectId(meta) { const directive = meta?.directive ?? "unknown"; const expression = ellipsize(meta?.expression ?? "", 32); const elem = meta?.element; const elemId = elem ? (elem.dataset?.perfid ?? elem.id ?? elem.dataset?.testid ?? this.getNodePath(elem)) : "unknown"; return `${directive}:${elemId}:${expression}`; } /** * Records an effect execution for performance tracking. */ recordEffectExecution(meta, duration) { const id = this.buildEffectId(meta); const stats = this._perfData.effects.get(id) ?? { count: 0, totalTime: 0 }; stats.count++; stats.totalTime += duration; this._perfData.effects.set(id, stats); } /** * Returns a structured performance report. */ performanceReport() { const effects = Array.from(this._perfData.effects.entries()); const byDirective = {}; for (const [id, stats] of effects) { const directive = id.split(":")[0]; if (!byDirective[directive]) byDirective[directive] = { count: 0, totalTime: 0 }; byDirective[directive].count += stats.count; byDirective[directive].totalTime += stats.totalTime; } const sorted = effects .map(([id, s]) => ({ id, executionCount: s.count, totalTime: s.totalTime, avgTime: s.count > 0 ? s.totalTime / s.count : 0, })) .sort((a, b) => b.totalTime - a.totalTime) .slice(0, 10); return { lifecycle: this._perfData.lifecycle, effects: { total: effects.length, byDirective, slowest: sorted, }, observers: this.getObserverStats(), }; } /** * Override effect() to add performance tracking. * Tracks effect execution time and logs slow effects (>16ms). */ effect(observer, meta) { // Skip tracking if debugging is off. if (!this.shouldLog("lifecycle")) { return super.effect(observer, meta); } const startTime = performance.now(); const result = super.effect(observer, meta); const duration = performance.now() - startTime; // Record effect execution for performance report. if (meta) { this.recordEffectExecution(meta, duration); } // Log slow effects (>16ms = potentially dropped frame). if (duration > 16) { console.warn(`Slow effect (${duration.toFixed(1)}ms):`, this.buildEffectId(meta)); } // Log individual effect timings at 'effects' level. if (this.shouldLog("effects")) { console.debug(`Effect (${duration.toFixed(2)}ms):`, this.buildEffectId(meta)); } return result; } /** * Fetches the remote file at the specified path and returns its content as a string. * @param fpath - The path of the remote file to fetch. * @param params - Optional parameters for the fetch operation. * @returns A promise that resolves to the content of the remote file as a string. */ async fetchRemote(fpath, params) { return fetch(fpath, { cache: params?.cache ?? "default" }).then((res) => res.text()); } /** * Fetches a local path and returns its content as a string. * * @param fpath - The file path of the resource. * @param params - Optional render parameters. * @returns A promise that resolves to the fetched resource as a string. */ async fetchLocal(fpath, params) { return this.fetchRemote(fpath, params); } /** * Preprocesses a string content with optional rendering and parsing parameters. * * @param content - The string content to preprocess. * @param params - Optional rendering and parsing parameters. * @returns A promise that resolves to a DocumentFragment representing the preprocessed content. */ async preprocessString(content, params) { this.log("Preprocessing string content with params:\n", params); const fragment = this.parseHTML(content, params); await this.preprocessNode(fragment, params); return fragment; } /** * Preprocesses a remote file by fetching its content and applying preprocessing steps. * @param fpath - The path to the remote file. * @param params - Optional parameters for rendering and parsing. * @returns A Promise that resolves to a DocumentFragment representing the preprocessed content. */ async preprocessRemote(fpath, params) { const fetchOptions = {}; if (params?.cache) fetchOptions.cache = params.cache; const content = await fetch(fpath, fetchOptions).then((res) => res.text()); return this.preprocessString(content, { ...params, dirpath: dirname(fpath), rootDocument: params?.rootDocument ?? !fpath.endsWith(".tpl.html"), }); } /** * Preprocesses a local file by fetching its content and applying preprocessing steps. * @param fpath - The path to the local file. * @param params - Optional parameters for rendering and parsing. * @returns A promise that resolves to the preprocessed document fragment. */ async preprocessLocal(fpath, params) { const content = await this.fetchLocal(fpath, params); return this.preprocessString(content, { ...params, dirpath: dirname(fpath), rootDocument: params?.rootDocument ?? !fpath.endsWith(".tpl.html"), }); } /** * Creates a subrenderer from the current renderer instance. * @returns A new instance of the renderer with the same state as the original. */ subrenderer() { const instance = new this.constructor().debug(this.debugging); // NOTE: Using the store object directly to avoid modifying ancestor values. // Attach ourselves as the parent of the new instance. instance._store.set("$parent", this); // Add a reference to the root renderer, or assume that we are the root renderer. instance._store.set("$rootRenderer", this.get("$rootRenderer") ?? this); // Custom elements are shared across all instances. instance._customElements = this._customElements; return instance; } /** * Logs the provided arguments if verbose debugging is enabled. * @param args - The arguments to be logged. */ log(...args) { if (this.shouldLog("verbose")) console.debug(...args); } /** * Preprocesses a node by applying all the registered preprocessing plugins. * * @template T - The type of the input node. * @param {T} root - The root node to preprocess. * @param {RenderParams} [params] - Optional parameters for preprocessing. * @returns {Promise<T>} - A promise that resolves to the preprocessed node. */ async preprocessNode(root, params) { const startTime = this.shouldLog("lifecycle") ? performance.now() : 0; params = { dirpath: this.dirpath, maxdepth: 10, ...params }; const promises = new Iterator(traverse(root, this._skipNodes)).map(async (node) => { this.log("Preprocessing node:\n", nodeToString(node, 128)); // Resolve all the includes in the node. await RendererPlugins.resolveIncludes.call(this, node, params); // Resolve all the relative paths in the node (including :render). await RendererPlugins.rebaseRelativePaths.call(this, node, params); // Register all the custom elements in the node. await RendererPlugins.registerCustomElements.call(this, node, params); // Resolve all the custom elements in the node. await RendererPlugins.resolveCustomElements.call(this, node, params); }); // Wait for all the rendering operations to complete. await Promise.all(promises.generator()); // Record preprocess timing. if (startTime) { this._perfData.lifecycle.preprocessTime = (this._perfData.lifecycle.preprocessTime ?? 0) + (performance.now() - startTime); } // Return the input node, which should now be fully preprocessed. return root; } /** * Renders the node and applies all the registered rendering plugins. * * @template T - The type of the root node (Document, DocumentFragment, or Node). * @param {T} root - The root node to render. * @param {RenderParams} [params] - Optional parameters for rendering. * @returns {Promise<T>} - A promise that resolves to the fully rendered root node. */ async renderNode(root, params) { const startTime = this.shouldLog("lifecycle") ? performance.now() : 0; // Iterate over all the nodes and apply appropriate handlers. // Do these steps one at a time to avoid any potential race conditions. for (const node of traverse(root, this._skipNodes)) { this.log("Rendering node:\n", nodeToString(node, 128)); // Resolve :for first - creates copies before other plugins modify the template. await RendererPlugins.resolveForAttribute.call(this, node, params); // Resolve :render - creates subrenderer, mounts, then runs init after descendants. await RendererPlugins.resolveRenderAttribute.call(this, node, params); // Resolve the :data attribute in the node. await RendererPlugins.resolveDataAttribute.call(this, node, params); // Resolve the :text attribute in the node. await RendererPlugins.resolveTextAttributes.call(this, node, params); // Resolve the :html attribute in the node. await RendererPlugins.resolveHtmlAttribute.call(this, node, params); // Resolve the :if attribute in the node. await RendererPlugins.resolveIfAttribute.call(this, node, params); // Resolve the :show attribute in the node. await RendererPlugins.resolveShowAttribute.call(this, node, params); // Resolve the :class attribute in the node. await RendererPlugins.resolveClassAttribute.call(this, node, params); // Resolve the :bind attribute in the node. await RendererPlugins.resolveBindAttribute.call(this, node, params); // Resolve all :on:event attributes in the node. await RendererPlugins.resolveEventAttributes.call(this, node, params); // Replace all the {{ variables }} in the text. await RendererPlugins.resolveTextNodeExpressions.call(this, node, params); // Resolve the :attr:{name} attribute in the node. await RendererPlugins.resolveCustomAttribute.call(this, node, params); // Resolve the :prop:{name} attribute in the node. await RendererPlugins.resolveCustomProperty.call(this, node, params); // Strip :types and data-types attributes from rendered output. await RendererPlugins.stripTypes.call(this, node, params); } // Retry any .value assignments that failed during rendering. // See _pendingValueRetries documentation for why this is needed. for (const retry of this._pendingValueRetries.splice(0)) { retry(); } // Record render timing. if (startTime) { this._perfData.lifecycle.renderTime = (this._perfData.lifecycle.renderTime ?? 0) + (performance.now() - startTime); } // Return the input node, which should now be fully rendered. return root; } /** * Mounts the Mancha application to a root element in the DOM. * * @param root - The root element to mount the application to. * @param params - Optional parameters for rendering the application. * @returns A promise that resolves when the mounting process is complete. */ async mount(root, params) { const startTime = this.shouldLog("lifecycle") ? performance.now() : 0; // Reset performance data for this mount cycle. if (startTime) this.resetPerfData(); params = { ...params, rootNode: root }; // Attach ourselves to the HTML node. setProperty(root, "renderer", this); // Attach the HTML node to the renderer instance. // NOTE: Using the store object directly to avoid modifying ancestor values. this._store.set("$rootNode", root); // Set ourselves as the root renderer if not already set. if (!this.has("$rootRenderer")) { // NOTE: Using the store object directly to avoid modifying ancestor values. this._store.set("$rootRenderer", this); } // Setup query parameter bindings if we are the root renderer. if (this.get("$rootRenderer") === this) { await setupQueryParamBindings(this); } // Preprocess all the elements recursively first. await this.preprocessNode(root, params); // Now that the DOM is complete, render all the nodes. await this.renderNode(root, params); // Record mount timing. if (startTime) { this._perfData.lifecycle.mountTime = performance.now() - startTime; } } } //# sourceMappingURL=renderer.js.map