Parsers
Introduction
Components are loaded from hooks in the content, but how does the library know what a hook looks like and which component to load for it? This job is accomplished by Hook parsers.
They are what you pass along as the parsers
input/argument to the library. Each component has one and each must be of the type HookParserEntry
, which can be either:
- The component class itself.
- A SelectorHookParserConfig object literal.
- A custom
HookParser
instance. - A custom
HookParser
class. If this class is available as a provider/service, it will be injected.
Using the component class is the most straightforward option. It internally sets up a SelectorHookParser
for you which loads components just like in Angular templates. We have been using it in most simple examples, such as in Quick Start and most of the How to use page.
If you want more control, you can also manually configure a SelectorHookParser
by passing in a SelectorHookParserConfig, which provides additional options.
For even more specific use-cases, you may want to write your own HookParser. See the section Writing your own HookParser for more info about that.
SelectorHookParserConfig
A SelectorHookParserConfig
is an object literal that can be used in the parsers
field to create customized SelectorHookParser
for you (which loads components by their selectors similarly to Angular).
In its simplest form, it just contains the component class like {component: ExampleComponent}
, but it also accepts additional properties:
Property | Type | Default | Description |
---|---|---|---|
component |
ComponentConfig |
- | The component to be used. Can be its class or a function that returns a promise with the class (lazy-loading). |
name |
string |
- | The name of the parser. Only required if you want to black- or whitelist it. |
selector |
string |
The component selector | The selector to use to find the hook. |
hostElementTag |
string |
- | A custom tag to be used for the component host element. |
parseWithRegex |
boolean |
false |
Whether to use regex rather than HTML/DOM-based methods to find the hook elements. |
allowSelfClosing |
boolean |
true |
Whether to allow using self-closing tags (<hook/> ). Must use regex mode for this (see box below). |
enclosing |
boolean |
true |
Deprecated: Whether the selector is enclosing (<hook>...</hook> ) or not (<hook> ). Use allowSelfClosing for a more modern approach. |
bracketStyle |
{opening: string, closing: string} |
{opening: '<', closing: '>'} |
The brackets to use for the selector. |
parseInputs |
boolean |
true |
Whether to parse inputs into data types or leave them as strings. |
unescapeStrings |
boolean |
true |
Whether to remove escaping backslashes from inputs. |
injector |
Injector |
The nearest one | The Injector to create the component with. |
environmentInjector |
EnvironmentInjector |
The nearest one | The EnvironmentInjector to create the component with. |
inputsBlacklist |
string[] |
null |
A list of inputs to ignore. |
inputsWhitelist |
string[] |
null |
A list of inputs to allow exclusively. |
outputsBlacklist |
string[] |
null |
A list of outputs to ignore. |
outputsWhitelist |
string[] |
null |
A list of outputs to allow exclusively. |
allowContextInBindings |
boolean |
true |
Whether to allow the use of context object variables in inputs and outputs. |
allowContextFunctionCalls |
boolean |
true |
Whether to allow calling context object functions in inputs and outputs. |
See the How to use page for a simple SelectorHookParserConfig
example.
The SelectorHookParser uses HTML/DOM-based methods to find hook elements by default. However, it will switch over to regex-based parsing if either parseWithRegex
is true, enclosing
is false or you use a custom bracketStyle
. This allows the library to also find elements that are technically not valid HTML.
However, please note that when using regex-based parsing, you cannot use full CSS selectors in the selector
field. The selector can then only be the direct tag name, e.g. app-example
.
Writing your own HookParser
So far, we have only used the standard SelectorHookParser
, which is included in this library for convenience and is easy to use if all you need is to load components by their selectors. However, by creating custom parsers, any element or text pattern you want can be replaced by an Angular component.
What makes a parser
A hook parser is any class that follows the HookParser interface, which requires the following:
- An optional
name
property that is used for black/whitelisting the parser. findHooks()
orfindHookElements()
to tell the library where to load the components.loadComponent()
to specify which component class to load.getBindings()
to return the component inputs/outputs.
You only need to implement either findHooks()
or findHookElements()
, depending on whether you want to replace text or HTML elements with components.
The following section explains these main functions in detail. If you would rather see a custom parser in action right away, you can skip ahead to the examples.
findHooks()
findHooks(content: string, context: any, options: ParseOptions): HookPosition[]
Is given a string of content and is expected to return a HookPosition
array from it. Each HookPosition
represents a found text hook and specifies its position within the content string:
interface HookPosition {
openingTagStartIndex: number;
openingTagEndIndex: number;
closingTagStartIndex?: number;
closingTagEndIndex?: number;
}
The opening and closing tags simply refer to the text patterns that signal the start and end of the hook and thereby also define the <ng-content>
(think [HOOK_OPENINGTAG]...content...[HOOK_CLOSINGTAG]
). If you are looking for a singletag rather than an enclosing hook (...[HOOK]....
), you can just omit the two closing tag indexes.
How your hook looks like and how you find these indexes is completely up to you. You may look for them using Regex patterns or any other parsing method. Though, as a word of warning, do not try to parse enclosing hooks with Regex alone. That road leads to madness.
To make your life easier, you can just use the HookFinder
service that comes with this library. Its easy to use and safely finds both singletag and enclosing patterns in a string. You can see it in action in the “Emoji parser” example.
findHooks()
is only needed if you want to find text hooks. For element hooks, see findHookElements()
.
findHookElements()
findHookElements(contentElement: any, context: any, options: ParseOptions): any[]
Is given the main content element and is expected to return an array of child elements that should be used as element hooks.
Finding element hooks is rather easy as you can interact directly with the actual content element. You can typically just do something like this:
return Array.from(contentElement.querySelectorAll('.myHook'));
findHookElements()
is only needed if you want to find element hooks. For text hooks, see findHooks()
.
loadComponent()
loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: any[], options: ParseOptions): HookComponentData
Is given the found hook string or element as HookValue
and is expected to return a HookComponentData
object, which tells the library how to create the component for this hook:
interface HookComponentData {
component: ComponentConfig;
hostElementTag?: string;
injector?: Injector;
environmentInjector?: EnvironmentInjector;
content?: any[][];
}
You usually only need to fill out the component
field, which can be the component class or a LazyLoadComponentConfig
(see Lazy-loading components).
You may optionally also specify a custom host element tag, provide your own injectors or use custom content to replace the existing <ng-content>
(each entry in the outer array represends a <ng-content>
-slot and the inner array its content).
getBindings()
getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings;
Is given the HookValue
and is expected to return a HookBindings
object, which contains all the current inputs and outputs for the component:
interface HookBindings {
inputs?: {[key: string]: any};
outputs?: {[key: string]: (event: any, context: any) => any};
}
Both inputs
and outputs
must contain an object where each key is the name of the binding and each value what should be used for it. The functions you put in outputs
will be called when the corresponding @Output() triggers and are automatically given the event object as well as the current context object as parameters. To disallow or ignore inputs/outputs, simply don’t include them here.
How you determine the values for the component bindings is - again - completely up to you. You could for example have a look at the HookValue
and read them from the hook itself (like property bindings in selector hooks, e.g. [input]="'Hello!'
”). You could of course also just pass static values into the component.
Warning
Don't use JavaScript's eval()
function to parse values from text into code, if you can help it. It can create massive security loopholes. If all you need is a way to safely parse strings into standard JavaScript data types like strings, numbers, arrays, object literals etc., you can simply use the evaluate()
method from the DataTypeParser
service that you can also import from this library.
Example 1: Minimal
Let’s write a a minimal custom HookParser
for our trusty ExampleComponent
:
import { Injectable } from '@angular/core';
import { HookParser, HookValue, HookComponentData, HookBindings } from 'ngx-dynamic-hooks';
import { ExampleComponent } from 'somewhere';
@Injectable({
providedIn: 'root'
})
export class ExampleParser implements HookParser {
findHookElements(contentElement: any): any[] {
// Return the elements to load the component into
return Array.from(contentElement.querySelectorAll('some-element'));
}
loadComponent(): HookComponentData {
// Return the component class
return { component: ExampleComponent };
}
getBindings(): HookBindings {
// Return inputs/outputs to set
return {
inputs: {
message: 'Hello there!'
}
}
}
}
Now just pass the parser in the parsers
-field and it will work!
...
export class AppComponent {
content = 'Load a component here: <some-element></some-element>';
parsers = [ExampleParser];
}
<ngx-dynamic-hooks [content]="content" [parsers]="parsers"></ngx-dynamic-hooks>
See it in action in this Stackblitz:
Example 2: Emoji parser
Let’s say we want to replace replace text emoticons (smileys etc.) with an EmojiComponent
that renders animated emojis for them.
This means that we need to replace text instead of HTML elements with components this time and therefore must use findHooks()
instead of findHookElements()
.
For the purpose of this example, we have a simple EmojiComponent
that supports three emojis. It has a type
-input that determines which one to load (can be either laugh
, wow
or love
). The parser could then look like so:
import { Injectable } from '@angular/core';
import { HookParser, HookValue, HookComponentData, HookBindings, HookFinder, HookPosition } from 'ngx-dynamic-hooks';
import { EmojiComponent } from './emoji.component';
@Injectable({
providedIn: 'root'
})
export class EmojiParser implements HookParser {
constructor(private hookFinder: HookFinder) {}
findHooks(content: string): HookPosition[] {
// As an example, this regex finds the emoticons :-D, :-O and :-*
const emoticonRegex = /(?::-D|:-O|:-\*)/gm;
// We can use the HookFinder service provided by the library to easily
// find the HookPositions of any regex in the content string
return this.hookFinder.find(content, emoticonRegex);
}
loadComponent(): HookComponentData {
return { component: EmojiComponent };
}
getBindings(hookId: number, hookValue: HookValue): HookBindings {
// Lets see what kind of emoticon this hook is and assign a fitting emoji
let emojiType: string;
switch (hookValue.openingTag) {
case ':-D': emojiType = 'laugh'; break;
case ':-O': emojiType = 'wow'; break;
case ':-*': emojiType = 'love'; break;
}
// Set the 'type'-input in the EmojiComponent correspondingly
return {
inputs: {
type: emojiType!
}
};
}
}
See it in action in this Stackblitz:
Example 3: Image parser
A really neat use-case for custom parsers is to take standard HTML elements and replace them with more useful Angular components.
For example, we could automatically add lightboxes to all images of an article marked with a lightbox
class, so users can click on them to see a larger version. Assuming we have html like:
<img class="lightbox" src="image.jpeg" src-large="image-large.jpeg">
A parser that automatically replaces those <img>
elements with ClickableImgComponent
s could then look as follows:
...
export class ClickableImgParser implements HookParser {
findHookElements(contentElement: any): any[] {
// Find all img-elements with the lightbox class
return Array.from(contentElement.querySelectorAll('img.lightbox'));
}
loadComponent(): HookComponentData {
return {
component: ClickableImgComponent, // Load our component
hostElementTag: 'lightbox-img' // As img-elements can't have content, replace them with '<lightbox-img>' elements
};
}
getBindings(hookId: number, hookValue: HookValue): HookBindings {
// Read the image urls from the element attributes and pass along as inputs
const imgElement: HTMLImageElement = hookValue.elementSnapshot;
return {
inputs: {
src: imgElement.getAttribute('src'),
srcLarge: imgElement.getAttribute('src-large')
}
}
}
}
Our ClickableImgComponent
will then use src
to render the image in the article itself and use srcLarge
(if it exists) for the lightbox version.
See it in action in this Stackblitz:
Example 4: Link parser
Another cool idea for a custom parser is to enhance standard HTML links so that clicking on them uses the actual Angular router instead of reloading the whole browser tab, which is slow and costly.
We can simply write a custom HookParser
that looks for internal links in the content and automatically replaces them with a component that uses proper [routerLink]
s.
Let’s assume we have prepared a DynamicLinkComponent
that renders a single Angular link based on the inputs path
, queryParams
and fragment
. Here then, would be our custom HookParser
for it:
import { Inject, Injectable } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { HookParser, HookValue, HookComponentData, HookBindings } from 'ngx-dynamic-hooks';
import { DynamicLinkComponent } from 'somewhere';
@Injectable({
providedIn: 'root'
})
export class DynamicLinkParser implements HookParser {
base;
constructor(@Inject(DOCUMENT) private document: Document) {
this.base = `${this.document.location.protocol}//${this.document.location.hostname}`;
}
public findHookElements(contentElement: HTMLElement): any[] {
// First get all link elements
return Array.from(contentElement.querySelectorAll('a[href]'))
// Then filter them so that only those with own hostname are returned
.filter(linkElement => {
const url = new URL(linkElement.getAttribute('href')!, this.base);
return url.hostname === this.document.location.hostname;
});
}
public loadComponent(): HookComponentData {
return { component: DynamicLinkComponent };
}
public getBindings(hookId: number, hookValue: HookValue): HookBindings {
const url = new URL(hookValue.elementSnapshot.getAttribute('href')!, this.base);
// Extract what we need from the URL object and pass it along to DynamicLinkComponent
return {
inputs: {
path: url.pathname,
queryParams: Object.fromEntries(url.searchParams.entries()),
fragment: url.hash.replace('#', '')
}
};
}
}
See it in action in this Stackblitz: