@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.
- package/README.md +267 -0
- package/dist/dts/build/dom-shim.d.ts +10 -0
- package/dist/dts/build/dom-shim.d.ts.map +1 -0
- package/dist/dts/build/dom-shim.test.d.ts +2 -0
- package/dist/dts/build/dom-shim.test.d.ts.map +1 -0
- package/dist/dts/build/generate-stylesheets.d.ts +62 -0
- package/dist/dts/build/generate-stylesheets.d.ts.map +1 -0
- package/dist/dts/build/generate-stylesheets.test.d.ts +2 -0
- package/dist/dts/build/generate-stylesheets.test.d.ts.map +1 -0
- package/dist/dts/build/generate-templates.d.ts +69 -0
- package/dist/dts/build/generate-templates.d.ts.map +1 -0
- package/dist/dts/build/generate-templates.test.d.ts +2 -0
- package/dist/dts/build/generate-templates.test.d.ts.map +1 -0
- package/dist/dts/build/generate-webui-templates.d.ts +54 -0
- package/dist/dts/build/generate-webui-templates.d.ts.map +1 -0
- package/dist/dts/build/generate-webui-templates.test.d.ts +2 -0
- package/dist/dts/build/generate-webui-templates.test.d.ts.map +1 -0
- package/dist/dts/fixtures/assertions.d.ts +19 -0
- package/dist/dts/fixtures/assertions.d.ts.map +1 -0
- package/dist/dts/fixtures/csr-fixture.d.ts +114 -0
- package/dist/dts/fixtures/csr-fixture.d.ts.map +1 -0
- package/dist/dts/fixtures/csr-fixture.pw.spec.d.ts +2 -0
- package/dist/dts/fixtures/csr-fixture.pw.spec.d.ts.map +1 -0
- package/dist/dts/fixtures/index.d.ts +30 -0
- package/dist/dts/fixtures/index.d.ts.map +1 -0
- package/dist/dts/fixtures/ssr-fixture.d.ts +42 -0
- package/dist/dts/fixtures/ssr-fixture.d.ts.map +1 -0
- package/dist/dts/fixtures/ssr-fixture.pw.spec.d.ts +2 -0
- package/dist/dts/fixtures/ssr-fixture.pw.spec.d.ts.map +1 -0
- package/dist/dts/index.d.ts +7 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/ssr/entry-client.d.ts +2 -0
- package/dist/dts/ssr/entry-client.d.ts.map +1 -0
- package/dist/dts/ssr/render.d.ts +127 -0
- package/dist/dts/ssr/render.d.ts.map +1 -0
- package/dist/dts/ssr/render.test.d.ts +2 -0
- package/dist/dts/ssr/render.test.d.ts.map +1 -0
- package/dist/esm/build/dom-shim.js +142 -0
- package/dist/esm/build/dom-shim.test.js +202 -0
- package/dist/esm/build/generate-stylesheets.js +70 -0
- package/dist/esm/build/generate-stylesheets.test.js +74 -0
- package/dist/esm/build/generate-templates.js +243 -0
- package/dist/esm/build/generate-templates.test.js +231 -0
- package/dist/esm/build/generate-webui-templates.js +121 -0
- package/dist/esm/build/generate-webui-templates.test.js +179 -0
- package/dist/esm/fixtures/assertions.js +49 -0
- package/dist/esm/fixtures/csr-fixture.js +153 -0
- package/dist/esm/fixtures/csr-fixture.pw.spec.js +137 -0
- package/dist/esm/fixtures/index.js +48 -0
- package/dist/esm/fixtures/ssr-fixture.js +113 -0
- package/dist/esm/fixtures/ssr-fixture.pw.spec.js +189 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/ssr/entry-client.js +2 -0
- package/dist/esm/ssr/render.js +381 -0
- package/dist/esm/ssr/render.test.js +236 -0
- package/package.json +88 -0
- package/playwright.config.d.ts +4 -0
- package/playwright.config.mjs +38 -0
- package/public/styles.css +15 -0
- package/server.mjs +317 -0
- package/start.mjs +244 -0
- package/vite.config.d.ts +4 -0
- 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
|
+
}
|