All files / lib/pipeline/module htmlCompiler.ts

97.5% Statements 78/80
96.55% Branches 28/29
100% Functions 15/15
97.4% Lines 75/77

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 38426x 26x 26x 26x 26x 26x 26x 26x             26x 26x 26x 26x                   26x   88x     88x 100x 100x 100x     100x 100x       88x           26x             81x                                                                                                     103x     103x         1685x     1685x 17718x   14608x 14608x     14607x 129x                 1555x   503x 503x   1582x     1582x         1579x     200x   169x     31x       1379x           1552x 16829x   4602x 4602x     4602x 71x                                                               26x                                       1821x           19231x   1821x                 1821x 1821x 1821x                   3x 3x                     429x                             8x                 1569x                                 15x   15x                                   1685x             218x                                       429x   429x 1785x 8x     1777x     421x       8x   8x   19x 8x     11x            
import {ExpressionModule} from './compiler/expressionModule';
import {VarModule} from './compiler/varModule';
import {ScriptModule} from './compiler/scriptModule';
import {SlotModule} from './compiler/slotModule';
import {DomLogicModule} from './compiler/domLogicModule';
import {ImportModule} from './compiler/importModule';
import {FragmentModule} from './compiler/fragmentModule';
import {
    DocumentNode,
    EvalContext,
    Fragment,
    Node,
    NodeWithChildren
} from '../..';
import {StyleModule} from './compiler/styleModule';
import {DeduplicateModule} from './compiler/deduplicateModule';
import {AnchorModule} from './compiler/anchorModule';
import {WhitespaceModule} from './compiler/whitespaceModule';
import {StandardPipelineContext} from '../standardPipeline';
 
/**
 * Custom tagged template handler to allow promises inside a template string.
 * This should only be called natively by the JS runtime to handle a tagged template.
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates
 * @param stringParts Array of "plain" strings from template literal
 * @param dataParts Array of values from interpolated sections of template literal
 */
export async function interpolateEvalTemplateString(stringParts: TemplateStringsArray, ...dataParts: unknown[]): Promise<string> {
    // Append the first string part
    const outParts = [ stringParts[0] ];
 
    // Append data and text in alternation
    for (let i = 1; i < stringParts.length; i ++) {
        const rawData = dataParts[i - 1]; // dataParts indexes are -1 from stringParts
        const dataValue = (rawData instanceof Promise) ? (await rawData) : rawData;
        const dataString = String(dataValue);
 
        // Append data and next string
        outParts.push(dataString);
        outParts.push(stringParts[i]);
    }
 
    // Build string
    return outParts.join('');
}
 
/**
 * Provides HTML compilation support to the pipeline.
 */
export class HtmlCompiler {
    private readonly modules: HtmlCompilerModule[];
 
    /**
     * Create a new instance of the HTML compiler
     */
    constructor() {
        this.modules = [
            // ExpressionsModule is responsible for compiling inline expressions.
            // It needs to go before any modules that use attribute or text values
            new ExpressionModule(),
 
            // VarsModule is responsible for initializing the scripting / expression scope(s).
            // All other modules have access to local scope, so vars needs to go immediately after template text
            new VarModule(),
 
            // ScriptsModule executes external or embedded JS scripts
            new ScriptModule(),
 
            // SlotModule is responsible for pre-processing the uncompiled DOM to ensure that it contains the final version of the uncompiled input.
            // Incoming slot content may not be fully compiled, and should be compiled as if it is part of this compilation unit.
            new SlotModule(),
            
            // DomLogicModule handles structural logic, like m-if, m-for, etc.
            // This requires scopes to be initialized and expressions to have been evaluated, but needs to run before the DOM is finalized.
            new DomLogicModule(),
            
            // ImportsModule is responsible for converting custom tag names.
            // It needs to go before any modules that use data from tag names
            new ImportModule(),
 
            // StyleModule process <style> tags
            new StyleModule(),
 
            // DeduplicateModule removes redundant nodes, like duplicate stylesheets and link tags
            new DeduplicateModule(),
 
            // AnchorModule compiles anchor tags (<a>)
            new AnchorModule(),
 
            // WhitespaceModule processes whitespace and <m-whitespace>
            new WhitespaceModule(),
 
            // FragmentModule resolves <m-fragment> references and replaces them with HTML.
            new FragmentModule()
        ];
    }
 
