UNPKG

ngx-dynamic-hooks

Version:

Automatically insert live Angular components into a dynamic string of content (based on their selector or any pattern of your choice) and render the result in the DOM.

1,075 lines (1,059 loc) 225 kB
import * as i0 from '@angular/core'; import { InjectionToken, reflectComponentType, Injectable, SecurityContext, Inject, Optional, isDevMode, PLATFORM_ID, createComponent, APP_INITIALIZER, SkipSelf, createEnvironmentInjector, NgZone, EventEmitter, Component, Input, Output } from '@angular/core'; import { Observable, ReplaySubject, of, combineLatest, firstValueFrom } from 'rxjs'; import { mergeMap, tap, catchError, first, map } from 'rxjs/operators'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import * as i1 from '@angular/platform-browser'; import { createApplication } from '@angular/platform-browser'; /** * Custom injector tokens that are used for varous internal communication purposes */ const DYNAMICHOOKS_ALLSETTINGS = new InjectionToken('All of the settings registered in the whole app.'); const DYNAMICHOOKS_ANCESTORSETTINGS = new InjectionToken('The settings collected from all ancestor injectors'); const DYNAMICHOOKS_MODULESETTINGS = new InjectionToken('The settings for the currently loaded module.'); const contentElementAttr = '__ngx_dynamic_hooks_content'; const anchorElementTag = 'dynamic-component-anchor'; const anchorAttrHookId = '__ngx_dynamic_hooks_anchor_id'; const anchorAttrParseToken = '__ngx_dynamic_hooks_anchor_parsetoken'; const voidElementTags = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; var DynamicHooksInheritance; (function (DynamicHooksInheritance) { /** * Merges with settings from all injectors in the app. */ DynamicHooksInheritance[DynamicHooksInheritance["All"] = 0] = "All"; /** * (Default) Only merges with settings from direct ancestor injectors (such a father and grandfather injectors, but not "uncle" injectors). */ DynamicHooksInheritance[DynamicHooksInheritance["Linear"] = 1] = "Linear"; /** * Does not merge at all. Injector only uses own settings. */ DynamicHooksInheritance[DynamicHooksInheritance["None"] = 2] = "None"; })(DynamicHooksInheritance || (DynamicHooksInheritance = {})); /** * Returns the default values for the ParseOptions */ const getParseOptionDefaults = () => { return { sanitize: true, convertHTMLEntities: true, fixParagraphTags: true, updateOnPushOnly: false, compareInputsByValue: false, compareOutputsByValue: false, compareByValueDepth: 5, triggerDOMEvents: false, ignoreInputAliases: false, ignoreOutputAliases: false, acceptInputsForAnyProperty: false, acceptOutputsForAnyObservable: false, logOptions: { dev: true, prod: false, ssr: false } }; }; const regexes = {}; // General const variableName = '[a-zA-Z_$]+[a-zA-Z0-9_$]*'; const attributeName = '[a-zA-Z$\\-_:][a-zA-Z$\\-_:0-9\\.]*'; // Attribute regex regexes.attributeNameNoBracketsRegex = '(' + attributeName + ')'; regexes.attributeNameBracketsRegex = '\\[(' + attributeName + ')\\]'; regexes.attributeNameRoundBracketsRegex = '\\((' + attributeName + ')\\)'; regexes.attributeNameRegex = '(?:' + regexes.attributeNameNoBracketsRegex + '|' + regexes.attributeNameBracketsRegex + '|' + regexes.attributeNameRoundBracketsRegex + ')'; regexes.attributeValueDoubleQuotesRegex = '\"((?:\\\\.|[^\"])*?)\"'; // Clever bit of regex to allow escaped chars in strings: https://stackoverflow.com/a/1016356/3099523 regexes.attributeValueSingleQuotesRegex = '\'((?:\\\\.|[^\'])*?)\''; // Context var regex examples: https://regex101.com/r/zSbY7M/4 // Supports the dot notation, the [] notation as well as function calls () for building variable paths regexes.variablePathDotNotation = '\\.' + variableName; regexes.variableBracketsNotation = '\\[[^\\]]*\\]'; // Relies on nested '[]'brackets being encoded regexes.variablePathFunctionCall = '\\([^\\)]*\\)'; // Relies on nested '()'-brackets being encoded. regexes.variablePathPartRegex = '(?:' + regexes.variablePathDotNotation + '|' + regexes.variableBracketsNotation + '|' + regexes.variablePathFunctionCall + ')'; regexes.contextVariableRegex = 'context' + regexes.variablePathPartRegex + '*'; regexes.placeholderVariablePathDotNotation = '\\@@@cxtDot@@@' + variableName; regexes.placeholderVariableBracketsNotation = '@@@cxtOpenSquareBracket@@@[^\\]]*@@@cxtCloseSquareBracket@@@'; regexes.placeholderVariablePathFunctionCall = '@@@cxtOpenRoundBracket@@@[^\\)]*@@@cxtCloseRoundBracket@@@'; regexes.placeholderVariablePathPartRegex = '(?:' + regexes.placeholderVariablePathDotNotation + '|' + regexes.placeholderVariableBracketsNotation + '|' + regexes.placeholderVariablePathFunctionCall + ')'; regexes.placeholderContextVariableRegex = '__CXT__' + regexes.placeholderVariablePathPartRegex + '*'; /** * Polyfill for String.prototype.matchAll() from the ES2020 spec * * Note: The 'string.prototype.matchall' npm package was unstable for me so providing my own version here * * @param text - The text to search * @param regExp - The RegExp object to use */ function matchAll(text, regExp) { // Must be global if (!regExp.global) { throw Error('TypeError: matchAll called with a non-global RegExp argument'); } // Get matches const result = []; let match = regExp.exec(text); while (match !== null) { result.push(match); match = regExp.exec(text); } // Reset internal index regExp.lastIndex = 0; return result; } /** * Sort elements/nodes based on the order of their appearance in the document * * @param arr - The array to sort * @param sortCallback - The callback to use to sort the elements * @param getElement - An optional callback that returns the element to compare from each arr entry */ function sortElements(arr, sortCallback, getElementCallback) { const result = [...arr]; return result.sort(function (a, b) { if (typeof getElementCallback === 'function') { a = getElementCallback(a); b = getElementCallback(b); } return sortCallback(a, b); }); } /** * Indicates if an element is either a component host element or part of a component's view/template * * @param element - The element to inspect */ function isAngularManagedElement(element) { // Angular gives component host and view elements the following property, so can simply check for that return element?.__ngContext__ !== undefined; } /** * A text parser to load components with their bindings like in Angular templates. */ class TextSelectorHookParser { constructor(config, configResolver, tagHookFinder, bindingsValueManager) { this.configResolver = configResolver; this.tagHookFinder = tagHookFinder; this.bindingsValueManager = bindingsValueManager; this.savedBindings = {}; this.config = this.configResolver.processConfig(config); this.name = this.config.name; } findHooks(content, context, options) { let hookPositions = this.config.enclosing ? this.tagHookFinder.findEnclosingTags(content, this.config.selector, this.config.bracketStyle, options) : this.tagHookFinder.findSingleTags(content, this.config.selector, this.config.bracketStyle, options); if (this.config.allowSelfClosing) { hookPositions = [ ...hookPositions, ...this.tagHookFinder.findSelfClosingTags(content, this.config.selector, this.config.bracketStyle, options) ]; hookPositions.sort((a, b) => a.openingTagStartIndex - b.openingTagStartIndex); } return hookPositions; } loadComponent(hookId, hookValue, context, childNodes, options) { return { component: this.config.component, hostElementTag: this.config.hostElementTag || this.config.selector, // If no hostElementTag specified, use selector (which in the case of TextSelectorHookParser is only allowed to be tag name) injector: this.config.injector, environmentInjector: this.config.environmentInjector }; } getBindings(hookId, hookValue, context, options) { let hookBindings = this.savedBindings[hookId]; // Parse bindings once from hookValue, then reuse on subsequent runs if (hookBindings === undefined) { hookBindings = this.createBindings(hookValue.openingTag); this.savedBindings[hookId] = hookBindings; } // (Re)evaluate if needed this.bindingsValueManager.checkInputBindings(hookBindings.inputs, context, this.config, options); this.bindingsValueManager.checkOutputBindings(hookBindings.outputs, this.config, options); return { inputs: this.getValuesFromSavedBindings(hookBindings.inputs), outputs: this.getValuesFromSavedBindings(hookBindings.outputs) }; } // Bindings // -------------------------------------------------------------------------- /** * Returns RichBindingData for Angular-style inputs & output attrs from an openingTag * * @param openingTag - The openingTag to inspect */ createBindings(openingTag) { const rawInputs = this.collectRawInputs(openingTag); const inputBindings = {}; for (const [rawInputKey, rawInputValue] of Object.entries(rawInputs)) { inputBindings[rawInputKey] = { raw: rawInputValue, parsed: false, value: null, boundContextVariables: {} }; } const rawOutputs = this.collectRawOutputs(openingTag); const outputBindings = {}; for (const [rawOutputKey, rawOutputValue] of Object.entries(rawOutputs)) { outputBindings[rawOutputKey] = { raw: rawOutputValue, parsed: false, value: null, boundContextVariables: {} }; } return { inputs: inputBindings, outputs: outputBindings }; } /** * Collects Angular-style inputs from an openingTag * * @param openingTag - The openingTag to inspect */ collectRawInputs(openingTag) { const rawNoBracketInputs = this.getBindingsFromOpeningTag(openingTag, 'noBracketInputs', this.config.inputsBlacklist || null, this.config.inputsWhitelist || null); const rawBracketInputs = this.getBindingsFromOpeningTag(openingTag, 'bracketInputs', this.config.inputsBlacklist || null, this.config.inputsWhitelist || null); // NoBracketInputs are to be interpreted as plain strings, so wrap them in quotes for (const [noBracketInputName, noBracketInputValue] of Object.entries(rawNoBracketInputs)) { rawNoBracketInputs[noBracketInputName] = "'" + noBracketInputValue + "'"; } // Merge both input objects return { ...rawNoBracketInputs, ...rawBracketInputs }; } /** * Collects Angular-style outputs from an openingTag * * @param openingTag - The openingTag to inspect */ collectRawOutputs(openingTag) { return this.getBindingsFromOpeningTag(openingTag, 'outputs', this.config.outputsBlacklist || null, this.config.outputsWhitelist || null); } /** * Collects Angular-style inputs or outputs from an openingTag * * @param type - What kind of bindings to extract * @param openingTag - The opening tag to inspect * @param blacklist - A list of inputs/outputs to blacklist * @param whitelist - A list of inputs/outputs to whitelist */ getBindingsFromOpeningTag(openingTag, type, blacklist, whitelist) { const bindings = {}; // Examples: https://regex101.com/r/17x3cc/16 const attributeValuesOR = '(?:' + regexes.attributeValueDoubleQuotesRegex + '|' + regexes.attributeValueSingleQuotesRegex + ')'; let attributeNameRegex; switch (type) { case 'noBracketInputs': attributeNameRegex = regexes.attributeNameNoBracketsRegex; break; case 'bracketInputs': attributeNameRegex = regexes.attributeNameBracketsRegex; break; case 'outputs': attributeNameRegex = regexes.attributeNameRoundBracketsRegex; break; } const attributeRegex = attributeNameRegex + '\=' + attributeValuesOR; const attributePattern = new RegExp(attributeRegex, 'gim'); const attributeMatches = matchAll(openingTag, attributePattern); // Collect raw bindings for (const match of attributeMatches) { // Could be either of the attribute value capturing groups let rawBindingValue = match[2] || match[3]; // If value is empty (someInput=""), it will return undefined for it. When using noBracketInputs, return empty string instead. if (rawBindingValue === undefined && type === 'noBracketInputs') { rawBindingValue = ''; } bindings[match[1]] = rawBindingValue; } // Filter bindings const filteredBindings = {}; for (const [bindingName, bindingValue] of Object.entries(bindings)) { if (blacklist && blacklist.includes(bindingName)) { continue; } if (whitelist && !whitelist.includes(bindingName)) { continue; } filteredBindings[bindingName] = bindingValue; } return filteredBindings; } /** * Transforms a RichBindingData object into a normal bindings object * * @param richBindingsObject - The object containing the RichBindingData */ getValuesFromSavedBindings(richBindingsObject) { const result = {}; for (const [key, value] of Object.entries(richBindingsObject)) { result[key] = value.value; } return result; } } /** * An element parser to load components with their bindings like in Angular templates. */ class ElementSelectorHookParser { constructor(config, configResolver, platformService, bindingsValueManager) { this.configResolver = configResolver; this.platformService = platformService; this.bindingsValueManager = bindingsValueManager; this.savedBindings = {}; this.config = this.configResolver.processConfig(config); this.name = this.config.name; } findHookElements(contentElement, context, options) { return Array.from(this.platformService.querySelectorAll(contentElement, this.config.selector)); } loadComponent(hookId, hookValue, context, childNodes, options) { // Always scrub potential []-input- and ()-output-attrs from anchor elements this.scrubAngularBindingAttrs(hookValue.element); return { component: this.config.component, hostElementTag: this.config.hostElementTag, injector: this.config.injector, environmentInjector: this.config.environmentInjector }; } getBindings(hookId, hookValue, context, options) { let hookBindings = this.savedBindings[hookId]; // Parse bindings once from hookValue, then reuse on subsequent runs (raw values will never change as hookValue.element is a snapshot) if (hookBindings === undefined) { hookBindings = this.createBindings(hookValue.elementSnapshot); this.savedBindings[hookId] = hookBindings; } // (Re)evaluate if needed this.bindingsValueManager.checkInputBindings(hookBindings.inputs, context, this.config, options); this.bindingsValueManager.checkOutputBindings(hookBindings.outputs, this.config, options); return { inputs: this.getValuesFromSavedBindings(hookBindings.inputs), outputs: this.getValuesFromSavedBindings(hookBindings.outputs) }; } // Bindings // -------------------------------------------------------------------------- /** * Always removes angular-typical template attrs like []-input and ()-outputs from anchors * * @param anchorElement - The element to strub */ scrubAngularBindingAttrs(anchorElement) { const attrsToScrub = Array.from(anchorElement.attributes) .map((attrObj) => attrObj.name) .filter((attr) => (attr.startsWith('[') && attr.endsWith(']')) || (attr.startsWith('(') && attr.endsWith(')'))); for (const attr of attrsToScrub) { this.platformService.removeAttribute(anchorElement, attr); } } /** * Returns RichBindingData for Angular-style inputs & output attrs from an element * * @param element - The element to inspect */ createBindings(element) { const rawInputs = this.collectRawBindings(element, 'inputs', this.config.inputsBlacklist || null, this.config.inputsWhitelist || null); const inputBindings = {}; for (const [rawInputKey, rawInputValue] of Object.entries(rawInputs)) { inputBindings[rawInputKey] = { raw: rawInputValue, parsed: false, value: null, boundContextVariables: {} }; } const rawOutputs = this.collectRawBindings(element, 'outputs', this.config.outputsBlacklist || null, this.config.outputsWhitelist || null); const outputBindings = {}; for (const [rawOutputKey, rawOutputValue] of Object.entries(rawOutputs)) { outputBindings[rawOutputKey] = { raw: rawOutputValue, parsed: false, value: null, boundContextVariables: {} }; } return { inputs: inputBindings, outputs: outputBindings }; } /** * Returns Angular-style inputs or output attrs from an element * * @param element - The element to inspect * @param type - Whether to return the inputs or outputs * @param blacklist - A list of inputs/outputs to blacklist * @param whitelist - A list of inputs/outputs to whitelist */ collectRawBindings(element, type, blacklist, whitelist) { const bindings = {}; // Collect raw bindings const attrNames = this.platformService.getAttributeNames(element); for (let attrName of attrNames) { if (type === 'inputs' && (!attrName.startsWith('(') || !attrName.endsWith(')')) || type === 'outputs' && (attrName.startsWith('(') && attrName.endsWith(')'))) { let binding = this.platformService.getAttribute(element, attrName); // If input has []-brackets: Transform empty attr to undefined if (type === 'inputs' && attrName.startsWith('[') && attrName.endsWith(']') && binding === '') { binding = undefined; } // If input has no []-brackets: Should be interpreted as plain strings, so wrap in quotes if (type === 'inputs' && (!attrName.startsWith('[') || !attrName.endsWith(']'))) { binding = `'${binding}'`; } // Trim [] and () brackets from attr name attrName = attrName.replace(/^\[|^\(|\]$|\)$/g, ''); bindings[attrName] = binding; } } // Filter bindings const filteredBindings = {}; for (const [bindingName, bindingValue] of Object.entries(bindings)) { if (blacklist && blacklist.includes(bindingName)) { continue; } if (whitelist && !whitelist.includes(bindingName)) { continue; } filteredBindings[bindingName] = bindingValue; } return filteredBindings; } /** * Transforms a RichBindingData object into a normal bindings object * * @param richBindingsObject - The object containing the RichBindingData */ getValuesFromSavedBindings(richBindingsObject) { const result = {}; for (const [key, value] of Object.entries(richBindingsObject)) { result[key] = value.value; } return result; } } /** * The default values for the SelectorHookParserConfig */ const selectorHookParserConfigDefaults = { component: undefined, name: undefined, parseWithRegex: false, selector: undefined, hostElementTag: undefined, injector: undefined, allowSelfClosing: true, enclosing: true, bracketStyle: { opening: '<', closing: '>' }, parseInputs: true, unescapeStrings: true, inputsBlacklist: undefined, inputsWhitelist: undefined, outputsBlacklist: undefined, outputsWhitelist: undefined, allowContextInBindings: true, allowContextFunctionCalls: true }; /** * A helper class for resolving a SelectorHookParserConfig */ class SelectorHookParserConfigResolver { constructor() { } /** * Overwrites the default parser config with a (partial) SelectorHookParserConfig object and returns the result * * @param userParserConfig - The (partial) SelectorHookParserConfig object */ processConfig(userParserConfig) { const parserConfig = JSON.parse(JSON.stringify(selectorHookParserConfigDefaults)); // component if (!userParserConfig || !userParserConfig.hasOwnProperty('component')) { throw Error('Missing the required "component" property for the SelectorHookParserConfig. Must be either the component class or a LazyLoadComponentConfig.'); } parserConfig.component = userParserConfig.component; // If is class if (userParserConfig.component.hasOwnProperty('prototype')) { const compMeta = reflectComponentType(userParserConfig.component); parserConfig.selector = compMeta.selector; // If is LazyLoadingComponentConfig } else if (userParserConfig.component.hasOwnProperty('importPromise') && userParserConfig.component.hasOwnProperty('importName')) { if (!userParserConfig.hasOwnProperty('selector')) { throw Error(`When using lazy-loaded dynamic components, you have to specify the "selector" property in the parser config, as the real selector can't be known before the component is loaded.`); } // If is neither } else { throw Error('The "component" property in the SelectorHookParserConfig must either contain the component class or a LazyLoadComponentConfig.'); } // name if (userParserConfig.hasOwnProperty('name')) { if (typeof userParserConfig.name !== 'string') { throw Error('The submitted "name" property in the SelectorHookParserConfig must be of type string, was ' + typeof userParserConfig.name); } parserConfig.name = userParserConfig.name; } // selector (defaults to component selector) if (userParserConfig.hasOwnProperty('selector')) { if (typeof userParserConfig.selector !== 'string') { throw Error('The submitted "selector" property in the SelectorHookParserConfig must be of type string, was ' + typeof userParserConfig.selector); } parserConfig.selector = userParserConfig.selector; } // hostElementTag if (userParserConfig.hasOwnProperty('hostElementTag')) { if (typeof userParserConfig.hostElementTag !== 'string') { throw Error('The submitted "hostElementTag" property in the SelectorHookParserConfig must be of type string, was ' + typeof userParserConfig.hostElementTag); } parserConfig.hostElementTag = userParserConfig.hostElementTag; } // parseWithRegex if (userParserConfig.hasOwnProperty('parseWithRegex')) { if (typeof userParserConfig.parseWithRegex !== 'boolean') { throw Error('The submitted "parseWithRegex" property in the SelectorHookParserConfig must be of type boolean, was ' + typeof userParserConfig.parseWithRegex); } parserConfig.parseWithRegex = userParserConfig.parseWithRegex; } // allowSelfClosing if (userParserConfig.hasOwnProperty('allowSelfClosing')) { if (typeof userParserConfig.allowSelfClosing !== 'boolean') { throw Error('The submitted "allowSelfClosing" property in the SelectorHookParserConfig must be of type boolean, was ' + typeof userParserConfig.allowSelfClosing); } parserConfig.allowSelfClosing = userParserConfig.allowSelfClosing; } // enclosing if (userParserConfig.hasOwnProperty('enclosing')) { if (typeof userParserConfig.enclosing !== 'boolean') { throw Error('The submitted "enclosing" property in the SelectorHookParserConfig must be of type boolean, was ' + typeof userParserConfig.enclosing); } parserConfig.enclosing = userParserConfig.enclosing; } // bracketStyle if (userParserConfig.hasOwnProperty('bracketStyle')) { if (typeof userParserConfig.bracketStyle !== 'object' || typeof userParserConfig.bracketStyle.opening !== 'string' || typeof userParserConfig.bracketStyle.closing !== 'string') { throw Error('The submitted "bracketStyle" property in the SelectorHookParserConfig must have the form {opening: string, closing: string}'); } parserConfig.bracketStyle = userParserConfig.bracketStyle; } // injector (defaults to undefined) if (userParserConfig.hasOwnProperty('injector')) { parserConfig.injector = userParserConfig.injector; } // environmentInjector (defaults to undefined) if (userParserConfig.hasOwnProperty('environmentInjector')) { parserConfig.environmentInjector = userParserConfig.environmentInjector; } // unescapeStrings if (userParserConfig.hasOwnProperty('unescapeStrings')) { if (typeof userParserConfig.unescapeStrings !== 'boolean') { throw Error('The submitted "unescapeStrings" property in the SelectorHookParserConfig must be of type boolean, was ' + typeof userParserConfig.unescapeStrings); } parserConfig.unescapeStrings = userParserConfig.unescapeStrings; } // parseInputs if (userParserConfig.hasOwnProperty('parseInputs')) { if (typeof userParserConfig.parseInputs !== 'boolean') { throw Error('The submitted "parseInputs" property in the SelectorHookParserConfig must be of type boolean, was ' + typeof userParserConfig.parseInputs); } parserConfig.parseInputs = userParserConfig.parseInputs; } // inputsBlacklist if (userParserConfig.hasOwnProperty('inputsBlacklist')) { if (!Array.isArray(userParserConfig.inputsBlacklist)) { throw Error('The submitted "inputsBlacklist" property in the SelectorHookParserConfig must be an array of strings.'); } for (const entry of userParserConfig.inputsBlacklist) { if (typeof entry !== 'string') { throw Error('All entries of the submitted "inputsBlacklist" property in the SelectorHookParserConfig must be of type string, ' + typeof entry + ' found.'); } } parserConfig.inputsBlacklist = userParserConfig.inputsBlacklist; } // inputsWhitelist if (userParserConfig.hasOwnProperty('inputsWhitelist')) { if (!Array.isArray(userParserConfig.inputsWhitelist)) { throw Error('The submitted "inputsWhitelist" property in the SelectorHookParserConfig must be an array of strings.'); } for (const entry of userParserConfig.inputsWhitelist) { if (typeof entry !== 'string') { throw Error('All entries of the submitted "inputsWhitelist" property in the SelectorHookParserConfig must be of type string, ' + typeof entry + ' found.'); } } parserConfig.inputsWhitelist = userParserConfig.inputsWhitelist; } // outputsBlacklist if (userParserConfig.hasOwnProperty('outputsBlacklist')) { if (!Array.isArray(userParserConfig.outputsBlacklist)) { throw Error('The submitted "outputsBlacklist" property in the SelectorHookParserConfig must be an array of strings.'); } for (const entry of userParserConfig.outputsBlacklist) { if (typeof entry !== 'string') { throw Error('All entries of the submitted "outputsBlacklist" property in the SelectorHookParserConfig must be of type string, ' + typeof entry + ' found.'); } } parserConfig.outputsBlacklist = userParserConfig.outputsBlacklist; } // outputsWhitelist if (userParserConfig.hasOwnProperty('outputsWhitelist')) { if (!Array.isArray(userParserConfig.outputsWhitelist)) { throw Error('The submitted "outputsWhitelist" property in the SelectorHookParserConfig must be an array of strings.'); } for (const entry of userParserConfig.outputsWhitelist) { if (typeof entry !== 'string') { throw Error('All entries of the submitted "outputsWhitelist" property in the SelectorHookParserConfig must be of type string, ' + typeof entry + ' found.'); } } parserConfig.outputsWhitelist = userParserConfig.outputsWhitelist; } // allowContextInBindings if (userParserConfig.hasOwnProperty('allowContextInBindings')) { if (typeof userParserConfig.allowContextInBindings !== 'boolean') { throw Error('The submitted "allowContextInBindings" property in the SelectorHookParserConfig must be of type boolean, was ' + typeof userParserConfig.allowContextInBindings); } parserConfig.allowContextInBindings = userParserConfig.allowContextInBindings; } // allowContextFunctionCalls if (userParserConfig.hasOwnProperty('allowContextFunctionCalls')) { if (typeof userParserConfig.allowContextFunctionCalls !== 'boolean') { throw Error('The submitted "allowContextFunctionCalls" property in the SelectorHookParserConfig must be of type boolean, was ' + typeof userParserConfig.allowContextFunctionCalls); } parserConfig.allowContextFunctionCalls = userParserConfig.allowContextFunctionCalls; } return parserConfig; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SelectorHookParserConfigResolver, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SelectorHookParserConfigResolver, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SelectorHookParserConfigResolver, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); const PLATFORM_SERVICE = new InjectionToken('An injection token to retrieve an optionally user-provided PlatformService'); /** * General implementation of PlatformService suited for both the standard browser and server environments */ class DefaultPlatformService { constructor(document, rendererFactory, sanitizer) { this.document = document; this.rendererFactory = rendererFactory; this.sanitizer = sanitizer; this.renderer = this.rendererFactory.createRenderer(null, null); } getNgVersion() { if (typeof this.document !== "undefined") { const versionElement = this.querySelectorAll(this.document, '[ng-version]')?.[0]; const versionAttr = versionElement?.getAttribute('ng-version'); if (versionAttr) { return parseInt(versionAttr, 10); } } return null; } sanitize(content) { return this.sanitizer.sanitize(SecurityContext.HTML, content) || ''; } createElement(tagName) { return this.renderer.createElement(tagName); } sortElements(a, b) { if (a === b) return 0; if (!a.compareDocumentPosition) { // support for IE8 and below return a.sourceIndex - b.sourceIndex; } if (a.compareDocumentPosition(b) & 2) { // b comes before a return 1; } return -1; } cloneElement(element) { return element.cloneNode(true); } getTagName(element) { return element.tagName; } getOpeningTag(element) { // Approach by: https://stackoverflow.com/a/55859966/3099523 const innerLength = element.innerHTML.length; const outerLength = element.outerHTML.length; // Check for self-closing elements const openingTagLength = element.outerHTML[outerLength - 2] === '/' ? outerLength : outerLength - innerLength - element.tagName.length - 3; return element.outerHTML.slice(0, openingTagLength); } getClosingTag(element) { return element.outerHTML.slice(element.outerHTML.length - element.tagName.length - 3); } getAttributeNames(element) { return typeof element.getAttributeNames === 'function' ? element.getAttributeNames() : []; } getAttribute(element, attributeName) { return typeof element.getAttribute === 'function' ? element.getAttribute(attributeName) : null; } setAttribute(element, attributeName, value) { this.renderer.setAttribute(element, attributeName, value); } removeAttribute(element, attributeName) { this.renderer.removeAttribute(element, attributeName); } getParentNode(element) { try { return this.renderer.parentNode(element); } catch (e) { return null; } } querySelectorAll(parentElement, selector) { return Array.from(parentElement.querySelectorAll(selector)); } getChildNodes(node) { return Array.prototype.slice.call(node.childNodes); } appendChild(parentElement, childElement) { this.renderer.appendChild(parentElement, childElement); } insertBefore(parentElement, childElement, referenceElement) { this.renderer.insertBefore(parentElement, childElement, referenceElement); } clearChildNodes(element) { if (element) { while (element.firstChild) { this.removeChild(element, element.firstChild); } } } removeChild(parentElement, childElement) { parentElement.removeChild(childElement); } getInnerContent(element) { return element.innerHTML; } setInnerContent(element, content) { if (element) { element.innerHTML = content; } } isTextNode(element) { return element.nodeType === Node.TEXT_NODE; } createTextNode(content) { return document.createTextNode(content); } getTextContent(element) { return element.textContent; } dispatchEvent(element, name, payload) { element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DefaultPlatformService, deps: [{ token: DOCUMENT }, { token: i0.RendererFactory2 }, { token: i1.DomSanitizer }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DefaultPlatformService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DefaultPlatformService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: Document, decorators: [{ type: Inject, args: [DOCUMENT] }] }, { type: i0.RendererFactory2 }, { type: i1.DomSanitizer }] }); /** * Wrapper class that either calls user-provided PlatformService methods or falls back to default implementations */ class AutoPlatformService { constructor(userPlatformService, defaultPlatformService) { this.userPlatformService = userPlatformService; this.defaultPlatformService = defaultPlatformService; } getFor(methodName) { if (this.userPlatformService && typeof this.userPlatformService[methodName] === 'function') { return this.userPlatformService; } else { return this.defaultPlatformService; } } getNgVersion() { return this.getFor('getNgVersion').getNgVersion(); } sanitize(content) { return this.getFor('sanitize').sanitize(content); } createElement(tagName) { return this.getFor('createElement').createElement(tagName); } sortElements(a, b) { return this.getFor('sortElements').sortElements(a, b); } cloneElement(element) { return this.getFor('cloneElement').cloneElement(element); } getTagName(element) { return this.getFor('getTagName').getTagName(element); } getOpeningTag(element) { return this.getFor('getOpeningTag').getOpeningTag(element); } getClosingTag(element) { return this.getFor('getClosingTag').getClosingTag(element); } getAttributeNames(element) { return this.getFor('getAttributeNames').getAttributeNames(element); } getAttribute(element, attributeName) { return this.getFor('getAttribute').getAttribute(element, attributeName); } setAttribute(element, attributeName, value) { return this.getFor('setAttribute').setAttribute(element, attributeName, value); } removeAttribute(element, attributeName) { return this.getFor('removeAttribute').removeAttribute(element, attributeName); } getParentNode(element) { return this.getFor('getParentNode').getParentNode(element); } querySelectorAll(parentElement, selector) { return this.getFor('querySelectorAll').querySelectorAll(parentElement, selector); } getChildNodes(node) { return this.getFor('getChildNodes').getChildNodes(node); } appendChild(parentElement, childElement) { return this.getFor('appendChild').appendChild(parentElement, childElement); } insertBefore(parentElement, childElement, referenceElement) { return this.getFor('insertBefore').insertBefore(parentElement, childElement, referenceElement); } clearChildNodes(element) { return this.getFor('clearChildNodes').clearChildNodes(element); } removeChild(parentElement, childElement) { return this.getFor('removeChild').removeChild(parentElement, childElement); } getInnerContent(element) { return this.getFor('getInnerContent').getInnerContent(element); } setInnerContent(element, content) { return this.getFor('setInnerContent').setInnerContent(element, content); } isTextNode(element) { return this.getFor('isTextNode').isTextNode(element); } createTextNode(content) { return this.getFor('createTextNode').createTextNode(content); } getTextContent(element) { return this.getFor('getTextContent').getTextContent(element); } dispatchEvent(element, name, payload) { return this.getFor('dispatchEvent').dispatchEvent(element, name, payload); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AutoPlatformService, deps: [{ token: PLATFORM_SERVICE, optional: true }, { token: DefaultPlatformService }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AutoPlatformService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AutoPlatformService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [PLATFORM_SERVICE] }] }, { type: DefaultPlatformService }] }); /** * A utility service to print logs and warnings */ class Logger { constructor(platformId) { this.platformId = platformId; } log(content, options) { this.handleLog(content, options, 'log'); } warn(content, options) { this.handleLog(content, options, 'warn'); } error(content, options) { this.handleLog(content, options, 'error'); } /** * Logs an array of content according to the submitted options * * @param content - The content to log * @param options - The current ParseOptions * @param method - The console method to use */ handleLog(content, options, method) { if (options.logOptions?.dev && this.isDevMode() && isPlatformBrowser(this.platformId) || options.logOptions?.prod && !this.isDevMode() && isPlatformBrowser(this.platformId) || options.logOptions?.ssr && !isPlatformBrowser(this.platformId)) { console[method](...content); } } /** * Use local method that is easier to mock in tests */ isDevMode() { return isDevMode(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Logger, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Logger, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Logger, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Inject, args: [PLATFORM_ID] }] }] }); /** * A utility service to easily parse hooks from text content */ class HookFinder { constructor(logger) { this.logger = logger; } /** * Finds all text hooks in a piece of content, e.g. <hook>...</hook>, and returns their positions * * @param content - The text to parse * @param openingTagRegex - The regex for the opening tag * @param closingTagRegex - The regex for the closing tag * @param includeNested - Whether to include nested hooks in the result * @param options - The current ParseOptions */ find(content, openingTagRegex, closingTagRegex, includeNested, options = getParseOptionDefaults()) { if (!closingTagRegex) { return this.findSingletagHooks(content, openingTagRegex); } else { return this.findEnclosingHooks(content, openingTagRegex, closingTagRegex, includeNested, options); } } /** * Finds all text hooks that are non-enclosing in a piece of text, e.g. <hook> * * @param content - The text to search * @param hookRegex - The regex to use for the hook */ findSingletagHooks(content, hookRegex) { const result = []; // Find all hooks const openingTagMatches = matchAll(content, hookRegex); for (const match of openingTagMatches) { result.push({ openingTagStartIndex: match.index, openingTagEndIndex: match.index + match[0].length, closingTagStartIndex: null, closingTagEndIndex: null, }); } return result; } /** * Finds all text hooks that are enclosing in a piece of text, e.g. <hook>...</hook> * * Correctly finding enclosing hooks requires a programmatic parser rather then just regex alone, as regex cannot handle * patterns that are potentially nested within themselves. * * - If the content between the opening and closing is lazy (.*?), it would take the first closing tag after the opening tag, * regardless if it belongs to the opening tag or actually a nested hook. This would falsely match the first and third tag * in this example: '<hook><hook></hook></hook>' * * - If the content between the opening and closing is greedy (.*), it would only end on the last closing tag in the string, * ignoring any previous closing tags. This would falsely match the first and fourth tag in this example: * '<hook></hook><hook></hook>' * * There is no regex that works for both scenarios. This method therefore manually counts and compares the opening tags with the closing tags. * * @param content - The text to parse * @param openingTagRegex - The regex for the opening tag * @param closingTagRegex - The regex for the closing tag * @param includeNested - Whether to include nested hooks in the result * @param options - The current parseOptions */ findEnclosingHooks(content, openingTagRegex, closingTagRegex, includeNested, options = getParseOptionDefaults()) { const allTags = []; const result = []; // Find all opening tags const openingTagMatches = matchAll(content, openingTagRegex); for (const match of openingTagMatches) { allTags.push({ isOpening: true, value: match[0], startIndex: match.index, endIndex: match.index + match[0].length }); } // Find all closing tags const closingTagMatches = matchAll(content, closingTagRegex); for (const match of closingTagMatches) { allTags.push({ isOpening: false, value: match[0], startIndex: match.index, endIndex: match.index + match[0].length }); } // Sort by startIndex allTags.sort((a, b) => a.startIndex - b.startIndex); // Create HookPositions by figuring out which opening tag belongs to which closing tag const openedTags = []; allTagsLoop: for (const [index, tag] of allTags.entries()) { // Any subsequent tag is only allowed to start after previous tag has ended if (index > 0 && tag.startIndex < allTags[index - 1].endIndex) { this.logger.warn(['Syntax error - New tag "' + tag.value + '" started at position ' + tag.startIndex + ' before previous tag "' + allTags[index - 1].value + '" ended at position ' + allTags[index - 1].endIndex + '. Ignoring.'], options); continue; } // Opening or closing tag? if (tag.isOpening) { openedTags.push(tag); } else { // Syntax error: Closing tag without preceding opening tag. Syntax error. if (openedTags.length === 0) { this.logger.warn(['Syntax error - Closing tag without preceding opening tag found: "' + tag.value + '". Ignoring.'], options); continue; } // If nested hooks not allowed and more than one tag is open, discard both this closing tag and the latest opening tag if (includeNested === false && openedTags.length > 1) { openedTags.pop(); continue; } // Valid hook! Add to result array const openingTag = openedTags[openedTags.length - 1]; result.push({ openingTagStartIndex: openingTag.startIndex, openingTagEndIndex: openingTag.startIndex + openingTag.value.length, closingTagStartIndex: tag.startIndex, closingTagEndIndex: tag.startIndex + tag.value.length }); openedTags.pop(); } } if (openedTags.length > 0) { this.logger.warn(['Syntax error - Opening tags without corresponding closing tags found.'], options); } return result; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookFinder, deps: [{ token: Logger }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookFinder, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookFinder, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: Logger }] }); /** * A utility service for the TextSelectorHookParser that finds Angular component selectors in the content */ class TagHookFinder { constructor(hookFinder) { this.hookFinder = hookFinder; } /** * Finds singletag Angular component selectors * * @param content - The content to parse * @param selector - The Angular selector to find * @param bracketStyle - What bracket style to use * @param options - The current ParseOptions */ findSingleTags(content, selector, br