diff --git a/projects/ng-dynamic-component/src/lib/dynamic-io-v2/dynamic-io-v2.directive.ts b/projects/ng-dynamic-component/src/lib/dynamic-io-v2/dynamic-io-v2.directive.ts new file mode 100644 index 000000000..f89c6cb2a --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/dynamic-io-v2/dynamic-io-v2.directive.ts @@ -0,0 +1,66 @@ +/* eslint-disable @angular-eslint/no-conflicting-lifecycle */ +import { + Directive, + DoCheck, + Inject, + Input, + OnChanges, + SimpleChanges, +} from '@angular/core'; +import { + DynamicComponentInjector, + DynamicComponentInjectorToken, +} from '../component-injector'; +import { IoService } from '../io'; +import { IoAdapterService } from '../io/io-adapter.service'; +import { IOData } from '../io/io-data'; +import { TemplateParser, TemplateTokeniser } from '../template'; + +@Directive({ + selector: '[ndcDynamicIo]', + exportAs: 'ndcDynamicIo', + providers: [IoService, IoAdapterService], +}) +export class DynamicIoV2Directive implements DoCheck, OnChanges { + @Input() + ndcDynamicIo?: IOData | string | null; + + private get componentInst(): Record { + return ( + (this.compInjector.componentRef?.instance as Record) ?? + {} + ); + } + + constructor( + private ioService: IoAdapterService, + @Inject(DynamicComponentInjectorToken) + private compInjector: DynamicComponentInjector, + ) {} + + async ngOnChanges(changes: SimpleChanges) { + if (changes['ndcDynamicIo'] && typeof this.ndcDynamicIo === 'string') { + this.updateIo(await this.strToIo(this.ndcDynamicIo)); + } + } + + ngDoCheck() { + if (typeof this.ndcDynamicIo !== 'string') { + this.updateIo(this.ndcDynamicIo); + } + } + + private async updateIo(io?: IOData | null) { + this.ioService.update(io); + } + + private strToIo(ioStr: string) { + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, this.componentInst); + const ioPromise = parser.getIo(); + + tokeniser.feed(ioStr); + + return ioPromise; + } +} diff --git a/projects/ng-dynamic-component/src/lib/dynamic-io-v2/dynamic-io-v2.module.ts b/projects/ng-dynamic-component/src/lib/dynamic-io-v2/dynamic-io-v2.module.ts new file mode 100644 index 000000000..0dc6bbdb4 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/dynamic-io-v2/dynamic-io-v2.module.ts @@ -0,0 +1,15 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { ComponentOutletInjectorModule } from '../component-outlet'; +import { DynamicIoV2Directive } from './dynamic-io-v2.directive'; + +/** + * @public + */ +@NgModule({ + imports: [CommonModule], + exports: [DynamicIoV2Directive, ComponentOutletInjectorModule], + declarations: [DynamicIoV2Directive], +}) +export class DynamicIoV2Module {} diff --git a/projects/ng-dynamic-component/src/lib/dynamic-io-v2/index.ts b/projects/ng-dynamic-component/src/lib/dynamic-io-v2/index.ts new file mode 100644 index 000000000..f51822690 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/dynamic-io-v2/index.ts @@ -0,0 +1,2 @@ +export * from './dynamic-io-v2.directive'; +export * from './dynamic-io-v2.module'; diff --git a/projects/ng-dynamic-component/src/lib/io/index.ts b/projects/ng-dynamic-component/src/lib/io/index.ts index 764e7262a..ec314332d 100644 --- a/projects/ng-dynamic-component/src/lib/io/index.ts +++ b/projects/ng-dynamic-component/src/lib/io/index.ts @@ -2,3 +2,5 @@ export * from './types'; export * from './event-argument'; export * from './io.service'; export * from './io-factory.service'; +export * from './io-data'; +export * from './io-adapter.service'; diff --git a/projects/ng-dynamic-component/src/lib/io/io-adapter.service.ts b/projects/ng-dynamic-component/src/lib/io/io-adapter.service.ts new file mode 100644 index 000000000..a11a551b8 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/io/io-adapter.service.ts @@ -0,0 +1,184 @@ +import { Inject, Injectable, KeyValueDiffers } from '@angular/core'; +import { + DynamicComponentInjector, + DynamicComponentInjectorToken, +} from '../component-injector'; +import { IOData } from './io-data'; +import { IoService } from './io.service'; +import { EventHandler, InputsType, OutputsType } from './types'; + +@Injectable() +export class IoAdapterService { + private ioDiffer = this.differs.find({}).create(); + + private inputs: InputsType = {}; + private outputs: OutputsType = {}; + + private get componentInst(): Record { + return ( + (this.compInjector.componentRef?.instance as Record) ?? + {} + ); + } + + constructor( + private differs: KeyValueDiffers, + private ioService: IoService, + @Inject(DynamicComponentInjectorToken) + private compInjector: DynamicComponentInjector, + ) {} + + update(io?: IOData | null): void { + if (!io) { + io = {}; + } + + const ioChanges = this.ioDiffer.diff(io); + + if (!ioChanges) { + return; + } + + ioChanges.forEachRemovedItem((record) => { + const name = this.getIOName(record.key); + delete this.inputs[name]; + delete this.outputs[name]; + }); + + ioChanges.forEachAddedItem((record) => { + this.updateProp(record.key, record.currentValue); + }); + + ioChanges.forEachChangedItem((record) => { + this.updateProp(record.key, record.currentValue); + }); + + this.ioService.update(this.inputs, this.outputs); + } + + private getIOName(prop: string) { + if (prop.startsWith('[') || prop.startsWith('(')) { + return prop.slice(1, -1); + } + + if (prop.startsWith('[(')) { + return prop.slice(2, -2); + } + + return prop; + } + + private updateProp(prop: string, data: unknown) { + if (this.maybeInputBind(prop, data, this.inputs)) { + return; + } + + if (this.maybeOutput(prop, data, this.outputs)) { + return; + } + + if (this.maybeInput2W(prop, data, this.inputs, this.outputs)) { + return; + } + + if (this.maybeInputProp(prop, data, this.inputs)) { + return; + } + + throw new Error(`IoAdapterService: Unknown binding type '${prop}!'`); + } + + private maybeInputBind(prop: string, data: unknown, record: InputsType) { + if (!prop.startsWith('[') || !prop.endsWith(']')) { + return false; + } + + const name = prop.slice(1, -1); + + if (typeof data === 'string' && data in this.componentInst) { + this.addPropGetter(record, name); + return true; + } + + try { + if (typeof data === 'string') { + data = JSON.parse(data); + } + } catch { + throw new Error( + `Input binding must be a string or valid JSON string but given ${typeof data}!`, + ); + } + + record[name] = data; + + return true; + } + + private maybeInputProp(prop: string, data: unknown, inputs: InputsType) { + if (typeof data !== 'string') { + throw new Error(`Input binding should be a string!`); + } + + inputs[prop] = data; + + return true; + } + + private maybeInput2W( + prop: string, + data: unknown, + inputs: InputsType, + outputs: OutputsType, + ) { + if (!prop.startsWith('[(') || !prop.endsWith(')]')) { + return false; + } + + if (typeof data !== 'string') { + throw new Error(`Two-way binding must be a string!`); + } + + const input = prop.slice(2, -2); + const output = `${input}Change`; + + this.addPropGetter(inputs, input, data); + + outputs[output] = (value) => void (this.componentInst[data] = value); + + return true; + } + + private maybeOutput(prop: string, data: unknown, record: OutputsType) { + if (!prop.startsWith('(') || !prop.endsWith(')')) { + return false; + } + + const name = prop.slice(1, -1); + + if (typeof data === 'string' && data in this.componentInst) { + this.addPropGetter(record, name); + return true; + } + + if (typeof data !== 'function') { + throw new Error(`Output binding must be function or method name!`); + } + + record[name] = data as EventHandler; + + return true; + } + + private addPropGetter( + obj: Record, + name: string, + prop = name, + ) { + Object.defineProperty(obj, name, { + configurable: true, + enumerable: true, + get: () => this.componentInst[prop], + }); + } +} diff --git a/projects/ng-dynamic-component/src/lib/io/io-data.ts b/projects/ng-dynamic-component/src/lib/io/io-data.ts new file mode 100644 index 000000000..77ef64165 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/io/io-data.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from '@angular/core'; + +export interface IOData { + [prop: string]: unknown; +} + +type InferEventEmitter = T extends EventEmitter ? E : unknown; + +type SkipPropsByType = { + [K in keyof T]: T[K] extends TSkip ? never : K; +}[keyof T]; + +type PickPropsWithOutputs< + O extends string | number | symbol, + I extends string | number | symbol, +> = O extends `${infer K}Change` ? (K extends I ? K : never) : never; + +export type Inputs = Pick; + +export type InputProps = { + [P in K as `[${P & string}]`]: T[P]; +}; + +export type Inputs2Way = { + [P in K as `([${P & string}])`]: string; +}; + +export type InputsAttrs = { + [P in [] as `[attr.${string}]`]?: string | null; +}; + +export type InputsClasses = { + [P in [] as `[class.${string}]`]?: string | boolean | null; +}; + +export type InputsStyles = { + [P in [] as `[style.${string}]`]?: unknown; +}; + +export type Outputs = { + [P in K as `(${P & string})`]: (event: InferEventEmitter) => void; +}; + +export type IO< + T, + I extends keyof T = SkipPropsByType>, + O extends keyof T = Exclude, + I2W extends keyof T = PickPropsWithOutputs, +> = Partial< + Inputs & + InputProps & + Inputs2Way & + Outputs & + InputsAttrs & + InputsClasses & + InputsStyles & + Record +>; diff --git a/projects/ng-dynamic-component/src/lib/io/io.service.ts b/projects/ng-dynamic-component/src/lib/io/io.service.ts index 8702bc5a8..3e018f23e 100644 --- a/projects/ng-dynamic-component/src/lib/io/io.service.ts +++ b/projects/ng-dynamic-component/src/lib/io/io.service.ts @@ -12,6 +12,8 @@ import { Optional, StaticProvider, Type, + reflectComponentType, + ComponentMirror } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -28,11 +30,11 @@ import { import { EventHandler, InputsType, OutputsType, OutputWithArgs } from './types'; interface IOMapInfo { - propName: string; - templateName: string; + readonly propName: string; + readonly templateName: string; } -type IOMappingList = IOMapInfo[]; +type IOMappingList = readonly IOMapInfo[]; type KeyValueChangesAny = KeyValueChanges; @@ -56,10 +58,7 @@ export class IoService implements OnDestroy { private lastComponentInst: unknown = null; private lastChangedInputs = new Set(); private inputsDiffer = this.differs.find({}).create(); - // TODO: Replace ComponentFactory once new API is created - // @see https://github.com/angular/angular/issues/44926 - // eslint-disable-next-line deprecation/deprecation - private compFactory: ComponentFactory | null = null; + private compMeta: ComponentMirror | null = null; private outputsShouldDisconnect$ = new Subject(); private outputsEventContext: unknown; @@ -161,7 +160,7 @@ export class IoService implements OnDestroy { private updateInputs(isFirstChange = false) { if (isFirstChange) { - this.updateCompFactory(); + this.updateCompMeta(); } const compRef = this.compRef; @@ -227,31 +226,12 @@ export class IoService implements OnDestroy { differ.forEachRemovedItem(addRecordKeyToSet); } - // TODO: Replace ComponentFactory once new API is created - // @see https://github.com/angular/angular/issues/44926 - // eslint-disable-next-line deprecation/deprecation - private resolveCompFactory(): ComponentFactory | null { - if (!this.compRef) { - return null; - } - - try { - try { - return this.cfr.resolveComponentFactory(this.compRef.componentType); - } catch (e) { - // Fallback if componentType does not exist (happens on NgComponentOutlet) - return this.cfr.resolveComponentFactory( - (this.compRef.instance as any).constructor as Type, - ); - } - } catch (e) { - // Factory not available - bailout - return null; - } + private resolveCompMeta(): ComponentMirror | null { + return this.compRef && reflectComponentType(this.compRef.componentType); } - private updateCompFactory() { - this.compFactory = this.resolveCompFactory(); + private updateCompMeta() { + this.compMeta = this.resolveCompMeta(); } private resolveOutputs(outputs: OutputsType): OutputsType { @@ -259,11 +239,11 @@ export class IoService implements OnDestroy { outputs = this.processOutputs(outputs); - if (!this.compFactory) { + if (!this.compMeta) { return outputs; } - return this.remapIO(outputs, this.compFactory.outputs); + return this.remapIO(outputs, this.compMeta.outputs); } private updateOutputsEventContext() { diff --git a/projects/ng-dynamic-component/src/lib/template/index.ts b/projects/ng-dynamic-component/src/lib/template/index.ts new file mode 100644 index 000000000..2f5f20f37 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/index.ts @@ -0,0 +1,2 @@ +export * from './parser'; +export * from './tokeniser'; diff --git a/projects/ng-dynamic-component/src/lib/template/parser.spec.ts b/projects/ng-dynamic-component/src/lib/template/parser.spec.ts new file mode 100644 index 000000000..a609d2b0c --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/parser.spec.ts @@ -0,0 +1,135 @@ +import { TemplateParser } from './parser'; +import { TemplateTokeniser } from './tokeniser'; + +describe('TemplateParser', () => { + it('should parse IO object from tokens and component', async () => { + const component = { prop: 'val', handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('[input]=prop (output)=handler($event)'); + + await expect(io).resolves.toMatchObject({ + '[input]': 'val', + '(output)': { + handler: expect.any(Function), + args: ['$event'], + }, + }); + + ((await io)['(output)'] as any).handler('mock-event'); + + expect(component.handler).toHaveBeenCalledWith('mock-event'); + }); + + describe('inputs', () => { + it('should parse plain input', async () => { + const component = { prop: 'val' }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('input=prop '); + + await expect(io).resolves.toMatchObject({ + input: 'val', + }); + }); + + it('should parse prop input', async () => { + const component = { prop: 'val' }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('[input]=prop '); + + await expect(io).resolves.toMatchObject({ + '[input]': 'val', + }); + }); + + it('should NOT parse input with quotes', async () => { + const component = { '"prop"': 'val' }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('[input]="prop" '); + + await expect(io).resolves.toMatchObject({ + '[input]': 'val', + }); + }); + }); + + describe('outputs', () => { + it('should parse output without args', async () => { + const component = { handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('(output)=handler()'); + + await expect(io).resolves.toMatchObject({ + '(output)': { + handler: expect.any(Function), + args: [], + }, + }); + + ((await io)['(output)'] as any).handler(); + + expect(component.handler).toHaveBeenCalledWith(); + }); + + it('should parse output with one arg', async () => { + const component = { handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('(output)=handler($event)'); + + await expect(io).resolves.toMatchObject({ + '(output)': { + handler: expect.any(Function), + args: ['$event'], + }, + }); + + ((await io)['(output)'] as any).handler('mock-event'); + + expect(component.handler).toHaveBeenCalledWith('mock-event'); + }); + + it('should parse output with multiple args', async () => { + const component = { handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('(output)=handler($event, prop)'); + + await expect(io).resolves.toMatchObject({ + '(output)': { + handler: expect.any(Function), + args: ['$event', 'prop'], + }, + }); + + ((await io)['(output)'] as any).handler('mock-event', 'val'); + + expect(component.handler).toHaveBeenCalledWith('mock-event', 'val'); + }); + }); +}); diff --git a/projects/ng-dynamic-component/src/lib/template/parser.ts b/projects/ng-dynamic-component/src/lib/template/parser.ts new file mode 100644 index 000000000..ec51b2fb9 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/parser.ts @@ -0,0 +1,169 @@ +import { OutputWithArgs } from '../io'; +import { + TemplateToken, + TemplateTokenAssignment, + TemplateTokenComma, + TemplateTokenInputPropBindingClose, + TemplateTokenInputPropBindingOpen, + TemplateTokeniser, + TemplateTokenOutputBindingClose, + TemplateTokenOutputBindingOpen, + TemplateTokenString, + TemplateTokenMap, +} from './tokeniser'; + +enum TemplateParserState { + Idle, + InInput, + InOutput, + InValue, + InArgs, +} + +export class TemplateParser { + constructor( + protected tokeniser: TemplateTokeniser, + protected component: Record, + protected tokenMap = TemplateTokenMap, + ) {} + + async getIo() { + const io: Record = {}; + + let state = TemplateParserState.Idle; + let lastState = TemplateParserState.Idle; + let ioBinding = ''; + + for await (const token of this.tokeniser) { + if (token instanceof TemplateTokenInputPropBindingOpen) { + if (state !== TemplateParserState.Idle) { + throw new TemplateParserError('Unexpected input binding', token); + } + + state = TemplateParserState.InInput; + ioBinding += this.tokenMap.InputPropBindingOpen; + continue; + } else if (token instanceof TemplateTokenInputPropBindingClose) { + if (state !== TemplateParserState.InInput) { + throw new TemplateParserError( + 'Unexpected input binding closing', + token, + ); + } + + ioBinding += this.tokenMap.InputPropBindingClose; + io[ioBinding] = undefined; + continue; + } else if (token instanceof TemplateTokenOutputBindingOpen) { + if ( + state !== TemplateParserState.Idle && + state !== TemplateParserState.InOutput + ) { + throw new TemplateParserError('Unexpected output binding', token); + } + + if (state === TemplateParserState.InOutput) { + state = TemplateParserState.InArgs; + } else { + state = TemplateParserState.InOutput; + ioBinding += this.tokenMap.OutputBindingOpen; + } + + continue; + } else if (token instanceof TemplateTokenOutputBindingClose) { + if ( + state !== TemplateParserState.InOutput && + state !== TemplateParserState.InArgs + ) { + throw new TemplateParserError( + 'Unexpected output binding closing', + token, + ); + } + + if (state === TemplateParserState.InArgs) { + state = TemplateParserState.Idle; + ioBinding = ''; + } else { + ioBinding += this.tokenMap.OutputBindingClose; + io[ioBinding] = undefined; + } + + continue; + } else if (token instanceof TemplateTokenAssignment) { + if ( + state !== TemplateParserState.InInput && + (state as any) !== TemplateParserState.InOutput + ) { + throw new TemplateParserError('Unexpected assignment', token); + } + + lastState = state; + state = TemplateParserState.InValue; + continue; + } else if (token instanceof TemplateTokenString) { + if ( + state === TemplateParserState.InInput || + state === TemplateParserState.InOutput + ) { + ioBinding += token.string; + continue; + } else if (state === TemplateParserState.InValue) { + if (lastState === TemplateParserState.InInput) { + delete io[ioBinding]; + Object.defineProperty(io, ioBinding, { + enumerable: true, + configurable: true, + get: () => this.component[token.string], + }); + state = lastState = TemplateParserState.Idle; + ioBinding = ''; + continue; + } else if (lastState === TemplateParserState.InOutput) { + const handler = () => this.component[token.string] as any; + io[ioBinding] = { + get handler() { + return handler(); + }, + args: [], + } as OutputWithArgs; + state = TemplateParserState.InOutput; + lastState = TemplateParserState.Idle; + continue; + } + + throw new TemplateParserError('Unexpected identifier', token); + } else if (state === TemplateParserState.InArgs) { + (io[ioBinding] as OutputWithArgs).args!.push(token.string); + continue; + } else if (state === TemplateParserState.Idle) { + state = TemplateParserState.InInput; + ioBinding = token.string; + io[ioBinding] = undefined; + continue; + } + + throw new TemplateParserError('Unexpected identifier', token); + } else if (token instanceof TemplateTokenComma) { + if (state !== TemplateParserState.InArgs) { + throw new TemplateParserError('Unexpected comma', token); + } + continue; + } + + throw new TemplateParserError('Unexpected token', token); + } + + return io; + } +} + +export class TemplateParserError extends Error { + constructor(reason: string, token: TemplateToken) { + super( + `${reason} ${token.constructor.name}` + + ` at (${token.start}:${token.end})` + + `\n${JSON.stringify(token, null, 2)}`, + ); + } +} diff --git a/projects/ng-dynamic-component/src/lib/template/tokeniser.spec.ts b/projects/ng-dynamic-component/src/lib/template/tokeniser.spec.ts new file mode 100644 index 000000000..94645b1ea --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/tokeniser.spec.ts @@ -0,0 +1,165 @@ +import { + TemplateTokenAssignment, + TemplateTokenString, + TemplateTokenInputPropBindingClose, + TemplateTokenInputPropBindingOpen, + TemplateTokeniser, + TemplateTokenOutputBindingClose, + TemplateTokenOutputBindingOpen, + TemplateToken, + TemplateTokenComma, +} from './tokeniser'; + +describe('TemplateTokeniser', () => { + it('should produce no tokens without template', async () => { + const tokeniser = new TemplateTokeniser(); + + await expect(tokeniser.getAll()).resolves.toEqual([]); + }); + + it('should produce no tokens from empty template', async () => { + const tokeniser = new TemplateTokeniser(); + + tokeniser.feed(''); + + await expect(tokeniser.getAll()).resolves.toEqual([]); + }); + + it('should produce tokens from template', async () => { + const tokeniser = new TemplateTokeniser(); + + tokeniser.feed('[input]=prop (out'); + tokeniser.feed('put)=handler($eve'); + tokeniser.feed('nt, p'); + tokeniser.feed('rop)'); + + await expect(tokeniser.getAll()).resolves.toEqual([ + new TemplateTokenInputPropBindingOpen(0, 1), + new TemplateTokenString('input', 1, 6), + new TemplateTokenInputPropBindingClose(6, 7), + new TemplateTokenAssignment(7, 8), + new TemplateTokenString('prop', 8, 12), + new TemplateTokenOutputBindingOpen(13, 14), + new TemplateTokenString('output', 14, 20), + new TemplateTokenOutputBindingClose(20, 21), + new TemplateTokenAssignment(21, 22), + new TemplateTokenString('handler', 22, 29), + new TemplateTokenOutputBindingOpen(29, 30), + new TemplateTokenString('$event', 30, 36), + new TemplateTokenComma(36, 37), + new TemplateTokenString('prop', 38, 42), + new TemplateTokenOutputBindingClose(42, 43), + ]); + }); + + it('should produce tokens from template stream', async () => { + const tokeniser = new TemplateTokeniser(); + const stream = new ControlledStream(); + + tokeniser.feed(stream); + + const tokenStream = tokeniser.getStream(); + + let actualTokens: Promise>[] = []; + let expectedTokens: IteratorResult[] = []; + + function collectNextToken(expectedToken: TemplateToken | null) { + expectedTokens.push({ + value: expectedToken ?? undefined, + done: !expectedToken, + } as IteratorResult); + actualTokens.push(tokenStream.next()); + } + + collectNextToken(new TemplateTokenInputPropBindingOpen(0, 1)); + collectNextToken(new TemplateTokenString('input', 1, 6)); + collectNextToken(new TemplateTokenInputPropBindingClose(6, 7)); + collectNextToken(new TemplateTokenAssignment(7, 8)); + collectNextToken(new TemplateTokenString('prop', 8, 12)); + collectNextToken(new TemplateTokenOutputBindingOpen(13, 14)); + + await stream.flushBuffer(['[input]=prop', ' (out']); + + await expect(Promise.all(actualTokens)).resolves.toEqual(expectedTokens); + + actualTokens = []; + expectedTokens = []; + + collectNextToken(new TemplateTokenString('output', 14, 20)); + collectNextToken(new TemplateTokenOutputBindingClose(20, 21)); + collectNextToken(new TemplateTokenAssignment(21, 22)); + collectNextToken(new TemplateTokenString('handler', 22, 29)); + collectNextToken(new TemplateTokenOutputBindingOpen(29, 30)); + collectNextToken(new TemplateTokenOutputBindingClose(30, 31)); + collectNextToken(null); + + await stream.flushBuffer(['put)=handler()', null]); + + await expect(Promise.all(actualTokens)).resolves.toEqual(expectedTokens); + }); +}); + +class ControlledStream implements AsyncIterable { + protected finished = false; + protected bufferPromise?: Promise<(T | null)[]>; + protected bufferFlushedPromise?: Promise; + protected _flushBuffer = (buffer: (T | null)[]) => Promise.resolve(); + protected bufferFlushed = () => {}; + + async *[Symbol.asyncIterator](): AsyncIterableIterator { + yield* this.getStream(); + } + + /** + * Flushes the buffer and resolves once buffer has been drained + * by the tokenizer and controls are ready for next setup + * `null` indicates the end of the stream + */ + flushBuffer(buffer: (T | null)[]): Promise { + return this._flushBuffer(buffer); + } + + async *getStream(): AsyncGenerator { + this.resetControls(); + + while (!this.finished) { + const buf = await this.bufferPromise!; + let i = 0; + + for (const template of buf) { + // Final yield will block this function + // so we need to schedule `bufferFlushed` call + // when we are on the last item in current buffer + // and reset controls before `bufferFlushed` call + // so the tests can prepare next buffer once call is done + if (++i >= buf.length) { + setTimeout(() => { + const _bufferFlushed = this.bufferFlushed; + this.resetControls(); + _bufferFlushed(); + }); + } + + if (template) { + yield template; + } else { + this.finished = true; + break; + } + } + } + } + + protected resetControls() { + this.bufferFlushedPromise = new Promise( + (res) => (this.bufferFlushed = res), + ); + this.bufferPromise = new Promise<(T | null)[]>( + (res) => + (this._flushBuffer = (buffer) => { + res(buffer); + return this.bufferFlushedPromise!; + }), + ); + } +} diff --git a/projects/ng-dynamic-component/src/lib/template/tokeniser.ts b/projects/ng-dynamic-component/src/lib/template/tokeniser.ts new file mode 100644 index 000000000..283648441 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/tokeniser.ts @@ -0,0 +1,193 @@ +export class TemplateToken { + constructor(public start: number, public end: number) {} +} + +export class TemplateTokenString extends TemplateToken { + constructor(public string: string, start: number, end: number) { + super(start, end); + } +} +export class TemplateTokenAssignment extends TemplateToken {} +export class TemplateTokenComma extends TemplateToken {} +export class TemplateTokenInputPropBindingOpen extends TemplateToken {} +export class TemplateTokenInputPropBindingClose extends TemplateToken {} +export class TemplateTokenOutputBindingOpen extends TemplateToken {} +export class TemplateTokenOutputBindingClose extends TemplateToken {} + +export enum TemplateTokenMap { + Space = ' ', + Assignment = '=', + Comma = ',', + InputPropBindingOpen = '[', + InputPropBindingClose = ']', + OutputBindingOpen = '(', + OutputBindingClose = ')', +} + +export class TemplateTokeniser implements AsyncIterable { + protected templatesIters: (Iterator | AsyncIterator)[] = []; + protected templatesQueue: string[] = []; + + protected currentTemplate?: string; + protected currentPos = 0; + protected totalPos = 0; + protected nextToken?: TemplateToken; + protected lastToken?: TemplateToken; + + constructor(protected tokenMap = TemplateTokenMap) {} + + async *[Symbol.asyncIterator](): AsyncIterableIterator { + yield* this.getStream(); + } + + feed(template: string | Iterable | AsyncIterable) { + if (typeof template === 'string') { + this.templatesQueue.push(template); + } else if (this.isIterable(template)) { + this.templatesIters.push(template[Symbol.iterator]()); + } else { + this.templatesIters.push(template[Symbol.asyncIterator]()); + } + } + + async getAll(): Promise { + const array: TemplateToken[] = []; + for await (const item of this) { + array.push(item); + } + return array; + } + + async *getStream(): AsyncIterableIterator { + while (await this.nextTemplate()) { + if (this.nextToken) { + yield this.consumeNextToken()!; + } + + const token = this.consumeToken() ?? this.consumeNextToken(); + + if (token) { + yield token; + } + } + + if (this.nextToken) { + yield this.consumeNextToken()!; + } + } + + protected consumeToken() { + let token = this.consumeLastToken(); + let i = this.currentPos; + let tokenEnded = false; + let lastCharIdx = this.currentTemplate!.length - 1; + + for (i; i <= lastCharIdx; i++) { + const char = this.currentTemplate![i]; + const posStart = this.totalPos + i; + const posEnd = posStart + 1; + + switch (char) { + case this.tokenMap.Space: + tokenEnded = true; + break; + case this.tokenMap.Assignment: + this.nextToken = new TemplateTokenAssignment(posStart, posEnd); + break; + case this.tokenMap.Comma: + this.nextToken = new TemplateTokenComma(posStart, posEnd); + break; + case this.tokenMap.InputPropBindingOpen: + this.nextToken = new TemplateTokenInputPropBindingOpen( + posStart, + posEnd, + ); + break; + case this.tokenMap.InputPropBindingClose: + this.nextToken = new TemplateTokenInputPropBindingClose( + posStart, + posEnd, + ); + break; + case this.tokenMap.OutputBindingOpen: + this.nextToken = new TemplateTokenOutputBindingOpen(posStart, posEnd); + break; + case this.tokenMap.OutputBindingClose: + this.nextToken = new TemplateTokenOutputBindingClose( + posStart, + posEnd, + ); + break; + default: + if (!token || token instanceof TemplateTokenString === false) { + token = new TemplateTokenString(char, posStart, posEnd); + } else { + (token as TemplateTokenString).string += char; + token.end++; + } + if (i >= lastCharIdx) { + this.lastToken = token; + token = undefined; + } + break; + } + + if (this.nextToken || (tokenEnded && (token || this.nextToken))) { + i++; + break; + } else { + tokenEnded = false; + } + } + + this.currentPos = i; + + return token; + } + + protected consumeNextToken() { + const token = this.nextToken; + this.nextToken = undefined; + return token; + } + + protected consumeLastToken() { + const token = this.lastToken; + this.lastToken = undefined; + return token; + } + + protected async nextTemplate() { + if ( + !this.currentTemplate || + this.currentPos >= this.currentTemplate.length + ) { + if (!this.templatesQueue.length) { + await this.drainTemplateIters(); + } + + this.currentTemplate = this.templatesQueue.shift(); + this.totalPos += this.currentPos; + this.currentPos = 0; + } + + return this.currentTemplate; + } + + protected async drainTemplateIters() { + for (const iter of this.templatesIters) { + const result = await iter.next(); + + if (!result.done) { + this.templatesQueue.push(result.value); + break; + } else { + this.templatesIters.shift(); + } + } + } + + protected isIterable(val: unknown | Iterable): val is Iterable { + return typeof val === 'object' && !!val && Symbol.iterator in val; + } +} diff --git a/projects/ng-dynamic-component/src/public-api.ts b/projects/ng-dynamic-component/src/public-api.ts index 427d23163..ac25b941d 100644 --- a/projects/ng-dynamic-component/src/public-api.ts +++ b/projects/ng-dynamic-component/src/public-api.ts @@ -8,6 +8,7 @@ export * from './lib/component-outlet'; export * from './lib/dynamic.module'; export * from './lib/dynamic.component'; export * from './lib/dynamic-io'; +export * from './lib/dynamic-io-v2'; export * from './lib/dynamic-attributes'; export * from './lib/dynamic-directives'; export * from './lib/reflect';