@datashow/super-visual-cli 0.0.0-stage → 0.1.0-rc.1

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 (30) hide show
  1. package/bin/supervisual.mjs +9 -0
  2. package/package.json +45 -4
  3. package/src/fabric-client.mjs +101 -0
  4. package/src/main.mjs +729 -0
  5. package/src/project-target.mjs +80 -0
  6. package/src/project.mjs +307 -0
  7. package/src/semantic-preview.mjs +528 -0
  8. package/templates/blank/README.md +26 -0
  9. package/templates/blank/contracts/page-set.yaml +78 -0
  10. package/templates/blank/contracts/report-source.json +30 -0
  11. package/templates/blank/contracts/semantic-model.yaml +39 -0
  12. package/templates/blank/contracts/widget-broker.json +33 -0
  13. package/templates/blank/deployment/CERTIFICATION.md +18 -0
  14. package/templates/blank/package.json +15 -0
  15. package/templates/blank/source/report-design.less +20 -0
  16. package/templates/blank/source/reportApplication.tsx +29 -0
  17. package/templates/blank/supervisual.yaml +24 -0
  18. package/templates/hello-world/README.md +29 -0
  19. package/templates/hello-world/contracts/page-set.yaml +74 -0
  20. package/templates/hello-world/contracts/report-source.json +45 -0
  21. package/templates/hello-world/contracts/semantic-model.yaml +32 -0
  22. package/templates/hello-world/contracts/widget-broker.json +37 -0
  23. package/templates/hello-world/deployment/CERTIFICATION.md +17 -0
  24. package/templates/hello-world/package.json +15 -0
  25. package/templates/hello-world/source/report-design.less +35 -0
  26. package/templates/hello-world/source/reportApplication.tsx +69 -0
  27. package/templates/hello-world/supervisual.yaml +24 -0
  28. package/templates/semantic-preview/index.html +14 -0
  29. package/templates/semantic-preview/src/main.tsx +172 -0
  30. package/README.md +0 -3
