@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,189 @@
1
+ import { expect, test } from "@microsoft/fast-test-harness";
2
+ test.describe("SSR: setTemplate", () => {
3
+ test.use({ tagName: "test-widget", ssr: true });
4
+ test("should navigate to the SSR fixture page", async ({ fastPage, page }) => {
5
+ await fastPage.setTemplate();
6
+ const url = new URL(page.url());
7
+ expect(url.pathname).toMatch(/^\/ssr-.*\.html$/);
8
+ expect(url.pathname).toContain("should_navigate_to_the_ssr_fixture_page");
9
+ });
10
+ test("should render the element with default options", async ({ fastPage }) => {
11
+ await fastPage.setTemplate();
12
+ await expect(fastPage.element).toHaveCount(1);
13
+ });
14
+ test("should render with innerHTML", async ({ fastPage }) => {
15
+ await fastPage.setTemplate({ innerHTML: "<span>child</span>" });
16
+ await expect(fastPage.element).toHaveCount(1);
17
+ await expect(fastPage.element).toContainText("child");
18
+ });
19
+ test("should render with attributes", async ({ fastPage }) => {
20
+ await fastPage.setTemplate({
21
+ attributes: { label: "Hello", size: "large" },
22
+ });
23
+ await expect(fastPage.element).toHaveAttribute("label", "Hello");
24
+ await expect(fastPage.element).toHaveAttribute("size", "large");
25
+ });
26
+ test("should render with boolean attribute", async ({ fastPage }) => {
27
+ await fastPage.setTemplate({ attributes: { disabled: true } });
28
+ await expect(fastPage.element).toHaveAttribute("disabled");
29
+ });
30
+ test("should render with a raw HTML string", async ({ fastPage }) => {
31
+ await fastPage.setTemplate(`<test-widget label="raw">raw content</test-widget>`);
32
+ await expect(fastPage.element).toHaveAttribute("label", "raw");
33
+ await expect(fastPage.element).toContainText("raw content");
34
+ });
35
+ test("should replace the previous template on subsequent calls", async ({ fastPage, }) => {
36
+ await fastPage.setTemplate({ attributes: { label: "first" } });
37
+ await expect(fastPage.element).toHaveAttribute("label", "first");
38
+ await fastPage.setTemplate({ attributes: { label: "second" } });
39
+ await expect(fastPage.element).toHaveAttribute("label", "second");
40
+ await expect(fastPage.element).toHaveCount(1);
41
+ });
42
+ });
43
+ test.describe("SSR: updateTemplate", () => {
44
+ test.use({ tagName: "test-widget", ssr: true });
45
+ test("should update attributes on an existing element", async ({ fastPage }) => {
46
+ await fastPage.setTemplate({ attributes: { label: "before" } });
47
+ await expect(fastPage.element).toHaveAttribute("label", "before");
48
+ await fastPage.updateTemplate("test-widget", {
49
+ attributes: { label: "after" },
50
+ });
51
+ await expect(fastPage.element).toHaveAttribute("label", "after");
52
+ });
53
+ test("should remove an attribute when set to false", async ({ fastPage }) => {
54
+ await fastPage.setTemplate({ attributes: { disabled: true } });
55
+ await expect(fastPage.element).toHaveAttribute("disabled");
56
+ await fastPage.updateTemplate("test-widget", {
57
+ attributes: { disabled: false },
58
+ });
59
+ await expect(fastPage.element).not.toHaveAttribute("disabled");
60
+ });
61
+ test("should update innerHTML", async ({ fastPage }) => {
62
+ await fastPage.setTemplate({ innerHTML: "original" });
63
+ await fastPage.updateTemplate("test-widget", {
64
+ innerHTML: "updated",
65
+ });
66
+ await expect(fastPage.element).toContainText("updated");
67
+ });
68
+ test("should accept a Locator", async ({ fastPage }) => {
69
+ await fastPage.setTemplate({ attributes: { label: "loc" } });
70
+ await fastPage.updateTemplate(fastPage.element, {
71
+ attributes: { label: "via-locator" },
72
+ });
73
+ await expect(fastPage.element).toHaveAttribute("label", "via-locator");
74
+ });
75
+ });
76
+ test.describe("SSR: applyTokens", () => {
77
+ test.use({ tagName: "test-widget", ssr: true });
78
+ test("should set CSS custom properties on the body", async ({ fastPage }) => {
79
+ await fastPage.setTemplate();
80
+ await fastPage.applyTokens({
81
+ "color-primary": "#0078d4",
82
+ "spacing-m": "8px",
83
+ });
84
+ const value = await fastPage.page.evaluate(() => document.body.style.getPropertyValue("--color-primary"));
85
+ expect(value).toBe("#0078d4");
86
+ });
87
+ });
88
+ test.describe("SSR: waitForCustomElement", () => {
89
+ test.use({ tagName: "test-widget", ssr: true });
90
+ test("should resolve for a registered element", async ({ fastPage }) => {
91
+ await fastPage.setTemplate();
92
+ await fastPage.waitForCustomElement("test-widget");
93
+ });
94
+ });
95
+ test.describe("SSR: toHaveCustomState", () => {
96
+ test.use({ tagName: "test-widget", ssr: true });
97
+ test("should pass when element has the custom state", async ({ fastPage }) => {
98
+ await fastPage.setTemplate({ attributes: { disabled: true } });
99
+ await expect(fastPage.element).toHaveCustomState("disabled");
100
+ });
101
+ test("should fail (negated) when element does not have the state", async ({ fastPage, }) => {
102
+ await fastPage.setTemplate();
103
+ await expect(fastPage.element).not.toHaveCustomState("disabled");
104
+ });
105
+ });
106
+ test.describe("SSR: element locator", () => {
107
+ test.use({ tagName: "test-widget", ssr: true });
108
+ test("should locate the element by tag name", async ({ fastPage }) => {
109
+ await fastPage.setTemplate();
110
+ await expect(fastPage.element).toHaveCount(1);
111
+ });
112
+ });
113
+ test.describe("SSR: declarative shadow DOM", () => {
114
+ test.use({ tagName: "test-widget", ssr: true });
115
+ test("should have a shadow root after SSR render", async ({ fastPage }) => {
116
+ await fastPage.setTemplate({ attributes: { label: "DSD" } });
117
+ const hasShadow = await fastPage.element.evaluate(el => el.shadowRoot !== null);
118
+ expect(hasShadow).toBe(true);
119
+ });
120
+ });
121
+ test.describe("SSR: template placeholder replacement", () => {
122
+ test.use({ tagName: "test-widget", ssr: true });
123
+ test("should replace the fixture placeholder with rendered component HTML", async ({ fastPage, page, }) => {
124
+ await fastPage.setTemplate({ attributes: { label: "test" } });
125
+ const bodyHtml = await page.locator("body").innerHTML();
126
+ expect(bodyHtml).not.toContain("<!--fixture-->");
127
+ expect(bodyHtml).toContain("<test-widget");
128
+ });
129
+ test("should replace the title placeholder with the test title", async ({ fastPage, page, }) => {
130
+ await fastPage.setTemplate();
131
+ const title = await page.title();
132
+ expect(title).not.toBe("<!--fixturetitle-->");
133
+ expect(title.length).toBeGreaterThan(0);
134
+ });
135
+ test("should replace the templates placeholder with template content", async ({ fastPage, page, }) => {
136
+ await fastPage.setTemplate();
137
+ const bodyHtml = await page.locator("body").innerHTML();
138
+ expect(bodyHtml).not.toContain("<!--templates-->");
139
+ });
140
+ test("should replace the stylespreload placeholder with preload links", async ({ fastPage, page, }) => {
141
+ await fastPage.setTemplate();
142
+ const headHtml = await page.locator("head").innerHTML();
143
+ expect(headHtml).not.toContain("<!--stylespreload-->");
144
+ expect(headHtml).toContain("/test-theme.css");
145
+ });
146
+ test("should not contain any unreplaced placeholder comments", async ({ fastPage, page, }) => {
147
+ await fastPage.setTemplate();
148
+ const fullHtml = await page.content();
149
+ expect(fullHtml).not.toContain("<!--fixture-->");
150
+ expect(fullHtml).not.toContain("<!--fixturetitle-->");
151
+ expect(fullHtml).not.toContain("<!--templates-->");
152
+ expect(fullHtml).not.toContain("<!--stylespreload-->");
153
+ });
154
+ });
155
+ test.describe("SSR: addStyleTag buffering", () => {
156
+ test.use({ tagName: "test-widget", ssr: true });
157
+ test("should include buffered styles in the SSR page", async ({ fastPage, page }) => {
158
+ await fastPage.addStyleTag({
159
+ content: "test-widget { outline: 3px solid blue; }",
160
+ });
161
+ await fastPage.setTemplate();
162
+ const hasStyle = await page.evaluate(() => {
163
+ const styles = document.querySelectorAll("style");
164
+ return Array.from(styles).some(s => s.textContent?.includes("outline: 3px solid blue"));
165
+ });
166
+ expect(hasStyle).toBe(true);
167
+ });
168
+ test("should pass through addStyleTag calls after setTemplate", async ({ fastPage, page, }) => {
169
+ await fastPage.setTemplate();
170
+ await fastPage.addStyleTag({
171
+ content: "test-widget { margin: 99px; }",
172
+ });
173
+ const hasStyle = await page.evaluate(() => {
174
+ const styles = document.querySelectorAll("style");
175
+ return Array.from(styles).some(s => s.textContent?.includes("margin: 99px"));
176
+ });
177
+ expect(hasStyle).toBe(true);
178
+ });
179
+ });
180
+ test.describe("SSR: multiple elements", () => {
181
+ test.use({ tagName: "test-widget", ssr: true });
182
+ test("should handle templates with multiple instances", async ({ fastPage }) => {
183
+ await fastPage.setTemplate(`
184
+ <test-widget label="first">one</test-widget>
185
+ <test-widget label="second">two</test-widget>
186
+ `);
187
+ await expect(fastPage.element).toHaveCount(2);
188
+ });
189
+ });
@@ -0,0 +1,6 @@
1
+ export { installDomShim } from "./build/dom-shim.js";
2
+ export { toHaveCustomState } from "./fixtures/assertions.js";
3
+ export { CSRFixture, } from "./fixtures/csr-fixture.js";
4
+ export { expect, test } from "./fixtures/index.js";
5
+ export { SSRFixture } from "./fixtures/ssr-fixture.js";
6
+ export { createSSRRenderer, } from "./ssr/render.js";
@@ -0,0 +1,2 @@
1
+ import { TemplateElement } from "@microsoft/fast-html";
2
+ TemplateElement.define({ name: "f-template" });
@@ -0,0 +1,381 @@
1
+ /**
2
+ * SSR rendering powered by `@microsoft/fast-build`'s WASM renderer.
3
+ *
4
+ * Provides a high-level `createSSRRenderer()` factory that scans for
5
+ * component build artifacts (f-templates, stylesheets) and returns a
6
+ * `render()` function compatible with the test harness server's
7
+ * `entry-server.ts` contract.
8
+ *
9
+ * Supports two layout modes:
10
+ *
11
+ * **Multi-component package**: one package contains all
12
+ * components in subdirectories.
13
+ * ```ts
14
+ * const { render } = createSSRRenderer({
15
+ * packageName: "@fluentui/web-components",
16
+ * tagPrefix: "fluent",
17
+ * });
18
+ * ```
19
+ *
20
+ * **Per-component packages**: each component is a separate npm package with
21
+ * flat exports.
22
+ * ```ts
23
+ * const { render } = createSSRRenderer({
24
+ * tagPrefix: "mai",
25
+ * components: [
26
+ * { name: "button", packageName: "@mai-ui/button" },
27
+ * { name: "checkbox", packageName: "@mai-ui/checkbox" },
28
+ * ],
29
+ * });
30
+ * ```
31
+ */
32
+ import { globSync, readFileSync } from "node:fs";
33
+ import { createRequire } from "node:module";
34
+ import { dirname, join, relative } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ /**
37
+ * Try to load the `@microsoft/fast-build` WASM module. Returns `null`
38
+ * if the package is not installed.
39
+ */
40
+ function loadWasm() {
41
+ try {
42
+ // The WASM module is CJS and must be loaded synchronously.
43
+ // Use a targeted createRequire anchored to this module so it
44
+ // resolves from the harness's own dependencies.
45
+ return createRequire(import.meta.url)("@microsoft/fast-build/wasm/microsoft_fast_build.js");
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Resolve a file from a package export. Returns the absolute path or null.
53
+ */
54
+ function resolveSpecifier(specifier) {
55
+ try {
56
+ return fileURLToPath(import.meta.resolve(specifier));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Resolve a package root directory from its package.json export.
64
+ */
65
+ function resolvePackageRoot(packageName) {
66
+ return dirname(fileURLToPath(import.meta.resolve(`${packageName}/package.json`)));
67
+ }
68
+ /**
69
+ * Convert an absolute path to a server-relative URL.
70
+ */
71
+ function toServerUrl(absolutePath, packageRoot) {
72
+ return `/${relative(packageRoot, absolutePath).replace(/\\/g, "/")}`;
73
+ }
74
+ /**
75
+ * Parse a JavaScript default value string from CEM into a JSON-safe value.
76
+ * @internal
77
+ */
78
+ export function parseDefaultValue(raw) {
79
+ const trimmed = raw.trim();
80
+ if (trimmed === "" || trimmed === "undefined" || trimmed === "null") {
81
+ return "";
82
+ }
83
+ if (trimmed === "true") {
84
+ return true;
85
+ }
86
+ if (trimmed === "false") {
87
+ return false;
88
+ }
89
+ if (trimmed.startsWith("'") || trimmed.startsWith('"')) {
90
+ return trimmed.slice(1, -1);
91
+ }
92
+ const num = Number(trimmed);
93
+ if (!Number.isNaN(num)) {
94
+ return num;
95
+ }
96
+ try {
97
+ return JSON.parse(trimmed);
98
+ }
99
+ catch {
100
+ return "";
101
+ }
102
+ }
103
+ /**
104
+ * Load a Custom Elements Manifest and extract default state for
105
+ * each custom element declaration. Returns a map of tag names to
106
+ * their default property values.
107
+ */
108
+ function loadDefaultStateFromCEM(packageName, tagPrefix) {
109
+ const cemPath = resolveSpecifier(`${packageName}/custom-elements.json`);
110
+ if (!cemPath) {
111
+ return new Map();
112
+ }
113
+ try {
114
+ const cem = JSON.parse(readFileSync(cemPath, "utf8"));
115
+ const result = new Map();
116
+ for (const mod of cem.modules ?? []) {
117
+ for (const decl of mod.declarations ?? []) {
118
+ if (!decl.customElement) {
119
+ continue;
120
+ }
121
+ const tagName = decl.tagName ?? `${tagPrefix}-${decl.name?.toLowerCase()}`;
122
+ const state = {};
123
+ for (const member of decl.members ?? []) {
124
+ if (member.kind !== "field" || member.privacy !== "public") {
125
+ continue;
126
+ }
127
+ state[member.name] =
128
+ member.default != null
129
+ ? parseDefaultValue(String(member.default))
130
+ : "";
131
+ }
132
+ if (Object.keys(state).length > 0) {
133
+ result.set(tagName, state);
134
+ }
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+ catch {
140
+ return new Map();
141
+ }
142
+ }
143
+ /**
144
+ * Load artifacts for a component from a monolithic package
145
+ * (e.g., `@fluentui/web-components/button/template.html`).
146
+ */
147
+ function loadMonolithicComponent(packageName, componentDir, packageRoot) {
148
+ const fTemplatePath = resolveSpecifier(`${packageName}/${componentDir}/template.html`);
149
+ const stylesPath = resolveSpecifier(`${packageName}/${componentDir}/styles.css`);
150
+ return {
151
+ componentName: componentDir,
152
+ fTemplate: fTemplatePath ? readFileSync(fTemplatePath, "utf8") : null,
153
+ stylesUrl: stylesPath ? toServerUrl(stylesPath, packageRoot) : "",
154
+ };
155
+ }
156
+ /**
157
+ * Load artifacts for a component from a per-component package
158
+ * (e.g., `@mai-ui/button/template.html`).
159
+ */
160
+ function loadPerPackageComponent(reg) {
161
+ const fTemplatePath = resolveSpecifier(`${reg.packageName}/template.html`);
162
+ return {
163
+ componentName: reg.name,
164
+ fTemplate: fTemplatePath ? readFileSync(fTemplatePath, "utf8") : null,
165
+ stylesUrl: `${reg.packageName}/styles.css`,
166
+ };
167
+ }
168
+ /**
169
+ * Replace the `{{styles}}` placeholder in an f-template with a
170
+ * stylesheet `<link>` tag. Falls back to injecting after the opening
171
+ * `<template>` tag if the placeholder is absent.
172
+ * @internal
173
+ */
174
+ export function renderTemplate(rawTemplate, styles) {
175
+ if (!styles) {
176
+ return rawTemplate.replace("{{styles}}", "");
177
+ }
178
+ const link = `<link rel="stylesheet" href="${styles}">`;
179
+ const template = rawTemplate.replace("{{styles}}", link);
180
+ if (template === rawTemplate) {
181
+ return template.replace(/(<template[^>]*>)/i, match => `${match}${link}`);
182
+ }
183
+ return template;
184
+ }
185
+ /**
186
+ * Build the entry HTML that the WASM renderer processes.
187
+ * Constructs either a raw HTML fixture or a single custom element
188
+ * from the query parameters.
189
+ * @internal
190
+ */
191
+ export function buildEntryHtml(queryObj) {
192
+ if ("html" in queryObj) {
193
+ return String(queryObj.html);
194
+ }
195
+ const tagName = String(queryObj.tagName ?? "");
196
+ if (!tagName) {
197
+ return "";
198
+ }
199
+ let attributes = {};
200
+ if (queryObj.attributes) {
201
+ try {
202
+ attributes =
203
+ typeof queryObj.attributes === "string"
204
+ ? JSON.parse(queryObj.attributes)
205
+ : queryObj.attributes;
206
+ }
207
+ catch {
208
+ // Ignore invalid JSON.
209
+ }
210
+ }
211
+ const innerHTML = queryObj.innerHTML ? String(queryObj.innerHTML) : "";
212
+ const attrs = Object.entries(attributes)
213
+ .filter(([, value]) => value !== false && value != null)
214
+ .map(([key, value]) => {
215
+ if (value === true) {
216
+ return key;
217
+ }
218
+ const escaped = String(value)
219
+ .replace(/&/g, "&amp;")
220
+ .replace(/"/g, "&quot;")
221
+ .replace(/</g, "&lt;")
222
+ .replace(/>/g, "&gt;");
223
+ return `${key}="${escaped}"`;
224
+ })
225
+ .join(" ");
226
+ return `<${tagName}${attrs ? ` ${attrs}` : ""}>${innerHTML}</${tagName}>`;
227
+ }
228
+ /**
229
+ * Build the state JSON for the WASM renderer from the query
230
+ * parameters. Includes attribute values and normalised (hyphen-
231
+ * stripped) variants so bindings like `{{arialabel}}` resolve for
232
+ * an `aria-label` HTML attribute.
233
+ * @internal
234
+ */
235
+ export function buildState(queryObj) {
236
+ let attributes = {};
237
+ if (queryObj.attributes) {
238
+ try {
239
+ attributes =
240
+ typeof queryObj.attributes === "string"
241
+ ? JSON.parse(queryObj.attributes)
242
+ : queryObj.attributes;
243
+ }
244
+ catch {
245
+ // Ignore invalid JSON.
246
+ }
247
+ }
248
+ const state = {};
249
+ for (const [key, value] of Object.entries(attributes)) {
250
+ state[key] = value;
251
+ const stripped = key.replace(/-/g, "");
252
+ if (stripped !== key) {
253
+ state[stripped] = value;
254
+ }
255
+ }
256
+ return state;
257
+ }
258
+ /**
259
+ * Create an SSR renderer that uses `@microsoft/fast-build`'s WASM
260
+ * module to render f-templates into declarative shadow DOM on each
261
+ * request, with full expression evaluation and nested component
262
+ * support.
263
+ *
264
+ * Supports two modes:
265
+ * - **`packageName`**: Scans a monolithic package's dist directory for
266
+ * components in subdirectories (Fluent-style).
267
+ * - **`components`**: Uses an explicit list of per-component packages
268
+ * with flat exports (MAI-style).
269
+ */
270
+ export function createSSRRenderer(options) {
271
+ const { tagPrefix } = options;
272
+ if (options.components && options.packageName) {
273
+ throw new Error("createSSRRenderer: 'components' and 'packageName' are mutually exclusive. " +
274
+ "Provide one or the other, not both.");
275
+ }
276
+ const wasm = loadWasm();
277
+ if (!wasm) {
278
+ throw new Error("@microsoft/fast-build is required for SSR rendering. " +
279
+ "Install it as a dependency to enable WASM-based template rendering.");
280
+ }
281
+ // Maps keyed by component name (e.g., "button").
282
+ const fTemplatesByName = new Map();
283
+ const styleUrlsByName = new Map();
284
+ // Collect component artifacts from either mode.
285
+ const artifacts = [];
286
+ if (options.components) {
287
+ // Per-component packages (MAI-style).
288
+ for (const reg of options.components) {
289
+ artifacts.push(loadPerPackageComponent(reg));
290
+ }
291
+ }
292
+ else if (options.packageName) {
293
+ // Monolithic package (Fluent-style).
294
+ const packageRoot = resolvePackageRoot(options.packageName);
295
+ const distDir = join(packageRoot, options.distDir ?? "dist/esm");
296
+ const pattern = "**/*.template.html";
297
+ for (const templatePath of globSync(pattern, { cwd: distDir })) {
298
+ if (templatePath.includes("template-dsd") ||
299
+ templatePath.includes("template-webui")) {
300
+ continue;
301
+ }
302
+ const componentDir = dirname(templatePath).replace(/\\/g, "/");
303
+ if (componentDir === ".") {
304
+ continue;
305
+ }
306
+ artifacts.push(loadMonolithicComponent(options.packageName, componentDir, packageRoot));
307
+ }
308
+ }
309
+ // Populate maps and collect default state from CEM.
310
+ const defaultStateByTag = new Map();
311
+ for (const art of artifacts) {
312
+ const tagName = `${tagPrefix}-${art.componentName}`;
313
+ if (art.fTemplate) {
314
+ fTemplatesByName.set(art.componentName, art.fTemplate);
315
+ }
316
+ styleUrlsByName.set(art.componentName, art.stylesUrl);
317
+ }
318
+ // Load CEM defaults per-package (each package may contain multiple elements).
319
+ if (options.components) {
320
+ for (const reg of options.components) {
321
+ const cemDefaults = loadDefaultStateFromCEM(reg.packageName, tagPrefix);
322
+ for (const [tag, state] of cemDefaults) {
323
+ defaultStateByTag.set(tag, state);
324
+ }
325
+ }
326
+ }
327
+ else if (options.packageName) {
328
+ const cemDefaults = loadDefaultStateFromCEM(options.packageName, tagPrefix);
329
+ for (const [tag, state] of cemDefaults) {
330
+ defaultStateByTag.set(tag, state);
331
+ }
332
+ }
333
+ // Inject styles into f-templates, then parse into the WASM
334
+ // templates map (tag-name → inner template content).
335
+ const templatesMap = {};
336
+ for (const [name, fTemplate] of fTemplatesByName) {
337
+ const styled = renderTemplate(fTemplate, styleUrlsByName.get(name) ?? "");
338
+ fTemplatesByName.set(name, styled);
339
+ const parsed = JSON.parse(wasm.parse_f_templates(styled));
340
+ const entry = parsed.find((t) => t.name !== null);
341
+ if (entry) {
342
+ templatesMap[`${tagPrefix}-${name}`] = entry.content;
343
+ }
344
+ else {
345
+ console.warn(`No named template found for ${tagPrefix}-${name}`);
346
+ }
347
+ }
348
+ const templatesJson = JSON.stringify(templatesMap);
349
+ // Concatenate all f-templates (with styles) for client hydration.
350
+ const allFTemplates = [...fTemplatesByName.values()].join("\n");
351
+ // Resolve theme stylesheet if provided.
352
+ let preloadLinks = "";
353
+ if (options.themeStylesheet) {
354
+ preloadLinks = `<link rel="stylesheet" href="${options.themeStylesheet}">`;
355
+ }
356
+ return {
357
+ render(queryObj = {}) {
358
+ const entryHtml = buildEntryHtml(queryObj);
359
+ const requestState = buildState(queryObj);
360
+ // Merge CEM default state (base) with request state (overrides).
361
+ const tagName = String(queryObj.tagName ?? "");
362
+ const defaults = defaultStateByTag.get(tagName) ?? {};
363
+ const state = { ...defaults, ...requestState };
364
+ let fixture = "";
365
+ if (entryHtml) {
366
+ try {
367
+ const rendered = wasm.render_entry_with_templates(`<html><body>${entryHtml}</body></html>`, templatesJson, JSON.stringify(state), "camelCase");
368
+ // Extract body content from the rendered document.
369
+ const bodyMatch = rendered.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
370
+ fixture = bodyMatch?.[1] ?? entryHtml;
371
+ }
372
+ catch (e) {
373
+ // Fall back to the raw entry HTML if rendering fails.
374
+ console.error("WASM render failed:", e);
375
+ fixture = entryHtml;
376
+ }
377
+ }
378
+ return { template: allFTemplates, fixture, preloadLinks };
379
+ },
380
+ };
381
+ }