diff --git a/change/@microsoft-fast-test-harness-2473cf9b-b505-47b0-900d-60dbeda05163.json b/change/@microsoft-fast-test-harness-2473cf9b-b505-47b0-900d-60dbeda05163.json
new file mode 100644
index 00000000000..5461bffec97
--- /dev/null
+++ b/change/@microsoft-fast-test-harness-2473cf9b-b505-47b0-900d-60dbeda05163.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "add support for repeat and when directives",
+ "packageName": "@microsoft/fast-test-harness",
+ "email": "863023+radium-v@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/packages/fast-test-harness/src/build/generate-templates.test.ts b/packages/fast-test-harness/src/build/generate-templates.test.ts
index 7924f36ea23..00e5386611d 100644
--- a/packages/fast-test-harness/src/build/generate-templates.test.ts
+++ b/packages/fast-test-harness/src/build/generate-templates.test.ts
@@ -10,6 +10,28 @@ import {
} from "@microsoft/fast-test-harness/build/generate-templates.js";
import { generateWebuiTemplates } from "@microsoft/fast-test-harness/build/generate-webui-templates.js";
+interface RepeatHost {
+ items: RepeatItem[];
+}
+
+interface RepeatChild {
+ name: string;
+}
+
+interface RepeatItem {
+ children: RepeatChild[];
+ label: string;
+ name: string;
+ visible: boolean;
+}
+
+interface WhenHost {
+ items: RepeatItem[];
+ label: string;
+ ready: boolean;
+ visible: boolean;
+}
+
test.describe("convertTemplate", async () => {
// Install the DOM shim before any tests — convertTemplate needs declarative
// syntax constants which require a DOM environment, and FAST Element needs
@@ -18,7 +40,9 @@ test.describe("convertTemplate", async () => {
// Dynamic import after the DOM shim is installed so FAST Element can
// access `document`, `CSSStyleSheet`, etc.
- const { html, ref, slotted, children } = await import("@microsoft/fast-element");
+ const { html, ref, slotted, children, elements, repeat, when } = await import(
+ "@microsoft/fast-element"
+ );
test("should wrap a static template in f-template tags", () => {
const template = html`hello
`;
const result = convertTemplate(template, "fast-test");
@@ -94,6 +118,34 @@ test.describe("convertTemplate", async () => {
assert.ok(result.includes("slottedItems"), `got: ${result}`);
});
+ test("should convert SlottedDirective factories with elements filter", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-slotted-elements");
+
+ assert.ok(result);
+ assert.ok(
+ result.includes('f-slotted="{slottedItems filter elements()}"'),
+ `got: ${result}`,
+ );
+ });
+
+ test("should convert SlottedDirective factories with selector elements filter", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-slotted-elements-selector");
+
+ assert.ok(result);
+ assert.ok(
+ result.includes('f-slotted="{slottedItems filter elements(p, ol)}"'),
+ `got: ${result}`,
+ );
+ });
+
test("should convert value bindings to {{expression}}", () => {
const template = html`${x => x.label}`;
const result = convertTemplate(template, "fast-binding");
@@ -136,6 +188,16 @@ test.describe("convertTemplate", async () => {
assert.ok(result.includes("handleClick"), `got: ${result}`);
});
+ test("should convert custom event bindings", () => {
+ const template = html` x.handleValueChange(c.event)}">`;
+ const result = convertTemplate(template, "fast-custom-event");
+
+ assert.ok(result);
+ assert.ok(result.includes("@value-change="), `got: ${result}`);
+ assert.ok(result.includes("handleValueChange"), `got: ${result}`);
+ assert.ok(result.includes("$e"), `got: ${result}`);
+ });
+
test("should convert property bindings to :prop expressions", () => {
const template = html``;
const result = convertTemplate(template, "fast-prop");
@@ -145,6 +207,37 @@ test.describe("convertTemplate", async () => {
assert.ok(result.includes("currentValue"), `got: ${result}`);
});
+ test("should convert plain innerHTML wrappers to unescaped content bindings", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-inner-html");
+
+ assert.ok(result);
+ assert.ok(result.includes("{{{html}}}"), `got: ${result}`);
+ assert.ok(!result.includes(":innerHTML"), `got: ${result}`);
+ });
+
+ test("should preserve innerHTML property bindings on divs with static attributes", () => {
+ const template = html``;
+ const result = convertTemplate(template, "fast-inner-html-attrs");
+
+ assert.ok(result);
+ assert.ok(
+ result.includes('
'),
+ `got: ${result}`,
+ );
+ assert.ok(!result.includes("{{{html}}}"), `got: ${result}`);
+ });
+
+ test("should preserve innerHTML property bindings on divs with other bindings", () => {
+ const template = html` x.hidden}">
`;
+ const result = convertTemplate(template, "fast-inner-html-bindings");
+
+ assert.ok(result);
+ assert.ok(result.includes(':innerHTML="{{html}}"'), `got: ${result}`);
+ assert.ok(result.includes('?hidden="{{hidden}}"'), `got: ${result}`);
+ assert.ok(!result.includes("{{{html}}}"), `got: ${result}`);
+ });
+
test("should handle multiple factories in a single template", () => {
const template = html`${x => x.label}`;
const result = convertTemplate(template, "fast-multi");
@@ -162,6 +255,140 @@ test.describe("convertTemplate", async () => {
assert.ok(result);
assert.ok(result.includes(""), `got: ${result}`);
});
+
+ test("should convert RepeatDirective factories to f-repeat directives", () => {
+ const itemTemplate = html`${x => x.label}`;
+ const template = html`${repeat(
+ x => x.items,
+ itemTemplate,
+ {
+ positioning: true,
+ recycle: false,
+ },
+ )}
`;
+ const result = convertTemplate(template, "fast-repeat");
+
+ assert.ok(result);
+ assert.ok(
+ result.includes(
+ '',
+ ),
+ `got: ${result}`,
+ );
+ assert.ok(result.includes("{{item.label}}"), `got: ${result}`);
+ assert.ok(result.includes(""), `got: ${result}`);
+ assert.ok(!result.includes("`,
+ "g",
+ );
+ fContent = fContent.replace(repeatBindingRe, (match: string, factoryId: string) => {
+ const factory = factoryMap.get(factoryId);
+ if (!factory) {
+ return match;
+ }
+
+ return convertRepeatDirective(factory, options) ?? match;
+ });
+
// Attribute-position markers → f-ref / f-slotted / f-children
const attrBindingRe = new RegExp(
`${prefix}-\\d+="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`,
@@ -289,15 +535,14 @@ export function convertTemplate(
typeof factory.options === "string"
? factory.options
: factory.options?.property;
- const filterStr = extractSlottedFilter(factory);
- return attributeDirective("children", `${prop}${filterStr}`);
+ return attributeDirective("children", prop);
}
return match;
});
// Event bindings → @event="{handler(e)}"
const eventBindingRe = new RegExp(
- `(@[a-z]+)="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`,
+ `(@[^\\s=]+)="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`,
"g",
);
fContent = fContent.replace(
@@ -307,7 +552,7 @@ export function convertTemplate(
if (!factory) {
return match;
}
- const evalStr = extractBindingExpression(factory);
+ const evalStr = extractBindingExpression(factory, options);
return `${aspect}="${wrapClientExpression(evalStr)}"`;
},
);
@@ -324,11 +569,13 @@ export function convertTemplate(
if (!factory) {
return match;
}
- const evalStr = extractBindingExpression(factory);
+ const evalStr = extractBindingExpression(factory, options);
return `${aspect}="${wrapDefaultExpression(evalStr)}"`;
},
);
+ fContent = convertInnerHTMLWrappers(fContent);
+
// Attribute-value and content bindings → attr="{{propName}}" or inline HTML
const valBindingRe = new RegExp(`${prefix}\\{(${prefix}-\\d+)\\}${prefix}`, "g");
fContent = fContent.replace(valBindingRe, (match: string, factoryId: string) => {
@@ -337,15 +584,46 @@ export function convertTemplate(
return match;
}
+ const whenDirective = convertWhenDirective(factory, options);
+ if (whenDirective !== null) {
+ return whenDirective;
+ }
+
const inlined = tryInlineStaticTemplate(factory);
if (inlined !== null) {
return inlined;
}
- const evalStr = extractBindingExpression(factory);
+ const evalStr = extractBindingExpression(factory, options);
return wrapDefaultExpression(evalStr);
});
+ return fContent;
+}
+
+/**
+ * Convert a ViewTemplate's html string and factories into an
+ * f-template HTML string.
+ */
+export function convertTemplate(
+ viewTemplate: ViewTemplate,
+ componentName: string,
+): string | null {
+ const { factories } = viewTemplate;
+ const html =
+ typeof viewTemplate.html === "string"
+ ? viewTemplate.html
+ : viewTemplate.html.innerHTML;
+
+ const factoryEntries = Object.entries(factories);
+ if (factoryEntries.length === 0) {
+ // No factories — pure static template.
+ const content = html.replace(templateWrapperTagPattern, "").trim();
+ return `${stylesMarker}${content}\n`;
+ }
+
+ const fContent = convertTemplateHTML(viewTemplate);
+
let fInner = fContent.trim();
if (!templateOpenTagPattern.test(fInner)) {
fInner = `${fInner}`;
diff --git a/packages/fast-test-harness/src/build/generate-webui-templates.test.ts b/packages/fast-test-harness/src/build/generate-webui-templates.test.ts
index 3fee85bea9a..e1e43409675 100644
--- a/packages/fast-test-harness/src/build/generate-webui-templates.test.ts
+++ b/packages/fast-test-harness/src/build/generate-webui-templates.test.ts
@@ -153,6 +153,80 @@ test.describe("generateWebuiTemplates", () => {
assert.ok(!html.includes("shadowrootdelegatesfocus"));
});
+ test("should convert f-when directives to webui if directives", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "conditional.template.js"),
+ `export const template = {
+ html: '{{label}}',
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(
+ join(distDir, "conditional.template-webui.html"),
+ "utf8",
+ );
+ assert.ok(
+ html.includes('{{label}}'),
+ `got: ${html}`,
+ );
+ assert.ok(!html.includes("f-when"), `got: ${html}`);
+ });
+
+ test("should convert f-repeat directives to webui for directives", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "list.template.js"),
+ `export const template = {
+ html: '',
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(join(distDir, "list.template-webui.html"), "utf8");
+ assert.ok(
+ html.includes('{{item.name}}'),
+ `got: ${html}`,
+ );
+ assert.ok(!html.includes("f-repeat"), `got: ${html}`);
+ assert.ok(!html.includes("positioning"), `got: ${html}`);
+ assert.ok(!html.includes("recycle"), `got: ${html}`);
+ });
+
+ test("should convert nested f-when and f-repeat directives", async () => {
+ const distDir = join(tempDir, "dist");
+ await mkdir(distDir, { recursive: true });
+
+ await writeFile(
+ join(distDir, "nested.template.js"),
+ `export const template = {
+ html: '{{item}}
',
+ factories: {}
+ };`,
+ );
+
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
+
+ const html = await readFile(join(distDir, "nested.template-webui.html"), "utf8");
+ assert.ok(
+ html.includes(
+ '{{item}}
',
+ ),
+ `got: ${html}`,
+ );
+ assert.ok(!html.includes("f-when"), `got: ${html}`);
+ assert.ok(!html.includes("f-repeat"), `got: ${html}`);
+ });
+
test("should strip the {{styles}} marker from output", async () => {
const distDir = join(tempDir, "dist");
await mkdir(distDir, { recursive: true });
diff --git a/packages/fast-test-harness/src/build/generate-webui-templates.ts b/packages/fast-test-harness/src/build/generate-webui-templates.ts
index e8b088ac11f..f8e0677bb35 100644
--- a/packages/fast-test-harness/src/build/generate-webui-templates.ts
+++ b/packages/fast-test-harness/src/build/generate-webui-templates.ts
@@ -7,6 +7,7 @@
* different wrapper:
* - `` instead of ``
* - No `{{styles}}` placeholder (styles are linked externally)
+ * - `` / `` are converted to WebUI `` / `` blocks
* - Shadow options (e.g. `shadowrootdelegatesfocus`) propagated from the
* companion `*.definition-async.js` module
*
@@ -41,6 +42,155 @@ const escapedStylesMarker = new RegExp(
"g",
);
+function isTagBoundary(char: string): boolean {
+ return char === "" || /[\s>/]/.test(char);
+}
+
+function startsWithTag(html: string, index: number, tagName: string): boolean {
+ return (
+ html.startsWith(`<${tagName}`, index) &&
+ isTagBoundary(html[index + tagName.length + 1] ?? "")
+ );
+}
+
+function findTagEnd(html: string, start: number): number {
+ let quote: string | null = null;
+
+ for (let index = start; index < html.length; index++) {
+ const char = html[index];
+ if (quote) {
+ if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ quote = char;
+ continue;
+ }
+
+ if (char === ">") {
+ return index;
+ }
+ }
+
+ return -1;
+}
+
+function extractQuotedAttribute(tag: string, name: string): string | null {
+ let index = 1;
+
+ while (index < tag.length) {
+ while (index < tag.length && /\s/.test(tag[index])) {
+ index++;
+ }
+
+ if (tag[index] === ">" || tag[index] === "/") {
+ break;
+ }
+
+ const nameStart = index;
+ while (index < tag.length && !/[\s=>/]/.test(tag[index])) {
+ index++;
+ }
+ const attrName = tag.slice(nameStart, index);
+
+ while (index < tag.length && /\s/.test(tag[index])) {
+ index++;
+ }
+
+ if (tag[index] !== "=") {
+ continue;
+ }
+
+ index++;
+ while (index < tag.length && /\s/.test(tag[index])) {
+ index++;
+ }
+
+ const quote = tag[index];
+ if (quote !== '"' && quote !== "'") {
+ continue;
+ }
+
+ const valueStart = index + 1;
+ const valueEnd = tag.indexOf(quote, valueStart);
+ if (valueEnd === -1) {
+ return null;
+ }
+
+ if (attrName === name) {
+ return tag.slice(valueStart, valueEnd);
+ }
+
+ index = valueEnd + 1;
+ }
+
+ return null;
+}
+
+function unwrapDefaultExpression(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed.startsWith(openExpression) || !trimmed.endsWith(closeExpression)) {
+ return null;
+ }
+
+ return trimmed.slice(openExpression.length, -closeExpression.length).trim();
+}
+
+function convertFastDirectivesToWebui(html: string): string {
+ let result = "";
+ let index = 0;
+
+ while (index < html.length) {
+ if (html.startsWith("", index)) {
+ result += "";
+ index += "".length;
+ continue;
+ }
+
+ if (html.startsWith("", index)) {
+ result += "";
+ index += "".length;
+ continue;
+ }
+
+ if (startsWithTag(html, index, "f-when")) {
+ const tagEnd = findTagEnd(html, index);
+ if (tagEnd !== -1) {
+ const tag = html.slice(index, tagEnd + 1);
+ const value = extractQuotedAttribute(tag, "value");
+ const expression = value ? unwrapDefaultExpression(value) : null;
+ if (expression !== null) {
+ result += ``;
+ index = tagEnd + 1;
+ continue;
+ }
+ }
+ }
+
+ if (startsWithTag(html, index, "f-repeat")) {
+ const tagEnd = findTagEnd(html, index);
+ if (tagEnd !== -1) {
+ const tag = html.slice(index, tagEnd + 1);
+ const value = extractQuotedAttribute(tag, "value");
+ const expression = value ? unwrapDefaultExpression(value) : null;
+ if (expression !== null) {
+ result += ``;
+ index = tagEnd + 1;
+ continue;
+ }
+ }
+ }
+
+ result += html[index];
+ index++;
+ }
+
+ return result;
+}
+
export interface GenerateWebuiTemplatesOptions {
/**
* Root directory of the package. Defaults to `process.cwd()`.
@@ -122,6 +272,9 @@ function fTemplateToWebui(
// Remove {{styles}} placeholder.
inner = inner.replace(escapedStylesMarker, "");
+ // Convert FAST declarative template directives to WebUI template directives.
+ inner = convertFastDirectivesToWebui(inner);
+
// Replace the opening tag with one that includes shadow attributes.
const extraAttrs = Object.entries(shadowAttrs)
.map(([k, v]) => (v ? ` ${k}="${v}"` : ` ${k}`))