    /**
     * Processes all custom nodes, logic, expressions, etc in a fragment.
     * Produces a DOM that can be serialized to valid HTML.
     * Does not apply HTML structure rules, such as requiring head / body tags or restricting the position of title elements.
     * 
     * @param fragment Fragment to compile
     * @param pipelineContext Current usage context
     */
    async compileFragment(fragment: Fragment, pipelineContext: StandardPipelineContext): Promise<void> {
        // create root context
        const htmlContext = new HtmlCompilerContext(pipelineContext, fragment.dom);
 
        // run modules
        await this.runModulesAt(fragment.dom, htmlContext);
    }
 
    private async runModulesAt(node: Node, parentHtmlContext: HtmlCompilerContext): Promise<void> {
        // create node data
        const htmlContext = parentHtmlContext.createChildData(node);
 
        // pre-node callback
        for (const module of this.modules) {
            if (module.enterNode !== undefined) {
                // Invoke "enter node" callback, and possibly await it
                const enterPromiseOrVoid = module.enterNode(htmlContext);
                if (enterPromiseOrVoid) await enterPromiseOrVoid;
 
                // stop processing if node is deleted
                if (htmlContext.isDeleted) {
                    return;
                }
            }
        }
 
        // Process children (children and siblings are already updated by this point)
        // Do not process children if the node has been removed.
        // For most nodes, this can be tested by checking if the parent exists.
        // This does not work for DocumentNode, BUT document node cannot be removed so we just bypass the check.
        if (DocumentNode.isDocumentNode(node) || (node.parentNode != null && NodeWithChildren.isNodeWithChildren(node))) {
            // do a linked search instead of array iteration, because the child list can be modified (such as by m-for)
            let currentChild: Node | null = node.firstChild;
            while (currentChild != null) {
                // save the adjacent siblings, in case the child deletes itself (m-if / similar)
                const savedPrevSibling: Node | null = currentChild.prevSibling;
    
                // process the child
                await this.runModulesAt(currentChild, htmlContext);
    
                // Move on to the next node.
                // To do this correctly, we need to detect if the current node was removed or replaced.
                // If either thing has happened, then we backtrack to the previous node and find the replacement.
                if (currentChild.parentNode !== node) {
                    // If this node was removed or replaced, then find the replacement via savedPrevSibling.
                    // If this was the first child, then just call parentNode.firstChild again to get the replacement.
                    if (savedPrevSibling != null) {
                        // If there is no replacement node, then this will be null and the loop will terminate correctly.
                        currentChild = savedPrevSibling.nextSibling;
                    } else {
                        // There was no previous node, so check get the parent's firstChild and go from there
                        currentChild = node.firstChild;
                    }
                } else {
                    // if the node was not removed or replaced, then move on to next.
                    currentChild = currentChild.nextSibling;
                }
            }
        }
 
        // post-node callback
        for (const module of this.modules) {
            if (module.exitNode !== undefined) {
                // Invoke "exit node" callback, and possibly await it
                const exitPromiseOrVoid = module.exitNode(htmlContext);
                if (exitPromiseOrVoid) await exitPromiseOrVoid;
 
                // stop processing if node is deleted
                if (htmlContext.isDeleted) {
                    return;
                }
            }
        }
    }
}
 
/**
 * A modular component of the HTML compiler
 */
export interface HtmlCompilerModule {
    /**
     * Called when the HTML Compiler begins compiling a node.
     * After all modules have finished their enterNode() section, the node's children will be compiled.
     * 
     * @param htmlContext Current semi-stateful compilation data
     */
    enterNode?(htmlContext: HtmlCompilerContext): void | Promise<void>;
 
    /**
     * Called when the HTML Compiler is finished compiling a node and its children.
     * The node can still be modified, however changes will not trickle down to children at this point.
     * 
     * @param htmlContext Current semi-stateful compilation data
     */
    exitNode?(htmlContext: HtmlCompilerContext): void | Promise<void>;
}
 
/**
 * Context for the current node-level unit or work.
 * This is unique for each node being processed.
 */
export class HtmlCompilerContext {
    /**
     * Current usage context
     */
    readonly pipelineContext: StandardPipelineContext;
 
    /**
     * HTML Compile data for the parent node, if this node has a parent.
     */
    readonly parentContext?: HtmlCompilerContext;
 
    /**
     * Node currently being compiled
     */
    readonly node: Node;
 
    /**
     * Registered m-import definitions within this immediate scope.
     * Do not access directly - use instance methods that will additionally check inherited parent data.
     */
    readonly localReferenceImports = new Map<string, ImportDefinition>();
 
