From b570902fee28420d087c0466b5719a779aeb8e10 Mon Sep 17 00:00:00 2001 From: Nicholas Rice <3213292+nicholasrice@users.noreply.github.com> Date: Tue, 1 Mar 2022 11:54:38 -0800 Subject: [PATCH] Adds TemplateParser to SSR package (#5645) * adding template-renderer feature and spec files * adding DOM emission configuration to TemplateRenderer * adding render function to TemplateRenderer * adding method description to TemplateRenderer.render * add template parser files and entry functions * parseTemplateToOpCodes should throw when used with an HTMLTemplateElement template * change script structure to allow breakpoints to percist in files from build to build * adding parse5 HTML parser * generate AST from ViewTemplate * implement AST traverser * adding node type checks * implement parser class that acts as a node visitor for node traversal * adding flushTo method to TemplateParser * implement completion method * writing a few test fixtures for pure HTML templates * add directive type and parsing test * move template-parser files to own directory * adding tests for directive ops * fix-up after rebase * emit op-codes for custom elements * adding attribute binding ops * formatting * adding tests for custom element attribute bindings * organize imports * fix processing of interpolated bindings and add test * Change files * Update packages/web-components/fast-ssr/src/template-parser/template-parser.ts Co-authored-by: Jane Chu <7559015+janechu@users.noreply.github.com> * rename ast variable * remove dependency on Markup.marker Co-authored-by: nicholasrice Co-authored-by: Jane Chu <7559015+janechu@users.noreply.github.com> --- ...-3ee397c7-e268-4a7d-b842-0f5cf799d3db.json | 7 + .../fast-element/docs/api-report.md | 9 +- packages/web-components/fast-ssr/package.json | 5 +- .../src/template-parser/attributes.ts | 14 + .../fast-ssr/src/template-parser/op-codes.ts | 91 +++++ .../template-parser/template-parser.spec.ts | 97 ++++++ .../src/template-parser/template-parser.ts | 311 ++++++++++++++++++ .../template-renderer.spec.ts | 19 ++ .../template-renderer/template-renderer.ts | 36 ++ 9 files changed, 583 insertions(+), 6 deletions(-) create mode 100644 change/@microsoft-fast-element-3ee397c7-e268-4a7d-b842-0f5cf799d3db.json create mode 100644 packages/web-components/fast-ssr/src/template-parser/attributes.ts create mode 100644 packages/web-components/fast-ssr/src/template-parser/op-codes.ts create mode 100644 packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts create mode 100644 packages/web-components/fast-ssr/src/template-parser/template-parser.ts create mode 100644 packages/web-components/fast-ssr/src/template-renderer/template-renderer.spec.ts create mode 100644 packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts diff --git a/change/@microsoft-fast-element-3ee397c7-e268-4a7d-b842-0f5cf799d3db.json b/change/@microsoft-fast-element-3ee397c7-e268-4a7d-b842-0f5cf799d3db.json new file mode 100644 index 00000000000..e9ed860aaf1 --- /dev/null +++ b/change/@microsoft-fast-element-3ee397c7-e268-4a7d-b842-0f5cf799d3db.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "automated update of api-report.md", + "packageName": "@microsoft/fast-element", + "email": "nicholasrice@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 20d9abd0d0d..31ece83ecda 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -49,7 +49,7 @@ export class AttributeDefinition implements Accessor { onAttributeChangedCallback(element: HTMLElement, value: any): void; readonly Owner: Function; setValue(source: HTMLElement, newValue: any): void; - } +} // @public export type AttributeMode = "reflect" | "boolean" | "fromView"; @@ -467,14 +467,14 @@ export class RepeatBehavior implements Behavior, Subscriber { // @internal (undocumented) handleChange(source: any, args: Splice[]): void; unbind(): void; - } +} // @public export class RepeatDirective extends HTMLDirective { constructor(itemsBinding: Binding, templateBinding: Binding, options: RepeatOptions); createBehavior(targets: ViewBehaviorTargets): RepeatBehavior; createPlaceholder: (index: number) => string; - } +} // @public export interface RepeatOptions { @@ -617,7 +617,7 @@ export class ViewTemplate impl readonly directives: ReadonlyArray; readonly html: string | HTMLTemplateElement; render(source: TSource, host: Node, hostBindingTarget?: Element): HTMLView; - } +} // @public export function volatile(target: {}, name: string | Accessor, descriptor: PropertyDescriptor): PropertyDescriptor; @@ -625,7 +625,6 @@ export function volatile(target: {}, name: string | Accessor, descriptor: Proper // @public export function when(binding: Binding, templateOrTemplateBinding: SyntheticViewTemplate | Binding): CaptureType; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/web-components/fast-ssr/package.json b/packages/web-components/fast-ssr/package.json index a01f2139ae4..e650709bc65 100644 --- a/packages/web-components/fast-ssr/package.json +++ b/packages/web-components/fast-ssr/package.json @@ -15,7 +15,9 @@ "url": "https://github.com/Microsoft/fast/issues/new/choose" }, "scripts": { - "build": "tsc -b --clean src && tsc -b src", + "clean": "tsc -b --clean src", + "build": "tsc -b src", + "prepare": "yarn run clean && yarn run build", "build-server": "tsc -b server", "eslint": "eslint . --ext .ts", "eslint:fix": "eslint . --ext .ts --fix", @@ -34,6 +36,7 @@ "dependencies": { "@lit-labs/ssr": "^1.0.0-rc.2", "@microsoft/fast-element": "^1.5.0", + "parse5": "^6.0.1", "tslib": "^1.11.1" }, "devDependencies": { diff --git a/packages/web-components/fast-ssr/src/template-parser/attributes.ts b/packages/web-components/fast-ssr/src/template-parser/attributes.ts new file mode 100644 index 00000000000..8e4f322a44a --- /dev/null +++ b/packages/web-components/fast-ssr/src/template-parser/attributes.ts @@ -0,0 +1,14 @@ +/** + * Extracts the attribute type from an attribute name + */ +export const attributeTypeRegExp = /([:?@])?(.*)/; + +/** + * The types of attributes applied in a template + */ +export enum AttributeType { + content, + booleanContent, + idl, + event, +} diff --git a/packages/web-components/fast-ssr/src/template-parser/op-codes.ts b/packages/web-components/fast-ssr/src/template-parser/op-codes.ts new file mode 100644 index 00000000000..78caf4e25fc --- /dev/null +++ b/packages/web-components/fast-ssr/src/template-parser/op-codes.ts @@ -0,0 +1,91 @@ +import { InlinableHTMLDirective } from "@microsoft/fast-element"; +import { AttributeType } from "./attributes.js"; + +/** + * Allows fast identification of operation types + */ +export enum OpType { + customElementOpen, + customElementClose, + customElementAttributes, + customElementShadow, + attributeBinding, + directive, + text, +} + +/** + * Operation to emit static text + */ +export type TextOp = { + type: OpType.text; + value: string; +}; + +/** + * Operation to open a custom element + */ +export type CustomElementOpenOp = { + type: OpType.customElementOpen; + /** + * The tagname of the custom element + */ + tagName: string; + + /** + * The constructor of the custom element + */ + ctor: typeof HTMLElement; + + /** + * Attributes of the custom element, non-inclusive of any attributes + * that are the product of bindings + */ + staticAttributes: Map; +}; + +/** + * Operation to close a custom element + */ +export type CustomElementCloseOp = { + type: OpType.customElementClose; +}; + +export type CustomElementShadowOp = { + type: OpType.customElementShadow; +}; + +/** + * Operation to emit static text + */ +export type DirectiveOp = { + type: OpType.directive; + directive: InlinableHTMLDirective; +}; + +/** + * Operation to emit a bound attribute + */ +export type AttributeBindingOp = { + type: OpType.attributeBinding; + directive: InlinableHTMLDirective; + name: string; + attributeType: AttributeType; + useCustomElementInstance: boolean; +}; + +/** + * Operation to emit to custom-element attributes + */ +export type CustomElementAttributes = { + type: OpType.customElementAttributes; +}; + +export type Op = + | AttributeBindingOp + | TextOp + | CustomElementOpenOp + | CustomElementCloseOp + | DirectiveOp + | CustomElementAttributes + | CustomElementShadowOp; diff --git a/packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts b/packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts new file mode 100644 index 00000000000..1156c0f51c4 --- /dev/null +++ b/packages/web-components/fast-ssr/src/template-parser/template-parser.spec.ts @@ -0,0 +1,97 @@ + +import "@lit-labs/ssr/lib/install-global-dom-shim.js"; +import { test, expect } from "@playwright/test"; +import { parseTemplateToOpCodes} from "./template-parser.js"; +import { ViewTemplate, html, FASTElement, customElement, defaultExecutionContext } from "@microsoft/fast-element" +import { Op, OpType, CustomElementOpenOp, AttributeBindingOp, DirectiveOp } from "./op-codes.js"; +import { AttributeType } from "./attributes.js"; + +@customElement("hello-world") +class HelloWorld extends FASTElement {} + +test.describe("parseTemplateToOpCodes", () => { + test("should throw when invoked with a ViewTemplate with a HTMLTemplateElement template", () => { + expect(() => { + parseTemplateToOpCodes(new ViewTemplate(document.createElement("template"), [])); + }).toThrow(); + }); + test("should not throw when invoked with a ViewTemplate with a string template", () => { + expect(() => { + parseTemplateToOpCodes(new ViewTemplate("", [])); + }).not.toThrow(); + }); + + test("should emit a single text op for a template with no bindings or directives", () => { + expect(parseTemplateToOpCodes(html`

Hello world

`)).toEqual([{type: OpType.text, value: "

Hello world

"}]) + }); + test("should emit doctype, html, head, and body elements as part of text op", () => { + expect(parseTemplateToOpCodes(html``)).toEqual([{type: OpType.text, value: ""}]) + }) + test("should emit a directive op from a binding", () => { + const input = html`${() => "hello world"}`; + expect(parseTemplateToOpCodes(input)).toEqual([{ type: OpType.directive, directive: input.directives[0]}]) + }); + test("should emit a directive op from text and a binding ", () => { + const input = html`Hello ${() => "World"}.`; + + const codes = parseTemplateToOpCodes(input); + const code = codes[0] as DirectiveOp; + expect(codes.length).toBe(1); + expect(code.type).toBe(OpType.directive); + expect(code.directive.binding(null, defaultExecutionContext)).toBe("Hello World.") + }); + test("should sandwich directive ops between text ops when binding native element content", () => { + + const input = html`

${() => "hello world"}

`; + expect(parseTemplateToOpCodes(input)).toEqual([ + { type: OpType.text, value: "

"}, + { type: OpType.directive, directive: input.directives[0]}, + { type: OpType.text, value: "

"}, + ]) + }); + test("should emit a custom element as text if it has not been defined", () => { + const input = html``; + expect(parseTemplateToOpCodes(input)).toEqual([{ type: OpType.text, value: ""}]) + }) + + test("should emit custom element open, close, attribute, and shadow ops for a defined custom element", () => { + const input = html``; + expect(parseTemplateToOpCodes(input)).toEqual([ + {type: OpType.customElementOpen, ctor: HelloWorld, tagName: "hello-world", staticAttributes: new Map()}, + {type: OpType.text, value: ""}, + {type: OpType.customElementShadow}, + {type: OpType.customElementClose}, + {type: OpType.text, value: ""} + ]) + }); + test("should emit static attributes of a custom element custom element open, close, attribute, and shadow ops for a defined custom element", () => { + const input = html``; + const code = parseTemplateToOpCodes(input).find((op) => op.type ===OpType.customElementOpen) as CustomElementOpenOp | undefined ; + expect(code).not.toBeUndefined(); + expect(code?.staticAttributes.get("string-value")).toBe("test"); + expect(code?.staticAttributes.get("bool-value")).toBe(""); + expect(code?.staticAttributes.size).toBe(2); + }); + test("should emit attributes binding ops for a native element with attribute bindings", () => { + const input = html`

`; + const codes = parseTemplateToOpCodes(input).filter(x => x.type === OpType.attributeBinding) as AttributeBindingOp[]; + + expect(codes.length).toBe(4); + expect(codes[0].attributeType).toBe(AttributeType.content); + expect(codes[1].attributeType).toBe(AttributeType.booleanContent); + expect(codes[2].attributeType).toBe(AttributeType.idl); + expect(codes[3].attributeType).toBe(AttributeType.event); + }); + test("should emit attributes binding ops for a custom element with attribute bindings", () => { + const input = html``; + const codes = parseTemplateToOpCodes(input).filter(x => x.type === OpType.attributeBinding) as AttributeBindingOp[]; + + expect(codes.length).toBe(4); + expect(codes[0].attributeType).toBe(AttributeType.content); + expect(codes[1].attributeType).toBe(AttributeType.booleanContent); + expect(codes[2].attributeType).toBe(AttributeType.idl); + expect(codes[3].attributeType).toBe(AttributeType.event); + }); +}) 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 new file mode 100644 index 00000000000..83db03d85ab --- /dev/null +++ b/packages/web-components/fast-ssr/src/template-parser/template-parser.ts @@ -0,0 +1,311 @@ +/** + * 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 { + Attribute, + DefaultTreeCommentNode, + DefaultTreeElement, + DefaultTreeNode, + DefaultTreeParentNode, + DefaultTreeTextNode, + parseFragment, +} from "parse5"; +import { AttributeType, attributeTypeRegExp } from "./attributes.js"; +import { Op, OpType } from "./op-codes.js"; + +/** + * Cache the results of template parsing. + */ +const opCache: Map = new Map(); + +interface Visitor { + visit?: (node: DefaultTreeNode) => void; + leave?: (node: DefaultTreeNode) => void; +} + +declare module "parse5" { + interface DefaultTreeElement { + isDefinedCustomElement?: boolean; + } +} + +/** + * Traverses a tree of nodes depth-first, invoking callbacks from visitor for each node as it goes. + * @param node - the node to traverse + * @param visitor - callbacks to be invoked during node traversal + */ +function traverse(node: DefaultTreeNode | DefaultTreeParentNode, visitor: Visitor) { + if (visitor.visit) { + visitor.visit(node); + } + + if ("childNodes" in node) { + const { childNodes } = node; + for (const child of childNodes) { + traverse(child, visitor); + } + } + + if (visitor.leave) { + visitor.leave(node); + } +} + +/** + * Test if a node is a comment node. + * @param node - the node to test + */ +function isCommentNode(node: DefaultTreeNode): node is DefaultTreeCommentNode { + return node.nodeName === "#comment"; +} + +/** + * Test if a node is a text node. + * @param node - the node to test + */ +function isTextNode(node: DefaultTreeNode): node is DefaultTreeTextNode { + return node.nodeName === "#text"; +} + +/** + * Test if a node is an element node + * @param node - the node to test + */ +function isElementNode(node: DefaultTreeNode): node is DefaultTreeElement { + return (node as DefaultTreeElement).tagName !== undefined; +} + +/** + * Determines which type of attribute binding an attribute is + * @param attr - The attribute to inspect + */ +function getAttributeType(attr: Attribute): AttributeType { + const result = attributeTypeRegExp.exec(attr.name); + + if (result === null) { + throw new Error("Failure to determine attribute binding type"); + } + + const prefix = result[1]; + + return prefix === ":" + ? AttributeType.idl + : prefix === "?" + ? AttributeType.booleanContent + : prefix === "@" + ? AttributeType.event + : AttributeType.content; +} + +/** + * Parses a template into a set of operation instructions + * @param template - The template to parse + */ +export function parseTemplateToOpCodes(template: ViewTemplate): Op[] { + const cached: Op[] | undefined = opCache.get(template); + if (cached !== undefined) { + return cached; + } + + const { html } = template; + + if (typeof html !== "string") { + throw new Error( + "@microsoft/fast-ssr only supports rendering a ViewTemplate with a string source." + ); + } + + /** + * Typescript thinks that `html` is a string | HTMLTemplateElement inside the functions defined + * below, so store in a new var that is just a string type + */ + const templateString = html; + const nodeTree = parseFragment(html, { sourceCodeLocationInfo: true }); + + if (!("nodeName" in nodeTree)) { + // I'm not sure when exactly this is encountered but the type system seems to say it's possible. + throw new Error(`Error parsing template:\n${template}`); + } + + /** + * Tracks the offset location in the source template string where the last + * flushing / skip took place. + */ + let lastOffset: number | undefined = 0; + + /** + * Collection of op codes + */ + const opCodes: Op[] = []; + opCache.set(template, opCodes); + + const { directives } = template; + + /** + * Parses an Element node, pushing all op codes for the element into + * the collection of ops for the template + * @param node - The element node to parse + */ + function parseElementNode(node: DefaultTreeElement): void { + // Track whether the opening tag of an element should be augmented. + // All constructable custom elements will need to be augmented, + // as well as any element with attribute bindings + let augmentOpeningTag = false; + const { tagName } = node; + let ctor: typeof HTMLElement | undefined; + + // Sort attributes by whether they're related to a binding or if they have + // static value + const attributes: { + static: Map; + dynamic: Attribute[]; + } = node.attrs.reduce( + (prev, current) => { + if (Parser.parse(current.value, directives)) { + prev.dynamic.push(current); + } else { + prev.static.set(current.name, current.value); + } + + return prev; + }, + { static: new Map(), dynamic: [] as Attribute[] } + ); + + // Special processing for any custom element + if (node.tagName.includes("-")) { + ctor = customElements.get(tagName); + + if (ctor !== undefined) { + augmentOpeningTag = true; + node.isDefinedCustomElement = true; + opCodes.push({ + type: OpType.customElementOpen, + tagName, + ctor, + staticAttributes: attributes.static, + }); + } + } + + // Push attribute binding op codes for any attributes that + // are dynamic + if (attributes.dynamic.length) { + for (const attr of attributes.dynamic) { + const location = node.sourceCodeLocation!.attrs[attr.name]; + flushTo(location.startOffset); + const attributeType = getAttributeType(attr); + const parsed = Parser.parse(attr.value, directives); + + if (parsed !== null) { + augmentOpeningTag = true; + opCodes.push({ + type: OpType.attributeBinding, + name: attr.name, + directive: Parser.aggregate(parsed), + attributeType, + useCustomElementInstance: Boolean(node.isDefinedCustomElement), + }); + skipTo(location.endOffset); + } + } + } + + if (augmentOpeningTag) { + if (ctor) { + flushTo(node.sourceCodeLocation!.startTag.endOffset - 1); + opCodes.push({ type: OpType.customElementAttributes }); + flush(">"); + skipTo(node.sourceCodeLocation!.startTag.endOffset); + } else { + flushTo(node.sourceCodeLocation!.startTag.endOffset); + } + } + + if (ctor !== undefined) { + opCodes.push({ type: OpType.customElementShadow }); + } + } + + /** + * Flushes a string value to op codes + * @param value - The value to flush + */ + function flush(value: string): void { + const last = opCodes[opCodes.length - 1]; + if (last?.type === OpType.text) { + last.value += value; + } else { + opCodes.push({ type: OpType.text, value }); + } + } + + /** + * Flush template content from lastIndex to provided offset + * @param offset - the offset to flush to + */ + function flushTo(offset?: number) { + if (lastOffset === undefined) { + throw new Error( + `Cannot flush template content from a last offset that is ${typeof lastOffset}.` + ); + } + + const prev = lastOffset; + lastOffset = offset; + const value = templateString.substring(prev, offset); + + if (value !== "") { + flush(value); + } + } + + function skipTo(offset: number) { + if (lastOffset === undefined) { + throw new Error("Could not skip from an undefined offset"); + } + if (offset < lastOffset) { + throw new Error(`offset must be greater than lastOffset. + offset: ${offset} + lastOffset: ${lastOffset} + `); + } + + lastOffset = offset; + } + + traverse(nodeTree, { + visit(node: DefaultTreeNode): void { + if (isCommentNode(node) || isTextNode(node)) { + const value = + (node as DefaultTreeCommentNode).data || + (node as DefaultTreeTextNode).value; + const parsed = Parser.parse(value, directives); + + if (parsed) { + flushTo(node.sourceCodeLocation!.startOffset); + opCodes.push({ + type: OpType.directive, + directive: Parser.aggregate(parsed), + }); + skipTo(node.sourceCodeLocation!.endOffset); + } + } else if (isElementNode(node)) { + parseElementNode(node); + } + }, + + leave(node: DefaultTreeNode): void { + if (isElementNode(node) && node.isDefinedCustomElement) { + opCodes.push({ type: OpType.customElementClose }); + } + }, + }); + + // Flush the remaining string content before returning op codes. + flushTo(); + + return opCodes; +} diff --git a/packages/web-components/fast-ssr/src/template-renderer/template-renderer.spec.ts b/packages/web-components/fast-ssr/src/template-renderer/template-renderer.spec.ts new file mode 100644 index 00000000000..2d2ccc77056 --- /dev/null +++ b/packages/web-components/fast-ssr/src/template-renderer/template-renderer.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test"; +import { TemplateRenderer } from "./template-renderer.js"; +import { template } from "@babel/core"; + +test.describe("TemplateRenderer", () => { + test.describe("should have an initial configuration", () => { + test("that emits to shadow DOM", () => { + const instance = new TemplateRenderer(); + expect(instance.componentDOMEmissionMode).toBe("shadow") + }); + }); + + test.describe("should allow configuration", () => { + test("that emits to light DOM", () => { + const instance = new TemplateRenderer({componentDOMEmissionMode: "light"}); + expect(instance.componentDOMEmissionMode).toBe("light") + }); + }); +}); diff --git a/packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts b/packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts new file mode 100644 index 00000000000..cc866d94f7c --- /dev/null +++ b/packages/web-components/fast-ssr/src/template-renderer/template-renderer.ts @@ -0,0 +1,36 @@ +import { ViewTemplate } from "@microsoft/fast-element"; +import { RenderInfo } from "@lit-labs/ssr"; + +export type ComponentDOMEmissionMode = "shadow" | "light"; +export interface TemplateRendererConfiguration { + /** + * Controls whether the template renderer should emit component template code to the component's shadow DOM or to its light DOM. + */ + componentDOMEmissionMode: ComponentDOMEmissionMode; +} + +export class TemplateRenderer implements Readonly { + /** + * {@inheritDoc TemplateRendererConfiguration.componentDOMEmissionMode} + */ + public readonly componentDOMEmissionMode: ComponentDOMEmissionMode = "shadow"; + constructor(config?: TemplateRendererConfiguration) { + if (config) { + Object.assign(this, config); + } + } + + /** + * + * @param template - The template to render. + * @param renderInfo - Information about the rendering context. + * @param source - Any source data to render the template and evaluate bindings with. + */ + public *render( + template: ViewTemplate, + renderInfo: RenderInfo, + source?: unknown + ): IterableIterator { + yield ""; + } +}