@@ -0,0 +1,528 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import { fileURLToPath } from "node:url";
7
+ import { buildSolution, loadProject, validateCompilation } from "@datashow/super-visual-compiler";
8
+ import { checkSemanticCompatibility, inspectSemanticModel } from "@datashow/super-visual-fabric";
9
+ import { createServer } from "vite";
10
+
11
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const previewTemplate = path.join(packageRoot, "templates", "semantic-preview");
13
+ const require = createRequire(import.meta.url);
14
+ const BROKER_FIELDS = ["WidgetId", "AxisKey", "Label", "Sort", "SeriesKey", "SeriesLabel", "WidgetValue", "BrokerContext"];
15
+ const BROKER_COLUMNS = BROKER_FIELDS.slice(0, 6);
16
+ const BROKER_MEASURES = ["Widget Value", "Broker Context"];
17
+ const PREVIEW_RESPONSE_HEADERS = {
18
+ "Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
19
+ "Referrer-Policy": "no-referrer",
20
+ "X-Content-Type-Options": "nosniff",
21
+ };
22
+
23
+ function daxString(value) {
24
+ return `"${String(value).replaceAll('"', '""')}"`;
25
+ }
26
+
27
+ function daxReference(reference) {
28
+ const separator = typeof reference === "string" && reference.endsWith("]")
29
+ ? reference.lastIndexOf("[", reference.length - 2)
30
+ : -1;
31
+ if (separator <= 0 || separator === reference.length - 2) throw new Error(`Semantic filter target must use Table[Column] notation: ${reference}`);
32
+ const table = reference.slice(0, separator);
33
+ const column = reference.slice(separator + 1, -1);
34
+ const escapedColumn = column.replaceAll("]", "]]");
35
+ return { table, column, dax: `'${table.replaceAll("'", "''")}'[${escapedColumn}]` };
36
+ }
37
+
38
+ function semanticColumn(semanticModel, reference) {
39
+ const target = daxReference(reference);
40
+ const table = semanticModel.tables.find((candidate) => candidate.name === target.table);
41
+ const column = table?.columns?.find((candidate) => candidate.name === target.column);
42
+ if (!column) throw new Error(`Unknown semantic column ${reference}`);
43
+ return { ...target, dataType: column.dataType };
44
+ }
45
+
46
+ function semanticMeasure(semanticModel, reference) {
47
+ if (typeof reference !== "string" || !reference.endsWith("]")) throw new Error(`Semantic measure must use [Measure] or Table[Measure] notation: ${reference}`);
48
+ const separator = reference.lastIndexOf("[", reference.length - 2);
49
+ if (separator > 0) {
50
+ const tableName = reference.slice(0, separator);
51
+ const measureName = reference.slice(separator + 1, -1);
52
+ const table = semanticModel.tables.find((candidate) => candidate.name === tableName);
53
+ if (!(table?.measures ?? []).some((candidate) => candidate.name === measureName)) throw new Error(`Unknown semantic measure ${reference}`);
54
+ return { table: tableName, name: measureName, dax: `'${tableName.replaceAll("'", "''")}'[${measureName.replaceAll("]", "]]" )}]` };
55
+ }
56
+ if (separator === 0) {
57
+ const measureName = reference.slice(1, -1);
58
+ const matches = semanticModel.tables.filter((table) => (table.measures ?? []).some((candidate) => candidate.name === measureName));
59
+ if (matches.length !== 1) throw new Error(`Semantic measure ${reference} must resolve uniquely`);
60
+ return { table: matches[0].name, name: measureName, dax: `[${measureName.replaceAll("]", "]]" )}]` };
61
+ }
62
+ throw new Error(`Semantic measure must use [Measure] or Table[Measure] notation: ${reference}`);
63
+ }
64
+
65
+ function axisKeyExpression(column) {
66
+ if (column.dataType === "String") return column.dax;
67
+ if (column.dataType === "DateTime") return `FORMAT(${column.dax}, "yyyy-MM-dd")`;
68
+ return `FORMAT(${column.dax}, "0.############")`;
69
+ }
70
+
71
+ function sortExpression(semanticModel, axis) {
72
+ const table = semanticModel.tables.find((candidate) => candidate.name === axis.table);
73
+ const column = table?.columns?.find((candidate) => candidate.name === axis.column);
74
+ const sort = column?.sortByColumn ? semanticColumn(semanticModel, `${axis.table}[${column.sortByColumn}]`) : axis;
75
+ if (sort.dataType === "DateTime") return `VALUE(FORMAT(${sort.dax}, "yyyymmdd"))`;
76
+ if (["Int64", "Decimal", "Double"].includes(sort.dataType)) return `INT(${sort.dax})`;
77
+ return "0";
78
+ }
79
+
80
+ function measureLabel(reference) {
81
+ const separator = reference.lastIndexOf("[", reference.length - 2);
82
+ return reference.slice(separator + 1, -1);
83
+ }
84
+
85
+ function daxValue(value, dataType) {
86
+ if (["Int64", "Decimal", "Double"].includes(dataType)) {
87
+ const numeric = Number(value);
88
+ if (!Number.isFinite(numeric)) throw new Error(`Selection value is not valid for ${dataType}`);
89
+ return String(numeric);
90
+ }
91
+ if (dataType === "Boolean") {
92
+ if (value === true || String(value).toLowerCase() === "true") return "TRUE()";
93
+ if (value === false || String(value).toLowerCase() === "false") return "FALSE()";
94
+ throw new Error("Selection value is not valid for Boolean");
95
+ }
96
+ if (dataType === "DateTime") {
97
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value));
98
+ if (!match) throw new Error("Selection value must use YYYY-MM-DD for DateTime");
99
+ return `DATE(${Number(match[1])}, ${Number(match[2])}, ${Number(match[3])})`;
100
+ }
101
+ return daxString(value);
102
+ }
103
+
104
+ function filterExpression(semanticModel, reference, values) {
105
+ const target = semanticColumn(semanticModel, reference);
106
+ if (!Array.isArray(values) || values.length === 0) return null;
107
+ return `TREATAS({ ${values.map((value) => daxValue(value, target.dataType)).join(", ")} }, ${target.dax})`;
108
+ }
109
+
110
+ function parameterValues(parameter, value) {
111
+ const values = Array.isArray(value) ? value : [value];
112
+ return values.flatMap((candidate) => {
113
+ if (candidate === null || candidate === undefined || candidate === "") return [];
114
+ const resolved = parameter.values?.[String(candidate)] ?? candidate;
115
+ return [resolved];
116
+ });
117
+ }
118
+
119
+ function validateQueryRequest(request, loaded) {
120
+ if (!request || typeof request !== "object" || Array.isArray(request)) throw new Error("Preview query body must be an object");
121
+ const unexpected = Object.keys(request).filter((key) => !["widgets", "selections", "parameters"].includes(key));
122
+ if (unexpected.length) throw new Error(`Preview query contains unsupported fields: ${unexpected.join(", ")}`);
123
+ const widgetById = new Map(loaded.broker.widgets.map((widget) => [widget.id, widget]));
124
+ const knownWidgets = new Set(widgetById.keys());
125
+ const widgets = Array.isArray(request.widgets) ? [...new Set(request.widgets)] : [];
126
+ if (!widgets.length || widgets.some((widget) => typeof widget !== "string" || !knownWidgets.has(widget))) throw new Error("Preview query contains an unknown or empty widget selection");
127
+ const selections = request.selections && typeof request.selections === "object" && !Array.isArray(request.selections) ? request.selections : {};
128
+ for (const [widget, value] of Object.entries(selections)) {
129
+ if (!knownWidgets.has(widget)) throw new Error(`Preview selection references unknown widget ${widget}`);
130
+ if (widgetById.get(widget).selectable !== true) throw new Error(`Preview selection references non-selectable widget ${widget}`);
131
+ if (!["string", "number", "boolean"].includes(typeof value)) throw new Error(`Preview selection for ${widget} must be a scalar value`);
132
+ }
133
+ const parameters = request.parameters && typeof request.parameters === "object" && !Array.isArray(request.parameters) ? request.parameters : {};
134
+ const knownParameters = new Set((loaded.reportSource.adapter?.parameters ?? []).map((parameter) => parameter.name));
135
+ for (const [name, value] of Object.entries(parameters)) {
136
+ if (!knownParameters.has(name)) throw new Error(`Preview request references unknown parameter ${name}`);
137
+ const values = Array.isArray(value) ? value : [value];
138
+ if (values.some((candidate) => !["string", "number", "boolean"].includes(typeof candidate))) throw new Error(`Preview parameter ${name} must contain only scalar values`);
139
+ }
140
+ return { widgets, selections, parameters };
141
+ }
142
+
143
+ function buildManagedSemanticQuery({ broker, semanticModel, reportSource }, request) {
144
+ const loaded = { broker, semanticModel, reportSource };
145
+ const { widgets, selections, parameters } = validateQueryRequest(request, loaded);
146
+ const widgetById = new Map(broker.widgets.map((widget) => [widget.id, widget]));
147
+ const filters = [
148
+ `TREATAS({ ${widgets.map(daxString).join(", ")} }, 'Widget Axis'[WidgetId])`,
149
+ ...Object.entries(selections).map(([widget, value]) => filterExpression(semanticModel, widgetById.get(widget).axis, [value])),
150
+ ...Object.entries(parameters).map(([name, value]) => {
151
+ const parameter = reportSource.adapter.parameters.find((candidate) => candidate.name === name);
152
+ return filterExpression(semanticModel, parameter.target, parameterValues(parameter, value));
153
+ }),
154
+ ].filter(Boolean);
155
+ return `EVALUATE
156
+ VAR __BrokerRows =
157
+ CALCULATETABLE(
158
+ SELECTCOLUMNS(
159
+ 'Widget Axis',
160
+ "WidgetId", 'Widget Axis'[WidgetId],
161
+ "AxisKey", 'Widget Axis'[AxisKey],
162
+ "Label", 'Widget Axis'[Label],
163
+ "Sort", 'Widget Axis'[Sort],
164
+ "SeriesKey", 'Widget Axis'[SeriesKey],
165
+ "SeriesLabel", 'Widget Axis'[SeriesLabel],
166
+ "WidgetValue", [Widget Value],
167
+ "BrokerContext", [Broker Context]
168
+ ),
169
+ ${filters.join(",\n ")}
170
+ )
171
+ RETURN
172
+ FILTER(__BrokerRows, NOT ISBLANK([WidgetValue]))
173
+ ORDER BY [WidgetId], [Sort], [AxisKey], [SeriesKey]`;
174
+ }
175
+
176
+ export function buildSemanticPreviewQuery({ broker, semanticModel, reportSource }, request) {
177
+ const loaded = { broker, semanticModel, reportSource };
178
+ const { widgets, selections, parameters } = validateQueryRequest(request, loaded);
179
+ const widgetById = new Map(broker.widgets.map((widget) => [widget.id, widget]));
180
+ const parameterFilters = Object.entries(parameters).map(([name, value]) => {
181
+ const parameter = reportSource.adapter.parameters.find((candidate) => candidate.name === name);
182
+ return filterExpression(semanticModel, parameter.target, parameterValues(parameter, value));
183
+ }).filter(Boolean);
184
+ const variables = [];
185
+ const rowSets = [];
186
+
187
+ widgets.forEach((widgetId, widgetIndex) => {
188
+ const widget = widgetById.get(widgetId);
189
+ const dimensions = widget.dimensions.map((dimension) => ({
190
+ key: semanticColumn(semanticModel, dimension.key),
191
+ label: semanticColumn(semanticModel, dimension.label),
192
+ }));
193
+ const axis = semanticColumn(semanticModel, widget.axis);
194
+ const measures = widget.measures.map((measure) => ({ reference: measure, ...semanticMeasure(semanticModel, measure) }));
195
+ const groupBy = [...new Set(dimensions.flatMap((dimension) => [dimension.key.dax, dimension.label.dax]))];
196
+ const table = semanticModel.tables.find((candidate) => candidate.name === axis.table);
197
+ const axisColumn = table?.columns?.find((candidate) => candidate.name === axis.column);
198
+ if (axisColumn?.sortByColumn) groupBy.push(semanticColumn(semanticModel, `${axis.table}[${axisColumn.sortByColumn}]`).dax);
199
+ const selectionFilters = Object.entries(selections)
200
+ .filter(([selectedWidget]) => selectedWidget !== widgetId)
201
+ .map(([selectedWidget, value]) => filterExpression(semanticModel, widgetById.get(selectedWidget).axis, [value]));
202
+ const filters = [...selectionFilters, ...parameterFilters].filter(Boolean);
203
+ const candidate = `__Widget${widgetIndex + 1}Candidates`;
204
+ const selected = `__Widget${widgetIndex + 1}Selected`;
205
+ const valueNames = measures.map((_measure, index) => `__Value${index + 1}`);
206
+ const summarized = [
207
+ ...groupBy,
208
+ ...filters,
209
+ ...measures.flatMap((measure, index) => [daxString(valueNames[index]), `CALCULATE(${measure.dax})`]),
210
+ ].join(",\n ");
211
+ const axisKey = dimensions.map((dimension) => axisKeyExpression(dimension.key)).join(' & "␟" & ');
212
+ const label = dimensions.map((dimension) => `FORMAT(${dimension.label.dax}, "")`).join(' & "␟" & ');
213
+ const order = ["top-by-value", "bottom-by-value"].includes(widget.rowSelection)
214
+ ? `[${valueNames[0]}], ${widget.rowSelection === "bottom-by-value" ? "ASC" : "DESC"}, [__Sort], ASC`
215
+ : `[__Sort], ${widget.rowSelection === "last-by-axis" ? "DESC" : "ASC"}, [__AxisKey], ASC`;
216
+ variables.push(`VAR ${candidate} =
217
+ FILTER(
218
+ SELECTCOLUMNS(
219
+ SUMMARIZECOLUMNS(
220
+ ${summarized}
221
+ ),
222
+ "__AxisKey", ${axisKey},
223
+ "__Label", ${label},
224
+ "__Sort", ${sortExpression(semanticModel, axis)},
225
+ ${measures.flatMap((_measure, index) => [daxString(valueNames[index]), `[${valueNames[index]}]`]).join(",\n ")}
226
+ ),
227
+ NOT ISBLANK([${valueNames[0]}])
228
+ )
229
+ VAR ${selected} = TOPN(${widget.rowLimit}, ${candidate}, ${order})`);
230
+ measures.forEach((measure, measureIndex) => {
231
+ rowSets.push(`SELECTCOLUMNS(
232
+ ${selected},
233
+ "WidgetId", ${daxString(widgetId)},
234
+ "AxisKey", [__AxisKey],
235
+ "Label", [__Label],
236
+ "Sort", [__Sort],
237
+ "SeriesKey", ${daxString(measure.reference)},
238
+ "SeriesLabel", ${daxString(measureLabel(measure.reference))},
239
+ "WidgetValue", [${valueNames[measureIndex]}],
240
+ "BrokerContext", ${daxString(Object.entries(selections).map(([id, value]) => `${id}=${value}`).join(";"))}
241
+ )`);
242
+ });
243
+ });
244
+
245
+ return `EVALUATE
246
+ ${variables.join("\n")}
247
+ VAR __SuperVisualRows =
248
+ ${rowSets.length === 1 ? rowSets[0] : `UNION(\n ${rowSets.join(",\n ")}\n )`}
249
+ RETURN
250
+ __SuperVisualRows
251
+ ORDER BY [WidgetId], [Sort], [AxisKey], [SeriesKey]`;
252
+ }
253
+
254
+ function rowValue(row, field) {
255
+ if (Object.hasOwn(row, field)) return row[field];
256
+ if (Object.hasOwn(row, `[${field}]`)) return row[`[${field}]`];
257
+ const key = Object.keys(row).find((candidate) => candidate.endsWith(`[${field}]`));
258
+ return key ? row[key] : null;
259
+ }
260
+
261
+ export function normalizeSemanticBrokerRows(rows) {
262
+ if (!Array.isArray(rows)) throw new Error("Power BI executeQueries did not return a row array");
263
+ return rows.map((row) => Object.fromEntries(BROKER_FIELDS.map((field) => [field, rowValue(row, field)])));
264
+ }
265
+
266
+ function usesSemanticCatalog(broker) {
267
+ return (broker.widgets ?? []).some((widget) => [
268
+ widget.axis,
269
+ ...(widget.dimensions ?? []).flatMap((dimension) => [dimension.key, dimension.label]),
270
+ ...(widget.measures ?? []),
271
+ ].some((reference) => typeof reference === "string" && reference.startsWith("Super Visual Model Catalog[")));
272
+ }
273
+
274
+ function semanticCatalogRows(model) {
275
+ const entities = [{ type: "Model", parent: "", entity: model.displayName }];
276
+ for (const table of model.tables ?? []) {
277
+ if (table.name === "Widget Axis" || table.name === "Super Visual Model Catalog") continue;
278
+ entities.push({ type: "Table", parent: model.displayName, entity: table.name });
279
+ for (const column of table.columns ?? []) entities.push({ type: "Column", parent: table.name, entity: column.name });
280
+ for (const measure of table.measures ?? []) entities.push({ type: "Measure", parent: table.name, entity: measure.name });
281
+ for (const hierarchy of table.hierarchies ?? []) {
282
+ entities.push({ type: "Hierarchy", parent: table.name, entity: hierarchy.name });
283
+ for (const level of hierarchy.levels ?? []) entities.push({ type: "Hierarchy level", parent: `${table.name}.${hierarchy.name}`, entity: level.name });
284
+ }
285
+ }
286
+ for (const relationship of model.relationships ?? []) entities.push({ type: "Relationship", parent: `${relationship.from ?? ""} → ${relationship.to ?? ""}`, entity: relationship.name });
287
+ for (const parameter of model.parameters ?? []) entities.push({ type: "Parameter", parent: model.displayName, entity: parameter.name });
288
+ return entities.map((item, index) => ({
289
+ WidgetId: "model-catalog",
290
+ AxisKey: `${item.type}␟${item.parent}␟${item.entity}`,
291
+ Label: `${item.type}␟${item.parent}␟${item.entity}`,
292
+ Sort: index + 1,
293
+ SeriesKey: "[Entity Count]",
294
+ SeriesLabel: "Entity Count",
295
+ WidgetValue: 1,
296
+ BrokerContext: `model=${model.id}`,
297
+ }));
298
+ }
299
+
300
+ export async function executeSemanticPreviewQuery({ client, workspaceId, semanticModelId, loaded, request }) {
301
+ const validated = validateQueryRequest(request, loaded);
302
+ const started = performance.now();
303
+ if (loaded.catalogRows && validated.widgets.every((widget) => widget === "model-catalog")) {
304
+ return { rows: loaded.catalogRows, metadata: { requestedWidgets: validated.widgets, rowCount: loaded.catalogRows.length, elapsedMs: Math.round(performance.now() - started) } };
305
+ }
306
+ const query = loaded.previewMode === "managed" ? buildManagedSemanticQuery(loaded, validated) : buildSemanticPreviewQuery(loaded, validated);
307
+ const rows = normalizeSemanticBrokerRows(await client.executeDax(workspaceId, semanticModelId, query));
308
+ return { rows, metadata: { requestedWidgets: validated.widgets, rowCount: rows.length, elapsedMs: Math.round(performance.now() - started) } };
309
+ }
310
+
311
+ export function previewBrokerSurfaceDiagnostics(inspection) {
312
+ const broker = inspection.tables.find((table) => table.name === "Widget Axis");
313
+ if (!broker) return [{ code: "preview.broker.missing", message: "The semantic model does not contain the generated Widget Axis table." }];
314
+ const columns = new Set((broker.columns ?? []).map((column) => column.name));
315
+ const measures = new Set((broker.measures ?? []).map((measure) => measure.name));
316
+ return [
317
+ ...BROKER_COLUMNS.filter((name) => !columns.has(name)).map((name) => ({ code: "preview.broker.column.missing", message: `Widget Axis[${name}] was not found.` })),
318
+ ...BROKER_MEASURES.filter((name) => !measures.has(name)).map((name) => ({ code: "preview.broker.measure.missing", message: `[${name}] was not found on Widget Axis.` })),
319
+ ];
320
+ }
321
+
322
+ function packageDirectory(name) {
323
+ let current = path.dirname(require.resolve(name));
324
+ while (current !== path.dirname(current) && !existsSync(path.join(current, "package.json"))) current = path.dirname(current);
325
+ if (!existsSync(path.join(current, "package.json"))) throw new Error(`Could not locate preview dependency ${name}`);
326
+ return current;
327
+ }
328
+
329
+ function dependencyAliases() {
330
+ return ["react", "react-dom", "react-router-dom", "@fontsource/source-serif-4", "@nivo/bar", "@nivo/line", "@nivo/pie", "@nivo/sankey", "@nivo/tree", "d3"]
331
+ .map((name) => ({ find: name, replacement: packageDirectory(name) }));
332
+ }
333
+
334
+ async function readJsonBody(request, maximumBytes = 64 * 1024) {
335
+ const chunks = [];
336
+ let length = 0;
337
+ for await (const chunk of request) {
338
+ length += chunk.length;
339
+ if (length > maximumBytes) throw new Error("Preview request body exceeds 64 KiB");
340
+ chunks.push(chunk);
341
+ }
342
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
343
+ }
344
+
345
+ function sendJson(response, status, value) {
346
+ const body = JSON.stringify(value);
347
+ response.writeHead(status, {
348
+ "Content-Type": "application/json; charset=utf-8",
349
+ "Content-Length": Buffer.byteLength(body),
350
+ "Cache-Control": "no-store",
351
+ "X-Content-Type-Options": "nosniff",
352
+ });
353
+ response.end(body);
354
+ }
355
+
356
+ function semanticGateway({ client, workspaceId, semanticModelId, loaded, token }) {
357
+ return {
358
+ name: "supervisual-semantic-gateway",
359
+ configureServer(server) {
360
+ server.middlewares.use(async (request, response, next) => {
361
+ if (request.url !== "/__supervisual/query") return next();
362
+ const expectedOrigin = `http://${request.headers.host}`;
363
+ if (request.method !== "POST"
364
+ || request.headers["content-type"]?.split(";", 1)[0] !== "application/json"
365
+ || request.headers.origin !== expectedOrigin
366
+ || request.headers["x-supervisual-preview-token"] !== token) {
367
+ return sendJson(response, 403, { ok: false, error: "Preview gateway request was rejected." });
368
+ }
369
+ try {
370
+ const result = await executeSemanticPreviewQuery({ client, workspaceId, semanticModelId, loaded, request: await readJsonBody(request) });
371
+ return sendJson(response, 200, { ok: true, ...result });
372
+ } catch (error) {
373
+ return sendJson(response, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
374
+ }
375
+ });
376
+ },
377
+ };
378
+ }
379
+
380
+ function previewResponseHeaders() {
381
+ return {
382
+ name: "supervisual-preview-response-headers",
383
+ configureServer(server) {
384
+ server.middlewares.use((_request, response, next) => {
385
+ for (const [name, value] of Object.entries(PREVIEW_RESPONSE_HEADERS)) response.setHeader(name, value);
386
+ next();
387
+ });
388
+ },
389
+ };
390
+ }
391
+
392
+ async function writePreviewHost(root, token) {
393
+ await rm(root, { recursive: true, force: true });
394
+ await mkdir(path.join(root, "src"), { recursive: true });
395
+ await cp(previewTemplate, root, { recursive: true });
396
+ await writeFile(path.join(root, "src", "config.ts"), `export const PREVIEW_TOKEN = ${JSON.stringify(token)};\n`);
397
+ }
398
+
399
+ export async function startSemanticPreview({ client, projectFile, workspaceId, workspaceName, semanticModelId, host = "127.0.0.1", port = 5173, open = false }) {
400
+ if (!["127.0.0.1", "localhost", "::1"].includes(host)) throw new Error("Semantic preview may bind only to a loopback host");
401
+ const loaded = await loadProject(projectFile);
402
+ const diagnostics = validateCompilation(loaded);
403
+ if (diagnostics.length) throw new Error(`Preview validation failed:\n- ${diagnostics.join("\n- ")}`);
404
+ const inspected = await inspectSemanticModel({ client, workspaceId, workspaceName, semanticModelId });
405
+ const catalogMode = usesSemanticCatalog(loaded.broker);
406
+ const brokerDiagnostics = previewBrokerSurfaceDiagnostics(inspected.semanticModel);
407
+ const previewMode = catalogMode ? "catalog" : brokerDiagnostics.length ? "direct" : "managed";
408
+ const compatibility = catalogMode
409
+ ? { ok: true, summary: { required: 1, matched: 1, missing: 0, ambiguous: 0 }, checks: [], diagnostics: [] }
410
+ : checkSemanticCompatibility(inspected.semanticModel, loaded.semanticModel, loaded.broker);
411
+ if (!compatibility.ok) {
412
+ const messages = compatibility.diagnostics.map((item) => item.message);
413
+ throw new Error(`Semantic preview compatibility failed:\n- ${messages.join("\n- ")}`);
414
+ }
415
+ loaded.previewMode = previewMode;
416
+ if (catalogMode) loaded.catalogRows = semanticCatalogRows(inspected.semanticModel);
417
+
418
+ const previewRoot = path.join(loaded.root, ".supervisual", "semantic-preview");
419
+ const solutionRoot = path.join(previewRoot, "solution");
420
+ const hostRoot = path.join(previewRoot, "host");
421
+ await buildSolution(projectFile, { outputDirectory: solutionRoot });
422
+ const token = randomBytes(24).toString("base64url");
423
+ await writePreviewHost(hostRoot, token);
424
+ const server = await createServer({
425
+ root: hostRoot,
426
+ logLevel: "silent",
427
+ resolve: { alias: dependencyAliases(), dedupe: ["react", "react-dom"] },
428
+ server: {
429
+ host,
430
+ port: Number(port),
431
+ strictPort: Number(port) !== 0,
432
+ open,
433
+ allowedHosts: [host],
434
+ cors: false,
435
+ headers: PREVIEW_RESPONSE_HEADERS,
436
+ fs: { strict: true, allow: [previewRoot] },
437
+ },
438
+ plugins: [previewResponseHeaders(), semanticGateway({ client, workspaceId, semanticModelId, loaded, token })],
439
+ });
440
+ await server.listen();
441
+ const address = server.httpServer?.address();
442
+ const actualPort = typeof address === "object" && address ? address.port : Number(port);
443
+ const urlHost = host === "::1" ? "[::1]" : host;
444
+ return {
445
+ ok: true,
446
+ status: "ready",
447
+ mode: "live-semantic-model",
448
+ url: `http://${urlHost}:${actualPort}/`,
449
+ workspace: inspected.workspace,
450
+ semanticModel: { id: inspected.semanticModel.id, displayName: inspected.semanticModel.displayName },
451
+ compatibility: compatibility.summary,
452
+ project: projectFile,
453
+ generatedDirectory: previewRoot,
454
+ close: () => server.close(),
455
+ };
456
+ }
457
+
458
+ export async function runSemanticPreviewTest(options) {
459
+ const preview = await startSemanticPreview({ ...options, port: 0, open: false });
460
+ let browser;
461
+ try {
462
+ const { chromium } = await import("playwright");
463
+ browser = await chromium.launch({ headless: true });
464
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
465
+ const browserErrors = [];
466
+ page.on("pageerror", (error) => browserErrors.push(error.message));
467
+ await page.goto(preview.url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs });
468
+ try {
469
+ await page.waitForFunction(() => window.__SUPERVISUAL_PREVIEW__?.snapshot().status === "ready", null, { timeout: options.timeoutMs });
470
+ } catch (error) {
471
+ const snapshot = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__?.snapshot?.() ?? null).catch(() => null);
472
+ throw new Error(`Semantic preview did not become ready: ${JSON.stringify({ snapshot, browserErrors, cause: error instanceof Error ? error.message : String(error) })}`);
473
+ }
474
+ const baseline = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot());
475
+ if (baseline.receivedRowCount < 1) throw new Error("Semantic preview returned no broker rows");
476
+
477
+ let interaction = { available: false, tested: false, widget: null, selected: null };
478
+ let pagesTested = 1;
479
+ let selector = page.locator("select[data-filter-widget]").first();
480
+ if (!(await selector.count()) || await selector.locator("option").count() < 2) {
481
+ const links = page.locator('nav[aria-label="Report pages"] a');
482
+ for (let index = 0; index < await links.count(); index += 1) {
483
+ const beforeNavigation = (await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot())).requestSequence;
484
+ await links.nth(index).click();
485
+ pagesTested += 1;
486
+ await page.waitForFunction((sequence) => {
487
+ const snapshot = window.__SUPERVISUAL_PREVIEW__?.snapshot();
488
+ return snapshot?.status === "ready" && snapshot.requestSequence > sequence;
489
+ }, beforeNavigation, { timeout: options.timeoutMs });
490
+ selector = page.locator("select[data-filter-widget]").first();
491
+ if (await selector.count() && await selector.locator("option").count() > 1) break;
492
+ }
493
+ }
494
+ if (await selector.count() && await selector.locator("option").count() > 1) {
495
+ interaction.available = true;
496
+ interaction.widget = await selector.getAttribute("data-filter-widget");
497
+ const before = (await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot())).requestSequence;
498
+ await selector.selectOption({ index: 1 });
499
+ await page.waitForFunction((sequence) => {
500
+ const snapshot = window.__SUPERVISUAL_PREVIEW__?.snapshot();
501
+ return snapshot?.status === "ready" && snapshot.requestSequence > sequence && Object.keys(snapshot.activeSelections).length > 0;
502
+ }, before, { timeout: options.timeoutMs });
503
+ const selected = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot());
504
+ interaction.selected = { requestSequence: selected.requestSequence, receivedRowCount: selected.receivedRowCount, rowCounts: selected.rowCounts };
505
+ const beforeClear = selected.requestSequence;
506
+ await selector.selectOption("");
507
+ await page.waitForFunction((sequence) => {
508
+ const snapshot = window.__SUPERVISUAL_PREVIEW__?.snapshot();
509
+ return snapshot?.status === "ready" && snapshot.requestSequence > sequence && Object.keys(snapshot.activeSelections).length === 0;
510
+ }, beforeClear, { timeout: options.timeoutMs });
511
+ interaction.tested = true;
512
+ }
513
+ const final = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot());
514
+ if (browserErrors.length) throw new Error(`Preview browser errors: ${browserErrors.join("; ")}`);
515
+ const evidence = (snapshot) => ({
516
+ status: snapshot.status,
517
+ requestSequence: snapshot.requestSequence,
518
+ receivedWidgets: snapshot.receivedWidgets,
519
+ receivedRowCount: snapshot.receivedRowCount,
520
+ rowCounts: snapshot.rowCounts,
521
+ error: snapshot.error,
522
+ });
523
+ return { ok: true, status: "passed", mode: preview.mode, workspace: preview.workspace, semanticModel: preview.semanticModel, baseline: evidence(baseline), final: evidence(final), pagesTested, interaction };
524
+ } finally {
525
+ await browser?.close();
526
+ await preview.close();
527
+ }
528
+ }
@@ -0,0 +1,26 @@
1
+ # {{PROJECT_TITLE}}
2
+
3
+ This is the smallest complete Super Visual project: one semantic table, one
4
+ measure, one broker dataset, one page, and one accessible React chart.
5
+
6
+ This project is ready for the three-command workflow. If `init` was given a
7
+ workspace and Power BI semantic model, run:
8
+
9
+ ```bash
10
+ npx supervisual dev --open
11
+ npx supervisual publish
12
+ ```
13
+
14
+ `publish` performs the checks, packaging, verification, and planning, then asks
15
+ for confirmation of the exact plan ID. For a project without a saved binding,
16
+ pass the IDs once:
17
+
18
+ ```bash
19
+ npx supervisual dev . --workspace-id <workspace-guid> \
20
+ --semantic-model-id <semantic-model-guid> --open
21
+ npx supervisual preview test . --workspace-id <workspace-guid> \
22
+ --semantic-model-id <semantic-model-guid> --json
23
+ ```
24
+
25
+ Edit the contracts under `contracts/` to point at your governed data. Application
26
+ source receives data only through `ReportApplicationProps`.
@@ -0,0 +1,78 @@
1
+ contractVersion: 1
2
+ shell:
3
+ id: {{PROJECT_ID}}
4
+ title: {{PROJECT_TITLE}}
5
+ defaultPage: overview
6
+ designSystem:
7
+ id: hello-world
8
+ sha256: {{DESIGN_HASH}}
9
+ acceptedConcept: { id: {{PROJECT_ID}} }
10
+ governanceLabel: Power BI governed
11
+ locale: en-AU
12
+ currencyCode: AUD
13
+ filterContext: []
14
+ navigation:
15
+ - { page: overview, label: Overview }
16
+ theme:
17
+ background: '#F4F7F5'
18
+ surface: '#FFFFFF'
19
+ quietSurface: '#EAF0ED'
20
+ text: '#173C36'
21
+ mutedText: '#5E6D68'
22
+ metadata: '#71807A'
23
+ rule: '#D3DDD8'
24
+ grid: '#E3EAE6'
25
+ accent: '#167D6C'
26
+ attention: '#9B6B23'
27
+ positive: '#2E7D5B'
28
+ negative: '#B84A52'
29
+ context: ['#167D6C', '#6F6A9A']
30
+ dataColors: ['#167D6C', '#6F6A9A', '#9B6B23']
31
+ displayFont: Georgia, serif
32
+ bodyFont: Aptos, Segoe UI, sans-serif
33
+ contentWidth: 1200
34
+ layoutColumns: 12
35
+ layoutGap: 16
36
+ cardRadius: 8
37
+ cardPadding: [20, 20, 20, 20]
38
+ kpiTopAccent: 3
39
+ density: comfortable
40
+ typography: { findingMin: 40, findingMax: 56, sectionTitle: 22, kpiValue: 32, body: 15, chart: 12, table: 13, metadata: 11 }
41
+ pages:
42
+ - id: overview
43
+ title: Hello World
44
+ eyebrow: Super Visual / minimal example
45
+ summary:
46
+ headline: Your first governed React dashboard.
47
+ body: One dataset and one chart prove the complete semantic-to-visual path.
48
+ emphasisBinding: { dataset: sales, field: value, aggregation: sum }
49
+ search: null
50
+ kpis:
51
+ - id: total-sales
52
+ conceptRegionId: total-sales
53
+ label: Total sales
54
+ binding: { dataset: sales, field: value, aggregation: sum }
55
+ reportWidgetIds: [sales]
56
+ businessQuestionIds: [sales-mix]
57
+ format: currency-compact
58
+ comparison: null
59
+ widgets:
60
+ - id: sales
61
+ conceptRegionId: sales
62
+ primitive: bar
63
+ title: Sales by category
64
+ subtitle: Governed data from the semantic model
65
+ dataset: sales
66
+ reportWidgetIds: [sales]
67
+ businessQuestionIds: [sales-mix]
68
+ layout: { columns: 12, minHeight: 320 }
69
+ settings: { orientation: horizontal, curve: null, showLegend: false, showLabels: true, sort: descending, limit: 10, emphasis: bars }
70
+ interactions: []
71
+ designRationale:
72
+ narrative: Demonstrate the smallest complete Super Visual project.
73
+ decisions:
74
+ - Keep the example to one page, one dataset, one measure, and one chart.
75
+ accessibility:
76
+ - Use semantic headings, a native list, visible values, and no colour-only meaning.
77
+ risks:
78
+ - A Fabric deployment still requires a reachable SQL endpoint and connection binding.
@@ -0,0 +1,30 @@
1
+ {
2
+ "contractVersion": 1,
3
+ "designSystem": { "id": "hello-world", "sha256": "{{DESIGN_HASH}}" },
4
+ "acceptedConcept": { "id": "{{PROJECT_ID}}" },
5
+ "entry": "src/reportApplication.tsx",
6
+ "files": [
7
+ {
8
+ "path": "src/reportApplication.tsx",
9
+ "content": "import { useEffect, type ReactElement } from \"react\";\nimport type { ReportApplicationProps } from \"./reportRuntime\";\n\nexport function ReportApplication({ model, onVisibleDatasetsChange }: ReportApplicationProps): ReactElement {\n useEffect(() => onVisibleDatasetsChange?.([\"sales\"]), [onVisibleDatasetsChange]);\n const dataset = model.datasets.sales;\n const maximum = Math.max(1, ...(dataset?.points.map((point) => point.value) ?? [1]));\n const total = dataset?.points.reduce((sum, point) => sum + point.value, 0) ?? 0;\n\n return <main className=\"hello-world\">\n <p className=\"eyebrow\">Super Visual / minimal example</p>\n <h1>Hello, governed data.</h1>\n <p className=\"lede\">This React page is bound only through ReportApplicationProps.</p>\n <section aria-labelledby=\"total-sales\">\n <h2 id=\"total-sales\">Total sales</h2>\n <strong className=\"total\">{new Intl.NumberFormat(undefined, { style: \"currency\", currency: \"AUD\", maximumFractionDigits: 0 }).format(total)}</strong>\n </section>\n <section aria-labelledby=\"sales-by-category\">\n <h2 id=\"sales-by-category\">Sales by category</h2>\n {!dataset?.points.length ? <p>Waiting for Power BI data…</p> : <ol className=\"bars\">\n {dataset.points.map((point) => <li key={point.key}>\n <span>{point.label}</span>\n <span className=\"track\"><span className=\"bar\" style={{ width: `${Math.max(2, (point.value / maximum) * 100)}%` }} /></span>\n <strong>{new Intl.NumberFormat().format(point.value)}</strong>\n </li>)}\n </ol>}\n </section>\n </main>;\n}\n"
10
+ },
11
+ {
12
+ "path": "style/report-design.less",
13
+ "content": ".hello-world { box-sizing: border-box; min-height: 100%; padding: 32px; color: #173c36; background: #f4f7f5; font-family: Aptos, \"Segoe UI\", sans-serif; }\n.hello-world .eyebrow { margin: 0 0 8px; color: #167d6c; font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n.hello-world h1 { margin: 0; font: 600 clamp(36px, 6vw, 64px)/1 Georgia, serif; }\n.hello-world .lede { max-width: 620px; color: #5e6d68; font-size: 17px; }\n.hello-world section { margin-top: 28px; padding: 24px; border: 1px solid #d3ddd8; border-radius: 8px; background: #fff; }\n.hello-world h2 { margin: 0 0 16px; font-size: 18px; }\n.hello-world .total { font: 600 42px/1 Georgia, serif; }\n.hello-world .bars { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; }\n.hello-world .bars li { display: grid; grid-template-columns: minmax(90px, 1fr) 3fr minmax(70px, auto); gap: 12px; align-items: center; }\n.hello-world .track { height: 14px; overflow: hidden; border-radius: 7px; background: #eaf0ed; }\n.hello-world .bar { display: block; height: 100%; border-radius: inherit; background: #167d6c; }\n@media (max-width: 640px) { .hello-world { padding: 18px; } .hello-world .bars li { grid-template-columns: 1fr auto; } .hello-world .track { grid-column: 1 / -1; } }\n"
14
+ }
15
+ ],
16
+ "routes": [
17
+ { "id": "overview", "title": "Hello World", "datasets": ["sales"], "businessQuestionIds": ["sales-mix"] }
18
+ ],
19
+ "bindings": [
20
+ { "dataset": "sales", "component": "bar", "reportWidgetIds": ["sales"], "businessQuestionIds": ["sales-mix"] }
21
+ ],
22
+ "designRationale": {
23
+ "direction": "The smallest complete governed analytical application.",
24
+ "pageCompositions": ["One overview page"],
25
+ "dataVisualisation": ["One accessible horizontal bar chart"],
26
+ "responsiveBehaviour": ["The label and chart track stack on narrow screens."],
27
+ "accessibility": ["Semantic headings, native list structure, and visible numeric values."],
28
+ "risks": ["Live deployment requires a Fabric connection binding."]
29
+ }
30
+ }