├── .editorconfig ├── .github └── workflows │ └── ci-test.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── angular.json ├── package.json ├── projects └── ngx-dynamic-hooks │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── components │ │ │ ├── dynamicHooksComponent.ts │ │ │ └── dynamicSingleComponent.ts │ │ ├── constants │ │ │ ├── core.ts │ │ │ └── regexes.ts │ │ ├── dynamicHooksProviders.ts │ │ ├── interfaces.ts │ │ ├── interfacesPublic.ts │ │ ├── parsers │ │ │ └── selector │ │ │ │ ├── bindingsValueManager.ts │ │ │ │ ├── element │ │ │ │ └── elementSelectorHookParser.ts │ │ │ │ ├── selectorHookParserConfig.ts │ │ │ │ ├── selectorHookParserConfigResolver.ts │ │ │ │ └── text │ │ │ │ ├── tagHookFinder.ts │ │ │ │ └── textSelectorHookParser.ts │ │ ├── services │ │ │ ├── core │ │ │ │ ├── componentCreator.ts │ │ │ │ ├── componentUpdater.ts │ │ │ │ ├── elementHookFinder.ts │ │ │ │ └── textHookFinder.ts │ │ │ ├── dynamicHooksService.ts │ │ │ ├── platform │ │ │ │ ├── autoPlatformService.ts │ │ │ │ ├── defaultPlatformService.ts │ │ │ │ └── platformService.ts │ │ │ ├── settings │ │ │ │ ├── options.ts │ │ │ │ ├── parserEntry.ts │ │ │ │ ├── parserEntryResolver.ts │ │ │ │ ├── settings.ts │ │ │ │ └── settingsResolver.ts │ │ │ └── utils │ │ │ │ ├── contentSanitizer.ts │ │ │ │ ├── dataTypeEncoder.ts │ │ │ │ ├── dataTypeParser.ts │ │ │ │ ├── deepComparer.ts │ │ │ │ ├── hookFinder.ts │ │ │ │ ├── logger.ts │ │ │ │ └── utils.ts │ │ ├── standalone.ts │ │ └── standaloneHelper.ts │ ├── public-api.ts │ ├── test.ts │ └── tests │ │ ├── integration │ │ ├── componentBindings.spec.ts │ │ ├── componentLoading.spec.ts │ │ ├── dynamicHooksComponent.spec.ts │ │ ├── dynamicHooksService.spec.ts │ │ ├── dynamicSingleComponent.spec.ts │ │ ├── elementContent.spec.ts │ │ ├── forChild │ │ │ ├── forChild.spec.ts │ │ │ └── shared.ts │ │ ├── initialization.spec.ts │ │ ├── injectors.spec.ts │ │ ├── parserOptions.spec.ts │ │ ├── parsers │ │ │ ├── configuration.spec.ts │ │ │ ├── elementHooks.spec.ts │ │ │ └── stringHooks.spec.ts │ │ ├── selectorHookParser │ │ │ ├── elementSelectorHookParser.spec.ts │ │ │ ├── selectorHookParserConfig.spec.ts │ │ │ └── textSelectorHookParser.spec.ts │ │ ├── shared.ts │ │ ├── standalone.spec.ts │ │ └── standaloneHelper.spec.ts │ │ ├── resources │ │ ├── components │ │ │ ├── abstractTest.c.ts │ │ │ ├── emptyTest │ │ │ │ └── emptyTest.c.ts │ │ │ ├── lazyTest │ │ │ │ ├── lazyTest.c.html │ │ │ │ ├── lazyTest.c.scss │ │ │ │ └── lazyTest.c.ts │ │ │ ├── multiTagTest │ │ │ │ ├── multiTagTest.c.html │ │ │ │ ├── multiTagTest.c.scss │ │ │ │ └── multiTagTest.c.ts │ │ │ ├── ngContentTest │ │ │ │ ├── ngContentTest.c.html │ │ │ │ ├── ngContentTest.c.scss │ │ │ │ └── ngContentTest.c.ts │ │ │ ├── parentTest │ │ │ │ ├── childTest │ │ │ │ │ ├── childTest.c.html │ │ │ │ │ ├── childTest.c.scss │ │ │ │ │ └── childTest.c.ts │ │ │ │ ├── parentTest.c.html │ │ │ │ ├── parentTest.c.scss │ │ │ │ └── parentTest.c.ts │ │ │ ├── singleTag │ │ │ │ ├── singleTagTest.c.html │ │ │ │ ├── singleTagTest.c.scss │ │ │ │ └── singleTagTest.c.ts │ │ │ └── whateverTest │ │ │ │ ├── whateverTest.c.html │ │ │ │ ├── whateverTest.c.scss │ │ │ │ └── whateverTest.c.ts │ │ ├── forChild │ │ │ ├── contentString.ts │ │ │ ├── hyperlanes.ts │ │ │ ├── planetCities.ts │ │ │ ├── planetCountries.ts │ │ │ ├── planetSpecies.ts │ │ │ ├── planets.ts │ │ │ ├── root.ts │ │ │ └── stars.ts │ │ ├── parsers │ │ │ ├── genericElementParser.ts │ │ │ ├── genericMultiTagStringParser.ts │ │ │ ├── genericSingleTagStringParser.ts │ │ │ ├── genericWhateverElementParser.ts │ │ │ ├── genericWhateverStringParser.ts │ │ │ └── nonServiceTestParser.ts │ │ └── services │ │ │ ├── genericInjectionToken.ts │ │ │ └── rootTestService.ts │ │ ├── testing-api.ts │ │ └── unit │ │ ├── contentSanitizer.spec.ts │ │ ├── dataTypeEncoder.spec.ts │ │ ├── dataTypeParser.spec.ts │ │ ├── deepComparer.spec.ts │ │ ├── defaultPlatformService.spec.ts │ │ ├── hookFinder.spec.ts │ │ └── polyfills.spec.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/workflows/ci-test.yml: -------------------------------------------------------------------------------- 1 | name: CI tests 2 | run-name: Running automated tests 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | jobs: 9 | Running-automated-tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." 13 | - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" 14 | - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." 15 | 16 | - name: Checking out repository code... 17 | uses: actions/checkout@v4 18 | 19 | - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." 20 | 21 | - name: Installing dependencies... 22 | run: | 23 | npm config set progress=false &&\ 24 | npm install --force 25 | 26 | - name: Running tests... 27 | run: npm run ci:test 28 | 29 | # Could also use dedicated codecov action: https://github.com/codecov/codecov-action 30 | - name: Reporting coverage to codecov... 31 | run: npm run ci:reportCoverage 32 | env: 33 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 34 | 35 | - run: echo "🍏 This job's status is ${{ job.status }}." -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | /package-lock.json 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # cache 16 | .angular 17 | .sass-cache 18 | 19 | # profiling files 20 | chrome-profiler-events*.json 21 | speed-measure-plugin*.json 22 | 23 | # IDEs and editors 24 | .idea/ 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # Visual Studio Code 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | .history/* 39 | 40 | # Miscellaneous 41 | /connect.lock 42 | /coverage 43 | /libpeerconnection.log 44 | testem.log 45 | /typings 46 | 47 | # System files 48 | .DS_Store 49 | Thumbs.db 50 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Marvin Tobisch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | The logo for the Angular Dynamic Hooks library 2 | 3 | # Angular Dynamic Hooks 4 | 5 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/angular-dynamic-hooks/ngx-dynamic-hooks/ci-test.yml?style=flat-square&logo=github&label=CI%20tests)](https://github.com/angular-dynamic-hooks/ngx-dynamic-hooks/actions/workflows/ci-test.yml) 6 | [![Coverage](https://img.shields.io/codecov/c/gh/angular-dynamic-hooks/ngx-dynamic-hooks?style=flat-square)](https://codecov.io/gh/angular-dynamic-hooks/ngx-dynamic-hooks) 7 | [![NPM](https://img.shields.io/npm/v/ngx-dynamic-hooks?color=orange&style=flat-square)](https://www.npmjs.com/package/ngx-dynamic-hooks) 8 | [![License](https://img.shields.io/github/license/angular-dynamic-hooks/ngx-dynamic-hooks?color=blue&style=flat-square)](https://github.com/angular-dynamic-hooks/ngx-dynamic-hooks/blob/master/LICENSE.md) 9 | [![Static Badge](https://img.shields.io/badge/Donate%20-%20Thank%20you!%20-%20%23ff8282?style=flat-square)](https://www.paypal.com/donate/?hosted_button_id=3XVSEZKNQW8HC) 10 | 11 | Angular Dynamic Hooks allows you to load Angular components into dynamic content, such as HTML strings (similar to a "dynamic" template) or even already-existing HTML structures. 12 | 13 | Works as part of an Angular app or fully standalone. Load components by selectors or **any text pattern**. No JiT-compiler required - [just install and go](https://angular-dynamic-hooks.com/guide/quickstart). 14 | 15 | ![A short animated gif showing how to use the Angular Dynamic Hooks library to load components](https://github.com/angular-dynamic-hooks/ngx-dynamic-hooks/assets/12670925/ef27d405-4663-48a5-97b5-ca068d7b67d8) 16 | 17 | # Installation 18 | 19 | Simply install via npm (or yarn) 20 | 21 | ```sh 22 | npm install ngx-dynamic-hooks 23 | ``` 24 | 25 | # Compatibility 26 | 27 | | Angular | Version | NPM | 28 | | --- | --- | --- | 29 | | 6 - 12 | 1.x.x | `ngx-dynamic-hooks@^1` | 30 | | 13-16 | 2.x.x | `ngx-dynamic-hooks@^2` | 31 | | 17+ | 3.x.x | `ngx-dynamic-hooks@^3` | 32 | 33 | As the library does not rely on a runtime compiler, it works in both JiT- and AoT-environments. 34 | 35 | **Upgrading to v3**: If you have been using v2 of the library and are looking to upgrade, have a look at [Version 3 - What's new?](https://angular-dynamic-hooks.com/guide/version-3-whats-new) for a list of breaking changes. 36 | 37 | # Quickstart 38 | 39 | Import the `DynamicHooksComponent` as well as your dynamic component(s) to load: 40 | 41 | ```ts 42 | import { Component } from '@angular/core'; 43 | import { DynamicHooksComponent } from 'ngx-dynamic-hooks'; 44 | import { ExampleComponent } from 'somewhere'; 45 | 46 | @Component({ 47 | ... 48 | imports: [DynamicHooksComponent] 49 | }) 50 | export class AppComponent { 51 | // The content to parse 52 | content = 'Load a component here: '; 53 | // A list of components to look for 54 | parsers = [ExampleComponent]; 55 | } 56 | ``` 57 | Then just use `` where you want to render the content: 58 | 59 | ```html 60 | 61 | ``` 62 | 63 | That's it! If `` is the selector of `ExampleComponent`, it will automatically be loaded in its place, just like in a normal template. 64 | 65 | # Documentation 66 | 67 | Please note that the above is a very minimal example and that there are plenty more features and options available to you. [Check out the docs](https://angular-dynamic-hooks.com/guide/) to find out how to tailor the library to your exact needs. Highlights include: 68 | 69 | * ⭐ Loads fully-functional Angular components into dynamic content 70 | * 📖 Parses both strings and HTML trees to load components into them like a template 71 | * 🚀 Can be used fully standalone (load components into HTML without Angular) 72 | * 🏃 Works **without** needing the JiT compiler 73 | * 💻 Works **with** Server-Side-Rendering 74 | * 🔍 Loads components by their selectors, custom selectors or **any text pattern of your choice** 75 | * ⚙️ Services, Inputs/Outputs, Lifecycle Methods and other standard features all work normally 76 | * 💤 Allows lazy-loading components only if they appear in the content 77 | * 🔒 Can pass custom data safely to your components via an optional context object 78 | 79 | # Donate 80 | 81 | If you like the the library and would like to support the ongoing development, maintenance and free technical support, you can [consider making a small donation](https://www.paypal.com/donate/?hosted_button_id=3XVSEZKNQW8HC). Your help is greatly appreciated - Thank you! 82 | 83 | # Issues 84 | 85 | Please post bugs or any bigger or smaller questions you might have in the issues tab and I will have a look at them as soon as possible. -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngx-dynamic-hooks": { 7 | "projectType": "library", 8 | "root": "projects/ngx-dynamic-hooks", 9 | "sourceRoot": "projects/ngx-dynamic-hooks/src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "project": "projects/ngx-dynamic-hooks/ng-package.json" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "tsConfig": "projects/ngx-dynamic-hooks/tsconfig.lib.prod.json" 20 | }, 21 | "development": { 22 | "tsConfig": "projects/ngx-dynamic-hooks/tsconfig.lib.json" 23 | } 24 | }, 25 | "defaultConfiguration": "production" 26 | }, 27 | "test": { 28 | "builder": "@angular-devkit/build-angular:karma", 29 | "options": { 30 | "tsConfig": "projects/ngx-dynamic-hooks/tsconfig.spec.json", 31 | "karmaConfig": "projects/ngx-dynamic-hooks/karma.conf.js", 32 | "polyfills": [ 33 | "zone.js", 34 | "zone.js/testing" 35 | ] 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-dynamic-hooks-workspace", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "build-npm": "ng build --configuration production && cp ./README.md ./dist/ngx-dynamic-hooks/README.md", 9 | "test": "ng test", 10 | "watch": "ng build --watch --configuration development", 11 | "ci:test": "ng test --no-watch --no-progress --browsers=ChromeHeadlessCI --code-coverage", 12 | "ci:reportCoverage": "codecov" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "^17.3.0", 17 | "@angular/common": "^17.3.0", 18 | "@angular/compiler": "^17.3.0", 19 | "@angular/core": "^17.3.0", 20 | "@angular/forms": "^17.3.0", 21 | "@angular/platform-browser": "^17.3.0", 22 | "@angular/platform-browser-dynamic": "^17.3.0", 23 | "@angular/router": "^17.3.0", 24 | "rxjs": "~7.8.0", 25 | "tslib": "^2.3.0", 26 | "zone.js": "~0.14.3" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^17.3.6", 30 | "@angular/cli": "^17.3.5", 31 | "@angular/compiler-cli": "^17.3.0", 32 | "@types/jasmine": "~5.1.0", 33 | "codecov": "^3.8.3", 34 | "jasmine-core": "~5.1.0", 35 | "karma": "~6.4.0", 36 | "karma-chrome-launcher": "~3.2.0", 37 | "karma-coverage": "~2.2.0", 38 | "karma-jasmine": "~5.1.0", 39 | "karma-jasmine-html-reporter": "~2.1.0", 40 | "ng-packagr": "^17.3.0", 41 | "typescript": "~5.4.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, '../../coverage/ngx-dynamic-hooks'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' }, 33 | { type: 'lcov' } 34 | ] 35 | }, 36 | reporters: [ 37 | 'progress', 38 | 'kjhtml', 39 | 'coverage' 40 | ], 41 | browsers: ['Chrome'], 42 | retryLimit: -1, 43 | customLaunchers: { 44 | ChromeHeadlessCI: { 45 | base: 'ChromeHeadless', 46 | flags: ['--no-sandbox'] 47 | } 48 | }, 49 | restartOnFileChange: true 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngx-dynamic-hooks", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-dynamic-hooks", 3 | "version": "3.1.2", 4 | "description": "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.", 5 | "person": "Marvin Tobisch ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "angular", 9 | "angular2", 10 | "ng", 11 | "dynamic", 12 | "component", 13 | "hooks", 14 | "innerHTML", 15 | "content", 16 | "elements", 17 | "loader" 18 | ], 19 | "homepage": "https://github.com/angular-dynamic-hooks/ngx-dynamic-hooks", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/angular-dynamic-hooks/ngx-dynamic-hooks.git" 23 | }, 24 | "dependencies": { 25 | "tslib": "^2.3.0" 26 | }, 27 | "peerDependencies": { 28 | "@angular/core": ">=17", 29 | "@angular/common": ">=17", 30 | "@angular/platform-browser": ">=17", 31 | "rxjs": ">=7" 32 | }, 33 | "sideEffects": false 34 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/components/dynamicHooksComponent.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ElementRef, DoCheck, AfterViewChecked, Output, EventEmitter, Injector, Optional, Inject, SimpleChanges, EnvironmentInjector } from '@angular/core'; 2 | import { HookIndex, Hook, ParseResult } from '../interfacesPublic'; 3 | import { HookParser, LoadedComponent } from '../interfacesPublic'; 4 | import { DynamicHooksService } from '../services/dynamicHooksService'; 5 | import { HookParserEntry } from '../services/settings/parserEntry'; 6 | import { ComponentUpdater } from '../services/core/componentUpdater'; 7 | import { AutoPlatformService } from '../services/platform/autoPlatformService'; 8 | import { ParseOptions, getParseOptionDefaults } from '../../public-api'; 9 | 10 | /** 11 | * The main component of the ngx-dynamic-hooks library to dynamically load components into content 12 | */ 13 | @Component({ 14 | selector: 'ngx-dynamic-hooks', 15 | template: '', 16 | standalone: true, 17 | styles: [] 18 | }) 19 | export class DynamicHooksComponent implements DoCheck, OnChanges, AfterViewInit, AfterViewChecked, OnDestroy { 20 | @Input() content: any = null; 21 | @Input() context: any = null; 22 | @Input() globalParsersBlacklist: string[]|null = null; 23 | @Input() globalParsersWhitelist: string[]|null = null; 24 | @Input() parsers: HookParserEntry[]|null = null; 25 | @Input() options: ParseOptions|null = null; 26 | @Output() componentsLoaded: EventEmitter = new EventEmitter(); 27 | hookIndex: HookIndex = {}; 28 | activeOptions: ParseOptions = getParseOptionDefaults(); 29 | activeParsers: HookParser[] = []; 30 | token = Math.random().toString(36).substring(2, 12); 31 | initialized: boolean = false; 32 | 33 | constructor( 34 | private hostElement: ElementRef, 35 | private dynamicHooksService: DynamicHooksService, 36 | private componentUpdater: ComponentUpdater, 37 | private platformService: AutoPlatformService, 38 | private environmentInjector: EnvironmentInjector, 39 | private injector: Injector 40 | ) { 41 | } 42 | 43 | ngDoCheck(): void { 44 | // Update bindings on every change detection run? 45 | if (!this.activeOptions.updateOnPushOnly) { 46 | this.refresh(false); 47 | } 48 | } 49 | 50 | ngOnChanges(changes: SimpleChanges): void { 51 | // If text or options change, reset and parse from scratch 52 | if ( 53 | changes.hasOwnProperty('content') || 54 | changes.hasOwnProperty('globalParsersBlacklist') || 55 | changes.hasOwnProperty('globalParsersWhitelist') || 56 | changes.hasOwnProperty('parsers') || 57 | changes.hasOwnProperty('options') 58 | ) { 59 | this.reset(); 60 | this.parse(this.content); 61 | 62 | // If only context changed, just refresh hook inputs/outputs 63 | } else if (changes.hasOwnProperty('context')) { 64 | this.refresh(true); 65 | } 66 | } 67 | 68 | ngAfterViewInit(): void { 69 | } 70 | 71 | ngAfterViewChecked(): void { 72 | } 73 | 74 | ngOnDestroy(): void { 75 | this.reset(); 76 | } 77 | 78 | // ---------------------------------------------------------------------- 79 | 80 | /** 81 | * Empties the state of this component 82 | */ 83 | reset(): void { 84 | this.dynamicHooksService.destroy(this.hookIndex); 85 | 86 | // Reset state 87 | this.platformService.setInnerContent(this.hostElement.nativeElement, ''); 88 | this.hookIndex = {}; 89 | this.activeOptions = getParseOptionDefaults(); 90 | this.activeParsers = []; 91 | this.initialized = false; 92 | } 93 | 94 | /** 95 | * Parses the content and load components 96 | * 97 | * @param content - The content to parse 98 | */ 99 | parse(content: any): void { 100 | this.dynamicHooksService.parse( 101 | content, 102 | this.parsers, 103 | this.context, 104 | this.options, 105 | this.globalParsersBlacklist, 106 | this.globalParsersWhitelist, 107 | this.hostElement.nativeElement, 108 | this.hookIndex, 109 | this.environmentInjector, 110 | this.injector 111 | ).subscribe((parseResult: ParseResult) => { 112 | // hostElement and hookIndex are automatically filled 113 | this.activeParsers = parseResult.usedParsers; 114 | this.activeOptions = parseResult.usedOptions; 115 | this.initialized = true; 116 | 117 | // Return all loaded components 118 | const loadedComponents: LoadedComponent[] = Object.values(this.hookIndex).map((hook: Hook) => { 119 | return { 120 | hookId: hook.id, 121 | hookValue: hook.value, 122 | hookParser: hook.parser, 123 | componentRef: hook.componentRef! 124 | }; 125 | }); 126 | this.componentsLoaded.emit(loadedComponents); 127 | }); 128 | } 129 | 130 | /** 131 | * Updates the bindings for all existing components 132 | * 133 | * @param triggerOnDynamicChanges - Whether to trigger the OnDynamicChanges method of dynamically loaded components 134 | */ 135 | refresh(triggerOnDynamicChanges: boolean): void { 136 | if (this.initialized) { 137 | this.componentUpdater.refresh(this.hookIndex, this.context, this.activeOptions, triggerOnDynamicChanges); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/components/dynamicSingleComponent.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ElementRef, DoCheck, AfterViewChecked, Output, EventEmitter, Injector, Optional, Inject, SimpleChanges, EnvironmentInjector, reflectComponentType, ComponentRef } from '@angular/core'; 2 | import { HookIndex, Hook, ParseResult, HookComponentData, HookValue, HookBindings } from '../interfacesPublic'; 3 | import { HookParser } from '../interfacesPublic'; 4 | import { DynamicHooksService } from '../services/dynamicHooksService'; 5 | import { ComponentUpdater } from '../services/core/componentUpdater'; 6 | import { AutoPlatformService } from '../services/platform/autoPlatformService'; 7 | import { ParseOptions, getParseOptionDefaults } from '../../public-api'; 8 | import { anchorElementTag } from '../constants/core'; 9 | 10 | export interface DynamicHooksSingleOptions { 11 | updateOnPushOnly?: boolean; 12 | compareInputsByValue?: boolean; 13 | compareOutputsByValue?: boolean; 14 | compareByValueDepth?: number; 15 | ignoreInputAliases?: boolean; 16 | ignoreOutputAliases?: boolean; 17 | acceptInputsForAnyProperty?: boolean; 18 | acceptOutputsForAnyObservable?: boolean; 19 | } 20 | 21 | 22 | /** 23 | * A component that can be used to dynamically load a single component and pass bindings to it 24 | */ 25 | @Component({ 26 | selector: 'ngx-dynamic-single', 27 | template: '', 28 | standalone: true, 29 | styles: [] 30 | }) 31 | export class DynamicSingleComponent implements DoCheck, OnChanges, AfterViewInit, AfterViewChecked, OnDestroy { 32 | @Input() component: (new(...args: any[]) => any)|null = null; 33 | @Input() inputs: {[key:string]: any} = {}; 34 | @Input() outputs: {[key:string]: any} = {}; 35 | @Input() options: DynamicHooksSingleOptions = {}; 36 | @Output() componentLoaded: EventEmitter> = new EventEmitter(); 37 | parseResult: ParseResult|null = null; 38 | parseOptions: ParseOptions = {}; 39 | 40 | constructor( 41 | private hostElement: ElementRef, 42 | private platformService: AutoPlatformService, 43 | private dynamicHooksService: DynamicHooksService, 44 | private componentUpdater: ComponentUpdater 45 | ) { 46 | } 47 | 48 | ngDoCheck(): void { 49 | // Update on every change detection run? 50 | if (!this.parseOptions.updateOnPushOnly) { 51 | this.refresh(); 52 | } 53 | } 54 | 55 | ngOnChanges(changes: SimpleChanges): void { 56 | // If component changed, reset and load from scratch 57 | if ( 58 | changes.hasOwnProperty('component') 59 | ) { 60 | this.reset(); 61 | this.parseOptions = {...getParseOptionDefaults(), ...this.options}; 62 | this.loadComponent(); 63 | 64 | // If anything else changed, just refresh inputs/outputs 65 | } else if ( 66 | changes.hasOwnProperty('inputs') || 67 | changes.hasOwnProperty('outputs') || 68 | changes.hasOwnProperty('options') 69 | ) { 70 | this.parseOptions = {...getParseOptionDefaults(), ...this.options}; 71 | this.refresh(); 72 | } 73 | } 74 | 75 | ngAfterViewInit(): void { 76 | } 77 | 78 | ngAfterViewChecked(): void { 79 | } 80 | 81 | ngOnDestroy(): void { 82 | this.reset(); 83 | } 84 | 85 | // ---------------------------------------------------------------------- 86 | 87 | /** 88 | * Destroys the dynamic component and resets the state 89 | */ 90 | reset() { 91 | if (this.parseResult) { 92 | this.dynamicHooksService.destroy(this.parseResult.hookIndex); 93 | } 94 | 95 | this.platformService.setInnerContent(this.hostElement.nativeElement, ''); 96 | this.parseResult = null; 97 | this.parseOptions = {}; 98 | } 99 | 100 | /** 101 | * Loads the dynamic component 102 | */ 103 | loadComponent() { 104 | if (this.component) { 105 | const compMeta = reflectComponentType(this.component); 106 | 107 | if (!compMeta) { 108 | throw new Error('Provided component class input is not a valid Angular component.'); 109 | } 110 | 111 | // Try to use component selector as hostElement. Otherwise default to standard anchor. 112 | let selector; 113 | let componentHostElement; 114 | try { 115 | selector = compMeta.selector; 116 | componentHostElement = this.platformService.createElement(selector); 117 | } catch (e) { 118 | selector = anchorElementTag; 119 | componentHostElement = this.platformService.createElement(anchorElementTag); 120 | } 121 | this.platformService.clearChildNodes(this.hostElement.nativeElement); 122 | this.platformService.appendChild(this.hostElement.nativeElement, componentHostElement); 123 | 124 | // Create parser that finds created hostElement as hook and loads requested component into it 125 | const parser = this.createAdHocParser(selector); 126 | 127 | this.dynamicHooksService.parse(this.hostElement.nativeElement, [parser], {}, this.parseOptions) 128 | .subscribe(parseResult => { 129 | this.parseResult = parseResult; 130 | this.componentLoaded.next(parseResult.hookIndex[1].componentRef!); 131 | }); 132 | } 133 | } 134 | 135 | /** 136 | * Creates a parser specifically for the dynamic component 137 | * 138 | * @param selector - The selector to use for the component 139 | */ 140 | createAdHocParser(selector: string): (new(...args: any[]) => HookParser) { 141 | const that = this; 142 | 143 | class AdHocSingleComponentParser implements HookParser { 144 | 145 | findHookElements(contentElement: any, context: any): any[] { 146 | return that.platformService.querySelectorAll(contentElement, selector); 147 | } 148 | 149 | loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: any[]): HookComponentData { 150 | return { 151 | component: that.component! 152 | } 153 | } 154 | 155 | getBindings(hookId: number, hookValue: HookValue, context: any): HookBindings { 156 | return { 157 | inputs: that.inputs, 158 | outputs: that.outputs 159 | } 160 | } 161 | } 162 | 163 | return AdHocSingleComponentParser; 164 | } 165 | 166 | /** 167 | * Updates the bindings for the loaded component 168 | */ 169 | refresh() { 170 | if (this.parseResult) { 171 | this.componentUpdater.refresh(this.parseResult.hookIndex, {}, this.parseOptions, false); 172 | } 173 | } 174 | 175 | } 176 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/constants/core.ts: -------------------------------------------------------------------------------- 1 | export const contentElementAttr = '__ngx_dynamic_hooks_content' 2 | export const anchorElementTag = 'dynamic-component-anchor'; 3 | export const anchorAttrHookId = '__ngx_dynamic_hooks_anchor_id'; 4 | export const anchorAttrParseToken = '__ngx_dynamic_hooks_anchor_parsetoken'; 5 | export const voidElementTags = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/constants/regexes.ts: -------------------------------------------------------------------------------- 1 | export const regexes: any = {}; 2 | 3 | // General 4 | const variableName = '[a-zA-Z_$]+[a-zA-Z0-9_$]*'; 5 | const attributeName = '[a-zA-Z$\\-_:][a-zA-Z$\\-_:0-9\\.]*'; 6 | 7 | // Attribute regex 8 | regexes.attributeNameNoBracketsRegex = '(' + attributeName + ')'; 9 | regexes.attributeNameBracketsRegex = '\\[(' + attributeName + ')\\]'; 10 | regexes.attributeNameRoundBracketsRegex = '\\((' + attributeName + ')\\)'; 11 | regexes.attributeNameRegex = '(?:' + regexes.attributeNameNoBracketsRegex + '|' + regexes.attributeNameBracketsRegex + '|' + regexes.attributeNameRoundBracketsRegex + ')'; 12 | regexes.attributeValueDoubleQuotesRegex = '\"((?:\\\\.|[^\"])*?)\"'; // Clever bit of regex to allow escaped chars in strings: https://stackoverflow.com/a/1016356/3099523 13 | regexes.attributeValueSingleQuotesRegex = '\'((?:\\\\.|[^\'])*?)\''; 14 | 15 | // Context var regex examples: https://regex101.com/r/zSbY7M/4 16 | // Supports the dot notation, the [] notation as well as function calls () for building variable paths 17 | regexes.variablePathDotNotation = '\\.' + variableName; 18 | regexes.variableBracketsNotation = '\\[[^\\]]*\\]'; // Relies on nested '[]'brackets being encoded 19 | regexes.variablePathFunctionCall = '\\([^\\)]*\\)'; // Relies on nested '()'-brackets being encoded. 20 | regexes.variablePathPartRegex = '(?:' + regexes.variablePathDotNotation + '|' + regexes.variableBracketsNotation + '|' + regexes.variablePathFunctionCall + ')'; 21 | regexes.contextVariableRegex = 'context' + regexes.variablePathPartRegex + '*'; 22 | 23 | regexes.placeholderVariablePathDotNotation = '\\@@@cxtDot@@@' + variableName; 24 | regexes.placeholderVariableBracketsNotation = '@@@cxtOpenSquareBracket@@@[^\\]]*@@@cxtCloseSquareBracket@@@'; 25 | regexes.placeholderVariablePathFunctionCall = '@@@cxtOpenRoundBracket@@@[^\\)]*@@@cxtCloseRoundBracket@@@'; 26 | regexes.placeholderVariablePathPartRegex = '(?:' + regexes.placeholderVariablePathDotNotation + '|' + regexes.placeholderVariableBracketsNotation + '|' + regexes.placeholderVariablePathFunctionCall + ')'; 27 | regexes.placeholderContextVariableRegex = '__CXT__' + regexes.placeholderVariablePathPartRegex + '*'; -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/dynamicHooksProviders.ts: -------------------------------------------------------------------------------- 1 | import { Type, SkipSelf, Optional, Provider, APP_INITIALIZER, Injectable, OnDestroy } from '@angular/core'; // Don't remove InjectionToken here. It will compile with a dynamic import otherwise which breaks Ng<5 support 2 | import { DynamicHooksSettings } from './services/settings/settings'; 3 | import { DynamicHooksService } from './services/dynamicHooksService'; 4 | import { PLATFORM_SERVICE, PlatformService } from './services/platform/platformService'; 5 | import { DYNAMICHOOKS_ALLSETTINGS, DYNAMICHOOKS_ANCESTORSETTINGS, DYNAMICHOOKS_MODULESETTINGS } from './interfaces'; 6 | import { HookParserEntry } from './services/settings/parserEntry'; 7 | 8 | export const allSettings: DynamicHooksSettings[] = []; 9 | 10 | /** 11 | * Sets up global parsers and options for the ngx-dynamic-hooks library 12 | * 13 | * @param settings - Parsers/options to be are shared in this injection context 14 | * @param platformService - (optional) If desired, you can specify a custom PlatformService to use here 15 | */ 16 | export const provideDynamicHooks: (settings?: DynamicHooksSettings|HookParserEntry[], platformService?: Type) => Provider[] = (settings, platformService) => { 17 | const moduleSettings: DynamicHooksSettings|undefined = Array.isArray(settings) ? {parsers: settings} : settings; 18 | 19 | if (moduleSettings !== undefined) { 20 | allSettings.push(moduleSettings); 21 | } 22 | 23 | const providers: Provider[] = [ 24 | { 25 | provide: APP_INITIALIZER, 26 | useFactory: () => () => {}, 27 | multi: true, 28 | deps: [DynamicHooksInitService] 29 | }, 30 | 31 | // Settings 32 | { provide: DYNAMICHOOKS_ALLSETTINGS, useValue: allSettings }, 33 | // AncestorSettings is a hierarchical array of provided settings 34 | // By having itself as a dependency with SkipSelf, a circular reference is avoided as Angular will look for DYNAMICHOOKS_ANCESTORSETTINGS in the parent injector. 35 | // It will keep traveling injectors upwards until it finds another or just use null as the dep. 36 | // Also, by returning a new array reference each time, the result will only contain the direct ancestor child settings, not all child settings from every module in the app. 37 | // See: https://stackoverflow.com/questions/49406615/is-there-a-way-how-to-use-angular-multi-providers-from-all-multiple-levels 38 | { 39 | provide: DYNAMICHOOKS_ANCESTORSETTINGS, 40 | useFactory: (ancestorSettings: DynamicHooksSettings[]) => { 41 | ancestorSettings = Array.isArray(ancestorSettings) ? ancestorSettings : []; 42 | ancestorSettings = moduleSettings !== undefined ? [...ancestorSettings, moduleSettings] : ancestorSettings; 43 | return ancestorSettings; 44 | }, 45 | deps: [[new SkipSelf(), new Optional(), DYNAMICHOOKS_ANCESTORSETTINGS]] 46 | }, 47 | { provide: DYNAMICHOOKS_MODULESETTINGS, useValue: moduleSettings }, 48 | 49 | // Must provide a separate instance of DynamicHooksService each time you call provideDynamicHooks, 50 | // so it can see passed settings of this level 51 | DynamicHooksService 52 | ] 53 | 54 | if (platformService) { 55 | providers.push({ provide: PLATFORM_SERVICE, useClass: platformService }); 56 | } 57 | 58 | return providers; 59 | } 60 | 61 | /** 62 | * A service that will always be created on app init, even without using a DynamicHooksComponent 63 | */ 64 | @Injectable({ 65 | providedIn: 'root' 66 | }) 67 | class DynamicHooksInitService implements OnDestroy { 68 | ngOnDestroy(): void { 69 | // Reset allSettings on app close for the benefit of vite live reloads and tests (which does not destroy allSettings reference between app reloads) 70 | // Safer to do this only on app close rather than on app start as it acts like a cleanup function and the order of execution matters less 71 | allSettings.length = 0; 72 | } 73 | } 74 | 75 | export const resetDynamicHooks: () => void = () => { 76 | allSettings.length = 0; 77 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { DynamicHooksSettings } from './services/settings/settings'; 3 | 4 | /** 5 | * Custom injector tokens that are used for varous internal communication purposes 6 | */ 7 | export const DYNAMICHOOKS_ALLSETTINGS = new InjectionToken('All of the settings registered in the whole app.'); 8 | export const DYNAMICHOOKS_ANCESTORSETTINGS = new InjectionToken('The settings collected from all ancestor injectors'); 9 | export const DYNAMICHOOKS_MODULESETTINGS = new InjectionToken('The settings for the currently loaded module.'); 10 | 11 | export interface SavedBindings { 12 | inputs?: {[key: string]: RichBindingData}; 13 | outputs?: {[key: string]: RichBindingData}; 14 | } 15 | 16 | /** 17 | * A detailed information object for a single binding, containing the raw unparsed binding, 18 | * its parsed value and all used context variables, if any 19 | */ 20 | export interface RichBindingData { 21 | raw: string; 22 | parsed: boolean; 23 | value: any; 24 | boundContextVariables: {[key: string]: any}; 25 | } 26 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/parsers/selector/bindingsValueManager.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { DataTypeParser } from '../../services/utils/dataTypeParser'; 4 | import { SelectorHookParserConfig } from './selectorHookParserConfig'; 5 | import { RichBindingData } from '../../interfaces'; 6 | import { Logger } from '../../services/utils/logger'; 7 | import { ParseOptions } from '../../services/settings/options'; 8 | 9 | 10 | /** 11 | * A helper service for the SelectorHookParsers that evaluates bindings and only updates them when needed so references are retained when possible 12 | */ 13 | @Injectable({ 14 | providedIn: 'root' 15 | }) 16 | export class BindingsValueManager { 17 | 18 | constructor(private dataTypeParser: DataTypeParser, private logger: Logger) { 19 | } 20 | 21 | // Inputs 22 | // ------------------------------------------------------------------------------------- 23 | 24 | /** 25 | * Checks input bindings and evaluates/updates them as needed 26 | * 27 | * @param bindings - A list of @Input() bindings 28 | * @param context - The current context object 29 | * @param parserConfig - The parser config 30 | * @param options - The current ParseOptions 31 | */ 32 | checkInputBindings(bindings: {[key: string]: RichBindingData}, context: any, parserConfig: SelectorHookParserConfig, options: ParseOptions) { 33 | for (const [inputName, inputBinding] of Object.entries(bindings)) { 34 | // If no need to parse, use raw as value 35 | if (!parserConfig.parseInputs) { 36 | inputBinding.value = inputBinding.raw; 37 | 38 | } else { 39 | // If not yet parsed, do so 40 | if (!inputBinding.parsed) { 41 | try { 42 | inputBinding.value = this.dataTypeParser.evaluate( 43 | inputBinding.raw, 44 | parserConfig.allowContextInBindings ? context : {}, 45 | undefined, 46 | parserConfig.unescapeStrings, 47 | inputBinding.boundContextVariables, 48 | parserConfig.allowContextFunctionCalls, 49 | options 50 | ); 51 | inputBinding.parsed = true; 52 | } catch (e: any) { 53 | this.logger.error([`Hook input parsing error\nselector: ` + parserConfig.selector + `\ninput: ` + inputName + `\nvalue: "` + inputBinding.value + `"`], options); 54 | this.logger.error([e.stack], options); 55 | // If binding could not be parsed at all due to syntax error, remove from list of inputs. 56 | // No amount of calls to updateInputBindings() will fix this kind of error. 57 | delete bindings[inputName]; 58 | } 59 | 60 | // Otherwise check if needs an update 61 | } else { 62 | this.updateInputBindingIfStale(inputBinding, context, parserConfig); 63 | } 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * We can detect if a binding needs to be reevaluated via the bound context variables. There are three cases to consider: 70 | * 71 | * a) If a binding does not use context vars, don't reevaluate (binding is static and won't ever need to be updated) 72 | * b) If a binding does use context vars, but context vars haven't changed, don't reevaluate either (would evalute the same) 73 | * c) If a binding uses context vars and they have changed, reevaluate the binding from scratch to get the new version 74 | * 75 | * This is in line with the standard Angular behavior when evaluating template vars like [input]="{prop: this.something}". 76 | * When 'this.something' changes so that it returns false on a === comparison with its previous value, Angular does not 77 | * simply replace the reference bound to 'prop', but recreates the whole object literal and passes a new reference into the 78 | * input, triggering ngOnChanges. 79 | * 80 | * @param binding - The previous bindings 81 | * @param context - The current context object 82 | * @param parserConfig - The current parser config 83 | */ 84 | private updateInputBindingIfStale(binding: RichBindingData, context: any, parserConfig: SelectorHookParserConfig): void { 85 | 86 | if (Object.keys(binding.boundContextVariables).length > 0) { 87 | // Check if bound context vars have changed 88 | let boundContextVarHasChanged = false; 89 | for (const [contextVarName, contextVarValue] of Object.entries(binding.boundContextVariables)) { 90 | const encodedContextVarName = this.dataTypeParser.encodeDataTypeString(contextVarName); 91 | // Compare with previous value 92 | const newContextVarValue = this.dataTypeParser.loadContextVariable(encodedContextVarName, context, undefined, parserConfig.unescapeStrings, {}, parserConfig.allowContextFunctionCalls); 93 | if (newContextVarValue !== contextVarValue) { 94 | boundContextVarHasChanged = true; 95 | break; 96 | } 97 | } 98 | 99 | // Bound context var has changed! Reevaluate whole binding (which may include more than one context var, or point to some child property) 100 | if (boundContextVarHasChanged) { 101 | binding.boundContextVariables = {}; 102 | binding.value = this.dataTypeParser.evaluate( 103 | binding.raw, 104 | parserConfig.allowContextInBindings ? context : {}, 105 | undefined, 106 | parserConfig.unescapeStrings, 107 | binding.boundContextVariables, 108 | parserConfig.allowContextFunctionCalls 109 | ); 110 | } 111 | } 112 | } 113 | 114 | // Outputs 115 | // ------------------------------------------------------------------------------------- 116 | 117 | /** 118 | * Checks output bindings and evaluates/updates them as needed 119 | * 120 | * @param bindings - A list of @Output() bindings 121 | * @param parserConfig - The current parser config 122 | * @param options - The current ParseOptions 123 | */ 124 | checkOutputBindings(bindings: {[key: string]: RichBindingData}, parserConfig: SelectorHookParserConfig, options: ParseOptions) { 125 | for (const [outputName, outputBinding] of Object.entries(bindings)) { 126 | // Unlike inputs, outputs only need to be created once by the parser, never updated, as you only create a wrapper function around the logic to execute. 127 | // As this logic is run fresh whenever the output triggers, there is no need to replace this wrapper function on updates. 128 | if (!outputBinding.parsed) { 129 | outputBinding.value = (event: any, context: any) => { 130 | try { 131 | this.dataTypeParser.evaluate( 132 | outputBinding.raw, 133 | parserConfig.allowContextInBindings ? context : {}, 134 | event, 135 | parserConfig.unescapeStrings, 136 | outputBinding.boundContextVariables, 137 | parserConfig.allowContextFunctionCalls 138 | ); 139 | } catch (e: any) { 140 | this.logger.error([`Hook output parsing error\nselector: ` + parserConfig.selector + `\noutput: ` + outputName + `\nvalue: "` + outputBinding.value + `"`], options); 141 | this.logger.error([e.stack], options); 142 | } 143 | }; 144 | outputBinding.parsed = true; 145 | } 146 | } 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/parsers/selector/element/elementSelectorHookParser.ts: -------------------------------------------------------------------------------- 1 | import { RichBindingData, SavedBindings } from '../../../interfaces'; 2 | import { HookParser, HookValue, HookComponentData, HookBindings } from '../../../interfacesPublic'; 3 | import { BindingsValueManager } from '../bindingsValueManager'; 4 | import { SelectorHookParserConfig } from '../selectorHookParserConfig'; 5 | import { SelectorHookParserConfigResolver } from '../selectorHookParserConfigResolver'; 6 | import { AutoPlatformService } from '../../../services/platform/autoPlatformService'; 7 | import { ParseOptions } from '../../../services/settings/options'; 8 | 9 | /** 10 | * An element parser to load components with their bindings like in Angular templates. 11 | */ 12 | export class ElementSelectorHookParser implements HookParser { 13 | name: string|undefined; 14 | config: SelectorHookParserConfig; 15 | savedBindings: {[key: number]: SavedBindings} = {}; 16 | 17 | constructor(config: SelectorHookParserConfig, private configResolver: SelectorHookParserConfigResolver, private platformService: AutoPlatformService, private bindingsValueManager: BindingsValueManager) { 18 | this.config = this.configResolver.processConfig(config); 19 | this.name = this.config.name; 20 | } 21 | 22 | public findHookElements(contentElement: any, context: any, options: ParseOptions): any[] { 23 | return Array.from(this.platformService.querySelectorAll(contentElement, this.config.selector!)); 24 | } 25 | 26 | public loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: any[], options: ParseOptions): HookComponentData { 27 | 28 | // Always scrub potential []-input- and ()-output-attrs from anchor elements 29 | this.scrubAngularBindingAttrs(hookValue.element); 30 | 31 | return { 32 | component: this.config.component, 33 | hostElementTag: this.config.hostElementTag, 34 | injector: this.config.injector, 35 | environmentInjector: this.config.environmentInjector 36 | }; 37 | } 38 | 39 | public getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings { 40 | let hookBindings = this.savedBindings[hookId]; 41 | 42 | // Parse bindings once from hookValue, then reuse on subsequent runs (raw values will never change as hookValue.element is a snapshot) 43 | if (hookBindings === undefined) { 44 | hookBindings = this.createBindings(hookValue.elementSnapshot!); 45 | this.savedBindings[hookId] = hookBindings; 46 | } 47 | 48 | // (Re)evaluate if needed 49 | this.bindingsValueManager.checkInputBindings(hookBindings.inputs!, context, this.config, options); 50 | this.bindingsValueManager.checkOutputBindings(hookBindings.outputs!, this.config, options); 51 | 52 | return { 53 | inputs: this.getValuesFromSavedBindings(hookBindings.inputs!), 54 | outputs: this.getValuesFromSavedBindings(hookBindings.outputs!) 55 | }; 56 | } 57 | 58 | // Bindings 59 | // -------------------------------------------------------------------------- 60 | 61 | /** 62 | * Always removes angular-typical template attrs like []-input and ()-outputs from anchors 63 | * 64 | * @param anchorElement - The element to strub 65 | */ 66 | scrubAngularBindingAttrs(anchorElement: any) { 67 | const attrsToScrub = Array.from(anchorElement.attributes) 68 | .map((attrObj: any) => attrObj.name) 69 | .filter((attr: string) => 70 | (attr.startsWith('[') && attr.endsWith(']')) || 71 | (attr.startsWith('(') && attr.endsWith(')')) 72 | ); 73 | 74 | for (const attr of attrsToScrub) { 75 | this.platformService.removeAttribute(anchorElement, attr); 76 | } 77 | } 78 | 79 | /** 80 | * Returns RichBindingData for Angular-style inputs & output attrs from an element 81 | * 82 | * @param element - The element to inspect 83 | */ 84 | createBindings(element: any): SavedBindings { 85 | const rawInputs = this.collectRawBindings(element!, 'inputs', this.config.inputsBlacklist || null, this.config.inputsWhitelist || null); 86 | const inputBindings: {[key: string]: RichBindingData} = {}; 87 | for (const [rawInputKey, rawInputValue] of Object.entries(rawInputs)) { 88 | inputBindings[rawInputKey] = {raw: rawInputValue, parsed: false, value: null, boundContextVariables: {}}; 89 | } 90 | 91 | const rawOutputs = this.collectRawBindings(element!, 'outputs', this.config.outputsBlacklist || null, this.config.outputsWhitelist || null); 92 | const outputBindings: {[key: string]: RichBindingData} = {}; 93 | for (const [rawOutputKey, rawOutputValue] of Object.entries(rawOutputs)) { 94 | outputBindings[rawOutputKey] = {raw: rawOutputValue, parsed: false, value: null, boundContextVariables: {}}; 95 | } 96 | 97 | return { 98 | inputs: inputBindings, 99 | outputs: outputBindings 100 | }; 101 | } 102 | 103 | /** 104 | * Returns Angular-style inputs or output attrs from an element 105 | * 106 | * @param element - The element to inspect 107 | * @param type - Whether to return the inputs or outputs 108 | * @param blacklist - A list of inputs/outputs to blacklist 109 | * @param whitelist - A list of inputs/outputs to whitelist 110 | */ 111 | collectRawBindings (element: any, type: 'inputs'|'outputs', blacklist: string[]|null, whitelist: string[]|null): {[key: string]: any} { 112 | const bindings: {[key: string]: any} = {}; 113 | 114 | // Collect raw bindings 115 | const attrNames = this.platformService.getAttributeNames(element); 116 | for (let attrName of attrNames) { 117 | if ( 118 | type === 'inputs' && (!attrName.startsWith('(') || !attrName.endsWith(')')) || 119 | type === 'outputs' && (attrName.startsWith('(') && attrName.endsWith(')')) 120 | ) { 121 | let binding: any = this.platformService.getAttribute(element, attrName); 122 | 123 | // If input has []-brackets: Transform empty attr to undefined 124 | if (type === 'inputs' && attrName.startsWith('[') && attrName.endsWith(']') && binding === '') { 125 | binding = undefined; 126 | } 127 | 128 | // If input has no []-brackets: Should be interpreted as plain strings, so wrap in quotes 129 | if (type === 'inputs' && (!attrName.startsWith('[') || !attrName.endsWith(']'))) { 130 | binding = `'${binding}'`; 131 | } 132 | 133 | // Trim [] and () brackets from attr name 134 | attrName = attrName.replace(/^\[|^\(|\]$|\)$/g, ''); 135 | 136 | bindings[attrName] = binding; 137 | } 138 | } 139 | 140 | // Filter bindings 141 | const filteredBindings: {[key: string]: any} = {}; 142 | for (const [bindingName, bindingValue] of Object.entries(bindings)) { 143 | if (blacklist && blacklist.includes(bindingName)) { 144 | continue; 145 | } 146 | if (whitelist && !whitelist.includes(bindingName)) { 147 | continue; 148 | } 149 | filteredBindings[bindingName] = bindingValue; 150 | } 151 | 152 | return filteredBindings; 153 | } 154 | 155 | /** 156 | * Transforms a RichBindingData object into a normal bindings object 157 | * 158 | * @param richBindingsObject - The object containing the RichBindingData 159 | */ 160 | private getValuesFromSavedBindings(richBindingsObject: {[key: string]: RichBindingData}): {[key: string]: any} { 161 | const result: {[key: string]: any} = {}; 162 | for (const [key, value] of Object.entries(richBindingsObject)) { 163 | result[key] = value.value; 164 | } 165 | return result; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/parsers/selector/selectorHookParserConfig.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentInjector, Injector } from '@angular/core'; 2 | import { ComponentConfig } from '../../interfacesPublic'; 3 | 4 | /** 5 | * Several options to configure and instantiate a `SelectorHookParser` with 6 | */ 7 | export interface SelectorHookParserConfig { 8 | /** 9 | * The component to be used. Can be its class or a `LazyLoadComponentConfig`. 10 | */ 11 | component: ComponentConfig; 12 | 13 | /** 14 | * The name of the parser. Only required if you want to black- or whitelist it. 15 | */ 16 | name?: string; 17 | 18 | /** 19 | * The selector to use to find the hook. 20 | */ 21 | selector?: string; 22 | 23 | /** 24 | * A custom tag to be used for the component host element. 25 | */ 26 | hostElementTag?: string; 27 | 28 | /** 29 | * Whether to use regular expressions rather than HTML/DOM-based methods to find the hook elements 30 | */ 31 | parseWithRegex?: boolean; 32 | 33 | /** 34 | * Whether to allow using self-closing selector tags (``) in addition to enclosing tags (`...`) 35 | */ 36 | allowSelfClosing?: boolean; 37 | 38 | /** 39 | * @deprecated Whether the selector is enclosing (`...`) or not (``). Use the "allowSelfClosing" option for a more modern approach. 40 | */ 41 | enclosing?: boolean; 42 | 43 | /** 44 | * The brackets to use for the selector. 45 | */ 46 | bracketStyle?: {opening: string, closing: string}; 47 | 48 | /** 49 | * Whether to parse inputs into data types or leave them as strings. 50 | */ 51 | parseInputs?: boolean; 52 | 53 | /** 54 | * Whether to remove escaping backslashes from inputs. 55 | */ 56 | unescapeStrings?: boolean; 57 | 58 | /** 59 | * The Injector to create the component with. 60 | */ 61 | injector?: Injector; 62 | 63 | /** 64 | * The EnvironmentInjector to create the component with. 65 | */ 66 | environmentInjector?: EnvironmentInjector; 67 | 68 | /** 69 | * A list of inputs to ignore. 70 | */ 71 | inputsBlacklist?: string[]; 72 | 73 | /** 74 | * A list of inputs to allow exclusively. 75 | */ 76 | inputsWhitelist?: string[]; 77 | 78 | /** 79 | * A list of outputs to ignore. 80 | */ 81 | outputsBlacklist?: string[]; 82 | 83 | /** 84 | * A list of outputs to allow exclusively. 85 | */ 86 | outputsWhitelist?: string[]; 87 | 88 | /** 89 | * Whether to allow the use of context object variables in inputs and outputs. 90 | */ 91 | allowContextInBindings?: boolean; 92 | 93 | /** 94 | * Whether to allow calling context object functions in inputs and outputs. 95 | */ 96 | allowContextFunctionCalls?: boolean; 97 | } 98 | 99 | // Overwrites SelectorHookParserConfig so some values can be undefined for the defaults. If still undefined after merging with user config, throws error programmatically. 100 | export type SelectorHookParserConfigDefaults = Omit & { component: ComponentConfig|undefined }; 101 | 102 | /** 103 | * The default values for the SelectorHookParserConfig 104 | */ 105 | export const selectorHookParserConfigDefaults: SelectorHookParserConfigDefaults = { 106 | component: undefined, 107 | name: undefined, 108 | parseWithRegex: false, 109 | selector: undefined, 110 | hostElementTag: undefined, 111 | injector: undefined, 112 | allowSelfClosing: true, 113 | enclosing: true, 114 | bracketStyle: {opening: '<', closing: '>'}, 115 | parseInputs: true, 116 | unescapeStrings: true, 117 | inputsBlacklist: undefined, 118 | inputsWhitelist: undefined, 119 | outputsBlacklist: undefined, 120 | outputsWhitelist: undefined, 121 | allowContextInBindings: true, 122 | allowContextFunctionCalls: true 123 | }; 124 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/parsers/selector/text/tagHookFinder.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { HookPosition } from '../../../interfacesPublic'; 4 | import { regexes } from '../../../constants/regexes'; 5 | import { HookFinder } from '../../../services/utils/hookFinder'; 6 | import { ParseOptions } from '../../../services/settings/options'; 7 | 8 | 9 | /** 10 | * A utility service for the TextSelectorHookParser that finds Angular component selectors in the content 11 | */ 12 | @Injectable({ 13 | providedIn: 'root' 14 | }) 15 | export class TagHookFinder { 16 | 17 | constructor(private hookFinder: HookFinder) { 18 | } 19 | 20 | /** 21 | * Finds singletag Angular component selectors 22 | * 23 | * @param content - The content to parse 24 | * @param selector - The Angular selector to find 25 | * @param bracketStyle - What bracket style to use 26 | * @param options - The current ParseOptions 27 | */ 28 | findSingleTags(content: string, selector: string, bracketStyle: {opening: string, closing: string} = {opening: '<', closing: '>'}, options: ParseOptions): HookPosition[] { 29 | // Create opening tag regex 30 | const openingTagRegex = this.generateOpeningTagRegex(selector, bracketStyle); 31 | 32 | return this.hookFinder.find(content, openingTagRegex, undefined, undefined, options); 33 | } 34 | 35 | /** 36 | * Finds enclosing Angular component selectors 37 | * 38 | * @param content - The content to parse 39 | * @param selector - The Angular selector to find 40 | * @param bracketStyle - What bracket style to use 41 | * @param options - The current ParseOptions 42 | */ 43 | findEnclosingTags(content: string, selector: string, bracketStyle: {opening: string, closing: string} = {opening: '<', closing: '>'}, options: ParseOptions): HookPosition[] { 44 | // Create opening and closing tag regex 45 | const openingTagRegex = this.generateOpeningTagRegex(selector, bracketStyle); 46 | const closingTagRegex = this.generateClosingTagRegex(selector, bracketStyle); 47 | 48 | return this.hookFinder.find(content, openingTagRegex, closingTagRegex, true, options); 49 | } 50 | 51 | /** 52 | * Finds self-closing Angular component selectors 53 | * 54 | * @param content - The content to parse 55 | * @param selector - The Angular selector to find 56 | * @param bracketStyle - What bracket style to use 57 | * @param options - The current ParseOptions 58 | */ 59 | findSelfClosingTags(content: string, selector: string, bracketStyle: {opening: string, closing: string} = {opening: '<', closing: '>'}, options: ParseOptions): HookPosition[] { 60 | const selfClosingTagRegex = this.generateOpeningTagRegex(selector, bracketStyle, true); 61 | 62 | return this.hookFinder.find(content, selfClosingTagRegex, undefined, undefined, options); 63 | } 64 | 65 | // Hook regex helper 66 | // ---------------------------------------------------------------------------------------------------------------------------------------- 67 | 68 | /** 69 | * Generates the opening tag regex for a standard Angular component selector 70 | * 71 | * @param selector - The selector name 72 | * @param bracketStyle - What bracket style to use 73 | */ 74 | private generateOpeningTagRegex(selector: string, bracketStyle: {opening: string, closing: string} = {opening: '<', closing: '>'}, selfClosing: boolean = false): RegExp { 75 | // Find opening tag of hook lazily 76 | // Examples for this regex: https://regex101.com/r/Glyt2Z/1 77 | // Features: Ignores redundant whitespace & line-breaks, supports n attributes, both normal and []-attribute-name-syntax, both ' and " as attribute-value delimiters 78 | const openingArrow = this.escapeRegex(bracketStyle.opening); 79 | const selectorName = this.escapeRegex(selector); 80 | const closingArrow = (selfClosing ? '\\/' : '') + this.escapeRegex(bracketStyle.closing); 81 | const space = '\\s'; 82 | 83 | const attributeValuesOR = '(?:' + regexes.attributeValueDoubleQuotesRegex + '|' + regexes.attributeValueSingleQuotesRegex + ')'; 84 | const attributes = '(?:' + space + '+' + regexes.attributeNameRegex + '\=' + attributeValuesOR + ')+'; 85 | 86 | const fullRegex = openingArrow + selectorName + '(?:' + space + '*' + closingArrow + '|' + attributes + space + '*' + closingArrow + ')'; 87 | 88 | const regexObject = new RegExp(fullRegex, 'gim'); 89 | 90 | return regexObject; 91 | } 92 | 93 | /** 94 | * Generates the opening tag regex for a standard hook 95 | * 96 | * @param selector - The selector of the hook 97 | * @param bracketStyle - What bracket style to use 98 | */ 99 | private generateClosingTagRegex(selector: string, bracketStyle: {opening: string, closing: string} = {opening: '<', closing: '>'}): RegExp { 100 | const openingArrow = this.escapeRegex(bracketStyle.opening) + '\/'; 101 | const selectorName = this.escapeRegex(selector); 102 | const closingArrow = this.escapeRegex(bracketStyle.closing); 103 | 104 | const fullRegex = openingArrow + selectorName + closingArrow; 105 | 106 | const regexObject = new RegExp(fullRegex, 'gim'); 107 | 108 | return regexObject; 109 | } 110 | 111 | /** 112 | * Safely escapes a string for use in regex 113 | * 114 | * @param text - The string to escape 115 | */ 116 | escapeRegex(text: string): string { 117 | return text.replace(new RegExp('[-\\/\\\\^$*+?.()|[\\]{}]', 'g'), '\\$&'); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/core/elementHookFinder.ts: -------------------------------------------------------------------------------- 1 | import { HookIndex } from '../../interfacesPublic'; 2 | import { HookParser } from '../../interfacesPublic'; 3 | import { Injectable } from '@angular/core'; 4 | import { AutoPlatformService } from '../platform/autoPlatformService'; 5 | import { isAngularManagedElement, sortElements } from '../utils/utils'; 6 | import { anchorAttrHookId, anchorAttrParseToken } from '../../constants/core'; 7 | import { ParseOptions } from '../settings/options'; 8 | import { Logger } from '../utils/logger'; 9 | 10 | /** 11 | * Stores a hook element along with the parser who found it 12 | */ 13 | export interface ParserFindHookElementsResult { 14 | parser: HookParser; 15 | hookElement: any; 16 | } 17 | 18 | /** 19 | * The service responsible for finding element hooks in the content and marking them with anchor attrs 20 | */ 21 | @Injectable({ 22 | providedIn: 'root' 23 | }) 24 | export class ElementHookFinder { 25 | 26 | constructor(private platformService: AutoPlatformService, private logger: Logger) { 27 | } 28 | 29 | /** 30 | * Finds all element hooks in an element and marks the corresponding anchor elements 31 | * 32 | * @param contentElement - The content element to parse 33 | * @param context - The current context object 34 | * @param parsers - The parsers to use 35 | * @param token - The current parse token 36 | * @param options - The current ParseOptions 37 | * @param hookIndex - The hookIndex object to fill 38 | */ 39 | find(contentElement: any, context: any, parsers: HookParser[], token: string, options: ParseOptions, hookIndex: HookIndex): HookIndex { 40 | 41 | // Collect all parser results 42 | let parserResults: ParserFindHookElementsResult[] = []; 43 | for (const parser of parsers) { 44 | if (typeof parser.findHookElements === 'function') { 45 | for (const hookElement of parser.findHookElements(contentElement, context, options)) { 46 | parserResults.push({parser, hookElement}); 47 | } 48 | } 49 | } 50 | parserResults = sortElements(parserResults, this.platformService.sortElements.bind(this.platformService), entry => entry.hookElement); 51 | 52 | // Validate parser results 53 | parserResults = this.validateHookElements(parserResults, contentElement, options); 54 | 55 | // Process parser results 56 | for (const pr of parserResults) { 57 | const hookId = Object.keys(hookIndex).length + 1; 58 | 59 | // Enter hook into index 60 | hookIndex[hookId] = { 61 | id: hookId, 62 | parser: pr.parser, 63 | value: { 64 | openingTag: this.platformService.getOpeningTag(pr.hookElement), 65 | closingTag: this.platformService.getClosingTag(pr.hookElement), 66 | element: pr.hookElement, 67 | elementSnapshot: this.platformService.cloneElement(pr.hookElement) 68 | }, 69 | data: null, 70 | isLazy: false, 71 | bindings: null, 72 | previousBindings: null, 73 | componentRef: null, 74 | dirtyInputs: new Set(), 75 | outputSubscriptions: {}, 76 | htmlEventSubscriptions: {} 77 | }; 78 | 79 | // Add anchor attrs 80 | this.platformService.setAttribute(pr.hookElement, anchorAttrHookId, hookId.toString()); 81 | this.platformService.setAttribute(pr.hookElement, anchorAttrParseToken, token); 82 | } 83 | 84 | return hookIndex; 85 | } 86 | 87 | /** 88 | * Checks the combined parserResults and validates them. Invalid ones are removed. 89 | * 90 | * @param parserResults - The parserResults to check 91 | * @param contentElement - The content element 92 | * @param options - The current ParseOptions 93 | */ 94 | private validateHookElements(parserResults: ParserFindHookElementsResult[], contentElement: any, options: ParseOptions): ParserFindHookElementsResult[] { 95 | const checkedParserResults = []; 96 | 97 | for (const [index, parserResult] of parserResults.entries()) { 98 | const previousCheckedParserResults = checkedParserResults.slice(0, index); 99 | const wasFoundAsElementHookAlready = previousCheckedParserResults.findIndex(entry => entry.hookElement === parserResult.hookElement) >= 0; 100 | 101 | // Must not already be a hook anchor (either from previous iteration of loop or text hook finder) 102 | if ( 103 | wasFoundAsElementHookAlready || 104 | this.platformService.getAttributeNames(parserResult.hookElement).includes(anchorAttrHookId) || 105 | this.platformService.getAttributeNames(parserResult.hookElement).includes(anchorAttrParseToken) 106 | ) { 107 | this.logger.warn(['An element hook tried to use an element that was found by another hook before. There may be multiple parsers looking for the same elements. Ignoring duplicates.', parserResult.hookElement], options) 108 | continue; 109 | } 110 | 111 | // Must not already be host or view element for an Angular component 112 | if (isAngularManagedElement(parserResult.hookElement)) { 113 | // this.logger.warn(['A hook element was found that is already a host or view element of an active Angular component. Ignoring.'], options); 114 | continue; 115 | } 116 | 117 | // If everything okay, add to result array 118 | checkedParserResults.push(parserResult); 119 | } 120 | 121 | return checkedParserResults; 122 | } 123 | 124 | 125 | } 126 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/dynamicHooksService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Optional, Inject, Injector, EnvironmentInjector } from '@angular/core'; 2 | import { of, Observable } from 'rxjs'; 3 | import { first, map } from 'rxjs/operators'; 4 | 5 | import { HookIndex, ParseResult } from '../interfacesPublic'; 6 | import { ParseOptions } from './settings/options'; 7 | import { TextHookFinder } from './core/textHookFinder'; 8 | import { ComponentCreator } from './core/componentCreator'; 9 | import { DynamicHooksSettings } from './settings/settings'; 10 | import { HookParserEntry } from './settings/parserEntry'; 11 | import { DYNAMICHOOKS_ALLSETTINGS, DYNAMICHOOKS_ANCESTORSETTINGS, DYNAMICHOOKS_MODULESETTINGS } from '../interfaces'; 12 | import { SettingsResolver } from './settings/settingsResolver'; 13 | import { ContentSanitizer } from './utils/contentSanitizer'; 14 | import { AutoPlatformService } from './platform/autoPlatformService'; 15 | import { ElementHookFinder } from './core/elementHookFinder'; 16 | import { contentElementAttr } from '../constants/core'; 17 | 18 | /** 19 | * The core service for the ngx-dynamic-hooks library. Provides the main logic internally used by all components. 20 | */ 21 | @Injectable({ 22 | providedIn: 'root' 23 | }) 24 | export class DynamicHooksService { 25 | 26 | constructor( 27 | @Optional() @Inject(DYNAMICHOOKS_ALLSETTINGS) private allSettings: DynamicHooksSettings[]|null, 28 | @Optional() @Inject(DYNAMICHOOKS_ANCESTORSETTINGS) public ancestorSettings: DynamicHooksSettings[]|null, 29 | @Optional() @Inject(DYNAMICHOOKS_MODULESETTINGS) private moduleSettings: DynamicHooksSettings|null, 30 | private settingsResolver: SettingsResolver, 31 | private textHookFinder: TextHookFinder, 32 | private elementHookFinder: ElementHookFinder, 33 | private contentSanitizer: ContentSanitizer, 34 | private componentCreator: ComponentCreator, 35 | private platformService: AutoPlatformService, 36 | private environmentInjector: EnvironmentInjector, 37 | private injector: Injector 38 | ) { 39 | } 40 | 41 | /** 42 | * Parses content and loads components for all found hooks 43 | * 44 | * @param content - The content to parse 45 | * @param parsers - An optional list of parsers to use instead of the global ones 46 | * @param context - An optional context object 47 | * @param options - An optional list of options 48 | * @param globalParsersBlacklist - An optional list of global parsers to blacklist 49 | * @param globalParsersWhitelist - An optional list of global parsers to whitelist 50 | * @param targetElement - An optional HTML element to use as the container for the loaded content. 51 | * @param targetHookIndex - An optional object to fill with the programmatic hook data. If none is provided, one is created and returned for you. 52 | * @param environmentInjector - An optional environmentInjector to use for the dynamically-loaded components. If none is provided, the default environmentInjector is used. 53 | * @param injector - An optional injector to use for the dynamically-loaded components. If none is provided, the default injector is used. 54 | */ 55 | parse( 56 | content: any = null, 57 | parsers: HookParserEntry[]|null = null, 58 | context: any = null, 59 | options: ParseOptions|null = null, 60 | globalParsersBlacklist: string[]|null = null, 61 | globalParsersWhitelist: string[]|null = null, 62 | targetElement: HTMLElement|null = null, 63 | targetHookIndex: HookIndex = {}, 64 | environmentInjector: EnvironmentInjector|null = null, 65 | injector: Injector|null = null 66 | ): Observable { 67 | const usedEnvironmentInjector = environmentInjector || this.environmentInjector; 68 | const usedInjector = injector || this.injector; 69 | 70 | // Resolve options and parsers 71 | const { parsers: usedParsers, options: usedOptions } = this.settingsResolver.resolve( 72 | usedInjector, // Use element injector for resolving service parsers (instead of environment injector). Will fallback to environment injector anyway if doesn't find anything. 73 | content, 74 | this.allSettings, 75 | this.ancestorSettings, 76 | this.moduleSettings, 77 | parsers, 78 | options, 79 | globalParsersBlacklist, 80 | globalParsersWhitelist 81 | ); 82 | 83 | // Needs string or element as content 84 | if (!content) { 85 | return of({ 86 | element: targetElement || this.platformService.createElement('div'), 87 | hookIndex: targetHookIndex, 88 | context: context, 89 | usedParsers, 90 | usedOptions, 91 | usedInjector, 92 | usedEnvironmentInjector, 93 | destroy: () => this.destroy(targetHookIndex) 94 | }); 95 | } 96 | 97 | const token = Math.random().toString(36).substring(2, 12); 98 | let contentElement = typeof content === 'string' ? this.platformService.createElement('div') : content; 99 | this.platformService.setAttribute(contentElement, contentElementAttr, '1'); 100 | 101 | // a) Find all text hooks in string content 102 | if (typeof content === 'string') { 103 | const result = this.textHookFinder.find(content, context, usedParsers, token, usedOptions, targetHookIndex); 104 | this.platformService.setInnerContent(contentElement, result.content); 105 | 106 | // b) Find all text hooks in element content 107 | } else { 108 | this.textHookFinder.findInElement(contentElement, context, usedParsers, token, usedOptions, targetHookIndex); 109 | } 110 | 111 | // Find all element hooks 112 | targetHookIndex = this.elementHookFinder.find(contentElement, context, usedParsers, token, usedOptions, targetHookIndex); 113 | 114 | // Sanitize? 115 | if (usedOptions?.sanitize) { 116 | this.contentSanitizer.sanitize(contentElement, targetHookIndex, token); 117 | } 118 | 119 | // After sanitizing, insert into targetElement, if any 120 | if (targetElement && targetElement !== contentElement) { 121 | this.platformService.removeAttribute(contentElement, contentElementAttr); 122 | this.platformService.setAttribute(targetElement, contentElementAttr, '1'); 123 | this.platformService.clearChildNodes(targetElement); 124 | for (const childNode of this.platformService.getChildNodes(contentElement)) { 125 | this.platformService.appendChild(targetElement, childNode); 126 | } 127 | contentElement = targetElement 128 | } 129 | 130 | // Dynamically create components in component selector elements 131 | return this.componentCreator.init(contentElement, targetHookIndex, token, context, usedOptions, usedEnvironmentInjector, usedInjector) 132 | .pipe(first()) 133 | .pipe(map((allComponentsLoaded: boolean) => { 134 | // Everything done! 135 | this.platformService.removeAttribute(contentElement, contentElementAttr); 136 | return { 137 | element: contentElement, 138 | hookIndex: targetHookIndex, 139 | context: context, 140 | usedParsers, 141 | usedOptions, 142 | usedInjector, 143 | usedEnvironmentInjector, 144 | destroy: () => this.destroy(targetHookIndex) 145 | }; 146 | })); 147 | } 148 | 149 | /** 150 | * Cleanly destroys all loaded components in a given HookIndex 151 | * 152 | * @param hookIndex - The hookIndex to process 153 | */ 154 | destroy(hookIndex: HookIndex): void { 155 | if (hookIndex) { 156 | // Destroy dynamic components 157 | for (const hookIndexEntry of Object.values(hookIndex)) { 158 | if (hookIndexEntry.componentRef) { 159 | hookIndexEntry.componentRef.destroy(); 160 | } 161 | } 162 | 163 | // Unsubscribe from hook outputs 164 | for (const hook of Object.values(hookIndex)) { 165 | for (const parserSub of Object.values(hook.outputSubscriptions)) { 166 | if (parserSub) { parserSub.unsubscribe(); } 167 | } 168 | for (const htmlEventSub of Object.values(hook.htmlEventSubscriptions)) { 169 | if (htmlEventSub) { htmlEventSub.unsubscribe(); } 170 | } 171 | } 172 | } 173 | } 174 | 175 | } 176 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/platform/autoPlatformService.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Optional } from '@angular/core'; 2 | import { CompletePlatformService, PLATFORM_SERVICE, PlatformService } from './platformService'; 3 | import { DefaultPlatformService } from './defaultPlatformService'; 4 | 5 | /** 6 | * Wrapper class that either calls user-provided PlatformService methods or falls back to default implementations 7 | */ 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class AutoPlatformService implements CompletePlatformService { 12 | 13 | constructor(@Optional() @Inject(PLATFORM_SERVICE) private userPlatformService: PlatformService, private defaultPlatformService: DefaultPlatformService) { 14 | } 15 | 16 | private getFor (methodName: string): PlatformService { 17 | if (this.userPlatformService && typeof (this.userPlatformService as any)[methodName] === 'function') { 18 | return this.userPlatformService 19 | } else { 20 | return this.defaultPlatformService; 21 | } 22 | } 23 | 24 | getNgVersion() { 25 | return this.getFor('getNgVersion').getNgVersion!(); 26 | } 27 | 28 | sanitize(content: string) { 29 | return this.getFor('sanitize').sanitize!(content); 30 | } 31 | 32 | createElement(tagName: string) { 33 | return this.getFor('createElement').createElement!(tagName); 34 | } 35 | 36 | sortElements(a: any, b: any): number { 37 | return this.getFor('sortElements').sortElements!(a, b); 38 | } 39 | 40 | cloneElement(element: any) { 41 | return this.getFor('cloneElement').cloneElement!(element); 42 | } 43 | 44 | getTagName(element: any) { 45 | return this.getFor('getTagName').getTagName!(element); 46 | } 47 | 48 | getOpeningTag(element: any) { 49 | return this.getFor('getOpeningTag').getOpeningTag!(element); 50 | } 51 | 52 | getClosingTag(element: any) { 53 | return this.getFor('getClosingTag').getClosingTag!(element); 54 | } 55 | 56 | getAttributeNames(element: any) { 57 | return this.getFor('getAttributeNames').getAttributeNames!(element); 58 | } 59 | 60 | getAttribute(element: any, attributeName: string) { 61 | return this.getFor('getAttribute').getAttribute!(element, attributeName); 62 | } 63 | 64 | setAttribute(element: any, attributeName: string, value: string) { 65 | return this.getFor('setAttribute').setAttribute!(element, attributeName, value); 66 | } 67 | 68 | removeAttribute(element: any, attributeName: string) { 69 | return this.getFor('removeAttribute').removeAttribute!(element, attributeName); 70 | } 71 | 72 | getParentNode(element: any) { 73 | return this.getFor('getParentNode').getParentNode!(element); 74 | } 75 | 76 | querySelectorAll(parentElement: any, selector: string) { 77 | return this.getFor('querySelectorAll').querySelectorAll!(parentElement, selector); 78 | } 79 | 80 | getChildNodes(node: any) { 81 | return this.getFor('getChildNodes').getChildNodes!(node); 82 | } 83 | 84 | appendChild(parentElement: any, childElement: any) { 85 | return this.getFor('appendChild').appendChild!(parentElement, childElement); 86 | } 87 | 88 | insertBefore(parentElement: any, childElement: any, referenceElement: any) { 89 | return this.getFor('insertBefore').insertBefore!(parentElement, childElement, referenceElement); 90 | } 91 | 92 | clearChildNodes(element: any) { 93 | return this.getFor('clearChildNodes').clearChildNodes!(element); 94 | } 95 | 96 | removeChild(parentElement: any, childElement: any) { 97 | return this.getFor('removeChild').removeChild!(parentElement, childElement); 98 | } 99 | 100 | getInnerContent(element: any) { 101 | return this.getFor('getInnerContent').getInnerContent!(element); 102 | } 103 | 104 | setInnerContent(element: any, content: string) { 105 | return this.getFor('setInnerContent').setInnerContent!(element, content); 106 | } 107 | 108 | isTextNode(element: any) { 109 | return this.getFor('isTextNode').isTextNode!(element); 110 | } 111 | 112 | createTextNode(content: string) { 113 | return this.getFor('createTextNode').createTextNode!(content); 114 | } 115 | 116 | getTextContent(element: any) { 117 | return this.getFor('getTextContent').getTextContent!(element); 118 | } 119 | 120 | dispatchEvent(element: any, name: string, payload: any) { 121 | return this.getFor('dispatchEvent').dispatchEvent!(element, name, payload); 122 | } 123 | 124 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/platform/defaultPlatformService.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Renderer2, RendererFactory2, SecurityContext } from '@angular/core'; 2 | import { DomSanitizer } from '@angular/platform-browser'; 3 | import { CompletePlatformService } from './platformService'; 4 | import { DOCUMENT } from '@angular/common'; 5 | 6 | /** 7 | * General implementation of PlatformService suited for both the standard browser and server environments 8 | */ 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class DefaultPlatformService implements CompletePlatformService { 13 | private renderer: Renderer2; 14 | 15 | constructor(@Inject(DOCUMENT) private document: Document, private rendererFactory: RendererFactory2, private sanitizer: DomSanitizer) { 16 | this.renderer = this.rendererFactory.createRenderer(null, null); 17 | } 18 | 19 | getNgVersion() { 20 | if (typeof this.document !== "undefined") { 21 | const versionElement = this.querySelectorAll(this.document, '[ng-version]')?.[0]; 22 | const versionAttr = versionElement?.getAttribute('ng-version'); 23 | if (versionAttr) { 24 | return parseInt(versionAttr, 10); 25 | } 26 | } 27 | 28 | return null; 29 | } 30 | 31 | sanitize(content: string) { 32 | return this.sanitizer.sanitize(SecurityContext.HTML, content) || ''; 33 | } 34 | 35 | createElement(tagName: string): Element { 36 | return this.renderer.createElement(tagName); 37 | } 38 | 39 | sortElements(a: Element, b: Element): number { 40 | if ( a === b) return 0; 41 | 42 | if ( !a.compareDocumentPosition) { 43 | // support for IE8 and below 44 | return (a as any).sourceIndex - (b as any).sourceIndex; 45 | } 46 | 47 | if ( a.compareDocumentPosition(b) & 2) { 48 | // b comes before a 49 | return 1; 50 | } 51 | 52 | return -1; 53 | } 54 | 55 | cloneElement(element: Element) { 56 | return element.cloneNode(true); 57 | } 58 | 59 | getTagName(element: Element) { 60 | return element.tagName; 61 | } 62 | 63 | getOpeningTag(element: any) { 64 | // Approach by: https://stackoverflow.com/a/55859966/3099523 65 | const innerLength = element.innerHTML.length 66 | const outerLength = element.outerHTML.length; 67 | 68 | // Check for self-closing elements 69 | const openingTagLength = element.outerHTML[outerLength - 2] === '/' ? 70 | outerLength : 71 | outerLength - innerLength - element.tagName.length - 3; 72 | 73 | return element.outerHTML.slice(0, openingTagLength); 74 | } 75 | 76 | getClosingTag(element: any) { 77 | return element.outerHTML.slice(element.outerHTML.length - element.tagName.length - 3); 78 | } 79 | 80 | getAttributeNames(element: Node) { 81 | return typeof (element as any).getAttributeNames === 'function' ? (element as any).getAttributeNames() : []; 82 | } 83 | 84 | getAttribute(element: Element, attributeName: string) { 85 | return typeof (element as any).getAttribute === 'function' ? (element as any).getAttribute(attributeName) : null; 86 | } 87 | 88 | setAttribute(element: Element, attributeName: string, value: string) { 89 | this.renderer.setAttribute(element, attributeName, value); 90 | } 91 | 92 | removeAttribute(element: any, attributeName: string) { 93 | this.renderer.removeAttribute(element, attributeName); 94 | } 95 | 96 | getParentNode(element: Node): Node|null { 97 | try { 98 | return this.renderer.parentNode(element); 99 | } catch (e) { 100 | return null; 101 | } 102 | } 103 | 104 | querySelectorAll(parentElement: Document|Element, selector: string): Element[] { 105 | return Array.from(parentElement.querySelectorAll(selector)); 106 | } 107 | 108 | getChildNodes(node: Node): Node[] { 109 | return Array.prototype.slice.call(node.childNodes); 110 | } 111 | 112 | appendChild(parentElement: Node, childElement: Node) { 113 | this.renderer.appendChild(parentElement, childElement); 114 | } 115 | 116 | insertBefore(parentElement: Node, childElement: Node, referenceElement: Node) { 117 | this.renderer.insertBefore(parentElement, childElement, referenceElement); 118 | } 119 | 120 | clearChildNodes(element: Node) { 121 | if (element) { 122 | while (element.firstChild) { 123 | this.removeChild(element, element.firstChild); 124 | } 125 | } 126 | } 127 | 128 | removeChild(parentElement: Node, childElement: Node) { 129 | parentElement.removeChild(childElement); 130 | } 131 | 132 | getInnerContent(element: Element) { 133 | return element.innerHTML; 134 | } 135 | 136 | setInnerContent(element: Element, content: string) { 137 | if (element) { 138 | element.innerHTML = content; 139 | } 140 | } 141 | 142 | isTextNode(element: Node) { 143 | return element.nodeType === Node.TEXT_NODE; 144 | } 145 | 146 | createTextNode(content: string) { 147 | return document.createTextNode(content); 148 | } 149 | 150 | getTextContent(element: Node) { 151 | return element.textContent; 152 | } 153 | 154 | dispatchEvent(element: Node, name: string, payload: any) { 155 | element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); 156 | } 157 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/platform/platformService.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from "@angular/core"; 2 | 3 | export type PlatformService = Partial; 4 | 5 | export const PLATFORM_SERVICE = new InjectionToken('An injection token to retrieve an optionally user-provided PlatformService'); 6 | 7 | /** 8 | * Extend this service to implement custom platform. 9 | */ 10 | export interface CompletePlatformService { 11 | 12 | /** 13 | * Returns the Angular Version. 14 | * Returns null when it couldn't be retrieved 15 | */ 16 | getNgVersion(): number|null; 17 | 18 | /** 19 | * Sanitizes a string of arbitrary html content to be safe for use in innerHTML 20 | * Returns the sanitized html string 21 | * @param content The content to be sanitized. 22 | */ 23 | sanitize(content: string): string; 24 | 25 | /** 26 | * Creates an element and returns it 27 | * @param tagName The name of the element 28 | */ 29 | createElement(tagName: string): any; 30 | 31 | /** 32 | * Given two elements, return a number indicating which one comes first 33 | * @param a - The first element 34 | * @param b - The second element 35 | * @returns - 1 if b comes before a, -1 if a comes before b, 0 if equivalent 36 | */ 37 | sortElements(a: any, b: any): number 38 | 39 | /** 40 | * Return a shallow clone of an element (just the element itself, not its children) 41 | * 42 | * @param element - The element to clone 43 | */ 44 | cloneElement(element: any): any 45 | 46 | /** 47 | * Returns the tag name of an element 48 | * @param element An element 49 | */ 50 | getTagName(element: any): string; 51 | 52 | /** 53 | * Returns the opening tag of an element as a string 54 | * @param element An element 55 | */ 56 | getOpeningTag(element: any): string; 57 | 58 | /** 59 | * Returns the closing tag of an element as a string 60 | * @param element An element 61 | */ 62 | getClosingTag(element: any): string; 63 | 64 | /** 65 | * Returns the names of all existing attributes of an element 66 | * Return an emtpy array if none exist 67 | * @param element The element 68 | */ 69 | getAttributeNames(element: any): string[]; 70 | 71 | /** 72 | * Returns the value of an element attribute. 73 | * Returns null when the attribute doesn't exist 74 | * @param element The element 75 | * @param attributeName Attribute Name 76 | */ 77 | getAttribute(element: any, attributeName: string): string|null; 78 | 79 | /** 80 | * Sets the value of an element attribute. 81 | * @param element The element 82 | * @param attributeName Attribute Name 83 | * @param value The attribute value 84 | */ 85 | setAttribute(element: any, attributeName: string, value: string): void; 86 | 87 | /** 88 | * Removes the value of an element attribute. 89 | * @param element The element 90 | * @param attributeName Attribute Name 91 | */ 92 | removeAttribute(element: any, attributeName: string): void; 93 | 94 | /** 95 | * Returns the parent of a node. 96 | * Returns null when a parent node doesn't exist 97 | * @param parentany The parent element 98 | */ 99 | getParentNode(parentNode: any): any|null; 100 | 101 | /** 102 | * Returns child elements of a parent element that match a certain css selector 103 | * Returns an empty array of none could be found 104 | * @param parentElement The parent element 105 | * @param selector A css-style selector (like "div.myClass") 106 | */ 107 | querySelectorAll(parentElement: any, selector: string): any[]; 108 | 109 | /** 110 | * Returns an array of child nodes. 111 | * Returns an empty array if none exist 112 | * @param parentNode A node 113 | */ 114 | getChildNodes(parentNode: any): any[]; 115 | 116 | /** 117 | * Appends a child node to a parent. 118 | * @param parentNode The parent node 119 | * @param childNode The child node to be removed 120 | */ 121 | appendChild(parentNode: any, childNode: any): void; 122 | 123 | /** 124 | * Inserts a child node before another child node of a parent node. 125 | * @param parentNode The parent node 126 | * @param childNode The child node to be inserted 127 | * @param referenceNode The existing node before which childNode is inserted 128 | */ 129 | insertBefore(parentNode: any, childNode: any, referenceNode: any): void; 130 | 131 | /** 132 | * Removes all child nodes from a parent node. 133 | * @param parentNode The parent node 134 | */ 135 | clearChildNodes(parentNode: any): void; 136 | 137 | /** 138 | * Removes a child node from its parent. 139 | * @param parentNode The parent node 140 | * @param childNode The child node to be removed 141 | */ 142 | removeChild(parentNode: any, childNode: any): void; 143 | 144 | /** 145 | * Returns the inner content of an element (like HTMLElement.innerHTML) 146 | * @param element An element 147 | */ 148 | getInnerContent(element: any): string; 149 | 150 | /** 151 | * Sets the content of an element. 152 | * @param element An element 153 | * @param content The element content 154 | */ 155 | setInnerContent(element: any, content: string): void; 156 | 157 | /** 158 | * Returns a boolean determining whether an element is a text node or not 159 | * @param element An element 160 | */ 161 | isTextNode(element: any): boolean; 162 | 163 | /** 164 | * Creates a text node and returns it 165 | * @param content The text content of the node 166 | */ 167 | createTextNode(content: string): any; 168 | 169 | /** 170 | * Returns the pure text content of an element (like Node.textContent) 171 | * @param element An element 172 | */ 173 | getTextContent(element: any): string|null; 174 | 175 | /** 176 | * Dispatches a event from an element 177 | * @param element The element 178 | * @param name The event name 179 | * @param payload The event content 180 | */ 181 | dispatchEvent(element: any, name: string, payload: any): void; 182 | 183 | } 184 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/settings/options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Options that allow you to customize the parsing process 3 | */ 4 | export interface ParseOptions { 5 | /** 6 | * Whether to use Angular's `DomSanitizer` to sanitize the content (hooks are unaffected by this). Defaults to `true` if content is a string, `false` if its an HTML element. 7 | */ 8 | sanitize?: boolean; 9 | 10 | /** 11 | * Whether to replace HTML entities like `&` with normal characters. 12 | */ 13 | convertHTMLEntities?: boolean; 14 | 15 | /** 16 | * When using a WYSIWYG-editor, enclosing text hooks may collide with its generated HTML (the `

