@microsoft/fast-test-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +267 -0
  2. package/dist/dts/build/dom-shim.d.ts +10 -0
  3. package/dist/dts/build/dom-shim.d.ts.map +1 -0
  4. package/dist/dts/build/dom-shim.test.d.ts +2 -0
  5. package/dist/dts/build/dom-shim.test.d.ts.map +1 -0
  6. package/dist/dts/build/generate-stylesheets.d.ts +62 -0
  7. package/dist/dts/build/generate-stylesheets.d.ts.map +1 -0
  8. package/dist/dts/build/generate-stylesheets.test.d.ts +2 -0
  9. package/dist/dts/build/generate-stylesheets.test.d.ts.map +1 -0
  10. package/dist/dts/build/generate-templates.d.ts +69 -0
  11. package/dist/dts/build/generate-templates.d.ts.map +1 -0
  12. package/dist/dts/build/generate-templates.test.d.ts +2 -0
  13. package/dist/dts/build/generate-templates.test.d.ts.map +1 -0
  14. package/dist/dts/build/generate-webui-templates.d.ts +54 -0
  15. package/dist/dts/build/generate-webui-templates.d.ts.map +1 -0
  16. package/dist/dts/build/generate-webui-templates.test.d.ts +2 -0
  17. package/dist/dts/build/generate-webui-templates.test.d.ts.map +1 -0
  18. package/dist/dts/fixtures/assertions.d.ts +19 -0
  19. package/dist/dts/fixtures/assertions.d.ts.map +1 -0
  20. package/dist/dts/fixtures/csr-fixture.d.ts +114 -0
  21. package/dist/dts/fixtures/csr-fixture.d.ts.map +1 -0
  22. package/dist/dts/fixtures/csr-fixture.pw.spec.d.ts +2 -0
  23. package/dist/dts/fixtures/csr-fixture.pw.spec.d.ts.map +1 -0
  24. package/dist/dts/fixtures/index.d.ts +30 -0
  25. package/dist/dts/fixtures/index.d.ts.map +1 -0
  26. package/dist/dts/fixtures/ssr-fixture.d.ts +42 -0
  27. package/dist/dts/fixtures/ssr-fixture.d.ts.map +1 -0
  28. package/dist/dts/fixtures/ssr-fixture.pw.spec.d.ts +2 -0
  29. package/dist/dts/fixtures/ssr-fixture.pw.spec.d.ts.map +1 -0
  30. package/dist/dts/index.d.ts +7 -0
  31. package/dist/dts/index.d.ts.map +1 -0
  32. package/dist/dts/ssr/entry-client.d.ts +2 -0
  33. package/dist/dts/ssr/entry-client.d.ts.map +1 -0
  34. package/dist/dts/ssr/render.d.ts +127 -0
  35. package/dist/dts/ssr/render.d.ts.map +1 -0
  36. package/dist/dts/ssr/render.test.d.ts +2 -0
  37. package/dist/dts/ssr/render.test.d.ts.map +1 -0
  38. package/dist/esm/build/dom-shim.js +142 -0
  39. package/dist/esm/build/dom-shim.test.js +202 -0
  40. package/dist/esm/build/generate-stylesheets.js +70 -0
  41. package/dist/esm/build/generate-stylesheets.test.js +74 -0
  42. package/dist/esm/build/generate-templates.js +243 -0
  43. package/dist/esm/build/generate-templates.test.js +231 -0
  44. package/dist/esm/build/generate-webui-templates.js +121 -0
  45. package/dist/esm/build/generate-webui-templates.test.js +179 -0
  46. package/dist/esm/fixtures/assertions.js +49 -0
  47. package/dist/esm/fixtures/csr-fixture.js +153 -0
  48. package/dist/esm/fixtures/csr-fixture.pw.spec.js +137 -0
  49. package/dist/esm/fixtures/index.js +48 -0
  50. package/dist/esm/fixtures/ssr-fixture.js +113 -0
  51. package/dist/esm/fixtures/ssr-fixture.pw.spec.js +189 -0
  52. package/dist/esm/index.js +6 -0
  53. package/dist/esm/ssr/entry-client.js +2 -0
  54. package/dist/esm/ssr/render.js +381 -0
  55. package/dist/esm/ssr/render.test.js +236 -0
  56. package/package.json +88 -0
  57. package/playwright.config.d.ts +4 -0
  58. package/playwright.config.mjs +38 -0
  59. package/public/styles.css +15 -0
  60. package/server.mjs +317 -0
  61. package/start.mjs +244 -0
  62. package/vite.config.d.ts +4 -0
  63. package/vite.config.mjs +35 -0
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { generateStylesheets } from "@microsoft/fast-test-harness/build/generate-stylesheets.js";
7
+ test.describe("generateStylesheets", () => {
8
+ let tempDir;
9
+ test.beforeEach(async () => {
10
+ tempDir = await mkdtemp(join(tmpdir(), "fast-styles-"));
11
+ });
12
+ test.afterEach(async () => {
13
+ await rm(tempDir, { recursive: true, force: true });
14
+ });
15
+ test("should extract CSS from a styles module", async () => {
16
+ const distDir = join(tempDir, "dist");
17
+ await mkdir(distDir, { recursive: true });
18
+ // Write a fake styles module that exports an ElementStyles-like object.
19
+ await writeFile(join(distDir, "button.styles.js"), `export const styles = { styles: [":host { display: block; }", "span { color: red; }"] };`);
20
+ await generateStylesheets({ cwd: tempDir });
21
+ const css = await readFile(join(distDir, "button.styles.css"), "utf8");
22
+ assert.ok(css.includes(":host { display: block; }"));
23
+ assert.ok(css.includes("span { color: red; }"));
24
+ });
25
+ test("should write to outDir when specified", async () => {
26
+ const distDir = join(tempDir, "dist");
27
+ const outDir = join(tempDir, "out");
28
+ await mkdir(distDir, { recursive: true });
29
+ await writeFile(join(distDir, "card.styles.js"), `export const styles = { styles: [".card { padding: 8px; }"] };`);
30
+ await generateStylesheets({ cwd: tempDir, outDir: "out" });
31
+ const css = await readFile(join(outDir, "card.styles.css"), "utf8");
32
+ assert.ok(css.includes(".card { padding: 8px; }"));
33
+ });
34
+ test("should apply a format function", async () => {
35
+ const distDir = join(tempDir, "dist");
36
+ await mkdir(distDir, { recursive: true });
37
+ await writeFile(join(distDir, "link.styles.js"), `export const styles = { styles: ["a { color: blue; }"] };`);
38
+ await generateStylesheets({
39
+ cwd: tempDir,
40
+ format: css => `/* formatted */\n${css}`,
41
+ });
42
+ const css = await readFile(join(distDir, "link.styles.css"), "utf8");
43
+ assert.ok(css.startsWith("/* formatted */"));
44
+ });
45
+ test("should flatten nested styles arrays", async () => {
46
+ const distDir = join(tempDir, "dist");
47
+ await mkdir(distDir, { recursive: true });
48
+ await writeFile(join(distDir, "nested.styles.js"), `export const styles = {
49
+ styles: [
50
+ { styles: [":host { display: flex; }", "div { margin: 0; }"] },
51
+ "span { font-size: 14px; }"
52
+ ]
53
+ };`);
54
+ await generateStylesheets({ cwd: tempDir });
55
+ const css = await readFile(join(distDir, "nested.styles.css"), "utf8");
56
+ assert.ok(css.includes(":host { display: flex; }"));
57
+ assert.ok(css.includes("div { margin: 0; }"));
58
+ assert.ok(css.includes("span { font-size: 14px; }"));
59
+ });
60
+ test("should skip modules without a styles export", async () => {
61
+ const distDir = join(tempDir, "dist");
62
+ await mkdir(distDir, { recursive: true });
63
+ await writeFile(join(distDir, "empty.styles.js"), `export const template = "<div></div>";`);
64
+ await generateStylesheets({ cwd: tempDir });
65
+ // Should not create a CSS file
66
+ try {
67
+ await readFile(join(distDir, "empty.styles.css"), "utf8");
68
+ assert.fail("Should not have created a CSS file");
69
+ }
70
+ catch (err) {
71
+ assert.strictEqual(err.code, "ENOENT");
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * F-template generation — converts compiled FAST Element ViewTemplate
3
+ * JS modules into declarative `<f-template>` HTML files.
4
+ *
5
+ * This reverse-engineers FAST Element's internal binding marker format
6
+ * back into human-readable f-template syntax (`f-ref`, `f-slotted`,
7
+ * `@event`, `?bool`, `:prop`, `{{expr}}`).
8
+ *
9
+ * Usage as a module:
10
+ * ```ts
11
+ * import { generateFTemplates } from "@microsoft/fast-test-harness/build/generate-templates.js";
12
+ *
13
+ * await generateFTemplates({ cwd: process.cwd(), tagPrefix: "fluent" });
14
+ * ```
15
+ */
16
+ import { glob, mkdir, writeFile } from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { styleText } from "node:util";
20
+ import { attributeDirectivePrefix, clientSideCloseExpression, clientSideOpenExpression, closeExpression, eventArgAccessor, openExpression, } from "@microsoft/fast-html/syntax.js";
21
+ import { installDomShim } from "./dom-shim.js";
22
+ const stylesMarker = `${openExpression}styles${closeExpression}`;
23
+ function wrapClientExpression(expression) {
24
+ return `${clientSideOpenExpression}${expression}${clientSideCloseExpression}`;
25
+ }
26
+ function wrapDefaultExpression(expression) {
27
+ return `${openExpression}${expression}${closeExpression}`;
28
+ }
29
+ function attributeDirective(name, value) {
30
+ return `${attributeDirectivePrefix}${name}="${wrapClientExpression(value)}"`;
31
+ }
32
+ /**
33
+ * Extract a readable binding expression from a factory's evaluate function.
34
+ */
35
+ function extractBindingExpression(factory) {
36
+ if (!factory?.dataBinding?.evaluate) {
37
+ return "";
38
+ }
39
+ const fnStr = factory.dataBinding.evaluate.toString();
40
+ const arrowMatch = fnStr.match(/=>\s*(.+)$/s);
41
+ if (arrowMatch) {
42
+ let expr = arrowMatch[1].trim();
43
+ expr = expr.replace(/\bx\./g, "").replace(/c\.event\b/g, eventArgAccessor);
44
+ return expr;
45
+ }
46
+ return fnStr;
47
+ }
48
+ /**
49
+ * Extract the `filter elements(...)` suffix for a slotted or children
50
+ * directive.
51
+ */
52
+ function extractSlottedFilter(factory) {
53
+ const filter = factory.options?.filter;
54
+ if (!filter) {
55
+ return "";
56
+ }
57
+ if (filter.name === "selectElements") {
58
+ return " filter elements()";
59
+ }
60
+ let selector = null;
61
+ try {
62
+ filter({
63
+ nodeType: 1,
64
+ matches(s) {
65
+ selector = s;
66
+ return true;
67
+ },
68
+ });
69
+ }
70
+ catch {
71
+ // If extraction fails, fall back to no-arg elements().
72
+ }
73
+ if (selector) {
74
+ return ` filter elements(${selector})`;
75
+ }
76
+ return " filter elements()";
77
+ }
78
+ /**
79
+ * Check if a factory is a static sub-template interpolation. If so,
80
+ * evaluate it and return the inlined HTML.
81
+ */
82
+ function tryInlineStaticTemplate(factory) {
83
+ if (!factory?.dataBinding?.evaluate) {
84
+ return null;
85
+ }
86
+ const fnStr = factory.dataBinding.evaluate.toString();
87
+ if (/^\(\)\s*=>/.test(fnStr)) {
88
+ try {
89
+ const result = factory.dataBinding.evaluate({}, {});
90
+ if (result && typeof result === "object" && typeof result.html === "string") {
91
+ return result.html;
92
+ }
93
+ if (typeof result === "string") {
94
+ return result;
95
+ }
96
+ }
97
+ catch {
98
+ // Fall through to normal binding handling.
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+ /**
104
+ * Convert a ViewTemplate's html string and factories into an
105
+ * f-template HTML string.
106
+ */
107
+ export function convertTemplate(viewTemplate, componentName) {
108
+ const { factories } = viewTemplate;
109
+ const html = typeof viewTemplate.html === "string"
110
+ ? viewTemplate.html
111
+ : viewTemplate.html.innerHTML;
112
+ const factoryEntries = Object.entries(factories);
113
+ if (factoryEntries.length === 0) {
114
+ // No factories — pure static template.
115
+ const content = html.replace(/<\/?template[^>]*>/g, "").trim();
116
+ return `<f-template name="${componentName}" shadowrootmode="open"><template>${stylesMarker}${content}</template></f-template>\n`;
117
+ }
118
+ // Derive the binding marker prefix from the first factory key.
119
+ // Factory IDs follow the pattern `${marker}-${counter}` (e.g. "fast-a1b2c3-1"),
120
+ // where `marker` is generated once per session in fast-element's markup.ts.
121
+ const prefix = factoryEntries[0][0].replace(/-\d+$/, "");
122
+ const factoryMap = new Map();
123
+ for (const [id, factory] of factoryEntries) {
124
+ factoryMap.set(id, factory);
125
+ }
126
+ let fContent = html;
127
+ // Attribute-position markers → f-ref / f-slotted / f-children
128
+ const attrBindingRe = new RegExp(`${prefix}-\\d+="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`, "g");
129
+ fContent = fContent.replace(attrBindingRe, (match, factoryId) => {
130
+ const factory = factoryMap.get(factoryId);
131
+ if (!factory) {
132
+ return match;
133
+ }
134
+ if (factory.constructor.name === "RefDirective") {
135
+ const prop = typeof factory.options === "string"
136
+ ? factory.options
137
+ : factory.options?.property;
138
+ return attributeDirective("ref", prop);
139
+ }
140
+ if (factory.constructor.name === "SlottedDirective") {
141
+ const prop = typeof factory.options === "string"
142
+ ? factory.options
143
+ : factory.options?.property;
144
+ const filterStr = extractSlottedFilter(factory);
145
+ return attributeDirective("slotted", `${prop}${filterStr}`);
146
+ }
147
+ if (factory.constructor.name === "ChildrenDirective") {
148
+ const prop = typeof factory.options === "string"
149
+ ? factory.options
150
+ : factory.options?.property;
151
+ const filterStr = extractSlottedFilter(factory);
152
+ return attributeDirective("children", `${prop}${filterStr}`);
153
+ }
154
+ return match;
155
+ });
156
+ // Event bindings → @event="{handler(e)}"
157
+ const eventBindingRe = new RegExp(`(@[a-z]+)="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`, "g");
158
+ fContent = fContent.replace(eventBindingRe, (match, aspect, factoryId) => {
159
+ const factory = factoryMap.get(factoryId);
160
+ if (!factory) {
161
+ return match;
162
+ }
163
+ const evalStr = extractBindingExpression(factory);
164
+ return `${aspect}="${wrapClientExpression(evalStr)}"`;
165
+ });
166
+ // Boolean/property bindings → ?attr="{{expr}}"
167
+ const boolAttrBindingRe = new RegExp(`([?:][a-zA-Z-]+)="${prefix}\\{(${prefix}-\\d+)\\}${prefix}"`, "g");
168
+ fContent = fContent.replace(boolAttrBindingRe, (match, aspect, factoryId) => {
169
+ const factory = factoryMap.get(factoryId);
170
+ if (!factory) {
171
+ return match;
172
+ }
173
+ const evalStr = extractBindingExpression(factory);
174
+ return `${aspect}="${wrapDefaultExpression(evalStr)}"`;
175
+ });
176
+ // Attribute-value and content bindings → attr="{{propName}}" or inline HTML
177
+ const valBindingRe = new RegExp(`${prefix}\\{(${prefix}-\\d+)\\}${prefix}`, "g");
178
+ fContent = fContent.replace(valBindingRe, (match, factoryId) => {
179
+ const factory = factoryMap.get(factoryId);
180
+ if (!factory) {
181
+ return match;
182
+ }
183
+ const inlined = tryInlineStaticTemplate(factory);
184
+ if (inlined !== null) {
185
+ return inlined;
186
+ }
187
+ const evalStr = extractBindingExpression(factory);
188
+ return wrapDefaultExpression(evalStr);
189
+ });
190
+ let fInner = fContent.trim();
191
+ if (!/<template[^>]*>/.test(fInner)) {
192
+ fInner = `<template>${fInner}</template>`;
193
+ }
194
+ // Inject the {{styles}} marker immediately after the opening <template> tag
195
+ // so the test harness can substitute it with a <link rel="stylesheet"> at
196
+ // render time. Harness fallback auto-injects if the marker is missing, but
197
+ // emitting it explicitly keeps generated output consistent with hand-authored
198
+ // f-templates (see MAI core components).
199
+ fInner = fInner.replace(/(<template[^>]*>)/, `$1${stylesMarker}`);
200
+ return `<f-template name="${componentName}" shadowrootmode="open">\n${fInner}\n</f-template>\n`;
201
+ }
202
+ export async function generateFTemplates(options = {}) {
203
+ installDomShim();
204
+ const cwd = options.cwd ?? process.cwd();
205
+ const distDir = path.resolve(cwd, options.distDir ?? "dist");
206
+ const outDir = options.outDir ? path.resolve(cwd, options.outDir) : null;
207
+ const pattern = options.pattern ?? "**/*.template.js";
208
+ const tagPrefix = options.tagPrefix ?? "fast";
209
+ for await (const jsFile of glob(pattern, { cwd: distDir })) {
210
+ const jsFilePath = path.resolve(distDir, jsFile);
211
+ const componentBaseName = path.basename(jsFile, ".template.js");
212
+ const componentName = `${tagPrefix}-${componentBaseName}`;
213
+ try {
214
+ const mod = await import(pathToFileURL(jsFilePath).href);
215
+ const template = mod.template ?? mod.default;
216
+ if (!template?.html) {
217
+ continue;
218
+ }
219
+ const fTemplateHtml = convertTemplate(template, componentName);
220
+ if (!fTemplateHtml) {
221
+ continue;
222
+ }
223
+ let html = fTemplateHtml;
224
+ if (options.format) {
225
+ try {
226
+ html = await options.format(html, jsFilePath);
227
+ }
228
+ catch (formatError) {
229
+ console.warn(styleText(["yellow", "bold"], "⚠"), `Format failed for ${componentName}:`, formatError.message);
230
+ }
231
+ }
232
+ const fTemplatePath = outDir
233
+ ? path.resolve(outDir, `${componentBaseName}.template.html`)
234
+ : path.resolve(path.dirname(jsFilePath), `${componentBaseName}.template.html`);
235
+ await mkdir(path.dirname(fTemplatePath), { recursive: true });
236
+ await writeFile(fTemplatePath, html, "utf8");
237
+ console.log(styleText(["green", "bold"], "✔"), "f-template:", styleText("dim", path.relative(cwd, jsFilePath)), "→", styleText("bold", path.relative(cwd, fTemplatePath)));
238
+ }
239
+ catch (error) {
240
+ console.error(styleText(["red", "bold"], "✘"), `Failed: ${path.relative(cwd, jsFilePath)}`, error.message);
241
+ }
242
+ }
243
+ }
@@ -0,0 +1,231 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { installDomShim } from "@microsoft/fast-test-harness/build/dom-shim.js";
7
+ import { convertTemplate, generateFTemplates, } from "@microsoft/fast-test-harness/build/generate-templates.js";
8
+ import { generateWebuiTemplates } from "@microsoft/fast-test-harness/build/generate-webui-templates.js";
9
+ test.describe("convertTemplate", async () => {
10
+ // Install the DOM shim before any tests — convertTemplate needs fast-html
11
+ // syntax constants which require a DOM environment, and FAST Element needs
12
+ // basic DOM globals to initialize.
13
+ installDomShim();
14
+ // Dynamic import after the DOM shim is installed so FAST Element can
15
+ // access `document`, `CSSStyleSheet`, etc.
16
+ const { html, ref, slotted, children } = await import("@microsoft/fast-element");
17
+ test("should wrap a static template in f-template tags", () => {
18
+ const template = html `<template><div>hello</div></template>`;
19
+ const result = convertTemplate(template, "fast-test");
20
+ assert.ok(result);
21
+ assert.ok(result.includes('<f-template name="fast-test"'));
22
+ assert.ok(result.includes("shadowrootmode"));
23
+ assert.ok(result.includes("<div>hello</div>"));
24
+ assert.ok(result.includes("{{styles}}"));
25
+ });
26
+ test("should return null-safe for empty factories", () => {
27
+ const template = html `<template><span></span></template>`;
28
+ const result = convertTemplate(template, "fast-empty");
29
+ assert.ok(result);
30
+ assert.ok(result.includes("<span></span>"));
31
+ });
32
+ test("should inject {{styles}} after the opening template tag", () => {
33
+ const template = html `<template><p>content</p></template>`;
34
+ const result = convertTemplate(template, "fast-styles");
35
+ assert.ok(result);
36
+ const templateIdx = result.indexOf("<template>");
37
+ const stylesIdx = result.indexOf("{{styles}}");
38
+ assert.ok(stylesIdx > templateIdx, "{{styles}} should appear after <template>");
39
+ });
40
+ test("should convert RefDirective factories to f-ref attributes", () => {
41
+ const template = html `<template><div ${ref("myRef")}></div></template>`;
42
+ const result = convertTemplate(template, "fast-ref");
43
+ assert.ok(result);
44
+ assert.ok(result.includes('f-ref="{myRef}"'), `got: ${result}`);
45
+ });
46
+ test("should convert SlottedDirective factories to f-slotted attributes", () => {
47
+ const template = html `<template><slot ${slotted("slottedItems")}></slot></template>`;
48
+ const result = convertTemplate(template, "fast-slotted");
49
+ assert.ok(result);
50
+ assert.ok(result.includes("f-slotted="), `got: ${result}`);
51
+ assert.ok(result.includes("slottedItems"), `got: ${result}`);
52
+ });
53
+ test("should convert value bindings to {{expression}}", () => {
54
+ const template = html `<template><span>${x => x.label}</span></template>`;
55
+ const result = convertTemplate(template, "fast-binding");
56
+ assert.ok(result);
57
+ assert.ok(result.includes("{{label}}"), `got: ${result}`);
58
+ });
59
+ test("should convert boolean bindings to ?attr expressions", () => {
60
+ const template = html `<template><button ?disabled="${x => x.disabled}"></button></template>`;
61
+ const result = convertTemplate(template, "fast-bool");
62
+ assert.ok(result);
63
+ assert.ok(result.includes('?disabled="{{disabled}}"'), `got: ${result}`);
64
+ });
65
+ test("should inline static sub-templates", () => {
66
+ const template = html `<template><div>${() => "<svg>icon</svg>"}</div></template>`;
67
+ const result = convertTemplate(template, "fast-inline");
68
+ assert.ok(result);
69
+ assert.ok(result.includes("<svg>icon</svg>"), `got: ${result}`);
70
+ });
71
+ test("should convert ChildrenDirective factories to f-children attributes", () => {
72
+ const template = html `<template><div ${children("childItems")}></div></template>`;
73
+ const result = convertTemplate(template, "fast-children");
74
+ assert.ok(result);
75
+ assert.ok(result.includes("f-children="), `got: ${result}`);
76
+ assert.ok(result.includes("childItems"), `got: ${result}`);
77
+ });
78
+ test("should convert event bindings to @event expressions", () => {
79
+ const template = html `<template><button @click="${(x, c) => x.handleClick(c.event)}"></button></template>`;
80
+ const result = convertTemplate(template, "fast-event");
81
+ assert.ok(result);
82
+ assert.ok(result.includes("@click="), `got: ${result}`);
83
+ assert.ok(result.includes("handleClick"), `got: ${result}`);
84
+ });
85
+ test("should convert property bindings to :prop expressions", () => {
86
+ const template = html `<template><input :value="${x => x.currentValue}" /></template>`;
87
+ const result = convertTemplate(template, "fast-prop");
88
+ assert.ok(result);
89
+ assert.ok(result.includes(":value="), `got: ${result}`);
90
+ assert.ok(result.includes("currentValue"), `got: ${result}`);
91
+ });
92
+ test("should handle multiple factories in a single template", () => {
93
+ const template = html `<template><span>${x => x.label}</span><button ?disabled="${x => x.disabled}"></button></template>`;
94
+ const result = convertTemplate(template, "fast-multi");
95
+ assert.ok(result);
96
+ assert.ok(result.includes("{{label}}"), `got: ${result}`);
97
+ assert.ok(result.includes('?disabled="{{disabled}}"'), `got: ${result}`);
98
+ });
99
+ test("should inline a static sub-template ViewTemplate", () => {
100
+ const icon = html `<svg>icon</svg>`;
101
+ const template = html `<template><div>${() => icon}</div></template>`;
102
+ const result = convertTemplate(template, "fast-sub");
103
+ assert.ok(result);
104
+ assert.ok(result.includes("<svg>icon</svg>"), `got: ${result}`);
105
+ });
106
+ });
107
+ test.describe("generateFTemplates", () => {
108
+ let tempDir;
109
+ test.beforeEach(async () => {
110
+ tempDir = await mkdtemp(join(tmpdir(), "fast-ftemplates-"));
111
+ });
112
+ test.afterEach(async () => {
113
+ await rm(tempDir, { recursive: true, force: true });
114
+ });
115
+ test("should generate an f-template HTML file from a template module", async () => {
116
+ const distDir = join(tempDir, "dist");
117
+ await mkdir(distDir, { recursive: true });
118
+ await writeFile(join(distDir, "badge.template.js"), `export const template = {
119
+ html: "<template><slot></slot></template>",
120
+ factories: {}
121
+ };`);
122
+ await generateFTemplates({ cwd: tempDir, tagPrefix: "mai" });
123
+ const html = await readFile(join(distDir, "badge.template.html"), "utf8");
124
+ assert.ok(html.includes('<f-template name="mai-badge"'));
125
+ assert.ok(html.includes("<slot></slot>"));
126
+ assert.ok(html.includes("{{styles}}"));
127
+ });
128
+ test("should write to outDir when specified", async () => {
129
+ const distDir = join(tempDir, "dist");
130
+ await mkdir(distDir, { recursive: true });
131
+ await writeFile(join(distDir, "card.template.js"), `export const template = {
132
+ html: "<template><div>card</div></template>",
133
+ factories: {}
134
+ };`);
135
+ await generateFTemplates({ cwd: tempDir, outDir: "out", tagPrefix: "fast" });
136
+ const html = await readFile(join(tempDir, "out", "card.template.html"), "utf8");
137
+ assert.ok(html.includes('<f-template name="fast-card"'));
138
+ });
139
+ test("should skip modules without a template export", async () => {
140
+ const distDir = join(tempDir, "dist");
141
+ await mkdir(distDir, { recursive: true });
142
+ await writeFile(join(distDir, "empty.template.js"), `export const styles = ":host {}";`);
143
+ await generateFTemplates({ cwd: tempDir, tagPrefix: "fast" });
144
+ try {
145
+ await readFile(join(distDir, "empty.template.html"), "utf8");
146
+ assert.fail("Should not have created an HTML file");
147
+ }
148
+ catch (err) {
149
+ assert.strictEqual(err.code, "ENOENT");
150
+ }
151
+ });
152
+ test("should apply a format function", async () => {
153
+ const distDir = join(tempDir, "dist");
154
+ await mkdir(distDir, { recursive: true });
155
+ await writeFile(join(distDir, "text.template.js"), `export const template = {
156
+ html: "<template><span>text</span></template>",
157
+ factories: {}
158
+ };`);
159
+ await generateFTemplates({
160
+ cwd: tempDir,
161
+ tagPrefix: "fast",
162
+ format: html => `<!-- formatted -->\n${html}`,
163
+ });
164
+ const html = await readFile(join(distDir, "text.template.html"), "utf8");
165
+ assert.ok(html.startsWith("<!-- formatted -->"));
166
+ });
167
+ });
168
+ test.describe("generateWebuiTemplates", () => {
169
+ let tempDir;
170
+ test.beforeEach(async () => {
171
+ tempDir = await mkdtemp(join(tmpdir(), "fast-webui-"));
172
+ });
173
+ test.afterEach(async () => {
174
+ await rm(tempDir, { recursive: true, force: true });
175
+ });
176
+ test("should generate a webui template without f-template wrapper", async () => {
177
+ const distDir = join(tempDir, "dist");
178
+ await mkdir(distDir, { recursive: true });
179
+ await writeFile(join(distDir, "badge.template.js"), `export const template = {
180
+ html: "<template><slot></slot></template>",
181
+ factories: {}
182
+ };`);
183
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "mai" });
184
+ const html = await readFile(join(distDir, "badge.template-webui.html"), "utf8");
185
+ assert.ok(html.includes('<template shadowrootmode="open">'));
186
+ assert.ok(html.includes("<slot></slot>"));
187
+ assert.ok(!html.includes("<f-template"), "should not have f-template wrapper");
188
+ assert.ok(!html.includes("{{styles}}"), "should not have styles marker");
189
+ });
190
+ test("should write to outDir when specified", async () => {
191
+ const distDir = join(tempDir, "dist");
192
+ await mkdir(distDir, { recursive: true });
193
+ await writeFile(join(distDir, "card.template.js"), `export const template = {
194
+ html: "<template><div>card</div></template>",
195
+ factories: {}
196
+ };`);
197
+ await generateWebuiTemplates({
198
+ cwd: tempDir,
199
+ outDir: "out",
200
+ tagPrefix: "fast",
201
+ });
202
+ const html = await readFile(join(tempDir, "out", "card.template-webui.html"), "utf8");
203
+ assert.ok(html.includes('<template shadowrootmode="open">'));
204
+ });
205
+ test("should add shadowrootdelegatesfocus from definition-async", async () => {
206
+ const distDir = join(tempDir, "dist");
207
+ await mkdir(distDir, { recursive: true });
208
+ await writeFile(join(distDir, "input.template.js"), `export const template = {
209
+ html: "<template><input /></template>",
210
+ factories: {}
211
+ };`);
212
+ await writeFile(join(distDir, "input.definition-async.js"), `export const definition = {
213
+ name: "fast-input",
214
+ shadowOptions: { delegatesFocus: true },
215
+ };`);
216
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
217
+ const html = await readFile(join(distDir, "input.template-webui.html"), "utf8");
218
+ assert.ok(html.includes("shadowrootdelegatesfocus"), `should include delegatesFocus, got: ${html}`);
219
+ });
220
+ test("should not add shadowrootdelegatesfocus when absent", async () => {
221
+ const distDir = join(tempDir, "dist");
222
+ await mkdir(distDir, { recursive: true });
223
+ await writeFile(join(distDir, "div.template.js"), `export const template = {
224
+ html: "<template><div>hello</div></template>",
225
+ factories: {}
226
+ };`);
227
+ await generateWebuiTemplates({ cwd: tempDir, tagPrefix: "fast" });
228
+ const html = await readFile(join(distDir, "div.template-webui.html"), "utf8");
229
+ assert.ok(!html.includes("shadowrootdelegatesfocus"));
230
+ });
231
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * WebUI template generation — converts compiled FAST Element ViewTemplate
3
+ * JS modules into declarative shadow DOM `<template>` HTML files suitable
4
+ * for WebUI consumers.
5
+ *
6
+ * Webui templates preserve all bindings from the f-template but use a
7
+ * different wrapper:
8
+ * - `<template shadowrootmode="open">` instead of `<f-template name="...">`
9
+ * - No `{{styles}}` placeholder (styles are linked externally)
10
+ * - Shadow options (e.g. `shadowrootdelegatesfocus`) propagated from the
11
+ * companion `*.definition-async.js` module
12
+ *
13
+ * Usage as a module:
14
+ * ```ts
15
+ * import { generateWebuiTemplates } from "@microsoft/fast-test-harness/build/generate-webui-templates.js";
16
+ *
17
+ * await generateWebuiTemplates({ cwd: process.cwd(), tagPrefix: "mai" });
18
+ * ```
19
+ */
20
+ import { glob, mkdir, writeFile } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ import { styleText } from "node:util";
24
+ import { closeExpression, openExpression } from "@microsoft/fast-html/syntax.js";
25
+ import { installDomShim } from "./dom-shim.js";
26
+ import { convertTemplate } from "./generate-templates.js";
27
+ const stylesMarker = `${openExpression}styles${closeExpression}`;
28
+ const escapedStylesMarker = new RegExp(stylesMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
29
+ /**
30
+ * Try to load shadow options from a companion `*.definition-async.js`
31
+ * module next to the template module. Returns an object with template
32
+ * attribute strings to add (e.g. `shadowrootdelegatesfocus`).
33
+ */
34
+ async function loadShadowAttributes(templateJsPath) {
35
+ const dir = path.dirname(templateJsPath);
36
+ const base = path.basename(templateJsPath, ".template.js");
37
+ const defAsyncPath = path.resolve(dir, `${base}.definition-async.js`);
38
+ const attrs = {};
39
+ try {
40
+ const mod = await import(pathToFileURL(defAsyncPath).href);
41
+ const definition = mod.definition ?? mod.default;
42
+ if (definition?.shadowOptions?.delegatesFocus) {
43
+ attrs.shadowrootdelegatesfocus = "";
44
+ }
45
+ }
46
+ catch {
47
+ // No definition-async module or it failed to load — skip.
48
+ }
49
+ return attrs;
50
+ }
51
+ /**
52
+ * Transform an f-template string into a webui template by replacing the
53
+ * `<f-template>` wrapper with `<template shadowrootmode="open">` and
54
+ * removing the `{{styles}}` placeholder.
55
+ */
56
+ function fTemplateToWebui(fTemplateHtml, shadowAttrs) {
57
+ // Extract the inner <template>...</template> from within <f-template>
58
+ // using string operations to avoid polynomial backtracking in regex.
59
+ const fOpenStart = fTemplateHtml.indexOf("<f-template");
60
+ const fCloseTag = "</f-template>";
61
+ const fCloseStart = fTemplateHtml.lastIndexOf(fCloseTag);
62
+ if (fOpenStart === -1 || fCloseStart === -1) {
63
+ return fTemplateHtml;
64
+ }
65
+ const fOpenEnd = fTemplateHtml.indexOf(">", fOpenStart);
66
+ if (fOpenEnd === -1) {
67
+ return fTemplateHtml;
68
+ }
69
+ let inner = fTemplateHtml.slice(fOpenEnd + 1, fCloseStart).trim();
70
+ // Remove {{styles}} placeholder.
71
+ inner = inner.replace(escapedStylesMarker, "");
72
+ // Replace the opening <template> tag with one that includes shadow attributes.
73
+ const extraAttrs = Object.entries(shadowAttrs)
74
+ .map(([k, v]) => (v ? ` ${k}="${v}"` : ` ${k}`))
75
+ .join("");
76
+ inner = inner.replace(/^<template[^>]*>/, `<template shadowrootmode="open"${extraAttrs}>`);
77
+ return inner;
78
+ }
79
+ export async function generateWebuiTemplates(options = {}) {
80
+ installDomShim();
81
+ const cwd = options.cwd ?? process.cwd();
82
+ const distDir = path.resolve(cwd, options.distDir ?? "dist");
83
+ const outDir = options.outDir ? path.resolve(cwd, options.outDir) : null;
84
+ const pattern = options.pattern ?? "**/*.template.js";
85
+ const tagPrefix = options.tagPrefix ?? "fast";
86
+ for await (const jsFile of glob(pattern, { cwd: distDir })) {
87
+ const jsFilePath = path.resolve(distDir, jsFile);
88
+ const componentBaseName = path.basename(jsFile, ".template.js");
89
+ const componentName = `${tagPrefix}-${componentBaseName}`;
90
+ try {
91
+ const mod = await import(pathToFileURL(jsFilePath).href);
92
+ const template = mod.template ?? mod.default;
93
+ if (!template?.html) {
94
+ continue;
95
+ }
96
+ const fTemplateHtml = convertTemplate(template, componentName);
97
+ if (!fTemplateHtml) {
98
+ continue;
99
+ }
100
+ const shadowAttrs = await loadShadowAttributes(jsFilePath);
101
+ let html = fTemplateToWebui(fTemplateHtml, shadowAttrs);
102
+ if (options.format) {
103
+ try {
104
+ html = await options.format(html, jsFilePath);
105
+ }
106
+ catch (formatError) {
107
+ console.warn(styleText(["yellow", "bold"], "⚠"), `Format failed for ${componentName}:`, formatError.message);
108
+ }
109
+ }
110
+ const webuiPath = outDir
111
+ ? path.resolve(outDir, `${componentBaseName}.template-webui.html`)
112
+ : path.resolve(path.dirname(jsFilePath), `${componentBaseName}.template-webui.html`);
113
+ await mkdir(path.dirname(webuiPath), { recursive: true });
114
+ await writeFile(webuiPath, html, "utf8");
115
+ console.log(styleText(["green", "bold"], "✔"), "webui:", styleText("dim", path.relative(cwd, jsFilePath)), "→", styleText("bold", path.relative(cwd, webuiPath)));
116
+ }
117
+ catch (error) {
118
+ console.error(styleText(["red", "bold"], "✘"), `Failed: ${path.relative(cwd, jsFilePath)}`, error.message);
119
+ }
120
+ }
121
+ }