    /**
     * If true, then the node has been deleted and compilation should stop
     */
    get isDeleted(): boolean {
        return this._isDeleted;
    }
    private _isDeleted = false;
 
    /**
     * Creates a new HTML compile data instance that optionally inherits from a parent
     * @param pipelineContext Pipeline-level shared context
     * @param node Node being compiled
     * @param parentContext Optional parent HtmlCompileData to inherit from
     */
    constructor(pipelineContext: StandardPipelineContext, node: Node, parentContext?: HtmlCompilerContext) {
        this.pipelineContext = pipelineContext;
        this.node = node;
        this.parentContext = parentContext;
    }
 
    /**
     * Registers an <m-import> definition for the current node and children
     * Alias will be lower cased.
     * 
     * @param def Import definition
     */
    defineImport(def: ImportDefinition): void {
        const key = def.alias.toLowerCase();
        this.localReferenceImports.set(key, def);
    }
 
    /**
     * Checks if an alias is a registered <m-import> definition for this node or any parent.
     * Alias will be lower cased.
     * 
     * @param alias Alias (tag name) to check
     * @return true if the alias is defined, false otherwise
     */
    hasImport(alias: string): boolean {
        return hasImport(this, alias);
    }
 
    /**
     * Gets the definition of a registered <m-import>.
     * Will search local data and all parents.
     * Alias will be lower cased.
     * This method will throw if alias is not defined.
     * Always check hasImport() before calling getImport().
     * 
     * @param alias Alias (tag name) to access.
     * @return The ImportDefinition object for the alias.
     * @throws If alias is not defined.
     */
    getImport(alias: string): ImportDefinition {
        return getImport(this, alias);
    }
 
    /**
     * Creates an EvalContext that can be used to execute embedded JS in a context matching the current compilation state.
     * Scope will be initialized from current node data.
     * @returns an EvalContext bound to the data in this HtmlCompileData and the provided scope
     */
    createEvalContext(): EvalContext {
        return {
            pipelineContext: this.pipelineContext,
            scope: this.node.nodeData,
            sourceNode: this.node,
            expressionTagger: interpolateEvalTemplateString
        };
    }
 
    /**
     * Creates an EvalContext with scope bound to the parent context.
     * If this is a root EvalContext (ie. there is no parent) then the current context will be used.
     * In all other respects this is identical to {@link createEvalContext}.
     *
     * @returns an EvalContext bound to the parent scope
     */
    createParentScopeEvalContext(): EvalContext {
        // if there is a parent context, then use it for the scope
        Eif (this.parentContext !== undefined) {
            // custom eval context with parent scope but everything else local
            return {
                pipelineContext: this.pipelineContext,
                scope: this.parentContext.node.nodeData,
                sourceNode: this.node,
                expressionTagger: interpolateEvalTemplateString
            };
        } else {
            // fall back to current scope if there is no parent
            return this.createEvalContext();
        }
    }
 
    /**
     * Create a new HtmlCompileData that inherits from this one
     * @param node node to compile with the new instance
     * @returns new HtmlCompileData instance
     */
    createChildData(node: Node): HtmlCompilerContext {
        return new HtmlCompilerContext(this.pipelineContext, node, this);
    }
 
    /**
     * Marks the current node as deleted and stops further processing.
     */
    setDeleted(): void {
        this._isDeleted = true;
    }
}
 
/**
 * An <m-import> definition
 */
export interface ImportDefinition {
    /**
     * Alias (tag name) that this ImportDefinition defines
     */
    alias: string;
 
    /**
     * Source path to load import from
     */
    source: string;
}
 
function hasImport(htmlContext: HtmlCompilerContext | undefined, alias: string): boolean {
    const key = alias.toLowerCase();
 
    while (htmlContext !== undefined) {
        if (htmlContext.localReferenceImports.has(key)) {
            return true;
        }
 
        htmlContext = htmlContext.parentContext;
    }
 
    return false;
}
 
function getImport(htmlContext: HtmlCompilerContext | undefined, alias: string): ImportDefinition {
    const key = alias.toLowerCase();
 
    while (htmlContext !== undefined) {
 
        if (htmlContext.localReferenceImports.has(key)) {
            return htmlContext.localReferenceImports.get(key) as ImportDefinition;
        }
 
        htmlContext = htmlContext.parentContext;
    }
 
    throw new Error(`Alias ${ key } is not defined. Always call hasImport() before getImport()`);
}