`-tag starting before the hook and the corresponding `

`-tag ending inside, and vice versa). This will result in faulty HTML when rendered in a browser. This setting removes these ripped-apart tags. 17 | */ 18 | fixParagraphTags?: boolean; 19 | 20 | /** 21 | * Whether to update the bindings of dynamic components only when the context object passed to the `DynamicHooksComponent` changes by reference. 22 | */ 23 | updateOnPushOnly?: boolean; 24 | 25 | /** 26 | * Whether to deeply-compare inputs for dynamic components by their value instead of by their reference on updates. 27 | */ 28 | compareInputsByValue?: boolean; 29 | 30 | /** 31 | * Whether to deeply-compare outputs for dynamic components by their value instead of by their reference on updates. 32 | */ 33 | compareOutputsByValue?: boolean; 34 | 35 | /** 36 | * When comparing by value, how many levels deep to compare them (may impact performance). 37 | */ 38 | compareByValueDepth?: number; 39 | 40 | /** 41 | * Whether to emit CustomEvents from the component host elements when an output emits. The event name will be the output name. Defaults to true in standalone mode, otherwise false. 42 | */ 43 | triggerDOMEvents?: boolean; 44 | 45 | /** 46 | * Whether to ignore input aliases like `@Input('someAlias')` in dynamic components and use the actual property names instead. 47 | */ 48 | ignoreInputAliases?: boolean; 49 | 50 | /** 51 | * Whether to ignore output aliases like `@Output('someAlias')` in dynamic components and use the actual property names instead. 52 | */ 53 | ignoreOutputAliases?: boolean; 54 | 55 | /** 56 | * Whether to disregard `@Input()`-decorators completely and allow passing in values to any property in dynamic components. 57 | */ 58 | acceptInputsForAnyProperty?: boolean; 59 | 60 | /** 61 | * Whether to disregard `@Output()`-decorators completely and allow subscribing to any `Observable` in dynamic components. 62 | */ 63 | acceptOutputsForAnyObservable?: boolean; 64 | 65 | /** 66 | * Accepts a `LogOptions` object to customize when to log text, warnings and errors. 67 | */ 68 | logOptions?: LogOptions; 69 | } 70 | 71 | export interface LogOptions { 72 | 73 | /** 74 | * Whether to enable logging when in dev mode 75 | */ 76 | dev?: boolean; 77 | 78 | /** 79 | * Whether to enable logging when in prod mode 80 | */ 81 | prod?: boolean; 82 | 83 | /** 84 | * Whether to enable logging during Server-Side-Rendering 85 | */ 86 | ssr?: boolean; 87 | } 88 | 89 | /** 90 | * Returns the default values for the ParseOptions 91 | */ 92 | export const getParseOptionDefaults: () => ParseOptions = () => { 93 | return { 94 | sanitize: true, 95 | convertHTMLEntities: true, 96 | fixParagraphTags: true, 97 | updateOnPushOnly: false, 98 | compareInputsByValue: false, 99 | compareOutputsByValue: false, 100 | compareByValueDepth: 5, 101 | triggerDOMEvents: false, 102 | ignoreInputAliases: false, 103 | ignoreOutputAliases: false, 104 | acceptInputsForAnyProperty: false, 105 | acceptOutputsForAnyObservable: false, 106 | logOptions: { 107 | dev: true, 108 | prod: false, 109 | ssr: false 110 | } 111 | }; 112 | } 113 | 114 | 115 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/settings/parserEntry.ts: -------------------------------------------------------------------------------- 1 | import { HookParser } from '../../interfacesPublic'; 2 | import { SelectorHookParserConfig } from '../../parsers/selector/selectorHookParserConfig'; 3 | 4 | /** 5 | * An configuration entry for a HookParser. This can either be: 6 | * 7 | * 1. The component class itself. 8 | * 2. A SelectorHookParserConfig object literal. 9 | * 3. A custom HookParser instance. 10 | * 4. A custom HookParser class. If this class is available as a provider/service, it will be injected. 11 | */ 12 | export type HookParserEntry = (new(...args: any[]) => any) | SelectorHookParserConfig | HookParser; 13 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/settings/settings.ts: -------------------------------------------------------------------------------- 1 | import { HookParserEntry } from './parserEntry'; 2 | import { HookParser } from '../../interfacesPublic'; 3 | import { ParseOptions } from './options'; 4 | 5 | export enum DynamicHooksInheritance { 6 | /** 7 | * Merges with settings from all injectors in the app. 8 | */ 9 | All, 10 | 11 | /** 12 | * (Default) Only merges with settings from direct ancestor injectors (such a father and grandfather injectors, but not "uncle" injectors). 13 | */ 14 | Linear, 15 | 16 | /** 17 | * Does not merge at all. Injector only uses own settings. 18 | */ 19 | None 20 | } 21 | 22 | /** 23 | * The interface for users to define the global options 24 | */ 25 | export interface DynamicHooksSettings { 26 | 27 | /** 28 | * A list of parsers to use globally 29 | */ 30 | parsers?: HookParserEntry[]; 31 | 32 | /** 33 | * Options to use globally 34 | */ 35 | options?: ParseOptions; 36 | 37 | /** 38 | * Used for providing child settings in child injector contexts 39 | */ 40 | inheritance?: DynamicHooksInheritance; 41 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/settings/settingsResolver.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Injector, Optional } from '@angular/core'; 2 | import { DynamicHooksSettings, DynamicHooksInheritance } from './settings'; 3 | import { ParserEntryResolver } from './parserEntryResolver'; 4 | import { HookParserEntry } from './parserEntry'; 5 | import { HookParser } from '../../interfacesPublic'; 6 | import { ParseOptions, getParseOptionDefaults } from './options'; 7 | 8 | /** 9 | * A helper class for resolving a combined settings object from all provided ones 10 | */ 11 | @Injectable({ 12 | providedIn: 'root' 13 | }) 14 | export class SettingsResolver { 15 | 16 | constructor( 17 | private parserEntryResolver: ParserEntryResolver 18 | ) { 19 | } 20 | 21 | /** 22 | * Takes all provided settings objects and combines them into a final settings object 23 | * 24 | * @param injector - The current injector 25 | * @param content - The content 26 | * @param allSettings - All settings provided anywhere 27 | * @param ancestorSettings - All ancestor settings 28 | * @param moduleSettings - The current module settings 29 | * @param localParsers - A list of local parsers 30 | * @param localOptions - A local options object 31 | * @param globalParsersBlacklist - A list of global parsers to blacklist 32 | * @param globalParsersWhitelist - A list of global parsers to whitelist 33 | */ 34 | public resolve( 35 | injector: Injector, 36 | content: any, 37 | allSettings: DynamicHooksSettings[]|null, 38 | ancestorSettings: DynamicHooksSettings[]|null, 39 | moduleSettings: DynamicHooksSettings|null, 40 | localParsers: HookParserEntry[]|null = null, 41 | localOptions: ParseOptions|null = null, 42 | globalParsersBlacklist: string[]|null = null, 43 | globalParsersWhitelist: string[]|null = null, 44 | ): { 45 | parsers: HookParser[]; 46 | options: ParseOptions; 47 | } { 48 | let resolvedSettings: DynamicHooksSettings = {}; 49 | allSettings = allSettings || []; 50 | ancestorSettings = ancestorSettings || []; 51 | moduleSettings = moduleSettings || {}; 52 | const defaultSettings: DynamicHooksSettings = { options: getParseOptionDefaults() }; 53 | 54 | // Merge settings according to inheritance 55 | if (!moduleSettings.hasOwnProperty('inheritance') || moduleSettings.inheritance === DynamicHooksInheritance.Linear) { 56 | resolvedSettings = this.mergeSettings([ 57 | defaultSettings, 58 | ...ancestorSettings, 59 | {parsers: localParsers || undefined, options: localOptions || undefined} 60 | ]); 61 | 62 | } else if (moduleSettings.inheritance === DynamicHooksInheritance.All) { 63 | // Additionally merge ancestorSettings after allSettings to give settings closer to the current injector priority 64 | resolvedSettings = this.mergeSettings([ 65 | defaultSettings, 66 | ...allSettings, 67 | ...ancestorSettings, 68 | {options: localOptions || undefined} 69 | ]); 70 | 71 | } else { 72 | resolvedSettings = this.mergeSettings([ 73 | defaultSettings, 74 | moduleSettings || {}, 75 | {options: localOptions || undefined} 76 | ]) 77 | } 78 | 79 | const finalOptions = resolvedSettings.options!; 80 | 81 | // Disabled sanitization if content is not string 82 | if (content && typeof content !== 'string') { 83 | finalOptions.sanitize = false; 84 | } 85 | 86 | // Process parsers entries. Local parsers fully replace global ones. 87 | let finalParsers: HookParser[] = []; 88 | if (localParsers) { 89 | finalParsers = this.parserEntryResolver.resolve(localParsers, injector, null, null, finalOptions); 90 | } else if (resolvedSettings.parsers) { 91 | finalParsers = this.parserEntryResolver.resolve(resolvedSettings.parsers, injector, globalParsersBlacklist, globalParsersWhitelist, finalOptions); 92 | } 93 | 94 | return { 95 | parsers: finalParsers, 96 | options: finalOptions 97 | }; 98 | } 99 | 100 | /** 101 | * Merges multiple settings objects, overwriting previous ones with later ones in the provided array 102 | * 103 | * @param settingsArray - The settings objects to merge 104 | */ 105 | private mergeSettings(settingsArray: DynamicHooksSettings[]): DynamicHooksSettings { 106 | const mergedSettings: DynamicHooksSettings = {}; 107 | 108 | for (const settings of settingsArray) { 109 | // Unique parsers are simply all collected, not overwritten 110 | if (settings.parsers !== undefined) { 111 | if (mergedSettings.parsers === undefined) { 112 | mergedSettings.parsers = []; 113 | } 114 | for (const parserEntry of settings.parsers) { 115 | if (!mergedSettings.parsers.includes(parserEntry)) { 116 | mergedSettings.parsers.push(parserEntry); 117 | } 118 | } 119 | } 120 | // Options are individually overwritten 121 | if (settings.options !== undefined) { 122 | if (mergedSettings.options === undefined) { 123 | mergedSettings.options = {}; 124 | } 125 | 126 | mergedSettings.options = this.recursiveAssign(mergedSettings.options, settings.options); 127 | } 128 | } 129 | 130 | return mergedSettings; 131 | } 132 | 133 | /** 134 | * Recursively merges two objects 135 | * 136 | * @param a - The target object to merge into 137 | * @param b - The other object being merged 138 | */ 139 | private recursiveAssign (a: any, b: any) { 140 | if (Object(b) !== b) return b; 141 | if (Object(a) !== a) a = {}; 142 | for (const key in b) { 143 | a[key] = this.recursiveAssign(a[key], b[key]); 144 | } 145 | return a; 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/utils/contentSanitizer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HookIndex } from '../../interfacesPublic'; 3 | import { AutoPlatformService } from '../platform/autoPlatformService'; 4 | import { anchorAttrHookId, anchorAttrParseToken } from '../../constants/core'; 5 | import { matchAll } from './utils'; 6 | 7 | const sanitizerPlaceholderTag = 'dynamic-hooks-sanitization-placeholder'; 8 | const sanitizerPlaceholderRegex = new RegExp(`<\/?${sanitizerPlaceholderTag}.*?>`, 'g'); 9 | 10 | /** 11 | * A utility service that sanitizes an Element and all of its children while exluding found hook elements 12 | */ 13 | @Injectable({ 14 | providedIn: 'root' 15 | }) 16 | export class ContentSanitizer { 17 | 18 | attrWhitelist = [anchorAttrHookId, anchorAttrParseToken, 'class', 'href', 'src'] 19 | 20 | constructor(private platformService: AutoPlatformService) {} 21 | 22 | /** 23 | * Sanitizes an element while preserving marked hook anchors 24 | * 25 | * @param contentElement - The element to sanitize 26 | * @param hookIndex - The current hookIndex 27 | * @param token - The current ParseToken 28 | */ 29 | sanitize(contentElement: any, hookIndex: HookIndex, token: string): any { 30 | const originalHookAnchors: {[key: string]: any} = {}; 31 | 32 | // Replace all hook anchors with custom placeholder elements 33 | // This is so the browser has no predefined rules where they can and can't exist in the dom hierarchy and doesn't edit the html. 34 | for (const hook of Object.values(hookIndex)) { 35 | const anchorElement = this.platformService.querySelectorAll(contentElement, `[${anchorAttrHookId}="${hook.id}"][${anchorAttrParseToken}="${token}"]`)?.[0]; 36 | if (anchorElement) { 37 | originalHookAnchors[hook.id] = anchorElement; 38 | 39 | const parentElement = this.platformService.getParentNode(anchorElement); 40 | const childNodes = this.platformService.getChildNodes(anchorElement); 41 | 42 | const placeholderElement = this.platformService.createElement(sanitizerPlaceholderTag); 43 | this.platformService.setAttribute(placeholderElement, anchorAttrHookId, hook.id.toString()); 44 | this.platformService.setAttribute(placeholderElement, anchorAttrParseToken, token); 45 | this.platformService.insertBefore(parentElement, placeholderElement, anchorElement); 46 | this.platformService.removeChild(parentElement, anchorElement); 47 | for (const node of childNodes) { 48 | this.platformService.appendChild(placeholderElement, node); 49 | } 50 | } 51 | } 52 | 53 | // Encode sanitization placeholders (so they survive sanitization) 54 | let innerHTML = this.platformService.getInnerContent(contentElement); 55 | innerHTML = this.findAndEncodeTags(innerHTML, sanitizerPlaceholderRegex); 56 | 57 | // Sanitize (without warnings) 58 | const consoleWarnFn = console.warn; 59 | console.warn = () => {}; 60 | let sanitizedInnerHtml = this.platformService.sanitize(innerHTML); 61 | console.warn = consoleWarnFn; 62 | 63 | // Decode sanitization placeholders 64 | sanitizedInnerHtml = this.decodeTagString(sanitizedInnerHtml); 65 | contentElement.innerHTML = sanitizedInnerHtml || ''; 66 | 67 | // Restore original hook anchors 68 | for (const [hookId, anchorElement] of Object.entries(originalHookAnchors)) { 69 | const placeholderElement = this.platformService.querySelectorAll(contentElement, `${sanitizerPlaceholderTag}[${anchorAttrHookId}="${hookId}"]`)?.[0]; 70 | if (placeholderElement) { 71 | const parentElement = this.platformService.getParentNode(placeholderElement); 72 | const childNodes = this.platformService.getChildNodes(placeholderElement); 73 | this.platformService.insertBefore(parentElement, anchorElement, placeholderElement); 74 | this.platformService.removeChild(parentElement, placeholderElement); 75 | for (const node of childNodes) { 76 | this.platformService.appendChild(anchorElement, node); 77 | } 78 | 79 | // As a last step, sanitize the hook anchor attrs as well 80 | this.sanitizeElementAttrs(anchorElement); 81 | } 82 | } 83 | 84 | return contentElement; 85 | } 86 | 87 | /** 88 | * Sanitizes a single element's attributes 89 | * 90 | * @param element - The element in question 91 | */ 92 | private sanitizeElementAttrs(element: any): any { 93 | // Collect all existing attributes, put them on span-element, sanitize it, then copy surviving attrs back onto hook anchor element 94 | const attrs = this.platformService.getAttributeNames(element); 95 | const tmpWrapperElement = this.platformService.createElement('div'); 96 | const tmpElement = this.platformService.createElement('span'); 97 | this.platformService.appendChild(tmpWrapperElement, tmpElement); 98 | 99 | // Move attr to tmp 100 | for (const attr of attrs) { 101 | try { 102 | this.platformService.setAttribute(tmpElement, attr, this.platformService.getAttribute(element, attr)!); 103 | } catch (e) {} 104 | // Keep in separate try-catch, so the first doesn't stop the second 105 | try { 106 | // Always keep those two 107 | if (attr !== anchorAttrHookId && attr !== anchorAttrParseToken) { 108 | this.platformService.removeAttribute(element, attr); 109 | } 110 | } catch (e) {} 111 | } 112 | 113 | // Sanitize tmp 114 | tmpWrapperElement.innerHTML = this.platformService.sanitize(this.platformService.getInnerContent(tmpWrapperElement)); 115 | 116 | // Move surviving attrs back to element 117 | const sanitizedTmpElement = this.platformService.querySelectorAll(tmpWrapperElement, 'span')[0]; 118 | const survivingAttrs = this.platformService.getAttributeNames(sanitizedTmpElement); 119 | for (const survivingAttr of survivingAttrs) { 120 | try { 121 | this.platformService.setAttribute(element, survivingAttr, this.platformService.getAttribute(sanitizedTmpElement, survivingAttr)!); 122 | } catch (e) {} 123 | } 124 | 125 | return element; 126 | } 127 | 128 | // En/decoding placeholders 129 | // ------------------------ 130 | 131 | /** 132 | * Finds and encodes all tags that match the specified regex so that they survive sanitization 133 | * 134 | * @param content - The stringified html content to search 135 | * @param substrRegex - The regex that matches the element tags 136 | */ 137 | private findAndEncodeTags(content: string, substrRegex: RegExp): string { 138 | let encodedContent = content; 139 | 140 | const matches = matchAll(content, substrRegex); 141 | matches.sort((a, b) => b.index - a.index); 142 | 143 | for (const match of matches) { 144 | const startIndex = match.index; 145 | const endIndex = match.index + match[0].length; 146 | 147 | const textBeforeSelector = encodedContent.substring(0, startIndex); 148 | const encodedPlaceholder = this.encodeTagString(encodedContent.substring(startIndex, endIndex)); 149 | const textAfterSelector = encodedContent.substring(endIndex); 150 | encodedContent = textBeforeSelector + encodedPlaceholder + textAfterSelector; 151 | } 152 | 153 | return encodedContent; 154 | } 155 | 156 | /** 157 | * Encodes the special html chars in a html tag so that is is considered a harmless string 158 | * 159 | * @param element - The element as a string 160 | */ 161 | private encodeTagString(element: string): string { 162 | element = element.replace(//g, '@@@hook-gt@@@'); 164 | element = element.replace(/"/g, '@@@hook-dq@@@'); 165 | return element; 166 | } 167 | 168 | /** 169 | * Decodes the encoded html chars in a html tag again 170 | * 171 | * @param element - The element as a string 172 | */ 173 | private decodeTagString(element: string): string { 174 | element = element.replace(/@@@hook-lt@@@/g, '<'); 175 | element = element.replace(/@@@hook-gt@@@/g, '>'); 176 | element = element.replace(/@@@hook-dq@@@/g, '"'); 177 | return element; 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/utils/deepComparer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Logger } from './logger'; 3 | import { getParseOptionDefaults, ParseOptions } from '../settings/options'; 4 | 5 | 6 | /** 7 | * The object returned by the detailedStringify function in DeepComparer. 8 | * Contains the stringified value as well as the number of times the maximum stringify depth was reached. 9 | */ 10 | export interface DetailedStringifyResult { 11 | result: string|null; 12 | depthReachedCount: number; 13 | } 14 | 15 | /** 16 | * A service for comparing two variables by value instead of by reference 17 | */ 18 | @Injectable({ 19 | providedIn: 'root' 20 | }) 21 | export class DeepComparer { 22 | 23 | // 1. Inputs 24 | // ----------------------------------------------------------------- 25 | 26 | constructor(private logger: Logger) { 27 | } 28 | 29 | /** 30 | * Tests if two objects are equal by value 31 | * 32 | * @param a - The first object 33 | * @param b - The second object 34 | * @param compareDepth - How many levels deep to compare 35 | * @param options - The current parseOptions 36 | */ 37 | isEqual(a: any, b: any, compareDepth?: number, options: ParseOptions = getParseOptionDefaults()): boolean { 38 | const aStringified = this.detailedStringify(a, compareDepth); 39 | const bStringified = this.detailedStringify(b, compareDepth); 40 | 41 | if (aStringified.result === null || bStringified.result === null) { 42 | this.logger.warn([ 43 | 'Objects could not be compared by value as one or both of them could not be stringified. Returning false. \n', 44 | 'Objects:', a, b 45 | ], options); 46 | return false; 47 | } 48 | 49 | return aStringified.result === bStringified.result; 50 | } 51 | 52 | /** 53 | * Like JSON.stringify, but stringifies additional datatypes that would have been 54 | * nulled otherwise. It also doesn't throw errors on cyclic property paths. 55 | * 56 | * If obj can't be stringified for whatever reason, returns null. 57 | * 58 | * @param obj - The object to stringify 59 | * @param depth - How many levels deep to stringify 60 | */ 61 | detailedStringify(obj: any, depth?: number): DetailedStringifyResult { 62 | try { 63 | // Null cyclic paths 64 | const depthReached = {count: 0}; 65 | const decylcedObj = this.decycle(obj, [], depth, depthReached); 66 | 67 | const stringified = JSON.stringify(decylcedObj, (key, value) => { 68 | // If undefined 69 | if (value === undefined) { 70 | return 'undefined'; 71 | } 72 | // If function or class 73 | if (typeof value === 'function') { 74 | return value.toString(); 75 | } 76 | // If symbol 77 | if (typeof value === 'symbol') { 78 | return value.toString(); 79 | } 80 | return value; 81 | }); 82 | 83 | return {result: stringified, depthReachedCount: depthReached.count}; 84 | } catch (e) { 85 | return {result: null, depthReachedCount: 0}; 86 | } 87 | } 88 | 89 | /** 90 | * Travels on object and replaces cyclical references with null 91 | * 92 | * @param obj - The object to travel 93 | * @param stack - To keep track of already travelled objects 94 | * @param depth - How many levels deep to decycle 95 | * @param depthReached - An object to track the number of times the max depth was reached 96 | */ 97 | decycle(obj: any, stack: any[] = [], depth: number = 5, depthReached: { count: number; }): any { 98 | if (stack.length > depth) { 99 | depthReached.count++; 100 | return null; 101 | } 102 | 103 | if (!obj || typeof obj !== 'object' || obj instanceof Date) { 104 | return obj; 105 | } 106 | 107 | // Check if cyclical and we've traveled this obj already 108 | // 109 | // Note: Test this not by object reference, but by object PROPERTY reference/equality. If an object has identical properties, 110 | // the object is to be considered identical even if it has a different reference itself. 111 | // 112 | // Explanation: This is to prevent a sneaky bug when comparing by value and a parser returns an object as an input that contains a reference to the object holding it 113 | // (like returning the context object that contains a reference to the parent component holding the context object). 114 | // In this example, when the context object changes by reference, the old input will be compared with the new input. However, as the old input consists of 115 | // the old context object that now (through the parent component) contains a reference to the new context object, while the new input references the new context 116 | // object exclusively, the decycle function would produce different results for them if it only checked cyclical paths by reference (even if the context object 117 | // remained identical in value!) 118 | // 119 | // Though an unlikely scenario, checking cyclical paths via object properties rather than the object reference itself solves this problem. 120 | for (const stackObj of stack) { 121 | if (this.objEqualsProperties(obj, stackObj)) { 122 | return null; 123 | } 124 | } 125 | 126 | const s = stack.concat([obj]); 127 | 128 | if (Array.isArray(obj)) { 129 | const newArray = []; 130 | for (const entry of obj) { 131 | newArray.push(this.decycle(entry, s, depth, depthReached)); 132 | } 133 | return newArray; 134 | } else { 135 | const newObj: any = {}; 136 | for (const key of Object.keys(obj)) { 137 | newObj[key] = this.decycle(obj[key], s, depth, depthReached); 138 | } 139 | return newObj; 140 | } 141 | } 142 | 143 | /** 144 | * Returns true when all the properties of one object equal those of another object, otherwise false. 145 | * 146 | * @param a - The first object 147 | * @param b - The second object 148 | */ 149 | objEqualsProperties(a: any, b: any): boolean { 150 | const aKeys = Object.keys(a); 151 | const bKeys = Object.keys(b); 152 | 153 | if (aKeys.length !== bKeys.length) { 154 | return false; 155 | } 156 | 157 | for (const aKey of aKeys) { 158 | if (a[aKey] !== b[aKey]) { 159 | return false; 160 | } 161 | } 162 | 163 | return true; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/utils/hookFinder.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HookPosition } from '../../interfacesPublic'; 3 | import { matchAll } from './utils'; 4 | import { Logger } from './logger'; 5 | import { getParseOptionDefaults, ParseOptions } from '../settings/options'; 6 | 7 | /** 8 | * A utility service to easily parse hooks from text content 9 | */ 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class HookFinder { 14 | 15 | constructor(private logger: Logger) {} 16 | 17 | /** 18 | * Finds all text hooks in a piece of content, e.g. ..., and returns their positions 19 | * 20 | * @param content - The text to parse 21 | * @param openingTagRegex - The regex for the opening tag 22 | * @param closingTagRegex - The regex for the closing tag 23 | * @param includeNested - Whether to include nested hooks in the result 24 | * @param options - The current ParseOptions 25 | */ 26 | find(content: string, openingTagRegex: RegExp, closingTagRegex?: RegExp, includeNested?: boolean, options: ParseOptions = getParseOptionDefaults()): HookPosition[] { 27 | if (!closingTagRegex) { 28 | return this.findSingletagHooks(content, openingTagRegex); 29 | } else { 30 | return this.findEnclosingHooks(content, openingTagRegex, closingTagRegex, includeNested, options) 31 | } 32 | } 33 | 34 | /** 35 | * Finds all text hooks that are non-enclosing in a piece of text, e.g. 36 | * 37 | * @param content - The text to search 38 | * @param hookRegex - The regex to use for the hook 39 | */ 40 | findSingletagHooks(content: string, hookRegex: RegExp): HookPosition[] { 41 | const result: HookPosition[] = []; 42 | 43 | // Find all hooks 44 | const openingTagMatches = matchAll(content, hookRegex); 45 | 46 | for (const match of openingTagMatches) { 47 | result.push({ 48 | openingTagStartIndex: match.index, 49 | openingTagEndIndex: match.index + match[0].length, 50 | closingTagStartIndex: null, 51 | closingTagEndIndex: null, 52 | }); 53 | } 54 | 55 | return result; 56 | } 57 | 58 | /** 59 | * Finds all text hooks that are enclosing in a piece of text, e.g. ... 60 | * 61 | * Correctly finding enclosing hooks requires a programmatic parser rather then just regex alone, as regex cannot handle 62 | * patterns that are potentially nested within themselves. 63 | * 64 | * - If the content between the opening and closing is lazy (.*?), it would take the first closing tag after the opening tag, 65 | * regardless if it belongs to the opening tag or actually a nested hook. This would falsely match the first and third tag 66 | * in this example: '' 67 | * 68 | * - If the content between the opening and closing is greedy (.*), it would only end on the last closing tag in the string, 69 | * ignoring any previous closing tags. This would falsely match the first and fourth tag in this example: 70 | * '' 71 | * 72 | * There is no regex that works for both scenarios. This method therefore manually counts and compares the opening tags with the closing tags. 73 | * 74 | * @param content - The text to parse 75 | * @param openingTagRegex - The regex for the opening tag 76 | * @param closingTagRegex - The regex for the closing tag 77 | * @param includeNested - Whether to include nested hooks in the result 78 | * @param options - The current parseOptions 79 | */ 80 | findEnclosingHooks(content: string, openingTagRegex: RegExp, closingTagRegex: RegExp, includeNested?: boolean, options: ParseOptions = getParseOptionDefaults()): HookPosition[] { 81 | const allTags = []; 82 | const result: HookPosition[] = []; 83 | 84 | // Find all opening tags 85 | const openingTagMatches = matchAll(content, openingTagRegex); 86 | for (const match of openingTagMatches) { 87 | allTags.push({ 88 | isOpening: true, 89 | value: match[0], 90 | startIndex: match.index, 91 | endIndex: match.index + match[0].length 92 | }); 93 | } 94 | 95 | // Find all closing tags 96 | const closingTagMatches = matchAll(content, closingTagRegex); 97 | for (const match of closingTagMatches) { 98 | allTags.push({ 99 | isOpening: false, 100 | value: match[0], 101 | startIndex: match.index, 102 | endIndex: match.index + match[0].length 103 | }); 104 | } 105 | 106 | // Sort by startIndex 107 | allTags.sort((a, b) => a.startIndex - b.startIndex); 108 | 109 | // Create HookPositions by figuring out which opening tag belongs to which closing tag 110 | const openedTags = []; 111 | allTagsLoop: for (const [index, tag] of allTags.entries()) { 112 | 113 | // Any subsequent tag is only allowed to start after previous tag has ended 114 | if (index > 0 && tag.startIndex < allTags[index - 1].endIndex) { 115 | 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); 116 | continue; 117 | } 118 | 119 | // Opening or closing tag? 120 | if (tag.isOpening) { 121 | openedTags.push(tag); 122 | } else { 123 | // Syntax error: Closing tag without preceding opening tag. Syntax error. 124 | if (openedTags.length === 0) { 125 | this.logger.warn(['Syntax error - Closing tag without preceding opening tag found: "' + tag.value + '". Ignoring.'], options); 126 | continue; 127 | } 128 | 129 | // If nested hooks not allowed and more than one tag is open, discard both this closing tag and the latest opening tag 130 | if (includeNested === false && openedTags.length > 1) { 131 | openedTags.pop(); 132 | continue; 133 | } 134 | 135 | // Valid hook! Add to result array 136 | const openingTag = openedTags[openedTags.length - 1]; 137 | result.push({ 138 | openingTagStartIndex: openingTag.startIndex, 139 | openingTagEndIndex: openingTag.startIndex + openingTag.value.length, 140 | closingTagStartIndex: tag.startIndex, 141 | closingTagEndIndex: tag.startIndex + tag.value.length 142 | }); 143 | openedTags.pop(); 144 | } 145 | } 146 | 147 | if (openedTags.length > 0) { 148 | this.logger.warn(['Syntax error - Opening tags without corresponding closing tags found.'], options); 149 | } 150 | 151 | return result; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { isDevMode, Injectable, Inject, PLATFORM_ID } from '@angular/core'; 3 | import { ParseOptions } from '../settings/options'; 4 | 5 | /** 6 | * A utility service to print logs and warnings 7 | */ 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class Logger { 12 | 13 | constructor(@Inject(PLATFORM_ID) private platformId: string) { 14 | } 15 | 16 | log(content: any[], options: ParseOptions): void { 17 | this.handleLog(content, options, 'log'); 18 | } 19 | 20 | warn(content: any[], options: ParseOptions): void { 21 | this.handleLog(content, options, 'warn'); 22 | } 23 | 24 | error(content: any[], options: ParseOptions): void { 25 | this.handleLog(content, options, 'error'); 26 | } 27 | 28 | /** 29 | * Logs an array of content according to the submitted options 30 | * 31 | * @param content - The content to log 32 | * @param options - The current ParseOptions 33 | * @param method - The console method to use 34 | */ 35 | private handleLog(content: any[], options: ParseOptions, method: string) { 36 | if ( 37 | options.logOptions?.dev && this.isDevMode() && isPlatformBrowser(this.platformId) || 38 | options.logOptions?.prod && !this.isDevMode() && isPlatformBrowser(this.platformId) || 39 | options.logOptions?.ssr && !isPlatformBrowser(this.platformId) 40 | ) { 41 | (console as any)[method](...content); 42 | } 43 | } 44 | 45 | /** 46 | * Use local method that is easier to mock in tests 47 | */ 48 | private isDevMode(): boolean { 49 | return isDevMode(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/services/utils/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Polyfill for String.prototype.matchAll() from the ES2020 spec 4 | * 5 | * Note: The 'string.prototype.matchall' npm package was unstable for me so providing my own version here 6 | * 7 | * @param text - The text to search 8 | * @param regExp - The RegExp object to use 9 | */ 10 | export function matchAll(text: string, regExp: RegExp): {[index: number]: string, index: number, input: string}[] { 11 | // Must be global 12 | if (!regExp.global) { 13 | throw Error('TypeError: matchAll called with a non-global RegExp argument'); 14 | } 15 | 16 | // Get matches 17 | const result = []; 18 | let match = regExp.exec(text); 19 | while (match !== null) { 20 | result.push(match); 21 | match = regExp.exec(text); 22 | } 23 | 24 | // Reset internal index 25 | regExp.lastIndex = 0; 26 | 27 | return result; 28 | } 29 | 30 | /** 31 | * Sort elements/nodes based on the order of their appearance in the document 32 | * 33 | * @param arr - The array to sort 34 | * @param sortCallback - The callback to use to sort the elements 35 | * @param getElement - An optional callback that returns the element to compare from each arr entry 36 | */ 37 | export function sortElements(arr: T[], sortCallback: (a: any, b: any) => number, getElementCallback: (entry: T) => any): T[] { 38 | const result = [...arr]; 39 | return result.sort(function(a, b) { 40 | 41 | if (typeof getElementCallback === 'function') { 42 | a = getElementCallback(a); 43 | b = getElementCallback(b); 44 | } 45 | 46 | return sortCallback(a, b); 47 | }); 48 | } 49 | 50 | /** 51 | * Indicates if an element is either a component host element or part of a component's view/template 52 | * 53 | * @param element - The element to inspect 54 | */ 55 | export function isAngularManagedElement(element: any): boolean { 56 | // Angular gives component host and view elements the following property, so can simply check for that 57 | return element?.__ngContext__ !== undefined; 58 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/standalone.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentInjector, EnvironmentProviders, NgZone, Provider, createEnvironmentInjector } from '@angular/core'; 2 | import { createApplication } from '@angular/platform-browser'; 3 | import { firstValueFrom } from 'rxjs'; 4 | 5 | import { HookParserEntry } from './services/settings/parserEntry'; 6 | import { ParseOptions } from './services/settings/options'; 7 | import { HookIndex, ParseResult } from './interfacesPublic'; 8 | import { DynamicHooksService } from './services/dynamicHooksService'; 9 | 10 | // Global state 11 | // ---------- 12 | 13 | let sharedInjector: EnvironmentInjector|null = null; 14 | let scopes: ProvidersScope[] = []; 15 | let allParseResults: ParseResult[] = []; 16 | 17 | const createInjector = async (providers: (Provider | EnvironmentProviders)[] = [], parent?: EnvironmentInjector) => { 18 | // If no parent, create new root injector, so passed providers will also be actual root providers 19 | return parent ? createEnvironmentInjector(providers, parent) : (await createApplication({providers})).injector; 20 | } 21 | 22 | /** 23 | * Destroys all scopes and components created by standalone mode 24 | */ 25 | export const destroyAll = () => { 26 | // Destroy all scopes 27 | for (const scope of scopes) { 28 | scope.destroy(); 29 | } 30 | 31 | // Then all remaining independent parseResults 32 | for (const parseResult of allParseResults) { 33 | parseResult.destroy(); 34 | } 35 | 36 | sharedInjector = null; 37 | scopes = []; 38 | allParseResults = []; 39 | } 40 | 41 | // Providers scope 42 | // ---------- 43 | 44 | /** 45 | * Creates an isolated scope with its own providers that the dynamically-created components will then have access to. 46 | * 47 | * @param providers - A list of providers 48 | * @param parentScope - An optional parent scope created previously. Makes the parent providers also accessible to this scope. 49 | */ 50 | export const createProviders = (providers: (Provider | EnvironmentProviders)[] = [], parentScope?: ProvidersScope): ProvidersScope => { 51 | return new ProvidersScope(providers, parentScope); 52 | } 53 | 54 | /** 55 | * A scope with an internal list of providers. All dynamic components created by its `parse` method will have access to them. 56 | */ 57 | export class ProvidersScope { 58 | private _injector: EnvironmentInjector|null = null; 59 | public get injector(): EnvironmentInjector|null { 60 | return this._injector; 61 | }; 62 | private _parseResults: ParseResult[] = []; 63 | public get parseResults(): ParseResult[] { 64 | return this._parseResults; 65 | }; 66 | private _isDestroyed: boolean = false; 67 | get isDestroyed(): boolean { 68 | return this._isDestroyed; 69 | }; 70 | 71 | constructor(private providers: (Provider | EnvironmentProviders)[] = [], private parentScope?: ProvidersScope) { 72 | scopes.push(this); 73 | } 74 | 75 | /** 76 | * Parses content and loads components for all found hooks in standalone mode 77 | * 78 | * @param content - The content to parse 79 | * @param parsers - The parsers to use 80 | * @param context - An optional context object 81 | * @param options - An optional list of options 82 | * @param targetElement - An optional HTML element to use as the container for the loaded content. 83 | * @param targetHookIndex - An optional object to fill with the programmatic hook data. If none is provided, one is created and returned for you. 84 | * @param environmentInjector - An optional environmentInjector to use for the dynamically-loaded components. If none is provided, the default environmentInjector is used. 85 | */ 86 | public async parse( 87 | content: any, 88 | parsers: HookParserEntry[], 89 | context: any = null, 90 | options: ParseOptions|null = null, 91 | targetElement: HTMLElement|null = null, 92 | targetHookIndex: HookIndex = {}, 93 | environmentInjector: EnvironmentInjector|null = null 94 | ): Promise { 95 | this.checkIfDestroyed(); 96 | 97 | return parse(content, parsers, context, options, targetElement, targetHookIndex, environmentInjector || await this.resolveInjector()) 98 | .then(parseResult => { 99 | this.parseResults.push(parseResult); 100 | return parseResult; 101 | }); 102 | } 103 | 104 | /** 105 | * Returns the injector for this scope 106 | */ 107 | public async resolveInjector() { 108 | this.checkIfDestroyed(); 109 | 110 | if (!this.injector) { 111 | const parentInjector = this.parentScope ? await this.parentScope.resolveInjector() : undefined; 112 | this._injector = await createInjector(this.providers, parentInjector); 113 | } 114 | 115 | return this.injector!; 116 | } 117 | 118 | /** 119 | * Destroys this scope and all of its created components 120 | */ 121 | public destroy(): void { 122 | this.checkIfDestroyed(); 123 | 124 | for (const parseResult of this.parseResults) { 125 | parseResult.destroy(); 126 | allParseResults = allParseResults.filter(entry => entry !== parseResult); 127 | } 128 | 129 | if (this.injector) { 130 | this.injector.destroy(); 131 | } 132 | 133 | scopes = scopes.filter(scope => scope !== this); 134 | this._isDestroyed = true; 135 | } 136 | 137 | private checkIfDestroyed() { 138 | if (this.isDestroyed) { 139 | throw new Error('This scope has already been destroyed. It or its methods cannot be used any longer.'); 140 | } 141 | } 142 | } 143 | 144 | // parse 145 | // ---------- 146 | 147 | /** 148 | * Parses content and loads components for all found hooks in standalone mode 149 | * 150 | * @param content - The content to parse 151 | * @param parsers - The parsers to use 152 | * @param context - An optional context object 153 | * @param options - An optional list of options 154 | * @param targetElement - An optional HTML element to use as the container for the loaded content. 155 | * @param targetHookIndex - An optional object to fill with the programmatic hook data. If none is provided, one is created and returned for you. 156 | * @param environmentInjector - An optional environmentInjector to use for the dynamically-loaded components. If none is provided, the default environmentInjector is used. 157 | */ 158 | export const parse = async ( 159 | content: any, 160 | parsers: HookParserEntry[], 161 | context: any = null, 162 | options: ParseOptions|null = null, 163 | targetElement: HTMLElement|null = null, 164 | targetHookIndex: HookIndex = {}, 165 | environmentInjector: EnvironmentInjector|null = null, 166 | ): Promise => { 167 | 168 | // Reuse the same global injector for all independent parse calls 169 | if (!environmentInjector) { 170 | if (!sharedInjector) { 171 | sharedInjector = await createInjector(); 172 | } 173 | environmentInjector = sharedInjector; 174 | } 175 | 176 | // In standalone mode, emit HTML events from outputs by default 177 | if (!options) { 178 | options = {} 179 | } 180 | if (!options.hasOwnProperty('triggerDOMEvents')) { 181 | options.triggerDOMEvents = true; 182 | } 183 | 184 | const dynHooksService = environmentInjector.get(DynamicHooksService); 185 | 186 | // Needs to be run inside NgZone manually 187 | return environmentInjector.get(NgZone).run(() => { 188 | return firstValueFrom(dynHooksService 189 | .parse( 190 | content, 191 | parsers, 192 | context, 193 | options, 194 | null, 195 | null, 196 | targetElement, 197 | targetHookIndex, 198 | environmentInjector, 199 | null 200 | ) 201 | ).then(parseResult => { 202 | allParseResults.push(parseResult); 203 | return parseResult; 204 | }); 205 | }); 206 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/lib/standaloneHelper.ts: -------------------------------------------------------------------------------- 1 | import { isAngularManagedElement } from './services/utils/utils'; 2 | import { contentElementAttr } from './constants/core'; 3 | 4 | /** 5 | * A function that observes an HTMLElement and triggers a callback when new elements are added to it. 6 | * Does NOT trigger for Angular components or logic, only for neutral HTML elements. 7 | * 8 | * @param content - The HTMLElement to watch for element additions 9 | * @param callbackFn - The callback function to call when a change occurs. Will be called with the closest parent element of all added elements. 10 | */ 11 | export const observeElement = (content: HTMLElement, callbackFn: (parentElement: HTMLElement) => void): MutationObserver => { 12 | const observer = new MutationObserver((mutationsList, observer) => { 13 | 14 | // Collect only addded nodes 15 | let newNodes: Node[] = []; 16 | for (const mutation of mutationsList) { 17 | mutation.addedNodes.forEach(addedNode => newNodes.push(addedNode)); 18 | mutation.removedNodes.forEach(removedNode => newNodes = newNodes.filter(newNode => newNode !== removedNode)); 19 | } 20 | 21 | // Ignore new nodes created as part of Angular component views 22 | newNodes = newNodes.filter(newNode => 23 | newNode.nodeType === 1 && !isAngularManagedElement(newNode) || // Check HTMLElements 24 | newNode.nodeType === 3 && !isAngularManagedElement(newNode.parentNode!) // Check text node parents 25 | ); 26 | 27 | // Ignore new nodes that are children of a content element that is currently being parsed (lots of elements get created/removed during that time) 28 | newNodes = newNodes.filter(newNode => { 29 | const element: HTMLElement = newNode.nodeType === 1 ? newNode as HTMLElement : newNode.parentElement!; 30 | return element.closest(`[${contentElementAttr}]`) === null; 31 | }); 32 | 33 | if (newNodes.length) { 34 | // Find closest common parent 35 | const commonParent = findClosestCommonParent(newNodes)!; 36 | 37 | // Run callback 38 | callbackFn(commonParent); 39 | } 40 | }); 41 | 42 | observer.observe(content, { childList: true, subtree: true }); 43 | 44 | return observer; 45 | } 46 | 47 | /** 48 | * Finds the closest common parent element for multiple elements 49 | * 50 | * @param elements - The elements in question 51 | */ 52 | const findClosestCommonParent = (elements: Node[]): HTMLElement|null => { 53 | if (elements.length === 0) return null; 54 | let parent = elements[0]; 55 | 56 | for (const element of elements) { 57 | while (parent === element || !parent.contains(element)) { 58 | parent = parent.parentElement!; 59 | } 60 | } 61 | 62 | return parent as HTMLElement; 63 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngx-dynamic-hooks 3 | */ 4 | 5 | // General 6 | export * from './lib/dynamicHooksProviders'; 7 | export * from './lib/standalone'; 8 | export * from './lib/standaloneHelper'; 9 | export * from './lib/interfacesPublic'; 10 | 11 | // Settings 12 | export * from './lib/services/settings/options'; 13 | export * from './lib/services/settings/parserEntry'; 14 | 15 | // Main logic 16 | export * from './lib/components/dynamicHooksComponent'; 17 | export * from './lib/components/dynamicSingleComponent'; 18 | export * from './lib/services/dynamicHooksService'; 19 | export * from './lib/services/settings/settings'; 20 | 21 | // SelectorHookParser 22 | export * from './lib/parsers/selector/text/textSelectorHookParser'; 23 | export * from './lib/parsers/selector/element/elementSelectorHookParser'; 24 | export * from './lib/parsers/selector/selectorHookParserConfig'; 25 | 26 | // Utils 27 | export * from './lib/services/utils/dataTypeParser'; 28 | export * from './lib/services/utils/deepComparer'; 29 | export * from './lib/services/utils/hookFinder'; 30 | export * from './lib/constants/regexes'; 31 | 32 | // Utils 33 | export * from './lib/services/utils/utils'; 34 | 35 | // Platform 36 | export * from './lib/services/platform/platformService'; 37 | export * from './lib/services/platform/defaultPlatformService'; 38 | 39 | // Testing 40 | // export * from './tests/testing-api'; 41 | 42 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone'; 4 | import 'zone.js/dist/zone-testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting 9 | } from '@angular/platform-browser-dynamic/testing'; 10 | 11 | declare const require: { 12 | context(path: string, deep?: boolean, filter?: RegExp): { 13 | keys(): string[]; 14 | (id: string): T; 15 | }; 16 | }; 17 | 18 | // First, initialize the Angular testing environment. 19 | getTestBed().initTestEnvironment( 20 | BrowserDynamicTestingModule, 21 | platformBrowserDynamicTesting() 22 | ); 23 | // Then we find all the tests. 24 | const context = require.context('./', true, /\.spec\.ts$/); 25 | // And load the modules. 26 | context.keys().map(context); 27 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/integration/forChild/shared.ts: -------------------------------------------------------------------------------- 1 | import { RouterModule, Router } from '@angular/router'; 2 | import { TestBed, TestBedStatic, ComponentFixtureAutoDetect, ComponentFixture } from '@angular/core/testing'; 3 | 4 | // Testing api resources 5 | import { DynamicHooksInheritance, provideDynamicHooks } from '../../testing-api'; 6 | 7 | // Resources 8 | import { CONTENT_STRING, contentString } from '../../resources/forChild/contentString'; 9 | import { RootComponent, DynamicRootComponent } from '../../resources/forChild/root'; 10 | import { getPlanetsRoutes } from '../../resources/forChild/planets'; 11 | import { getStarsRoutes } from '../../resources/forChild/stars'; 12 | import { getHyperlaneRoutes } from '../../resources/forChild/hyperlanes'; 13 | 14 | export interface TestSetup { 15 | testBed: TestBedStatic; 16 | fixture: ComponentFixture; 17 | rootComp: RootComponent; 18 | } 19 | 20 | export const createForChildTestingModule = (mode: 'lazy'|'sync') => { 21 | TestBed.resetTestingModule(); 22 | 23 | // To run allsettings reset before loading child settings 24 | const rootHooksProviders = provideDynamicHooks({ 25 | parsers: [ 26 | {component: DynamicRootComponent} 27 | ], 28 | inheritance: DynamicHooksInheritance.All 29 | }); 30 | 31 | TestBed.configureTestingModule({ 32 | imports: [ 33 | RouterModule.forRoot(mode === 'lazy' ? 34 | [ 35 | { path: 'hyperlanes', children: getHyperlaneRoutes() }, 36 | { path: 'stars', loadChildren: () => new Promise(resolve => resolve(getStarsRoutes())) }, 37 | { path: 'planets', loadChildren: () => new Promise(resolve => resolve(getPlanetsRoutes(true))) } 38 | ] : [ 39 | { path: 'hyperlanes', children: getHyperlaneRoutes() }, 40 | { path: 'stars', children: getStarsRoutes() }, 41 | { path: 'planets', children: getPlanetsRoutes(false) }, 42 | ] 43 | ) 44 | ], 45 | providers: [ 46 | rootHooksProviders, 47 | { provide: ComponentFixtureAutoDetect, useValue: true }, 48 | { provide: CONTENT_STRING, useValue: {value: contentString} } 49 | ] 50 | }); 51 | 52 | const fixture = TestBed.createComponent(RootComponent); 53 | return { 54 | testBed: TestBed, 55 | fixture, 56 | rootComp: fixture.componentInstance 57 | }; 58 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/integration/injectors.spec.ts: -------------------------------------------------------------------------------- 1 | // Testing api resources 2 | import { ComponentCreator, DynamicHooksComponent, DynamicHooksService, HookComponentData, HookValue, ParseResult, provideDynamicHooks } from '../testing-api'; 3 | 4 | // Custom testing resources 5 | import { defaultBeforeEach } from './shared'; 6 | import { SingleTagTestComponent } from '../resources/components/singleTag/singleTagTest.c'; 7 | import { Component, EnvironmentInjector, Injector, NgModule, createEnvironmentInjector } from '@angular/core'; 8 | import { Route, Router, RouterModule, RouterOutlet } from '@angular/router'; 9 | import { TestBed, fakeAsync, tick } from '@angular/core/testing'; 10 | import { GENERICINJECTIONTOKEN } from '../resources/services/genericInjectionToken'; 11 | import { By } from '@angular/platform-browser'; 12 | import { GenericSingleTagStringParser } from '../resources/parsers/genericSingleTagStringParser'; 13 | 14 | 15 | describe('Injectors logic', () => { 16 | let testBed; 17 | let fixture: any; 18 | let comp: DynamicHooksComponent; 19 | let context: any; 20 | 21 | beforeEach(() => { 22 | ({testBed, fixture, comp, context} = defaultBeforeEach()); 23 | }); 24 | 25 | // ---------------------------------------------------------------------------- 26 | 27 | it('#should use the injectors from DynamicHooksComponent by default. If not specified on manual use, fallback to root injectors', fakeAsync(() => { 28 | const testText = '[singletag-string]'; 29 | 30 | // Create testing scaffolding: A root module with a lazily loaded child module 31 | // This is to create a separate (child module) environment injector, so the one injected into DynamicHooksComponent is different to the root one 32 | // We can then better test for when which of them is used 33 | @Component({ 34 | selector: 'app-root', 35 | imports: [DynamicHooksComponent, RouterOutlet], 36 | template: `
37 | Root component loaded! 38 | 39 |
`, 40 | standalone: true 41 | }) 42 | class RootComponent { 43 | constructor() {} 44 | }; 45 | 46 | TestBed.resetTestingModule(); 47 | TestBed.configureTestingModule({ 48 | imports: [ 49 | RouterModule.forRoot([ 50 | { path: 'lazyRoute', loadChildren: () => new Promise(resolve => resolve(lazyRoute)) }, 51 | ]) 52 | ], 53 | providers: [ 54 | provideDynamicHooks({parsers: [GenericSingleTagStringParser]}) 55 | ] 56 | }); 57 | 58 | @Component({ 59 | selector: 'app-lazychild', 60 | imports: [DynamicHooksComponent], 61 | template: `
62 | Lazy child component loaded! 63 | 64 |
`, 65 | standalone: true 66 | }) 67 | class LazyChildComponent { 68 | constructor() {} 69 | } 70 | 71 | const lazyRoute: Route[] = [ 72 | { path: '', component: LazyChildComponent, providers: [ 73 | {provide: GENERICINJECTIONTOKEN, useValue: { name: 'ChildModuleService works!' } } 74 | ] } 75 | ]; 76 | 77 | // Create app 78 | const fixture = TestBed.createComponent(RootComponent); 79 | const router = TestBed.inject(Router); 80 | const dynamicHooksService = TestBed.inject(DynamicHooksService); 81 | const componentCreator = TestBed.inject(ComponentCreator); 82 | spyOn(componentCreator, 'createComponent').and.callThrough(); 83 | 84 | // Load lazy route 85 | router.navigate(['lazyRoute']); 86 | tick(1000); 87 | fixture.detectChanges(); 88 | 89 | // Get DynHooksComponent from that lazy route 90 | const dynamicHooksComponent = fixture.debugElement.query(By.directive(DynamicHooksComponent)).componentInstance as DynamicHooksComponent; 91 | 92 | // Collect various injectors 93 | const rootModuleEnvInjector = TestBed.inject(EnvironmentInjector); 94 | const rootModuleInjector = TestBed.inject(Injector); 95 | const dynHooksEnvInjector = dynamicHooksComponent['environmentInjector']; 96 | const dynHooksInjector = dynamicHooksComponent['injector']; 97 | 98 | // Init DynHooksComponent normally. ComponentCreator should then use DynHooksComponent injectors. 99 | dynamicHooksComponent.content = testText; 100 | dynamicHooksComponent.ngOnChanges({content: true} as any); 101 | let latestArgs = (componentCreator.createComponent as any)['calls'].mostRecent().args; 102 | let latestUsedEnvInjector = latestArgs[6]; 103 | let latestUsedInjector = latestArgs[7]; 104 | 105 | expect(latestUsedEnvInjector).toEqual(dynHooksEnvInjector); 106 | expect(latestUsedInjector).toEqual(dynHooksInjector); 107 | 108 | // Test again directly via DynHooksService without passing injectors. ComponentCreator should then use root injectors. 109 | dynamicHooksService.parse(testText).subscribe((parseResult: ParseResult) => { 110 | let latestArgs = (componentCreator.createComponent as any)['calls'].mostRecent().args; 111 | let latestUsedEnvInjector = latestArgs[6]; 112 | let latestUsedInjector = latestArgs[7]; 113 | 114 | expect(latestUsedEnvInjector).toEqual(rootModuleEnvInjector); 115 | expect(latestUsedInjector).toEqual(rootModuleInjector); 116 | }); 117 | })); 118 | 119 | it('#should use custom injectors if passed by parser', () => { 120 | const configureParser = function (injector: Injector|null = null, envInjector: EnvironmentInjector|null = null) { 121 | const compData: HookComponentData = { 122 | component: SingleTagTestComponent 123 | }; 124 | if (injector) { 125 | compData.injector = injector; 126 | } 127 | if (envInjector) { 128 | compData.environmentInjector = envInjector; 129 | } 130 | 131 | let genericSingleTagParser = TestBed.inject(GenericSingleTagStringParser); 132 | genericSingleTagParser.onLoadComponent = (hookId: number, hookValue: HookValue, context: any, childNodes: Array) => compData; 133 | } 134 | 135 | const testText = `[singletag-string]`; 136 | 137 | comp.content = testText; 138 | comp.ngOnChanges({content: true} as any); 139 | 140 | // Should not be found as not provided anywhere 141 | expect(comp.hookIndex[1].componentRef!.instance.genericInjectionValue).toBeNull(); 142 | 143 | // Set custom injector 144 | const customInjector = Injector.create({ 145 | providers: [{provide: GENERICINJECTIONTOKEN, useValue: { name: 'injector test value' } }] 146 | }); 147 | configureParser(customInjector); 148 | comp.content = testText; 149 | comp.ngOnChanges({content: true} as any); 150 | 151 | // Should now be found 152 | expect(comp.hookIndex[1].componentRef!.instance.genericInjectionValue).toEqual({ name: 'injector test value' }); 153 | 154 | // Should also work for custom env injector 155 | const customEnvInjector = createEnvironmentInjector( 156 | [{provide: GENERICINJECTIONTOKEN, useValue: { name: 'env injector test value' } }], 157 | TestBed.inject(EnvironmentInjector), 158 | 'MyCustomEnvInjector' 159 | ); 160 | configureParser(null, customEnvInjector); 161 | comp.content = testText; 162 | comp.ngOnChanges({content: true} as any); 163 | 164 | expect(comp.hookIndex[1].componentRef!.instance.genericInjectionValue).toEqual({ name: 'env injector test value' }); 165 | }); 166 | 167 | }); 168 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/integration/shared.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef, Provider } from '@angular/core'; 2 | import { TestBed, ComponentFixtureAutoDetect, TestBedStatic, ComponentFixture } from '@angular/core/testing'; 3 | 4 | // Importing files through testing-api file here instead of their own paths. 5 | // This way, we can add easily add the testing-api file as an import to public-api if we want to 6 | // temporarily grant access to all testing resources in the final build. This is useful for testing this 7 | // library with different ng-versions, as it allows us to run the tests from the ng-app against a 8 | // compiled version of this library (by copying this spec-file over) instead of the uncompiled version here. 9 | // There is also no other way to test libraries with older ng-versions, as packagr did not exist back then. 10 | 11 | // Testing api resources 12 | import { provideDynamicHooks, HookParserEntry, resetDynamicHooks, DynamicHooksComponent } from '../testing-api'; 13 | 14 | // Custom testing resources 15 | import { SingleTagTestComponent } from '../resources/components/singleTag/singleTagTest.c'; 16 | import { MultiTagTestComponent } from '../resources/components/multiTagTest/multiTagTest.c'; 17 | import { GenericSingleTagStringParser } from '../resources/parsers/genericSingleTagStringParser'; 18 | import { GenericMultiTagStringParser } from '../resources/parsers/genericMultiTagStringParser'; 19 | import { GenericWhateverStringParser } from '../resources/parsers/genericWhateverStringParser'; 20 | import { GenericElementParser } from '../resources/parsers/genericElementParser'; 21 | import { GenericWhateverElementParser } from '../resources/parsers/genericWhateverElementParser'; 22 | 23 | 24 | // The standard parsers to be used for most tests 25 | export const testParsers: Array = [ 26 | // Generic text hook parsers 27 | GenericSingleTagStringParser, 28 | GenericMultiTagStringParser, 29 | GenericWhateverStringParser, 30 | // Generic element hook parsers 31 | GenericElementParser, 32 | GenericWhateverElementParser, 33 | // Text SelectorHookParsers 34 | { 35 | component: SingleTagTestComponent, 36 | selector: 'singletag-string-selector', 37 | bracketStyle: {opening: '[', closing: ']'}, // Forces the use of StringSelectorHookParser 38 | name: 'SingleTagStringSelectorParser', 39 | enclosing: false 40 | }, 41 | { 42 | component: MultiTagTestComponent, 43 | selector: 'multitag-string-selector', 44 | bracketStyle: {opening: '[', closing: ']'}, // Forces the use of StringSelectorHookParser 45 | name: 'MultiTagStringSelectorParser' 46 | }, 47 | // Element SelectorHookParsers 48 | { 49 | component: MultiTagTestComponent, 50 | selector: 'multitag-element-selector', 51 | name: 'MultiTagElementSelectorParser' 52 | } 53 | ]; 54 | 55 | export interface TestingModuleAndComponent { 56 | testBed: TestBedStatic; 57 | fixture: ComponentFixture; 58 | comp: DynamicHooksComponent; 59 | } 60 | 61 | // A simple function to reset and prepare the testing module 62 | export function prepareTestingModule(providers: () => Provider[]): TestingModuleAndComponent { 63 | // Create testing module 64 | TestBed.resetTestingModule(); 65 | TestBed.configureTestingModule({ 66 | providers: [ 67 | {provide: ComponentFixtureAutoDetect, useValue: true}, // Enables automatic change detection in test module 68 | ...providers() 69 | ] 70 | }); 71 | 72 | const fixture = TestBed.createComponent(DynamicHooksComponent); 73 | return { 74 | testBed: TestBed, 75 | fixture, 76 | comp: fixture.componentInstance 77 | }; 78 | } 79 | 80 | export interface TestingModuleComponentAndContext { 81 | testBed: TestBedStatic; 82 | fixture: ComponentFixture; 83 | comp: DynamicHooksComponent; 84 | context: any; 85 | } 86 | 87 | export function defaultBeforeEach(): TestingModuleComponentAndContext { 88 | // Just in case 89 | resetDynamicHooks(); 90 | 91 | const {testBed, fixture, comp} = prepareTestingModule(() => [ 92 | provideDynamicHooks({parsers: testParsers}) 93 | ]); 94 | 95 | const context = { 96 | parent: comp, 97 | greeting: 'Hello there!', 98 | order: 66, 99 | maneuvers: { 100 | modifyParent: (event: any) => (comp as any)['completelyNewProperty'] = event, 101 | getMentalState: () => 'angry', 102 | findAppropriateAction: (mentalState: any) => mentalState === 'angry' ? 'meditate' : 'protectDemocracy', 103 | meditate: () => { return {action:'meditating!', state: 'calm'}; }, 104 | protectDemocracy: () => { return {action: 'hunting sith!', state: 'vigilant'}; }, 105 | attack: (enemy: any) => 'attacking ' + enemy + '!', 106 | generateEnemy: (name: any) => { return {name: 'the evil ' + name, type: 'monster'}; }, 107 | defend: (person: any) => 'defending ' + person + '!', 108 | readJediCode: () => 'dont fall in love with pricesses from naboo', 109 | goIntoExile: () => 'into exile, i must go!', 110 | combo: (param1: any, param2: any) => 'Combo: ' + param1 + ' and ' + param2 111 | }, 112 | $lightSaberCollection: [ 113 | 'blue', 'green', 'orange', 'purple' 114 | ], 115 | _jediCouncil: { 116 | yoda900: 'there is no try', 117 | windu: 'take a seat', 118 | kenobi: 'wretched hive of scum and villainy', 119 | kiAdiMundi: ['but', 'what', 'about', 'the', 'droid', 'attack', 'on', 'the', { 120 | name: 'wookies', 121 | planet: 'kashyyyk' 122 | }], 123 | skywalker: undefined 124 | } 125 | }; 126 | 127 | return {testBed, fixture, comp, context}; 128 | } 129 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/abstractTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ChangeDetectorRef, Output, EventEmitter, Inject, DoCheck, Optional, InjectionToken, EnvironmentInjector, Injector, NgZone, input } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicData, OnDynamicChanges, OnDynamicMount } from '../../testing-api'; 3 | import { RootTestService } from '../services/rootTestService'; 4 | import { GENERICINJECTIONTOKEN } from '../services/genericInjectionToken'; 5 | import { CommonModule } from '@angular/common'; 6 | 7 | @Component({ 8 | selector: 'singletagtest', 9 | template: '', 10 | imports: [CommonModule], 11 | standalone: true 12 | }) 13 | export class AbstractTestComponent implements OnDynamicMount, OnDynamicChanges, DoCheck, OnInit, OnChanges, AfterViewInit, OnDestroy { 14 | nonInputProperty: string = 'this is the default value'; 15 | @Input() genericInput: any; 16 | @Input() inputWithoutBrackets!: string; 17 | @Input() emptyInputWithoutBrackets!: string; 18 | @Input() emptyInput!: string; 19 | @Input() emptyStringInput!: string; 20 | @Input() _weird5Input$Name13!: string; 21 | @Input('stringPropAlias') stringProp: any; 22 | @Input('data-somevalue') dataSomeValue!: string; 23 | @Input() numberProp: any; 24 | @Input() booleanProp!: boolean; 25 | @Input() nullProp: any; 26 | @Input() undefinedProp: any; 27 | @Input() simpleObject: any; 28 | @Input() simpleArray: any; 29 | @Input() variable!: string; 30 | @Input() variableLookalike!: string; 31 | @Input() variableInObject: any; 32 | @Input() variableInArray!: Array; 33 | @Input() contextWithoutAnything: any; 34 | @Input() nestedFunctions: any; 35 | @Input() nestedFunctionsInBrackets!: Array; 36 | @Input() everythingTogether!: Array; 37 | signalInput = input(); 38 | nonOutputEventEmitter: EventEmitter = new EventEmitter(); 39 | @Output() genericOutput: EventEmitter = new EventEmitter(); 40 | @Output() genericOtherOutput: EventEmitter = new EventEmitter(); 41 | @Output('componentClickedAlias') componentClicked: EventEmitter = new EventEmitter(); 42 | @Output('eventTriggeredAlias') eventTriggered: EventEmitter = new EventEmitter(); 43 | @Output() onDestroyEmitter: EventEmitter = new EventEmitter(); 44 | doCheckTriggers: number = 0; 45 | ngOnInitTriggered: boolean = false; 46 | ngOnChangesTriggered: boolean = false; 47 | latestNgOnChangesData: any; 48 | mountContext: any; 49 | mountContentChildren!: Array; 50 | changesContext: any; 51 | changesContentChildren!: Array; 52 | 53 | constructor ( 54 | public cd: ChangeDetectorRef, 55 | public ngZone: NgZone, 56 | @Optional() public rootTestService: RootTestService, 57 | @Optional() @Inject(GENERICINJECTIONTOKEN) public genericInjectionValue: any, 58 | public environmentInjector: EnvironmentInjector, 59 | public injector: Injector 60 | ) { 61 | //console.log(environmentInjector); 62 | //console.log(rootTestService); 63 | //console.log(genericInjectionValue); 64 | //console.log(singleTagComponentService); 65 | } 66 | 67 | ngDoCheck() { 68 | this.doCheckTriggers++; 69 | } 70 | 71 | ngOnInit () { 72 | this.ngOnInitTriggered = true; 73 | } 74 | 75 | ngOnChanges(changes: any) { 76 | this.ngOnChangesTriggered = true; 77 | this.latestNgOnChangesData = changes; 78 | // console.log(changes); 79 | } 80 | 81 | ngAfterViewInit() { 82 | } 83 | 84 | ngOnDestroy() { 85 | this.onDestroyEmitter.emit('Event triggered from onDestroy!'); 86 | } 87 | 88 | onDynamicMount(data: OnDynamicData) { 89 | this.mountContext = data.context; 90 | this.mountContentChildren = (data as any).contentChildren; 91 | } 92 | 93 | onDynamicChanges(data: OnDynamicData) { 94 | if (data.hasOwnProperty('context')) { 95 | this.changesContext = data.context; 96 | } 97 | if (data.hasOwnProperty('contentChildren')) { 98 | this.changesContentChildren = (data as any).contentChildren; 99 | } 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/emptyTest/emptyTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: 'empty-test', 5 | template: '
', 6 | standalone: true 7 | }) 8 | export class EmptyTestComponent { 9 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/lazyTest/lazyTest.c.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/lazyTest/lazyTest.c.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | } 3 | 4 | .bolder { 5 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/lazyTest/lazyTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ChangeDetectorRef, DoCheck } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicChanges, OnDynamicMount, OnDynamicData } from '../../../testing-api'; 3 | 4 | @Component({ 5 | selector: 'lazytest', 6 | templateUrl: './lazyTest.c.html', 7 | styleUrls: ['./lazyTest.c.scss'], 8 | standalone: true 9 | }) 10 | export class LazyTestComponent implements OnDynamicMount, OnDynamicChanges, DoCheck, OnInit, OnChanges, AfterViewInit, OnDestroy { 11 | @Input() name!: string; 12 | mountContext: any; 13 | mountContentChildren!: Array; 14 | changesContext: any; 15 | changesContentChildren!: Array; 16 | 17 | constructor (private cd: ChangeDetectorRef) { 18 | } 19 | 20 | 21 | ngOnInit () { 22 | // console.log('textbox init'); 23 | } 24 | 25 | ngOnChanges(changes: any) { 26 | // console.log('textbox changes'); 27 | } 28 | 29 | ngDoCheck() { 30 | // console.log('textbox doCheck'); 31 | } 32 | 33 | ngAfterViewInit() { 34 | // console.log('textbox afterviewinit'); 35 | } 36 | 37 | ngOnDestroy() { 38 | // console.log('textbox destroy'); 39 | } 40 | 41 | onDynamicMount(data: OnDynamicData) { 42 | this.mountContext = data.context; 43 | this.mountContentChildren = (data as any).contentChildren; 44 | } 45 | 46 | onDynamicChanges(data: OnDynamicData) { 47 | if (data.hasOwnProperty('context')) { 48 | this.changesContext = data.context; 49 | } 50 | if (data.hasOwnProperty('contentChildren')) { 51 | this.changesContentChildren = (data as any).contentChildren; 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/multiTagTest/multiTagTest.c.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/multiTagTest/multiTagTest.c.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | } 3 | 4 | .textbox { 5 | margin: 8px 0px; 6 | padding: 5px 10px; 7 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/multiTagTest/multiTagTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ChangeDetectorRef, DoCheck, Optional, Injector, inject, Output, EventEmitter, EnvironmentInjector, Inject, NgZone } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicChanges, OnDynamicMount, OnDynamicData } from '../../../testing-api'; 3 | import { RootTestService } from '../../services/rootTestService'; 4 | import { GENERICINJECTIONTOKEN } from '../../services/genericInjectionToken'; 5 | import { AbstractTestComponent } from '../abstractTest.c'; 6 | 7 | 8 | @Component({ 9 | selector: 'multitagtest', 10 | templateUrl: './multiTagTest.c.html', 11 | styleUrls: ['./multiTagTest.c.scss'], 12 | standalone: true 13 | }) 14 | export class MultiTagTestComponent extends AbstractTestComponent { 15 | constructor ( 16 | cd: ChangeDetectorRef, 17 | ngZone: NgZone, 18 | @Optional() rootTestService: RootTestService, 19 | @Optional() @Inject(GENERICINJECTIONTOKEN) genericInjectionValue: any, 20 | environmentInjector: EnvironmentInjector, 21 | injector: Injector 22 | ) { 23 | super(cd, ngZone, rootTestService, genericInjectionValue, environmentInjector, injector); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/ngContentTest/ngContentTest.c.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

First ng-content:

4 |
5 | 6 |
7 |
8 |
9 |

Second ng-content:

10 |
11 | 12 |
13 |
14 |
15 |

Third ng-content:

16 |
17 | 18 |
19 |
20 |
-------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/ngContentTest/ngContentTest.c.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/ngContentTest/ngContentTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, OnChanges, ChangeDetectorRef, DoCheck } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicChanges, OnDynamicMount, OnDynamicData } from '../../../testing-api'; 3 | 4 | 5 | @Component({ 6 | selector: 'ngcontenttest', 7 | templateUrl: './ngContentTest.c.html', 8 | styleUrls: ['./ngContentTest.c.scss'], 9 | standalone: true 10 | }) 11 | export class NgContentTestComponent implements OnDynamicMount, OnDynamicChanges { 12 | mountContext: any; 13 | mountContentChildren!: Array; 14 | changesContext: any; 15 | changesContentChildren!: Array; 16 | 17 | constructor(private cd: ChangeDetectorRef) { 18 | } 19 | 20 | onDynamicMount(data: OnDynamicData) { 21 | this.mountContext = data.context; 22 | this.mountContentChildren = (data as any).contentChildren; 23 | } 24 | 25 | onDynamicChanges(data: OnDynamicData) { 26 | if (data.hasOwnProperty('context')) { 27 | this.changesContext = data.context; 28 | } 29 | if (data.hasOwnProperty('contentChildren')) { 30 | this.changesContentChildren = (data as any).contentChildren; 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/parentTest/childTest/childTest.c.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/parentTest/childTest/childTest.c.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | } 3 | 4 | .bolder { 5 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/parentTest/childTest/childTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ChangeDetectorRef, DoCheck, Optional, Inject, InjectionToken } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicChanges, OnDynamicMount, OnDynamicData } from '../../../../testing-api'; 3 | import { BLUBBSERVICETOKEN } from '../parentTest.c'; 4 | 5 | @Component({ 6 | selector: 'childtest', 7 | templateUrl: './childTest.c.html', 8 | styleUrls: ['./childTest.c.scss'], 9 | standalone: true 10 | }) 11 | export class ChildTestComponent implements DoCheck, OnInit, OnChanges, AfterViewInit, OnDestroy { 12 | 13 | constructor(private cd: ChangeDetectorRef, @Optional() @Inject(BLUBBSERVICETOKEN) private blubbService: any) { 14 | console.log('CHILD_TEST', this.blubbService); 15 | } 16 | 17 | 18 | ngOnInit () { 19 | // console.log('textbox init'); 20 | } 21 | 22 | ngOnChanges(changes: any) { 23 | // console.log('textbox changes'); 24 | } 25 | 26 | ngDoCheck() { 27 | // console.log('textbox doCheck'); 28 | } 29 | 30 | ngAfterViewInit() { 31 | // console.log('textbox afterviewinit'); 32 | } 33 | 34 | ngOnDestroy() { 35 | // console.log('textbox destroy'); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/parentTest/parentTest.c.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/parentTest/parentTest.c.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | } 3 | 4 | .bolder { 5 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/parentTest/parentTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ChangeDetectorRef, DoCheck, Optional, Inject, InjectionToken, ViewChild } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicChanges, OnDynamicMount, OnDynamicData } from '../../../testing-api'; 3 | import { ChildTestComponent } from './childTest/childTest.c'; 4 | 5 | export const BLUBBSERVICETOKEN = new InjectionToken('the token for the blubb service'); 6 | 7 | @Component({ 8 | selector: 'parenttest', 9 | templateUrl: './parentTest.c.html', 10 | styleUrls: ['./parentTest.c.scss'], 11 | imports: [ChildTestComponent], 12 | providers: [{provide: BLUBBSERVICETOKEN, useValue: { name: 'blubb' } }], 13 | standalone: true 14 | }) 15 | export class ParentTestComponent implements DoCheck, OnInit, OnChanges, AfterViewInit, OnDestroy { 16 | @ViewChild(ChildTestComponent) childTestComponent: any; 17 | 18 | constructor(private cd: ChangeDetectorRef, @Optional() @Inject(BLUBBSERVICETOKEN) private blubbService: any) { 19 | console.log('PARENT_TEST', this.blubbService); 20 | } 21 | 22 | 23 | ngOnInit () { 24 | // console.log('textbox init'); 25 | } 26 | 27 | ngOnChanges(changes: any) { 28 | // console.log('textbox changes'); 29 | } 30 | 31 | ngDoCheck() { 32 | // console.log('textbox doCheck'); 33 | } 34 | 35 | ngAfterViewInit() { 36 | // console.log('textbox afterviewinit'); 37 | } 38 | 39 | ngOnDestroy() { 40 | // console.log('textbox destroy'); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/singleTag/singleTagTest.c.html: -------------------------------------------------------------------------------- 1 |
2 | {{ numberProp }} 3 |
-------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/singleTag/singleTagTest.c.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | } 3 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/singleTag/singleTagTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ChangeDetectorRef, Output, EventEmitter, Inject, DoCheck, Optional, InjectionToken, EnvironmentInjector, Injector, NgZone } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicData, OnDynamicChanges, OnDynamicMount } from '../../../testing-api'; 3 | import { RootTestService } from '../../services/rootTestService'; 4 | import { GENERICINJECTIONTOKEN } from '../../services/genericInjectionToken'; 5 | import { CommonModule } from '@angular/common'; 6 | import { AbstractTestComponent } from '../abstractTest.c'; 7 | 8 | export const SINGLETAGCOMPONENTSERVICE = new InjectionToken('A service that is only provided directly on the SingleTagTestComponent'); 9 | 10 | @Component({ 11 | selector: 'singletagtest', 12 | templateUrl: './singleTagTest.c.html', 13 | styleUrls: ['./singleTagTest.c.scss'], 14 | imports: [CommonModule], 15 | providers: [ 16 | {provide: SINGLETAGCOMPONENTSERVICE, useValue: { name: 'SingleTagComponentService works!' } } 17 | ], 18 | standalone: true 19 | }) 20 | export class SingleTagTestComponent extends AbstractTestComponent { 21 | 22 | constructor ( 23 | cd: ChangeDetectorRef, 24 | ngZone: NgZone, 25 | @Optional() rootTestService: RootTestService, 26 | @Optional() @Inject(GENERICINJECTIONTOKEN) genericInjectionValue: any, 27 | environmentInjector: EnvironmentInjector, 28 | injector: Injector, 29 | @Optional() @Inject(SINGLETAGCOMPONENTSERVICE) private singleTagComponentService: any, 30 | ) { 31 | super(cd, ngZone, rootTestService, genericInjectionValue, environmentInjector, injector); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/whateverTest/whateverTest.c.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/whateverTest/whateverTest.c.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | } 3 | 4 | .bolder { 5 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/components/whateverTest/whateverTest.c.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, AfterViewInit, OnDestroy, Input, OnChanges, ChangeDetectorRef, DoCheck, Optional, Inject, InjectionToken, inject } from '@angular/core'; 2 | import { DynamicContentChild, OnDynamicChanges, OnDynamicMount, OnDynamicData } from '../../../testing-api'; 3 | import { RootTestService } from '../../services/rootTestService'; 4 | 5 | @Component({ 6 | selector: 'whatevertest', 7 | templateUrl: './whateverTest.c.html', 8 | styleUrls: ['./whateverTest.c.scss'], 9 | standalone: true 10 | }) 11 | export class WhateverTestComponent implements OnDynamicMount, OnDynamicChanges, DoCheck, OnInit, OnChanges, AfterViewInit, OnDestroy { 12 | @Input() someString!: string; 13 | @Input() someNumber!: number; 14 | @Input() config: any; 15 | mountContext: any; 16 | mountContentChildren!: Array; 17 | changesContext: any; 18 | changesContentChildren!: Array; 19 | rootTestService: RootTestService; 20 | 21 | constructor(private cd: ChangeDetectorRef) { 22 | // Test DI via inject() 23 | this.rootTestService = inject(RootTestService); 24 | } 25 | 26 | ngOnInit () { 27 | // console.log('textbox init'); 28 | } 29 | 30 | ngOnChanges(changes: any) { 31 | // console.log('textbox changes'); 32 | } 33 | 34 | ngDoCheck() { 35 | // console.log('textbox doCheck'); 36 | } 37 | 38 | ngAfterViewInit() { 39 | // console.log('textbox afterviewinit'); 40 | } 41 | 42 | ngOnDestroy() { 43 | // console.log('textbox destroy'); 44 | } 45 | 46 | onDynamicMount(data: OnDynamicData) { 47 | this.mountContext = data.context; 48 | this.mountContentChildren = (data as any).contentChildren; 49 | } 50 | 51 | onDynamicChanges(data: OnDynamicData) { 52 | if (data.hasOwnProperty('context')) { 53 | this.changesContext = data.context; 54 | } 55 | if (data.hasOwnProperty('contentChildren')) { 56 | this.changesContentChildren = (data as any).contentChildren; 57 | } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/contentString.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from "@angular/core"; 2 | 3 | export const CONTENT_STRING = new InjectionToken('All of the settings registered in the whole app.'); 4 | export const contentString = ` 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | `; 14 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/hyperlanes.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ModuleWithProviders, Component, Inject, ElementRef, Provider } from '@angular/core'; 2 | import { Route, RouterModule } from '@angular/router'; 3 | import { CONTENT_STRING } from './contentString'; 4 | import { DynamicHooksComponent, DynamicHooksInheritance, provideDynamicHooks } from '../../testing-api'; 5 | 6 | @Component({ 7 | selector: 'app-dynamichyperlanes', 8 | template: `
DYNAMIC HYPERLANES COMPONENT
` 9 | }) 10 | export class DynamicHyperlanesComponent {} 11 | 12 | @Component({ 13 | selector: 'app-hyperlanes', 14 | imports: [DynamicHooksComponent], 15 | template: `
16 | Hyperlanes component exists 17 | 18 |
`, 19 | standalone: true 20 | }) 21 | export class HyperlanesComponent { 22 | constructor(public hostElement: ElementRef, @Inject(CONTENT_STRING) public contentString: any) {} 23 | } 24 | 25 | export const getHyperlaneRoutes: () => Route[] = () => { 26 | return [ 27 | { path: '', component: HyperlanesComponent, providers: [ 28 | provideDynamicHooks({ 29 | parsers: [ 30 | {component: DynamicHyperlanesComponent} 31 | ], 32 | inheritance: DynamicHooksInheritance.All 33 | }) 34 | ]} 35 | ]; 36 | } 37 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/planetCities.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, Inject, Optional } from '@angular/core'; 2 | import { Route } from '@angular/router'; 3 | import { DYNAMICHOOKS_ALLSETTINGS, DynamicHooksComponent, DynamicHooksSettings, DynamicHooksInheritance, DynamicHooksService, allSettings, provideDynamicHooks } from '../../testing-api'; 4 | import { CONTENT_STRING } from './contentString'; 5 | 6 | @Component({ 7 | selector: 'app-dynamicplanetcities', 8 | template: `
DYNAMIC PLANET CITIES COMPONENT
` 9 | }) 10 | export class DynamicPlanetCitiesComponent {} 11 | 12 | @Component({ 13 | selector: 'app-dynamicplanetcities-elementinjector', 14 | template: `
DYNAMIC PLANET CITIES FROM ELEMENTINJECTOR COMPONENT
` 15 | }) 16 | export class DynamicPlanetCitiesElementInjectorComponent {} 17 | 18 | export const planetCitiesComponentProviderSettings = { 19 | parsers: [ 20 | {component: DynamicPlanetCitiesElementInjectorComponent} 21 | ], 22 | // inheritance: DynamicHooksInheritance.Linear <-- Should be default 23 | }; 24 | 25 | @Component({ 26 | selector: 'app-planetcities', 27 | imports: [DynamicHooksComponent], 28 | template: `
29 | Planet cities component exists 30 | 31 |
`, 32 | standalone: true, 33 | providers: [ 34 | provideDynamicHooks(planetCitiesComponentProviderSettings) 35 | ] 36 | }) 37 | export class PlanetCitiesComponent { 38 | constructor( 39 | public hostElement: ElementRef, 40 | @Inject(CONTENT_STRING) public contentString: any, 41 | public dynamicHooksService: DynamicHooksService 42 | ) {} 43 | } 44 | 45 | 46 | export const getPlanetCitiesRoutes: () => Route[] = () => { 47 | // Due to component-level providers being immediately loaded once its esmodule is imported anywhere, they can't be easily reset for testing. 48 | // This is a problem for adding the component-level hook config to allSettings for each new test. However, we can simulate this by checking here 49 | // and adding it manually, if its missing. 50 | if (!allSettings.includes(planetCitiesComponentProviderSettings)) { 51 | allSettings.push(planetCitiesComponentProviderSettings); 52 | console.log('Missing DynamicPlanetCitiesElementInjectorComponent from allSettings. Re-adding...'); 53 | } 54 | 55 | return [ 56 | { path: '', component: PlanetCitiesComponent, providers: [ 57 | provideDynamicHooks({ 58 | parsers: [ 59 | {component: DynamicPlanetCitiesComponent} 60 | ], 61 | options: { 62 | sanitize: false 63 | } 64 | }) 65 | ]} 66 | ]; 67 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/planetCountries.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, Inject } from '@angular/core'; 2 | import { Route } from '@angular/router'; 3 | import { DynamicHooksSettings, DynamicHooksInheritance, DYNAMICHOOKS_ANCESTORSETTINGS, DynamicHooksService, provideDynamicHooks, DynamicHooksComponent, DYNAMICHOOKS_MODULESETTINGS } from '../../testing-api'; 4 | import { CONTENT_STRING } from './contentString'; 5 | 6 | @Component({ 7 | selector: 'app-dynamicplanetcountries', 8 | template: `
DYNAMIC PLANET COUNTRIES COMPONENT
` 9 | }) 10 | export class DynamicPlanetCountriesComponent {} 11 | 12 | @Component({ 13 | selector: 'app-planetcountries', 14 | imports: [DynamicHooksComponent], 15 | template: `
16 | Planet countries component exists 17 | {{ contentString.value }} 18 | 19 |
`, 20 | standalone: true 21 | }) 22 | export class PlanetCountriesComponent { 23 | constructor( 24 | public hostElement: ElementRef, 25 | @Inject(CONTENT_STRING) public contentString: any, 26 | @Inject(DYNAMICHOOKS_ANCESTORSETTINGS) public ancestorSettings: DynamicHooksSettings[], 27 | @Inject(DYNAMICHOOKS_MODULESETTINGS) public moduleSettings: DynamicHooksSettings, 28 | public dynamicHooksService: DynamicHooksService 29 | ) { 30 | } 31 | } 32 | 33 | export const getPlanetCountriesRoutes: () => Route[] = () => { 34 | return [ 35 | { path: '', component: PlanetCountriesComponent, providers: [ 36 | provideDynamicHooks({ 37 | parsers: [ 38 | {component: DynamicPlanetCountriesComponent} 39 | ], 40 | options: { 41 | convertHTMLEntities: true, 42 | updateOnPushOnly: true, 43 | compareOutputsByValue: true 44 | }, 45 | inheritance: DynamicHooksInheritance.All 46 | }) 47 | ]} 48 | ]; 49 | } 50 | 51 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/planetSpecies.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, ElementRef } from '@angular/core'; 2 | import { Route } from '@angular/router'; 3 | import { DynamicHooksComponent, DynamicHooksInheritance, provideDynamicHooks } from '../../testing-api'; 4 | import { CONTENT_STRING } from './contentString'; 5 | 6 | @Component({ 7 | selector: 'app-dynamicplanetspecies', 8 | template: `
DYNAMIC PLANET SPECIES COMPONENT
` 9 | }) 10 | export class DynamicPlanetSpeciesComponent {} 11 | 12 | @Component({ 13 | selector: 'app-planetspecies', 14 | imports: [DynamicHooksComponent], 15 | template: `
16 | Planet species component exists 17 | 18 |
`, 19 | standalone: true 20 | }) 21 | export class PlanetSpeciesComponent { 22 | constructor(public hostElement: ElementRef, @Inject(CONTENT_STRING) public contentString: any) {} 23 | } 24 | 25 | export const getPlanetSpeciesRoutes: () => Route[] = () => { 26 | return [ 27 | { path: '', component: PlanetSpeciesComponent, providers: [ 28 | provideDynamicHooks({ 29 | parsers: [ 30 | {component: DynamicPlanetSpeciesComponent} 31 | ], 32 | inheritance: DynamicHooksInheritance.None 33 | }) 34 | ]} 35 | ]; 36 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/planets.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, ElementRef } from '@angular/core'; 2 | import { Route, RouterOutlet } from '@angular/router'; 3 | import { CONTENT_STRING } from './contentString'; 4 | import { getPlanetCitiesRoutes } from './planetCities'; 5 | import { getPlanetCountriesRoutes } from './planetCountries'; 6 | import { getPlanetSpeciesRoutes } from './planetSpecies'; 7 | import { DynamicHooksComponent, DynamicHooksInheritance, provideDynamicHooks } from '../../testing-api'; 8 | 9 | @Component({ 10 | selector: 'app-dynamicplanets', 11 | template: `
DYNAMIC PLANETS COMPONENT
` 12 | }) 13 | export class DynamicPlanetsComponent {} 14 | 15 | @Component({ 16 | selector: 'app-planets', 17 | imports: [DynamicHooksComponent, RouterOutlet], 18 | template: `
19 | Planets component exists 20 | 21 | 22 |
`, 23 | standalone: true 24 | }) 25 | export class PlanetsComponent { 26 | constructor(public hostElement: ElementRef, @Inject(CONTENT_STRING) public contentString: any) {} 27 | } 28 | 29 | export const getPlanetsRoutes: (lazyChildren: boolean) => Route[] = (lazyChildren) => { 30 | return [ 31 | { path: '', 32 | component: PlanetsComponent, 33 | providers: [ 34 | provideDynamicHooks({ 35 | parsers: [ 36 | {component: DynamicPlanetsComponent} 37 | ], 38 | options: { 39 | sanitize: true, 40 | updateOnPushOnly: false, 41 | compareInputsByValue: true 42 | }, 43 | inheritance: DynamicHooksInheritance.All 44 | }) 45 | ], 46 | children: lazyChildren ? [ 47 | { path: 'countries', outlet: 'nestedOutlet', loadChildren: () => new Promise(resolve => resolve(getPlanetCountriesRoutes())) }, 48 | { path: 'cities', outlet: 'nestedOutlet', loadChildren: () => new Promise(resolve => resolve(getPlanetCitiesRoutes())) }, 49 | { path: 'species', outlet: 'nestedOutlet', loadChildren: () => new Promise(resolve => resolve(getPlanetSpeciesRoutes())) } 50 | ] : [ 51 | { path: 'countries', outlet: 'nestedOutlet', children: getPlanetCountriesRoutes() }, 52 | { path: 'cities', outlet: 'nestedOutlet', children: getPlanetCitiesRoutes() }, 53 | { path: 'species', outlet: 'nestedOutlet', children: getPlanetSpeciesRoutes() } 54 | ] 55 | } 56 | ]; 57 | } -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/root.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject, ElementRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { CONTENT_STRING } from './contentString'; 3 | import { DynamicHooksComponent } from '../../testing-api'; 4 | import { RouterOutlet } from '@angular/router'; 5 | 6 | @Component({ 7 | selector: 'app-dynamicroot', 8 | template: `
DYNAMIC ROOT COMPONENT
` 9 | }) 10 | export class DynamicRootComponent {} 11 | 12 | @Component({ 13 | selector: 'app-root', 14 | imports: [DynamicHooksComponent, RouterOutlet], 15 | template: `
16 | Root component exists 17 | 18 | 19 |
`, 20 | standalone: true 21 | }) 22 | export class RootComponent { 23 | constructor(public hostElement: ElementRef, @Inject(CONTENT_STRING) public contentString: any) { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/forChild/stars.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ModuleWithProviders, Component, Inject, ElementRef, Provider } from '@angular/core'; 2 | import { Route, RouterModule } from '@angular/router'; 3 | import { CONTENT_STRING } from './contentString'; 4 | import { DynamicHooksComponent, DynamicHooksInheritance, provideDynamicHooks } from '../../testing-api'; 5 | 6 | @Component({ 7 | selector: 'app-dynamicstars', 8 | template: `
DYNAMIC STARS COMPONENT
` 9 | }) 10 | export class DynamicStarsComponent {} 11 | 12 | @Component({ 13 | selector: 'app-stars', 14 | imports: [DynamicHooksComponent], 15 | template: `
16 | Stars component exists 17 | 18 |
`, 19 | standalone: true 20 | }) 21 | export class StarsComponent { 22 | constructor(public hostElement: ElementRef, @Inject(CONTENT_STRING) public contentString: any) {} 23 | } 24 | 25 | export const getStarsRoutes: () => Route[] = () => { 26 | return [ 27 | { path: '', component: StarsComponent, providers: [ 28 | provideDynamicHooks({ 29 | parsers: [ 30 | {component: DynamicStarsComponent} 31 | ], 32 | options: { 33 | sanitize: false, 34 | convertHTMLEntities: false, 35 | fixParagraphTags: true 36 | }, 37 | inheritance: DynamicHooksInheritance.All 38 | }) 39 | ]} 40 | ]; 41 | } 42 | 43 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/parsers/genericElementParser.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HookParser, HookPosition, HookValue, HookComponentData, HookBindings, HookFinder, ComponentConfig, ParseOptions } from '../../testing-api'; 3 | import { MultiTagTestComponent } from '../components/multiTagTest/multiTagTest.c'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class GenericElementParser implements HookParser { 9 | name: string = 'GenericElementParser'; 10 | component: ComponentConfig = MultiTagTestComponent; 11 | // Callbacks that you can overwrite for testing 12 | onFindHookElements: (contentElement: Element, context: any, options: ParseOptions) => any[] = (contentElement, context, options) => { 13 | return Array.from(contentElement.querySelectorAll('multitag-element')); 14 | }; 15 | onLoadComponent: (hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions) => HookComponentData = (hookId, hookValue, context, childNodes, options) => { 16 | return { 17 | component: this.component 18 | }; 19 | } 20 | onGetBindings: (hookId: number, hookValue: HookValue, context: any, options: ParseOptions) => HookBindings = (hookId, hookValue, context, options) => { 21 | return {}; 22 | } 23 | 24 | constructor(private hookFinder: HookFinder) { 25 | } 26 | 27 | public findHookElements(contentElement: Element, context: any, options: ParseOptions): any[] { 28 | return this.onFindHookElements(contentElement, context, options); 29 | } 30 | 31 | public loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions): HookComponentData { 32 | return this.onLoadComponent(hookId, hookValue, context, childNodes, options); 33 | } 34 | 35 | public getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings { 36 | return this.onGetBindings(hookId, hookValue, context, options); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/parsers/genericMultiTagStringParser.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HookParser, HookPosition, HookValue, HookComponentData, HookBindings, HookFinder, ComponentConfig, ParseOptions } from '../../testing-api'; 3 | import { MultiTagTestComponent } from '../components/multiTagTest/multiTagTest.c'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class GenericMultiTagStringParser implements HookParser { 9 | name: string = 'GenericMultiTagStringParser'; 10 | component: ComponentConfig = MultiTagTestComponent; 11 | // Callbacks that you can overwrite for testing 12 | onFindHooks: (content: string, context: any, options: ParseOptions) => HookPosition[] = (content, context, options) => { 13 | return this.hookFinder.find(content, /\[multitag-string\]/g, /\[\/multitag-string\]/g); 14 | }; 15 | onLoadComponent: (hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions) => HookComponentData = (hookId, hookValue, context, childNodes, options) => { 16 | return { 17 | component: this.component 18 | }; 19 | } 20 | onGetBindings: (hookId: number, hookValue: HookValue, context: any, options: ParseOptions) => HookBindings = (hookId, hookValue, context, options) => { 21 | return {}; 22 | } 23 | 24 | constructor(private hookFinder: HookFinder) { 25 | } 26 | 27 | public findHooks(content: string, context: any, options: ParseOptions): Array { 28 | return this.onFindHooks(content, context, options); 29 | } 30 | 31 | public loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions): HookComponentData { 32 | return this.onLoadComponent(hookId, hookValue, context, childNodes, options); 33 | } 34 | 35 | public getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings { 36 | return this.onGetBindings(hookId, hookValue, context, options); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/parsers/genericSingleTagStringParser.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HookParser, HookPosition, HookValue, HookComponentData, HookBindings, HookFinder, ComponentConfig, ParseOptions } from '../../testing-api'; 3 | import { SingleTagTestComponent } from '../components/singleTag/singleTagTest.c'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class GenericSingleTagStringParser implements HookParser { 9 | name: string = 'GenericSingleTagStringParser'; 10 | component: ComponentConfig = SingleTagTestComponent; 11 | // Callbacks that you can overwrite for testing 12 | onFindHooks: (content: string, context: any, options: ParseOptions) => HookPosition[] = (content, context, options) => { 13 | return this.hookFinder.find(content, /\[singletag-string\]/g); 14 | }; 15 | onLoadComponent: (hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions) => HookComponentData = (hookId, hookValue, context, childNodes, options) => { 16 | return { 17 | component: this.component 18 | }; 19 | } 20 | onGetBindings: (hookId: number, hookValue: HookValue, context: any, options: ParseOptions) => HookBindings = (hookId, hookValue, context, options) => { 21 | return {}; 22 | } 23 | 24 | constructor(private hookFinder: HookFinder) { 25 | } 26 | 27 | public findHooks(content: string, context: any, options: ParseOptions): Array { 28 | return this.onFindHooks(content, context, options); 29 | } 30 | 31 | public loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions): HookComponentData { 32 | return this.onLoadComponent(hookId, hookValue, context, childNodes, options); 33 | } 34 | 35 | public getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings { 36 | return this.onGetBindings(hookId, hookValue, context, options); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/parsers/genericWhateverElementParser.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HookParser, HookPosition, HookValue, HookComponentData, HookBindings, HookFinder, ComponentConfig, ParseOptions } from '../../testing-api'; 3 | import { WhateverTestComponent } from '../components/whateverTest/whateverTest.c'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class GenericWhateverElementParser implements HookParser { 9 | name: string = 'GenericWhateverElementParser'; 10 | component: ComponentConfig = WhateverTestComponent; 11 | // Callbacks that you can overwrite for testing 12 | onFindHookElements: (contentElement: Element, context: any, options: ParseOptions) => any[] = (contentElement, context, options) => { 13 | return Array.from(contentElement.querySelectorAll('whatever-element')); 14 | }; 15 | onLoadComponent: (hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions) => HookComponentData = (hookId, hookValue, context, childNodes, options) => { 16 | return { 17 | component: this.component 18 | }; 19 | } 20 | onGetBindings: (hookId: number, hookValue: HookValue, context: any, options: ParseOptions) => HookBindings = (hookId, hookValue, context, options) => { 21 | return {}; 22 | } 23 | 24 | constructor(private hookFinder: HookFinder) { 25 | } 26 | 27 | public findHookElements(contentElement: Element, context: any, options: ParseOptions): any[] { 28 | return this.onFindHookElements(contentElement, context, options); 29 | } 30 | 31 | public loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions): HookComponentData { 32 | return this.onLoadComponent(hookId, hookValue, context, childNodes, options); 33 | } 34 | 35 | public getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings { 36 | return this.onGetBindings(hookId, hookValue, context, options); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/parsers/genericWhateverStringParser.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HookParser, HookPosition, HookValue, HookComponentData, HookBindings, HookFinder, ComponentConfig, ParseOptions } from '../../testing-api'; 3 | import { WhateverTestComponent } from '../components/whateverTest/whateverTest.c'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class GenericWhateverStringParser implements HookParser { 9 | name: string = 'GenericWhateverStringParser'; 10 | component: ComponentConfig = WhateverTestComponent; 11 | // Callbacks that you can overwrite for testing 12 | onFindHooks: (content: string, context: any, options: ParseOptions) => HookPosition[] = (content, context, options) => { 13 | return this.hookFinder.find(content, /\[whatever-string\]/g, /\[\/whatever-string\]/g); 14 | }; 15 | onLoadComponent: (hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions) => HookComponentData = (hookId, hookValue, context, childNodes, options) => { 16 | return { 17 | component: this.component 18 | }; 19 | } 20 | onGetBindings: (hookId: number, hookValue: HookValue, context: any, options: ParseOptions) => HookBindings = (hookId, hookValue, context, options) => { 21 | return {}; 22 | } 23 | 24 | constructor(private hookFinder: HookFinder) { 25 | } 26 | 27 | public findHooks(content: string, context: any, options: ParseOptions): Array { 28 | return this.onFindHooks(content, context, options); 29 | } 30 | 31 | public loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions): HookComponentData { 32 | return this.onLoadComponent(hookId, hookValue, context, childNodes, options); 33 | } 34 | 35 | public getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings { 36 | return this.onGetBindings(hookId, hookValue, context, options); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/parsers/nonServiceTestParser.ts: -------------------------------------------------------------------------------- 1 | import { HookParser, HookPosition, HookValue, HookComponentData, HookBindings, HookFinder, ParseOptions } from '../../testing-api'; 2 | import { matchAll } from '../../testing-api'; 3 | import { SingleTagTestComponent } from '../components/singleTag/singleTagTest.c'; 4 | 5 | /** 6 | * This parsers serves to test configuring parsers that are classes or instances 7 | */ 8 | export class NonServiceTestParser implements HookParser { 9 | name: string = 'NonServiceTestParser'; 10 | component = SingleTagTestComponent; 11 | 12 | constructor() { 13 | } 14 | 15 | public findHooks(content: string, context: any, options: ParseOptions): Array { 16 | const result: HookPosition[] = []; 17 | 18 | // Find all hooks 19 | const openingTagMatches = matchAll(content, /customhook/g); 20 | 21 | for (const match of openingTagMatches) { 22 | result.push({ 23 | openingTagStartIndex: match.index, 24 | openingTagEndIndex: match.index + match[0].length, 25 | closingTagStartIndex: null, 26 | closingTagEndIndex: null, 27 | }); 28 | } 29 | 30 | return result; 31 | } 32 | 33 | public loadComponent(hookId: number, hookValue: HookValue, context: any, childNodes: Array, options: ParseOptions): HookComponentData { 34 | return { 35 | component: this.component, 36 | injector: undefined 37 | }; 38 | } 39 | 40 | public getBindings(hookId: number, hookValue: HookValue, context: any, options: ParseOptions): HookBindings { 41 | return {}; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/services/genericInjectionToken.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from "@angular/core"; 2 | 3 | export const GENERICINJECTIONTOKEN = new InjectionToken('A token that can be used to inject whatever is needed in various tests'); -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/resources/services/rootTestService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class RootTestService { 7 | someString: string = 'RootTestService works!'; 8 | someObj: any = {test: 'Hello!'}; 9 | 10 | constructor() { 11 | } 12 | 13 | 14 | } 15 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/testing-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Testing API Surface of ngx-dynamic-component-hooks 3 | * This serves as an abstraction layer that the tests can use to access all library components, services, etc. they need. 4 | * It can be temporarily included in public-api.ts to make everything needed for tests publically accessible in case you want to run the tests from a parent project (with the library compiled in node_modules) 5 | */ 6 | 7 | // Public module resources 8 | export { provideDynamicHooks, resetDynamicHooks, allSettings } from '../lib/dynamicHooksProviders'; 9 | export { parse } from '../lib/standalone'; 10 | export { observeElement } from '../lib/standaloneHelper'; 11 | export { DynamicHooksSettings, DynamicHooksInheritance } from '../lib/services/settings/settings'; 12 | export { HookIndex, Hook, PreviousHookBindings, PreviousHookBinding, DynamicContentChild, OnDynamicChanges, OnDynamicMount, OnDynamicData, HookParser, HookPosition, HookValue, HookComponentData, HookBindings, ParseResult, LoadedComponent, ComponentConfig } from '../lib/interfacesPublic'; 13 | export { DynamicHooksComponent } from '../lib/components/dynamicHooksComponent'; 14 | export { DynamicSingleComponent } from '../lib/components/dynamicSingleComponent'; 15 | export { ParseOptions, getParseOptionDefaults } from '../lib/services/settings/options'; 16 | export { HookParserEntry } from '../lib/services/settings/parserEntry'; 17 | export { TextSelectorHookParser } from '../lib/parsers/selector/text/textSelectorHookParser'; 18 | export { ElementSelectorHookParser } from '../lib/parsers/selector/element/elementSelectorHookParser'; 19 | export { SelectorHookParserConfig } from '../lib/parsers/selector/selectorHookParserConfig'; 20 | export { DynamicHooksService } from '../lib/services/dynamicHooksService'; 21 | export { AutoPlatformService } from '../lib/services/platform/autoPlatformService'; 22 | export { DefaultPlatformService } from '../lib/services/platform/defaultPlatformService'; 23 | export { PlatformService, PLATFORM_SERVICE } from '../lib/services/platform/platformService'; 24 | export { DataTypeEncoder } from '../lib/services/utils/dataTypeEncoder'; 25 | export { DataTypeParser } from '../lib/services/utils/dataTypeParser'; 26 | export { DeepComparer } from '../lib/services/utils/deepComparer'; 27 | export { HookFinder } from '../lib/services/utils/hookFinder'; 28 | export { Logger } from '../lib/services/utils/logger'; 29 | export { regexes } from '../lib/constants/regexes'; 30 | export { matchAll } from '../lib/services/utils/utils'; 31 | 32 | // Private module resources 33 | export { DYNAMICHOOKS_ALLSETTINGS, DYNAMICHOOKS_ANCESTORSETTINGS, DYNAMICHOOKS_MODULESETTINGS, SavedBindings} from '../lib/interfaces'; 34 | export { ParserEntryResolver } from '../lib/services/settings/parserEntryResolver'; 35 | export { ComponentCreator } from '../lib/services/core/componentCreator'; 36 | export { ComponentUpdater } from '../lib/services/core/componentUpdater'; 37 | export { TextHookFinder } from '../lib/services/core/textHookFinder'; 38 | export { SelectorHookParserConfigResolver } from '../lib/parsers/selector/selectorHookParserConfigResolver'; 39 | export { BindingsValueManager } from '../lib/parsers/selector/bindingsValueManager'; 40 | export { TagHookFinder } from '../lib/parsers/selector/text/tagHookFinder'; 41 | export * from '../lib/constants/core'; -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/unit/dataTypeEncoder.spec.ts: -------------------------------------------------------------------------------- 1 | import { DataTypeEncoder } from '../testing-api'; 2 | 3 | /** 4 | * DataTypeEncoder tests 5 | */ 6 | describe('DataTypeEncoder', () => { 7 | let dataTypeEncoder: DataTypeEncoder; 8 | beforeEach(() => { dataTypeEncoder = new DataTypeEncoder(); }); 9 | 10 | it('#should throw an error if a substring was not closed properly', () => { 11 | expect(() => dataTypeEncoder.encodeSubstrings('This is a normal "substring". This substring is not "closed.')) 12 | .toThrow(new Error('Input parse error. String was opened, but not closed.')); 13 | }); 14 | 15 | it('#should throw an error if a subfunction is closed without opening it first', () => { 16 | expect(() => dataTypeEncoder.encodeSubfunctions('{prop: func)}')) 17 | .toThrow(new Error('Input parse error. Closed function bracket without opening it first.')); 18 | }); 19 | 20 | it('#should throw an error if a subbracket was not closed properly', () => { 21 | expect(() => dataTypeEncoder.encodeVariableSubbrackets('{prop: context["normal"].something[}')) 22 | .toThrow(new Error('Input parse error. Opened bracket without closing it.')); 23 | }); 24 | 25 | it('#should escape double quotes', () => { 26 | expect(dataTypeEncoder.escapeDoubleQuotes('A text with "double quotes".')).toBe('A text with \\"double quotes\\".'); 27 | }); 28 | }); -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/unit/dataTypeParser.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../../lib/services/utils/logger'; 2 | import { DataTypeEncoder, DataTypeParser, getParseOptionDefaults } from '../testing-api'; 3 | 4 | /** 5 | * DataTypeParser tests 6 | */ 7 | describe('DataTypeParser', () => { 8 | let dataTypeParser: DataTypeParser; 9 | 10 | beforeEach(() => { dataTypeParser = new DataTypeParser(new DataTypeEncoder(), new Logger('browser')); }); 11 | 12 | it('#should throw an error if trying to parse a JSON with forbidden properties', () => { 13 | expect(() => dataTypeParser['parseAsJSON']('{__proto__: false}', true)) 14 | .toThrow(new Error('Setting the "__proto__" property in a hook input object is not allowed.')); 15 | 16 | expect(() => dataTypeParser['parseAsJSON']('{prototype: false}', true)) 17 | .toThrow(new Error('Setting the "prototype" property in a hook input object is not allowed.')); 18 | 19 | expect(() => dataTypeParser['parseAsJSON']('{constructor: false}', true)) 20 | .toThrow(new Error('Setting the "constructor" property in a hook input object is not allowed.')); 21 | }); 22 | 23 | it('#should replace the event keyword with the event placeholder in JSONs', () => { 24 | expect(dataTypeParser['parseAsJSON']('{someProp: $event}', true)).toEqual({someProp: '__EVENT__'}); 25 | }); 26 | 27 | it('#should replace the event placeholder with the event object in JSONs', () => { 28 | const obj: any = {someProp: '__EVENT__'}; 29 | dataTypeParser['loadJSONVariables'](obj, undefined, true, true, true, true, getParseOptionDefaults()); 30 | expect(obj).toEqual({someProp: true}); 31 | }); 32 | 33 | it('#should throw an error if fetching a context variable with a forbidden path', () => { 34 | expect(() => dataTypeParser['fetchContextVariable']({}, [{type: 'property', value: '__proto__'}])) 35 | .toThrow(new Error('Accessing the __proto__ property through a context variable is not allowed.')); 36 | 37 | expect(() => dataTypeParser['fetchContextVariable']({}, [{type: 'property', value: 'prototype'}])) 38 | .toThrow(new Error('Accessing the prototype property through a context variable is not allowed.')); 39 | 40 | expect(() => dataTypeParser['fetchContextVariable']({}, [{type: 'property', value: 'constructor'}])) 41 | .toThrow(new Error('Accessing the constructor property through a context variable is not allowed.')); 42 | }); 43 | 44 | it('#should not de/serialize nested context variables (fix)', () => { 45 | const context = { 46 | randomMethod: (param: any) => ({evaluatedParam: param}), 47 | randomObj: { 48 | thisIsAFunction: (firstParam: any, secondParam: any) => { /* some logic */ } 49 | } 50 | }; 51 | 52 | const firstAttempt = dataTypeParser.evaluate('context.randomMethod(context.randomObj)', context); 53 | expect(firstAttempt.evaluatedParam.thisIsAFunction).toBeDefined(); 54 | expect(typeof firstAttempt.evaluatedParam.thisIsAFunction).toBe('function'); 55 | 56 | // Test event param 57 | // Using a Map as an example, as Maps can't be serialized via JSON.stringify (will be empty object literal) 58 | const event = new Map(); 59 | event.set('firstEntry', 'someStringInMap'); 60 | 61 | const secondAttempt = dataTypeParser.evaluate('context.randomMethod($event)', context, event); 62 | expect(secondAttempt.evaluatedParam instanceof Map).toBeTrue(); 63 | expect(secondAttempt.evaluatedParam.get('firstEntry')).toBe('someStringInMap'); 64 | expect(secondAttempt.evaluatedParam).toBe(event); 65 | }); 66 | 67 | it('#should try to find functions in the __proto__ property if they cannot be found on the context property itself', () => { 68 | // Create method on instance prototype 69 | /* 70 | const Person = function(name: string): void { 71 | this.name = name; 72 | };*/ 73 | 74 | class Person { 75 | name: string; 76 | constructor (name: string) { 77 | this.name = name; 78 | } 79 | } 80 | (Person as any).prototype.getName = function(): string { 81 | return this.name; 82 | }; 83 | const john = new Person('John'); 84 | 85 | // Try to call that method from the context object 86 | const context: any = {author: john}; 87 | const name = dataTypeParser['fetchContextVariable'](context, [ 88 | {type: 'property', value: 'author'}, 89 | {type: 'property', value: 'getName'}, 90 | {type: 'function', value: []} 91 | ]); 92 | 93 | expect(name).toBe('John'); 94 | }); 95 | 96 | }); -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/unit/deepComparer.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../../lib/services/utils/logger'; 2 | import { DeepComparer } from '../testing-api'; 3 | 4 | /** 5 | * DeepComparer tests 6 | */ 7 | describe('DeepComparer', () => { 8 | let deepComparer: DeepComparer; 9 | 10 | beforeEach(() => { deepComparer = new DeepComparer(new Logger('browser')); }); 11 | 12 | it('#should be able to stringify simple numbers', () => { 13 | expect(deepComparer.detailedStringify(123456).result).toBe('123456'); 14 | }); 15 | 16 | it('#should be able to stringify float numbers', () => { 17 | expect(deepComparer.detailedStringify(123.456).result).toBe('123.456'); 18 | }); 19 | 20 | it('#should be able to stringify strings', () => { 21 | expect(deepComparer.detailedStringify('some string').result).toBe('"some string"'); 22 | }); 23 | 24 | it('#should be able to stringify objects', () => { 25 | expect(deepComparer.detailedStringify({ 26 | someProp: true, 27 | array: [1, 2] 28 | }).result).toBe('{"someProp":true,"array":[1,2]}'); 29 | }); 30 | 31 | it('#should preserve undefined in objects', () => { 32 | expect(deepComparer.detailedStringify({ 33 | someProp: undefined 34 | }).result).toBe('{"someProp":"undefined"}'); 35 | }); 36 | 37 | it('#should be able to stringify functions', () => { 38 | expect(deepComparer.detailedStringify({ 39 | someFunc: function (event: any, someParam: any) { console.log("this is a test"); var testVar = true; var anotherVar = ["this", "is", "an", "array"]; } 40 | }).result).toContain('{"someFunc":"function (event, someParam) {'); // The exact function stringification can vary a bit and sometimes has line breaks in it 41 | }); 42 | 43 | it('#should be able to stringify symbols', () => { 44 | expect(deepComparer.detailedStringify({ 45 | someSymbol: Symbol('uniqueSymbol') 46 | }).result).toBe('{"someSymbol":"Symbol(uniqueSymbol)"}'); 47 | }); 48 | 49 | it('#should remove cyclic objects paths', () => { 50 | const parentObject: any = {}; 51 | const childObject: any = {parent: parentObject}; 52 | parentObject['child'] = childObject; 53 | 54 | expect(deepComparer.detailedStringify(parentObject).result).toBe('{"child":{"parent":null}}'); 55 | }); 56 | 57 | it('#should remove cyclic objects paths by recognizing them by their properties, not reference', () => { 58 | const parentObject: any = {name: 'this is the parent'}; 59 | const childObject: any = {name: 'this is the child'}; 60 | const anotherParentObject: any = {name: 'this is the parent'}; 61 | parentObject['child'] = childObject; 62 | childObject['parent'] = anotherParentObject; 63 | anotherParentObject['child'] = childObject; 64 | 65 | expect(deepComparer.detailedStringify(parentObject).result).toBe('{"name":"this is the parent","child":{"name":"this is the child","parent":null}}'); 66 | }); 67 | 68 | it('#should stop at the maximum compareDepth', () => { 69 | const deepObj = { 70 | firstLevel: { 71 | secondLevel: { 72 | thirdLevel: { 73 | fourthLevel: { 74 | fifthLevel: { 75 | var: 'this should be cut off' 76 | } 77 | } 78 | } 79 | } 80 | } 81 | }; 82 | expect(deepComparer.detailedStringify(deepObj, 4).result).toBe('{"firstLevel":{"secondLevel":{"thirdLevel":{"fourthLevel":{"fifthLevel":null}}}}}'); 83 | }); 84 | 85 | it('#should be able to compare two objects by value', () => { 86 | const obj1 = {name: 'Marlene'}; 87 | const obj2 = {name: 'Marlene'}; 88 | expect(deepComparer.isEqual(obj1, obj2)).toBe(true); 89 | }); 90 | 91 | it('#should warn and return false if two object cannot be compared', () => { 92 | // Can't think of an input that would break it, but that doesn't mean one doesn't exist 93 | // So just break it manually by not removing cyclical refs for this test 94 | const childObj: any = {}; 95 | const parentObj: any = {child: childObj}; 96 | childObj['parent'] = parentObj; 97 | spyOn(deepComparer, 'decycle').and.returnValue(parentObj); 98 | spyOn(console, 'warn').and.callThrough(); 99 | 100 | const obj1 = {name: 'Marlene'}; 101 | const obj2 = {name: 'Marlene'}; 102 | expect(deepComparer.isEqual(obj1, obj2)).toBe(false); 103 | expect((console as any).warn['calls'].mostRecent().args[0]).toContain('Objects could not be compared by value as one or both of them could not be stringified. Returning false.'); 104 | }); 105 | 106 | }); -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/unit/hookFinder.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../../lib/services/utils/logger'; 2 | import { HookFinder } from '../testing-api'; 3 | 4 | /** 5 | * HookFinder tests 6 | */ 7 | describe('HookFinder', () => { 8 | let hookFinder: HookFinder; 9 | beforeEach(() => { hookFinder = new HookFinder(new Logger('browser')); }); 10 | 11 | it('#should find singletag hook positions as expected', () => { 12 | const openingTagRegex = /openingTag/g; 13 | const content = ` 14 |

