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 index 78caf4e25fc..669711ca992 100644 --- a/packages/web-components/fast-ssr/src/template-parser/op-codes.ts +++ b/packages/web-components/fast-ssr/src/template-parser/op-codes.ts @@ -11,6 +11,8 @@ export enum OpType { customElementShadow, attributeBinding, directive, + templateElementOpen, + templateElementClose, text, } @@ -74,6 +76,24 @@ export type AttributeBindingOp = { useCustomElementInstance: boolean; }; +/** + * Operation to emit a template element open tag + */ +export type TemplateElementOpenOp = { + type: OpType.templateElementOpen; + staticAttributes: Map; + // We need dynamic attributes here so we can emit the `` + // from one operation + dynamicAttributes: Pick[]; +}; + +/** + * Operation to emit a template element closing tag + */ +export type TemplateElementCloseOp = { + type: OpType.templateElementClose; +}; + /** * Operation to emit to custom-element attributes */ @@ -83,9 +103,11 @@ export type CustomElementAttributes = { export type Op = | AttributeBindingOp - | TextOp | CustomElementOpenOp | CustomElementCloseOp | DirectiveOp | CustomElementAttributes - | CustomElementShadowOp; + | CustomElementShadowOp + | TemplateElementOpenOp + | TemplateElementCloseOp + | TextOp; 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 index 1156c0f51c4..dc50af0d681 100644 --- 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 @@ -3,7 +3,7 @@ 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 { Op, OpType, CustomElementOpenOp, AttributeBindingOp, DirectiveOp, TemplateElementOpenOp, TextOp } from "./op-codes.js"; import { AttributeType } from "./attributes.js"; @customElement("hello-world") @@ -90,8 +90,66 @@ test.describe("parseTemplateToOpCodes", () => { expect(codes.length).toBe(4); expect(codes[0].attributeType).toBe(AttributeType.content); + expect(codes[0].name).toBe("string-value"); expect(codes[1].attributeType).toBe(AttributeType.booleanContent); + expect(codes[1].name).toBe("bool-value"); expect(codes[2].attributeType).toBe(AttributeType.idl); + expect(codes[2].name).toBe("property-value"); expect(codes[3].attributeType).toBe(AttributeType.event); + expect(codes[3].name).toBe("event"); }); -}) + test("should emit template open and close ops for a template element", () => { + const input = html``; + const codes = parseTemplateToOpCodes(input); + + expect(codes.length).toBe(2); + expect(codes[0].type).toBe(OpType.templateElementOpen); + expect(codes[1].type).toBe(OpType.templateElementClose); + }); + test("should emit template open ops with static attributes", () => { + const input = html``; + const open = parseTemplateToOpCodes(input)[0] as TemplateElementOpenOp; + + expect(open.staticAttributes.get("id")).toBe("foo"); + expect(open.staticAttributes.get("boolean")).toBe(""); + }); + test("should emit template open ops with dynamic attributes", () => { + const input = html``; + const open = parseTemplateToOpCodes(input)[0] as TemplateElementOpenOp; + + const attrs = new Map(open.dynamicAttributes.map(x => { + return [x.name, x]; + })) + + expect(attrs.has("id")).toBe(true); + expect(attrs.get("id")!.attributeType).toBe(AttributeType.content); + expect(attrs.has("boolean")).toBe(true); + expect(attrs.get("boolean")!.attributeType).toBe(AttributeType.booleanContent); + expect(attrs.has("event")).toBe(true); + expect(attrs.get("event")!.attributeType).toBe(AttributeType.event); + expect(attrs.has("property")).toBe(true); + expect(attrs.get("property")!.attributeType).toBe(AttributeType.idl); + }); + test("should emit template open ops with static and dynamic attributes", () => { + const input = html``; + const open = parseTemplateToOpCodes(input)[0] as TemplateElementOpenOp; + + expect(open.staticAttributes.size).toBe(1); + expect(open.staticAttributes.get("id")).toBe("foo"); + expect(open.dynamicAttributes.length).toBe(1); + expect(open.dynamicAttributes[0].name).toBe("boolean"); + }); + + test("should emit template template ops between other ops when nested inside of another element", () => { + const input = html`
`; + const codes = parseTemplateToOpCodes(input); + + expect(codes[0].type).toBe(OpType.text); + expect((codes[0] as TextOp).value).toBe(`
`); + expect(codes[1].type).toBe(OpType.templateElementOpen); + expect(codes[2].type).toBe(OpType.templateElementClose); + expect(codes[3].type).toBe(OpType.text); + expect((codes[3] as TextOp).value).toBe(`
`); + }) +}); +// TODO add test that name for property, bool, and event attrs has the prefix removed. 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 83db03d85ab..f2253197d40 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 @@ -13,7 +13,7 @@ import { parseFragment, } from "parse5"; import { AttributeType, attributeTypeRegExp } from "./attributes.js"; -import { Op, OpType } from "./op-codes.js"; +import { AttributeBindingOp, Op, OpType } from "./op-codes.js"; /** * Cache the results of template parsing. @@ -154,66 +154,74 @@ export function parseTemplateToOpCodes(template: ViewTemplate): Op[] { // as well as any element with attribute bindings let augmentOpeningTag = false; const { tagName } = node; - let ctor: typeof HTMLElement | undefined; + const ctor: typeof HTMLElement | undefined = customElements.get(node.tagName); // Sort attributes by whether they're related to a binding or if they have // static value const attributes: { static: Map; - dynamic: Attribute[]; + dynamic: Map; } = node.attrs.reduce( (prev, current) => { - if (Parser.parse(current.value, directives)) { - prev.dynamic.push(current); + const parsed = Parser.parse(current.value, directives); + const attributeType = getAttributeType(current); + if (parsed) { + prev.dynamic.set(current, { + type: OpType.attributeBinding, + name: + attributeType === AttributeType.content + ? current.name + : current.name.substring(1), + directive: Parser.aggregate(parsed), + attributeType, + useCustomElementInstance: Boolean(node.isDefinedCustomElement), + }); } else { prev.static.set(current.name, current.value); } return prev; }, - { static: new Map(), dynamic: [] as Attribute[] } + { + static: new Map(), + dynamic: new Map(), + } ); // 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, - }); - } + if (ctor !== undefined) { + augmentOpeningTag = true; + node.isDefinedCustomElement = true; + opCodes.push({ + type: OpType.customElementOpen, + tagName, + ctor, + staticAttributes: attributes.static, + }); + } else if (node.tagName === "template") { + flushTo(node.sourceCodeLocation?.startTag.startOffset); + opCodes.push({ + type: OpType.templateElementOpen, + staticAttributes: attributes.static, + dynamicAttributes: Array.from(attributes.dynamic.values()), + }); + skipTo(node.sourceCodeLocation!.startTag.endOffset); + return; } // Push attribute binding op codes for any attributes that // are dynamic - if (attributes.dynamic.length) { - for (const attr of attributes.dynamic) { + if (attributes.dynamic.size) { + for (const [attr, code] 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); - } + augmentOpeningTag = true; + opCodes.push(code); + skipTo(location.endOffset); } } - if (augmentOpeningTag) { + if (augmentOpeningTag && node.tagName !== "template") { if (ctor) { flushTo(node.sourceCodeLocation!.startTag.endOffset - 1); opCodes.push({ type: OpType.customElementAttributes }); @@ -298,8 +306,14 @@ export function parseTemplateToOpCodes(template: ViewTemplate): Op[] { }, leave(node: DefaultTreeNode): void { - if (isElementNode(node) && node.isDefinedCustomElement) { - opCodes.push({ type: OpType.customElementClose }); + if (isElementNode(node)) { + if (node.isDefinedCustomElement) { + opCodes.push({ type: OpType.customElementClose }); + } else if (node.tagName === "template") { + flushTo(node.sourceCodeLocation?.endTag.startOffset); + opCodes.push({ type: OpType.templateElementClose }); + skipTo(node.sourceCodeLocation!.endTag.endOffset); + } } }, });