From a8b52f5428abeefc79e6ee4187159a483e0042a1 Mon Sep 17 00:00:00 2001 From: Rob Eisenberg Date: Tue, 15 Mar 2022 13:22:15 -0400 Subject: [PATCH] feat: aspected html directive exposes metadata (#5739) * feat: aspected html directive exposes metadata * test: expand tests around aspected directives * Change files * chore: add missing comment to change file * replace Parser with Compiler * fix: remove dependency on this from markup helpers Co-authored-by: EisenbergEffect Co-authored-by: nicholasrice --- ...-4013c3d3-7d0e-405e-b3d0-c516701f2438.json | 7 + .../fast-element/docs/api-report.md | 55 ++---- .../web-components/fast-element/src/index.ts | 1 - .../fast-element/src/templating/binding.ts | 83 ++++----- .../src/templating/compiler.spec.ts | 4 +- .../fast-element/src/templating/compiler.ts | 176 +++++++++++++----- .../src/templating/html-directive.ts | 71 +++++-- .../fast-element/src/templating/markup.ts | 55 +----- .../src/templating/template.spec.ts | 125 +++++++++++-- .../fast-element/src/templating/template.ts | 35 +--- .../src/template-parser/template-parser.ts | 6 +- 11 files changed, 372 insertions(+), 246 deletions(-) create mode 100644 change/@microsoft-fast-element-4013c3d3-7d0e-405e-b3d0-c516701f2438.json diff --git a/change/@microsoft-fast-element-4013c3d3-7d0e-405e-b3d0-c516701f2438.json b/change/@microsoft-fast-element-4013c3d3-7d0e-405e-b3d0-c516701f2438.json new file mode 100644 index 00000000000..949fd2a6d79 --- /dev/null +++ b/change/@microsoft-fast-element-4013c3d3-7d0e-405e-b3d0-c516701f2438.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "feat: aspected html directive exposes metadata", + "packageName": "@microsoft/fast-element", + "email": "roeisenb@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 7a6863579ec..80789753aad 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -16,9 +16,13 @@ export interface Accessor { // @public export abstract class AspectedHTMLDirective extends HTMLDirective { + // Warning: (ae-forgotten-export) The symbol "Aspect" needs to be exported by the entry point index.d.ts + abstract readonly aspect: Aspect; + abstract readonly binding?: Binding; + abstract captureSource(source: string): void; createPlaceholder: (index: number) => string; - // (undocumented) - abstract setAspect(value: string): void; + abstract readonly source: string; + abstract readonly target: string; } // @public @@ -80,20 +84,7 @@ export interface BindingConfig { } // @alpha (undocumented) -export interface BindingMode { - // (undocumented) - attribute: BindingType; - // (undocumented) - booleanAttribute: BindingType; - // (undocumented) - content: BindingType; - // (undocumented) - event: BindingType; - // (undocumented) - property: BindingType; - // (undocumented) - tokenList: BindingType; -} +export type BindingMode = Record; // @public export interface BindingObserver extends Notifier { @@ -140,7 +131,16 @@ export class ChildrenDirective extends NodeObservationDirective = ChildListDirectiveOptions | SubtreeDirectiveOptions; // @public -export function compileTemplate(html: string | HTMLTemplateElement, directives: ReadonlyArray): HTMLTemplateCompilationResult; +export type CompilationStrategy = ( +html: string | HTMLTemplateElement, +directives: readonly HTMLDirective[]) => HTMLTemplateCompilationResult; + +// @public +export const Compiler: { + compile(html: string | HTMLTemplateElement, directives: ReadonlyArray): HTMLTemplateCompilationResult; + setDefaultStrategy(strategy: CompilationStrategy): void; + aggregate(parts: (string | HTMLDirective)[]): HTMLDirective; +}; // @public export type ComposableStyles = string | ElementStyles | CSSStyleSheet; @@ -350,11 +350,6 @@ export interface HTMLTemplateCompilationResult { createView(hostBindingTarget?: Element): HTMLView; } -// @public -export type HTMLTemplateCompiler = ( -html: string | HTMLTemplateElement, -directives: readonly HTMLDirective[]) => HTMLTemplateCompilationResult; - // @public export class HTMLView implements ElementView, SyntheticView { constructor(fragment: DocumentFragment, factories: ReadonlyArray, targets: ViewBehaviorTargets); @@ -371,14 +366,6 @@ export class HTMLView implemen unbind(): void; } -// @public -export abstract class InlinableHTMLDirective extends AspectedHTMLDirective { - // (undocumented) - abstract readonly binding: Binding; - // (undocumented) - abstract readonly rawAspect?: string; -} - // Warning: (ae-internal-missing-underscore) The name "KernelServiceId" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -395,9 +382,9 @@ export const enum KernelServiceId { // @public export const Markup: Readonly<{ - interpolation(index: number): string; - attribute(index: number): string; - comment(index: number): string; + interpolation: (index: number) => string; + attribute: (index: number) => string; + comment: (index: number) => string; }>; // Warning: (ae-internal-missing-underscore) The name "Mutable" should be prefixed with an underscore because the declaration is marked as @internal @@ -457,7 +444,6 @@ export const oneTime: BindingConfig & BindingConfigResolv // @public export const Parser: Readonly<{ parse(value: string, directives: readonly HTMLDirective[]): (string | HTMLDirective)[] | null; - aggregate(parts: (string | HTMLDirective)[]): HTMLDirective; }>; // @public @@ -645,7 +631,6 @@ export class ViewTemplate impl readonly directives: ReadonlyArray; readonly html: string | HTMLTemplateElement; render(source: TSource, host: Node, hostBindingTarget?: Element): HTMLView; - static setDefaultCompiler(compiler: HTMLTemplateCompiler): void; } // @public diff --git a/packages/web-components/fast-element/src/index.ts b/packages/web-components/fast-element/src/index.ts index a947c4cb003..a55722f21a6 100644 --- a/packages/web-components/fast-element/src/index.ts +++ b/packages/web-components/fast-element/src/index.ts @@ -41,7 +41,6 @@ export { ViewBehaviorFactory, HTMLDirective, AspectedHTMLDirective, - InlinableHTMLDirective, } from "./templating/html-directive.js"; export * from "./templating/ref.js"; export * from "./templating/when.js"; diff --git a/packages/web-components/fast-element/src/templating/binding.ts b/packages/web-components/fast-element/src/templating/binding.ts index b6e0ebae64b..57cae98380a 100644 --- a/packages/web-components/fast-element/src/templating/binding.ts +++ b/packages/web-components/fast-element/src/templating/binding.ts @@ -7,7 +7,8 @@ import { Observable, } from "../observation/observable.js"; import { - InlinableHTMLDirective, + Aspect, + AspectedHTMLDirective, ViewBehavior, ViewBehaviorTargets, } from "./html-directive.js"; @@ -38,14 +39,7 @@ export const notSupportedBindingType: BindingType = () => { /** * @alpha */ -export interface BindingMode { - attribute: BindingType; - booleanAttribute: BindingType; - property: BindingType; - content: BindingType; - tokenList: BindingType; - event: BindingType; -} +export type BindingMode = Record; /** * @alpha @@ -252,14 +246,12 @@ class TargetUpdateBinding extends BindingBase { eventType: BindingType = notSupportedBindingType ): BindingMode { return Object.freeze({ - attribute: this.createType(DOM.setAttribute), - booleanAttribute: this.createType(DOM.setBooleanAttribute), - property: this.createType( - (target, aspect, value) => (target[aspect] = value) - ), - content: createContentBinding(this).createType(updateContentTarget), - tokenList: this.createType(updateTokenListTarget), - event: eventType, + [Aspect.attribute]: this.createType(DOM.setAttribute), + [Aspect.booleanAttribute]: this.createType(DOM.setBooleanAttribute), + [Aspect.property]: this.createType((t, a, v) => (t[a] = v)), + [Aspect.content]: createContentBinding(this).createType(updateContentTarget), + [Aspect.tokenList]: this.createType(updateTokenListTarget), + [Aspect.event]: eventType, }); } @@ -274,7 +266,7 @@ class OneTimeBinding extends TargetUpdateBinding { const target = targets[directive.targetId]; this.updateTarget( target, - directive.aspect!, + directive.target!, directive.binding(source, context), source, context @@ -306,7 +298,7 @@ class OnSignalBinding extends TargetUpdateBinding { const handler = (target[directive.uniqueId] = () => { this.updateTarget( target, - directive.aspect!, + directive.target!, directive.binding(source, context), source, context @@ -378,7 +370,7 @@ class OnChangeBinding extends TargetUpdateBinding { this.updateTarget( target, - directive.aspect!, + directive.target!, observer.observe(source, context), source, context @@ -401,7 +393,7 @@ class OnChangeBinding extends TargetUpdateBinding { const context = (observer as any).context; this.updateTarget( target, - this.directive.aspect!, + this.directive.target!, observer.observe(source, context!), source, context @@ -430,7 +422,7 @@ class EventListener extends BindingBase { const target = targets[directive.targetId] as FASTEventSource; target.$fastSource = source; target.$fastContext = context; - target.addEventListener(directive.aspect!, this, directive.options); + target.addEventListener(directive.target!, this, directive.options); } unbind(source: any, context: ExecutionContext, targets: ViewBehaviorTargets): void { @@ -440,7 +432,7 @@ class EventListener extends BindingBase { protected removeEventListener(target: FASTEventSource): void { target.$fastSource = null; target.$fastContext = null; - target.removeEventListener(this.directive.aspect!, this, this.directive.options); + target.removeEventListener(this.directive.target!, this, this.directive.options); } handleEvent(event: Event): void { @@ -504,11 +496,12 @@ export const signal = (options: string | Binding): BindingConfig /** * @internal */ -export class HTMLBindingDirective extends InlinableHTMLDirective { - private factory!: BindingBehaviorFactory; +export class HTMLBindingDirective extends AspectedHTMLDirective { + private factory: BindingBehaviorFactory | null = null; - public readonly rawAspect?: string; - public readonly aspect?: string; + public readonly source: string = ""; + public readonly target: string = ""; + public readonly aspect: Aspect = Aspect.content; public constructor( public binding: Binding, @@ -518,8 +511,8 @@ export class HTMLBindingDirective extends InlinableHTMLDirective { super(); } - public setAspect(value: string): void { - (this as Mutable).rawAspect = value; + public captureSource(value: string): void { + (this as Mutable).source = value; if (!value) { return; @@ -527,44 +520,48 @@ export class HTMLBindingDirective extends InlinableHTMLDirective { switch (value[0]) { case ":": - (this as Mutable).aspect = value.substr(1); - switch (this.aspect) { + (this as Mutable).target = value.substring(1); + switch (this.target) { case "innerHTML": const binding = this.binding; /* eslint-disable-next-line */ this.binding = (s, c) => DOM.createHTML(binding(s, c)); - this.factory = this.mode.property(this); + (this as Mutable).aspect = Aspect.property; break; case "classList": - this.factory = this.mode.tokenList(this); + (this as Mutable).aspect = Aspect.tokenList; break; default: - this.factory = this.mode.property(this); + (this as Mutable).aspect = Aspect.property; break; } break; case "?": - (this as Mutable).aspect = value.substr(1); - this.factory = this.mode.booleanAttribute(this); + (this as Mutable).target = value.substring(1); + (this as Mutable).aspect = Aspect.booleanAttribute; break; case "@": - (this as Mutable).aspect = value.substr(1); - this.factory = this.mode.event(this); + (this as Mutable).target = value.substring(1); + (this as Mutable).aspect = Aspect.event; break; default: if (value === "class") { - (this as Mutable).aspect = "className"; - this.factory = this.mode.property(this); + (this as Mutable).target = "className"; + (this as Mutable).aspect = Aspect.property; } else { - (this as Mutable).aspect = value; - this.factory = this.mode.attribute(this); + (this as Mutable).target = value; + (this as Mutable).aspect = Aspect.attribute; } break; } } createBehavior(targets: ViewBehaviorTargets): ViewBehavior { - return (this.factory ?? this.mode.content(this)).createBehavior(targets); + if (this.factory == null) { + this.factory = this.mode[this.aspect](this); + } + + return this.factory.createBehavior(targets); } } diff --git a/packages/web-components/fast-element/src/templating/compiler.spec.ts b/packages/web-components/fast-element/src/templating/compiler.spec.ts index cb7b9a6f1c5..3471a0a9724 100644 --- a/packages/web-components/fast-element/src/templating/compiler.spec.ts +++ b/packages/web-components/fast-element/src/templating/compiler.spec.ts @@ -7,7 +7,7 @@ import { css } from "../styles/css"; import type { StyleTarget } from "../styles/element-styles"; import { toHTML, uniqueElementName } from "../__test__/helpers"; import { bind, HTMLBindingDirective } from "./binding"; -import { compileTemplate } from "./compiler"; +import { Compiler } from "./compiler"; import type { HTMLDirective, ViewBehaviorFactory } from "./html-directive"; import { html } from "./template"; @@ -22,7 +22,7 @@ interface CompilationResultInternals { describe("The template compiler", () => { function compile(html: string, directives: HTMLDirective[]) { - return compileTemplate(html, directives) as any as CompilationResultInternals; + return Compiler.compile(html, directives) as any as CompilationResultInternals; } function inline(index: number) { diff --git a/packages/web-components/fast-element/src/templating/compiler.ts b/packages/web-components/fast-element/src/templating/compiler.ts index f2e57322d95..53f60b724c6 100644 --- a/packages/web-components/fast-element/src/templating/compiler.ts +++ b/packages/web-components/fast-element/src/templating/compiler.ts @@ -1,5 +1,6 @@ import { isString } from "../interfaces.js"; import { DOM } from "../dom.js"; +import type { ExecutionContext } from "../observation/observable.js"; import { Parser } from "./markup.js"; import { bind, oneTime } from "./binding.js"; import type { @@ -7,7 +8,7 @@ import type { HTMLDirective, ViewBehaviorFactory, } from "./html-directive.js"; -import type { HTMLTemplateCompilationResult } from "./template.js"; +import type { HTMLTemplateCompilationResult as TemplateCompilationResult } from "./template.js"; import { HTMLView } from "./view.js"; const targetIdFrom = (parentId: string, nodeIndex: number): string => @@ -25,7 +26,7 @@ const next: NextNode = { node: null as ChildNode | null, }; -class CompilationContext implements HTMLTemplateCompilationResult { +class CompilationContext implements TemplateCompilationResult { private proto: any = null; private targetIds = new Set(); private descriptors: PropertyDescriptorMap = {}; @@ -51,7 +52,7 @@ class CompilationContext implements HTMLTemplateCompilationResult { this.factories.push(factory); } - public freeze(): HTMLTemplateCompilationResult { + public freeze(): TemplateCompilationResult { this.proto = Object.create(null, this.descriptors); return this; } @@ -131,10 +132,11 @@ function compileAttributes( if (parseResult === null) { if (includeBasicValues) { result = bind(() => attrValue, oneTime) as AspectedHTMLDirective; - (result as AspectedHTMLDirective).setAspect(attr.name); + (result as AspectedHTMLDirective).captureSource(attr.name); } } else { - result = Parser.aggregate(parseResult); + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + result = Compiler.aggregate(parseResult); } if (result !== null) { @@ -224,7 +226,13 @@ function compileNode( case 8: // comment const parts = Parser.parse((node as Comment).data, context.directives); if (parts !== null) { - context.addFactory(Parser.aggregate(parts), parentId, nodeId, nodeIndex); + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + context.addFactory( + Compiler.aggregate(parts), + parentId, + nodeId, + nodeIndex + ); } break; } @@ -243,55 +251,125 @@ function isMarker(node: Node, directives: ReadonlyArray): boolean } /** - * Compiles a template and associated directives into a compilation - * result which can be used to create views. - * @param html - The html string or template element to compile. - * @param directives - The directives referenced by the template. - * @remarks - * The template that is provided for compilation is altered in-place - * and cannot be compiled again. If the original template must be preserved, - * it is recommended that you clone the original and pass the clone to this API. + * A function capable of compiling a template from the preprocessed form produced + * by the html template function into a result that can instantiate views. * @public */ -export function compileTemplate( +export type CompilationStrategy = ( + /** + * The preprocessed HTML string or template to compile. + */ html: string | HTMLTemplateElement, - directives: ReadonlyArray -): HTMLTemplateCompilationResult { - let template: HTMLTemplateElement; + /** + * The directives used within the html that is being compiled. + */ + directives: readonly HTMLDirective[] +) => TemplateCompilationResult; - if (isString(html)) { - template = document.createElement("template"); - template.innerHTML = DOM.createHTML(html); +const templateTag = "TEMPLATE"; - const fec = template.content.firstElementChild; +/** + * Common APIs related to compilation. + * @public + */ +export const Compiler = { + /** + * Compiles a template and associated directives into a compilation + * result which can be used to create views. + * @param html - The html string or template element to compile. + * @param directives - The directives referenced by the template. + * @remarks + * The template that is provided for compilation is altered in-place + * and cannot be compiled again. If the original template must be preserved, + * it is recommended that you clone the original and pass the clone to this API. + * @public + */ + compile( + html: string | HTMLTemplateElement, + directives: ReadonlyArray + ): TemplateCompilationResult { + let template: HTMLTemplateElement; + + if (isString(html)) { + template = document.createElement(templateTag) as HTMLTemplateElement; + template.innerHTML = DOM.createHTML(html); + + const fec = template.content.firstElementChild; + + if (fec !== null && fec.tagName === templateTag) { + template = fec as HTMLTemplateElement; + } + } else { + template = html; + } - if (fec !== null && fec.tagName === "TEMPLATE") { - template = fec as HTMLTemplateElement; + // https://bugs.chromium.org/p/chromium/issues/detail?id=1111864 + const fragment = document.adoptNode(template.content); + const context = new CompilationContext(fragment, directives); + compileAttributes(context, "", template, /* host */ "h", 0, true); + + if ( + // If the first node in a fragment is a marker, that means it's an unstable first node, + // because something like a when, repeat, etc. could add nodes before the marker. + // To mitigate this, we insert a stable first node. However, if we insert a node, + // that will alter the result of the TreeWalker. So, we also need to offset the target index. + isMarker(fragment.firstChild!, directives) || + // Or if there is only one node and a directive, it means the template's content + // is *only* the directive. In that case, HTMLView.dispose() misses any nodes inserted by + // the directive. Inserting a new node ensures proper disposal of nodes added by the directive. + (fragment.childNodes.length === 1 && directives.length) + ) { + fragment.insertBefore(document.createComment(""), fragment.firstChild); } - } else { - template = html; - } - // https://bugs.chromium.org/p/chromium/issues/detail?id=1111864 - const fragment = document.adoptNode(template.content); - const context = new CompilationContext(fragment, directives); - compileAttributes(context, "", template, /* host */ "h", 0, true); - - if ( - // If the first node in a fragment is a marker, that means it's an unstable first node, - // because something like a when, repeat, etc. could add nodes before the marker. - // To mitigate this, we insert a stable first node. However, if we insert a node, - // that will alter the result of the TreeWalker. So, we also need to offset the target index. - isMarker(fragment.firstChild!, directives) || - // Or if there is only one node and a directive, it means the template's content - // is *only* the directive. In that case, HTMLView.dispose() misses any nodes inserted by - // the directive. Inserting a new node ensures proper disposal of nodes added by the directive. - (fragment.childNodes.length === 1 && directives.length) - ) { - fragment.insertBefore(document.createComment(""), fragment.firstChild); - } + compileChildren(context, fragment, /* root */ "r"); + next.node = null; // prevent leaks + return context.freeze(); + }, + + /** + * Sets the default compilation strategy that will be used by the ViewTemplate whenever + * it needs to compile a view preprocessed with the html template function. + * @param strategy - The compilation strategy to use when compiling templates. + */ + setDefaultStrategy(strategy: CompilationStrategy): void { + this.compile = strategy; + }, + + /** + * Aggregates an array of strings and directives into a single directive. + * @param parts - A heterogeneous array of static strings interspersed with + * directives. + * @returns A single inline directive that aggregates the behavior of all the parts. + */ + aggregate(parts: (string | HTMLDirective)[]): HTMLDirective { + if (parts.length === 1) { + return parts[0] as HTMLDirective; + } - compileChildren(context, fragment, /* root */ "r"); - next.node = null; // prevent leaks - return context.freeze(); -} + let source: string | undefined; + const partCount = parts.length; + const finalParts = parts.map((x: string | AspectedHTMLDirective) => { + if (isString(x)) { + return (): string => x; + } + + source = x.source || source; + return x.binding!; + }); + + const binding = (scope: unknown, context: ExecutionContext): string => { + let output = ""; + + for (let i = 0; i < partCount; ++i) { + output += finalParts[i](scope, context); + } + + return output; + }; + + const directive = bind(binding) as AspectedHTMLDirective; + directive.captureSource(source!); + return directive; + }, +}; diff --git a/packages/web-components/fast-element/src/templating/html-directive.ts b/packages/web-components/fast-element/src/templating/html-directive.ts index 81a29327c6e..0bb1a532fdd 100644 --- a/packages/web-components/fast-element/src/templating/html-directive.ts +++ b/packages/web-components/fast-element/src/templating/html-directive.ts @@ -1,6 +1,6 @@ -import { Markup, nextId } from "./markup.js"; import type { Behavior } from "../observation/behavior.js"; import type { Binding, ExecutionContext } from "../observation/observable.js"; +import { Markup, nextId } from "./markup.js"; /** * The target nodes available to a behavior. @@ -87,13 +87,67 @@ export abstract class HTMLDirective implements ViewBehaviorFactory { public abstract createBehavior(targets: ViewBehaviorTargets): Behavior | ViewBehavior; } +/** + * The type of HTML aspect to target. + */ +export enum Aspect { + /** + * An attribute. + */ + attribute = 0, + /** + * A boolean attribute. + */ + booleanAttribute = 1, + /** + * A property. + */ + property = 2, + /** + * Content + */ + content = 3, + /** + * A token list. + */ + tokenList = 4, + /** + * An event. + */ + event = 5, +} + /** * A {@link HTMLDirective} that targets a particular aspect * (attribute, property, event, etc.) of a node. * @public */ export abstract class AspectedHTMLDirective extends HTMLDirective { - abstract setAspect(value: string): void; + /** + * The original source aspect exactly as represented in the HTML. + */ + abstract readonly source: string; + + /** + * The evaluated target aspect, determined after processing the source. + */ + abstract readonly target: string; + + /** + * The type of aspect to target. + */ + abstract readonly aspect: Aspect; + + /** + * A binding to apply to the target, if applicable. + */ + abstract readonly binding?: Binding; + + /** + * Captures the original source aspect from HTML. + * @param source - The original source aspect. + */ + abstract captureSource(source: string): void; /** * Creates a placeholder string based on the directive's index within the template. @@ -102,15 +156,6 @@ export abstract class AspectedHTMLDirective extends HTMLDirective { public createPlaceholder: (index: number) => string = Markup.interpolation; } -/** - * A {@link HTMLDirective} that can be inlined within an attribute or text content. - * @public - */ -export abstract class InlinableHTMLDirective extends AspectedHTMLDirective { - abstract readonly binding: Binding; - abstract readonly rawAspect?: string; -} - /** @internal */ export abstract class StatelessAttachedAttributeDirective extends HTMLDirective implements ViewBehavior { @@ -136,9 +181,7 @@ export abstract class StatelessAttachedAttributeDirective extends HTMLDirecti * @remarks * Creates a custom attribute placeholder. */ - public createPlaceholder(index: number): string { - return Markup.attribute(index); - } + public createPlaceholder: (index: number) => string = Markup.attribute; /** * Bind this behavior to the source. diff --git a/packages/web-components/fast-element/src/templating/markup.ts b/packages/web-components/fast-element/src/templating/markup.ts index 327c9fe86d5..71a5b2148ac 100644 --- a/packages/web-components/fast-element/src/templating/markup.ts +++ b/packages/web-components/fast-element/src/templating/markup.ts @@ -1,7 +1,4 @@ -import { isString } from "../interfaces.js"; -import type { ExecutionContext } from "../observation/observable.js"; -import { bind } from "./binding.js"; -import type { HTMLDirective, InlinableHTMLDirective } from "./html-directive.js"; +import type { HTMLDirective } from "./html-directive.js"; const marker = `fast-${Math.random().toString(36).substring(2, 8)}`; const interpolationStart = `${marker}{`; @@ -25,9 +22,7 @@ export const Markup = Object.freeze({ * @remarks * Used internally by binding directives. */ - interpolation(index: number): string { - return `${interpolationStart}${index}${interpolationEnd}`; - }, + interpolation: (index: number) => `${interpolationStart}${index}${interpolationEnd}`, /** * Creates a placeholder that manifests itself as an attribute on an @@ -37,9 +32,8 @@ export const Markup = Object.freeze({ * @remarks * Used internally by attribute directives such as `ref`, `slotted`, and `children`. */ - attribute(index: number): string { - return `${nextId()}="${this.interpolation(index)}"`; - }, + attribute: (index: number) => + `${nextId()}="${interpolationStart}${index}${interpolationEnd}"`, /** * Creates a placeholder that manifests itself as a marker within the DOM structure. @@ -47,9 +41,7 @@ export const Markup = Object.freeze({ * @remarks * Used internally by structural directives such as `repeat`. */ - comment(index: number): string { - return ``; - }, + comment: (index: number) => ``, }); /** @@ -97,41 +89,4 @@ export const Parser = Object.freeze({ return result; }, - - /** - * Aggregates an array of strings and directives into a single directive. - * @param parts - A heterogeneous array of static strings interspersed with - * directives. - * @returns A single inline directive that aggregates the behavior of all the parts. - */ - aggregate(parts: (string | HTMLDirective)[]): HTMLDirective { - if (parts.length === 1) { - return parts[0] as HTMLDirective; - } - - let aspect: string | undefined; - const partCount = parts.length; - const finalParts = parts.map((x: string | InlinableHTMLDirective) => { - if (isString(x)) { - return (): string => x; - } - - aspect = x.rawAspect || aspect; - return x.binding; - }); - - const binding = (scope: unknown, context: ExecutionContext): string => { - let output = ""; - - for (let i = 0; i < partCount; ++i) { - output += finalParts[i](scope, context); - } - - return output; - }; - - const directive = bind(binding) as InlinableHTMLDirective; - directive.setAspect(aspect!); - return directive; - }, }); diff --git a/packages/web-components/fast-element/src/templating/template.spec.ts b/packages/web-components/fast-element/src/templating/template.spec.ts index 01fb18610c1..3a950fbc214 100644 --- a/packages/web-components/fast-element/src/templating/template.spec.ts +++ b/packages/web-components/fast-element/src/templating/template.spec.ts @@ -2,8 +2,8 @@ import { expect } from "chai"; import { html, ViewTemplate } from "./template"; import { Markup } from "./markup"; import { HTMLBindingDirective } from "./binding"; -import { HTMLDirective } from "./html-directive"; -import { bind, Binding, InlinableHTMLDirective, ViewBehaviorTargets } from ".."; +import { AspectedHTMLDirective, Aspect, HTMLDirective } from "./html-directive"; +import { bind, Binding, ViewBehaviorTargets } from ".."; describe(`The html tag template helper`, () => { it(`transforms a string into a ViewTemplate.`, () => { @@ -23,7 +23,9 @@ describe(`The html tag template helper`, () => { class Model { value: "value"; + doSomething() {} } + const stringValue = "string value"; const numberValue = 42; const interpolationScenarios = [ @@ -210,43 +212,117 @@ describe(`The html tag template helper`, () => { if (x.expectDirectives) { x.expectDirectives.forEach((type, index) => { - expect(x.template.directives[index]).to.be.instanceOf(type); + const directive = x.template.directives[index]; + + expect(directive).to.be.instanceOf(type); + + if (directive instanceof HTMLBindingDirective) { + expect(directive.aspect).to.equal(Aspect.content); + } }); } }); }); - it(`captures a case-sensitive property name when used with an expression`, () => { + it(`captures an attribute with an expression`, () => { + const template = html` x.value}>`; + const placeholder = Markup.interpolation(0); + const directive = template.directives[0] as HTMLBindingDirective; + + expect(template.html).to.equal( + `` + ); + + expect(directive).to.be.instanceOf(HTMLBindingDirective); + expect(directive.source).to.equal("some-attribute"); + expect(directive.target).to.equal("some-attribute"); + expect(directive.aspect).to.equal(Aspect.attribute); + }); + + it(`captures an attribute with a binding`, () => { + const template = html` x.value)}>`; + const placeholder = Markup.interpolation(0); + const directive = template.directives[0] as HTMLBindingDirective; + + expect(template.html).to.equal( + `` + ); + + expect(directive).to.be.instanceOf(HTMLBindingDirective); + expect(directive.source).to.equal("some-attribute"); + expect(directive.target).to.equal("some-attribute"); + expect(directive.aspect).to.equal(Aspect.attribute); + }); + + it(`captures a boolean attribute with an expression`, () => { + const template = html` x.value}>`; + const placeholder = Markup.interpolation(0); + const directive = template.directives[0] as HTMLBindingDirective; + + expect(template.html).to.equal( + `` + ); + + expect(directive).to.be.instanceOf(HTMLBindingDirective); + expect(directive.source).to.equal("?some-attribute"); + expect(directive.target).to.equal("some-attribute"); + expect(directive.aspect).to.equal(Aspect.booleanAttribute); + }); + + it(`captures a boolean attribute with a binding`, () => { + const template = html` x.value)}>`; + const placeholder = Markup.interpolation(0); + const directive = template.directives[0] as HTMLBindingDirective; + + expect(template.html).to.equal( + `` + ); + + expect(directive).to.be.instanceOf(HTMLBindingDirective); + expect(directive.source).to.equal("?some-attribute"); + expect(directive.target).to.equal("some-attribute"); + expect(directive.aspect).to.equal(Aspect.booleanAttribute); + }); + + it(`captures a case-sensitive property with an expression`, () => { const template = html` x.value}>`; const placeholder = Markup.interpolation(0); + const directive = template.directives[0] as HTMLBindingDirective; expect(template.html).to.equal( `` ); - expect((template.directives[0] as HTMLBindingDirective).rawAspect).to.equal( - ":someAttribute" - ); + + expect(directive).to.be.instanceOf(HTMLBindingDirective); + expect(directive.source).to.equal(":someAttribute"); + expect(directive.target).to.equal("someAttribute"); + expect(directive.aspect).to.equal(Aspect.property); }); - it(`captures a case-sensitive property name when used with a binding`, () => { + it(`captures a case-sensitive property with a binding`, () => { const template = html` x.value)}>`; const placeholder = Markup.interpolation(0); + const directive = template.directives[0] as HTMLBindingDirective; expect(template.html).to.equal( `` ); - expect((template.directives[0] as HTMLBindingDirective).rawAspect).to.equal( - ":someAttribute" - ); + + expect(directive).to.be.instanceOf(HTMLBindingDirective); + expect(directive.source).to.equal(":someAttribute"); + expect(directive.target).to.equal("someAttribute"); + expect(directive.aspect).to.equal(Aspect.property); }); - it(`captures a case-sensitive property name when used with an inline directive`, () => { - class TestDirective extends InlinableHTMLDirective { + it(`captures a case-sensitive property with an inline directive`, () => { + class TestDirective extends AspectedHTMLDirective { binding: Binding; - rawAspect: string; + source: string; + target: string; + aspect = Aspect.property; - setAspect(value) { - this.rawAspect = value; + captureSource(value) { + this.source = value; } createBehavior(targets: ViewBehaviorTargets) { @@ -260,11 +336,26 @@ describe(`The html tag template helper`, () => { expect(template.html).to.equal( `` ); - expect((template.directives[0] as TestDirective).rawAspect).to.equal( + expect((template.directives[0] as TestDirective).source).to.equal( ":someAttribute" ); }); + it(`captures a case-sensitive event when used with an expression`, () => { + const template = html` x.doSomething()}>`; + const placeholder = Markup.interpolation(0); + const directive = template.directives[0] as HTMLBindingDirective; + + expect(template.html).to.equal( + `` + ); + + expect(directive).to.be.instanceOf(HTMLBindingDirective); + expect(directive.source).to.equal("@someEvent"); + expect(directive.target).to.equal("someEvent"); + expect(directive.aspect).to.equal(Aspect.event); + }); + it("should dispose of embedded ViewTemplate when the rendering template contains *only* the embedded template", () => { const embedded = html`
` const template = html`${x => embedded}`; diff --git a/packages/web-components/fast-element/src/templating/template.ts b/packages/web-components/fast-element/src/templating/template.ts index cedf6b000b3..b2f8f367226 100644 --- a/packages/web-components/fast-element/src/templating/template.ts +++ b/packages/web-components/fast-element/src/templating/template.ts @@ -1,7 +1,7 @@ import { isFunction, isString } from "../interfaces.js"; import { Binding, defaultExecutionContext } from "../observation/observable.js"; import { bind, oneTime } from "./binding.js"; -import { compileTemplate as compileFASTTemplate } from "./compiler.js"; +import { Compiler } from "./compiler.js"; import { AspectedHTMLDirective, HTMLDirective } from "./html-directive.js"; import type { ElementView, HTMLView, SyntheticView } from "./view.js"; @@ -53,24 +53,6 @@ export interface HTMLTemplateCompilationResult { createView(hostBindingTarget?: Element): HTMLView; } -/** - * A function capable of compiling a template from the preprocessed form produced - * by the html template function into a result that can instantiate views. - * @public - */ -export type HTMLTemplateCompiler = ( - /** - * The preprocessed HTML string or template to compile. - */ - html: string | HTMLTemplateElement, - /** - * The directives used within the html that is being compiled. - */ - directives: readonly HTMLDirective[] -) => HTMLTemplateCompilationResult; - -let compileTemplate: HTMLTemplateCompiler; - /** * A template capable of creating HTMLView instances or rendering directly to DOM. * @public @@ -111,7 +93,7 @@ export class ViewTemplate */ public create(hostBindingTarget?: Element): HTMLView { if (this.result === null) { - this.result = compileTemplate(this.html, this.directives); + this.result = Compiler.compile(this.html, this.directives); } return this.result!.createView(hostBindingTarget); @@ -134,19 +116,8 @@ export class ViewTemplate view.appendTo(host); return view; } - - /** - * Sets the default compiler that will be used by the ViewTemplate whenever - * it needs to compile a view preprocessed with the html template function. - * @param compiler - The compiler to use when compiling templates. - */ - public static setDefaultCompiler(compiler: HTMLTemplateCompiler): void { - compileTemplate = compiler; - } } -ViewTemplate.setDefaultCompiler(compileFASTTemplate); - // Much thanks to LitHTML for working this out! const lastAttributeNameRegex = /* eslint-disable-next-line no-control-regex */ @@ -201,7 +172,7 @@ export function html( if (currentValue instanceof AspectedHTMLDirective) { const match = lastAttributeNameRegex.exec(currentString); if (match !== null) { - currentValue.setAspect(match[2]); + currentValue.captureSource(match[2]); } } diff --git a/packages/web-components/fast-ssr/src/template-parser/template-parser.ts b/packages/web-components/fast-ssr/src/template-parser/template-parser.ts index f2253197d40..a78cbf6c33e 100644 --- a/packages/web-components/fast-ssr/src/template-parser/template-parser.ts +++ b/packages/web-components/fast-ssr/src/template-parser/template-parser.ts @@ -2,7 +2,7 @@ * This code is largely a fork of lit's rendering implementation: https://github.com/lit/lit/blob/main/packages/labs/ssr/src/lib/render-lit-html.ts * with changes as necessary to render FAST components. A big thank you to those who contributed to lit's code above. */ -import { Parser, ViewTemplate } from "@microsoft/fast-element"; +import { Compiler, Parser, ViewTemplate } from "@microsoft/fast-element"; import { Attribute, DefaultTreeCommentNode, @@ -172,7 +172,7 @@ export function parseTemplateToOpCodes(template: ViewTemplate): Op[] { attributeType === AttributeType.content ? current.name : current.name.substring(1), - directive: Parser.aggregate(parsed), + directive: Compiler.aggregate(parsed), attributeType, useCustomElementInstance: Boolean(node.isDefinedCustomElement), }); @@ -296,7 +296,7 @@ export function parseTemplateToOpCodes(template: ViewTemplate): Op[] { flushTo(node.sourceCodeLocation!.startOffset); opCodes.push({ type: OpType.directive, - directive: Parser.aggregate(parsed), + directive: Compiler.aggregate(parsed), }); skipTo(node.sourceCodeLocation!.endOffset); }