Some html

15 |

Here is an openingTag

16 |

Somewhere in the middle of the content. And another openingTag at the end

17 | `; 18 | 19 | const position = hookFinder.find(content, openingTagRegex); 20 | 21 | expect(position).toEqual([ 22 | { 23 | openingTagStartIndex: 46, 24 | openingTagEndIndex: 56, 25 | closingTagStartIndex: null, 26 | closingTagEndIndex: null 27 | }, 28 | { 29 | openingTagStartIndex: 122, 30 | openingTagEndIndex: 132, 31 | closingTagStartIndex: null, 32 | closingTagEndIndex: null 33 | } 34 | ]); 35 | }); 36 | 37 | it('#should find enclosing hook positions as expected', () => { 38 | const openingTagRegex = /openingTag/g; 39 | const closingTagRegex = /closingTag/g; 40 | const content = ` 41 |

Some enclosing html

42 |

Here is an openingTag

43 |

Then we have a nested openingTag with a closingTag

44 |

And then the outer closingTag

45 | `; 46 | 47 | const position = hookFinder.find(content, openingTagRegex, closingTagRegex); 48 | 49 | expect(position).toEqual([ 50 | { 51 | openingTagStartIndex: 102, 52 | openingTagEndIndex: 112, 53 | closingTagStartIndex: 120, 54 | closingTagEndIndex: 130 55 | }, 56 | { 57 | openingTagStartIndex: 56, 58 | openingTagEndIndex: 66, 59 | closingTagStartIndex: 163, 60 | closingTagEndIndex: 173 61 | } 62 | ]); 63 | }); 64 | 65 | it('#should ignore tags that start before previous tag has ended when finding enclosing hooks', () => { 66 | spyOn(console, 'warn').and.callThrough(); 67 | const openingTagRegex = /openingTag/g; 68 | const closingTagRegex = /Tag/g; 69 | const content = 'Here is an openingTag.'; 70 | 71 | expect(hookFinder.find(content, openingTagRegex, closingTagRegex)).toEqual([]); 72 | expect((console as any).warn['calls'].allArgs()[0]) 73 | .toContain('Syntax error - New tag "Tag" started at position 18 before previous tag "openingTag" ended at position 21. Ignoring.'); 74 | }); 75 | 76 | it('#should ignore closing tags that appear without a corresponding opening tag', () => { 77 | spyOn(console, 'warn').and.callThrough(); 78 | const openingTagRegex = /openingTag/g; 79 | const closingTagRegex = /closingTag/g; 80 | 81 | const content = 'Here is an openingTag and a closingTag. Here is just a closingTag.'; 82 | expect(hookFinder.find(content, openingTagRegex, closingTagRegex)).toEqual([{ openingTagStartIndex: 11, openingTagEndIndex: 21, closingTagStartIndex: 28, closingTagEndIndex: 38 }]); 83 | expect((console as any).warn['calls'].allArgs()[0]) 84 | .toContain('Syntax error - Closing tag without preceding opening tag found: "closingTag". Ignoring.'); 85 | }); 86 | 87 | it('#should skip nested hooks, if not allowed', () => { 88 | const openingTagRegex = /openingTag/g; 89 | const closingTagRegex = /closingTag/g; 90 | 91 | const content = 'Here is the outer openingTag, an inner openingTag, an inner closingTag and an outer closingTag.'; 92 | expect(hookFinder.find(content, openingTagRegex, closingTagRegex, false)).toEqual([{ openingTagStartIndex: 18, openingTagEndIndex: 28, closingTagStartIndex: 84, closingTagEndIndex: 94 }]); 93 | }); 94 | 95 | }); -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/src/tests/unit/polyfills.spec.ts: -------------------------------------------------------------------------------- 1 | import { matchAll } from '../testing-api'; 2 | 3 | // Straight Jasmine testing without Angular's testing support 4 | describe('Polyfills', () => { 5 | 6 | it('#should throw an error if given a non-global regex', () => { 7 | expect(() => matchAll('something', /test/)) 8 | .toThrow(new Error('TypeError: matchAll called with a non-global RegExp argument')); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "types": [] 10 | }, 11 | "exclude": [ 12 | "**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/ngx-dynamic-hooks/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "**/*.spec.ts", 12 | "**/*.d.ts" 13 | ], 14 | "angularCompilerOptions": { 15 | "strictTemplates": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "skipLibCheck": true, 12 | "paths": { 13 | "ngx-dynamic-hooks": [ 14 | "./dist/ngx-dynamic-hooks" 15 | ] 16 | }, 17 | "esModuleInterop": true, 18 | "sourceMap": true, 19 | "declaration": false, 20 | "experimentalDecorators": true, 21 | "moduleResolution": "node", 22 | "importHelpers": true, 23 | "target": "ES2022", 24 | "module": "ES2022", 25 | "useDefineForClassFields": false, 26 | "lib": [ 27 | "ES2022", 28 | "dom" 29 | ] 30 | }, 31 | "angularCompilerOptions": { 32 | "enableI18nLegacyMessageIdFormat": false, 33 | "strictInjectionParameters": true, 34 | "strictInputAccessModifiers": true, 35 | "strictTemplates": true 36 | } 37 | } 38 | --------------------------------------------------------------------------------