@loomwork/blueprint-runtime 0.0.0-stage → 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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +83 -2
- package/dist/Blueprint.d.ts +14 -0
- package/dist/Blueprint.d.ts.map +1 -0
- package/dist/Blueprint.js +18 -0
- package/dist/Blueprint.js.map +1 -0
- package/dist/RendererRegistry.d.ts +9 -0
- package/dist/RendererRegistry.d.ts.map +1 -0
- package/dist/RendererRegistry.js +39 -0
- package/dist/RendererRegistry.js.map +1 -0
- package/dist/catalog.d.ts +129 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +435 -0
- package/dist/catalog.js.map +1 -0
- package/dist/coverage.d.ts +18 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +22 -0
- package/dist/coverage.js.map +1 -0
- package/dist/customWidget.d.ts +10 -0
- package/dist/customWidget.d.ts.map +1 -0
- package/dist/customWidget.js +34 -0
- package/dist/customWidget.js.map +1 -0
- package/dist/esm/Blueprint.js +15 -0
- package/dist/esm/Blueprint.js.map +1 -0
- package/dist/esm/RendererRegistry.js +35 -0
- package/dist/esm/RendererRegistry.js.map +1 -0
- package/dist/esm/catalog.js +430 -0
- package/dist/esm/catalog.js.map +1 -0
- package/dist/esm/coverage.js +19 -0
- package/dist/esm/coverage.js.map +1 -0
- package/dist/esm/customWidget.js +31 -0
- package/dist/esm/customWidget.js.map +1 -0
- package/dist/esm/identity.js +116 -0
- package/dist/esm/identity.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/irVersion.js +150 -0
- package/dist/esm/irVersion.js.map +1 -0
- package/dist/esm/layout.js +63 -0
- package/dist/esm/layout.js.map +1 -0
- package/dist/esm/normalize.js +115 -0
- package/dist/esm/normalize.js.map +1 -0
- package/dist/esm/pins.js +82 -0
- package/dist/esm/pins.js.map +1 -0
- package/dist/esm/traverseBlueprint.js +38 -0
- package/dist/esm/traverseBlueprint.js.map +1 -0
- package/dist/esm/traverseBlueprintAsync.js +48 -0
- package/dist/esm/traverseBlueprintAsync.js.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/widgetStatus.js +137 -0
- package/dist/esm/widgetStatus.js.map +1 -0
- package/dist/esm/widgets.js +105 -0
- package/dist/esm/widgets.js.map +1 -0
- package/dist/identity.d.ts +58 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +122 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/irVersion.d.ts +23 -0
- package/dist/irVersion.d.ts.map +1 -0
- package/dist/irVersion.js +153 -0
- package/dist/irVersion.js.map +1 -0
- package/dist/layout.d.ts +57 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +71 -0
- package/dist/layout.js.map +1 -0
- package/dist/normalize.d.ts +29 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +126 -0
- package/dist/normalize.js.map +1 -0
- package/dist/pins.d.ts +27 -0
- package/dist/pins.d.ts.map +1 -0
- package/dist/pins.js +86 -0
- package/dist/pins.js.map +1 -0
- package/dist/traverseBlueprint.d.ts +27 -0
- package/dist/traverseBlueprint.d.ts.map +1 -0
- package/dist/traverseBlueprint.js +40 -0
- package/dist/traverseBlueprint.js.map +1 -0
- package/dist/traverseBlueprintAsync.d.ts +18 -0
- package/dist/traverseBlueprintAsync.d.ts.map +1 -0
- package/dist/traverseBlueprintAsync.js +51 -0
- package/dist/traverseBlueprintAsync.js.map +1 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/widgetStatus.d.ts +66 -0
- package/dist/widgetStatus.d.ts.map +1 -0
- package/dist/widgetStatus.js +141 -0
- package/dist/widgetStatus.js.map +1 -0
- package/dist/widgets.d.ts +101 -0
- package/dist/widgets.d.ts.map +1 -0
- package/dist/widgets.js +115 -0
- package/dist/widgets.js.map +1 -0
- package/package.json +60 -3
- package/src/Blueprint.ts +28 -0
- package/src/RendererRegistry.ts +38 -0
- package/src/catalog.ts +638 -0
- package/src/coverage.ts +52 -0
- package/src/customWidget.ts +42 -0
- package/src/identity.ts +178 -0
- package/src/index.ts +84 -0
- package/src/irVersion.ts +195 -0
- package/src/layout.ts +105 -0
- package/src/normalize.ts +130 -0
- package/src/pins.ts +84 -0
- package/src/traverseBlueprint.bench.ts +47 -0
- package/src/traverseBlueprint.ts +94 -0
- package/src/traverseBlueprintAsync.ts +92 -0
- package/src/types.ts +195 -0
- package/src/widgetStatus.ts +231 -0
- package/src/widgets.ts +213 -0
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { BLUEPRINT_IR_VERSION } from "./irVersion";
|
|
2
|
+
import type { BindingStatus, Blueprint, GridCell, Layout, Widget } from "./types";
|
|
3
|
+
|
|
4
|
+
export const IR_VERSION_LEGACY = "1.0.0";
|
|
5
|
+
|
|
6
|
+
// ── Path resolution ───────────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// Lives here (not traverseBlueprint.ts) to avoid an import cycle: traverseBlueprint imports
|
|
9
|
+
// `asCell` from this module, and `resolveWidgetStatus` below needs `resolvePath`. Re-exported
|
|
10
|
+
// from traverseBlueprint.ts unchanged for existing consumers.
|
|
11
|
+
|
|
12
|
+
// Resolve a dot-path (e.g. "a.b.c") against a projection state. Null-safe and
|
|
13
|
+
// generic — no widget/UI coupling — so any live rendering surface can build a
|
|
14
|
+
// slice selector for `ProjectionRuntime.subscribeSelector` without duplicating
|
|
15
|
+
// this traversal (see useWidgetValue). An empty path binds to the whole state.
|
|
16
|
+
export function resolvePath(state: unknown, path: string): unknown {
|
|
17
|
+
// An empty path binds the widget to the whole projection surface (the root),
|
|
18
|
+
// not a nested field — otherwise `"".split(".")` yields `[""]` and dereferences
|
|
19
|
+
// a non-existent "" key, silently returning undefined.
|
|
20
|
+
if (path === "") return state;
|
|
21
|
+
const parts = path.split(".");
|
|
22
|
+
let current: unknown = state;
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
if (current === null || current === undefined) return undefined;
|
|
25
|
+
current = (current as Record<string, unknown>)[part];
|
|
26
|
+
}
|
|
27
|
+
return current;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const isWidget = (n: unknown): n is Widget =>
|
|
31
|
+
typeof n === "object" && n !== null && (n as { _tag?: unknown })._tag === "Widget";
|
|
32
|
+
const isCell = (n: unknown): n is GridCell =>
|
|
33
|
+
typeof n === "object" && n !== null && "node" in n && !("_tag" in n) && !("type" in n);
|
|
34
|
+
|
|
35
|
+
/** The one place a legacy bare grid child becomes a cell. Every walker calls this; nothing else branches on the union. */
|
|
36
|
+
export function asCell(child: GridCell | Widget | Layout): GridCell {
|
|
37
|
+
return isCell(child) ? child : { span: 1, node: child };
|
|
38
|
+
}
|
|
39
|
+
/** An explicit-span cell, optionally placed (IR 2.1.0). Only the placement fields given are written. */
|
|
40
|
+
export function cell(
|
|
41
|
+
span: number,
|
|
42
|
+
node: Widget | Layout,
|
|
43
|
+
placement: Pick<GridCell, "row" | "rowSpan" | "column"> = {}
|
|
44
|
+
): GridCell {
|
|
45
|
+
return {
|
|
46
|
+
span,
|
|
47
|
+
node,
|
|
48
|
+
...(placement.row !== undefined && { row: placement.row }),
|
|
49
|
+
...(placement.rowSpan !== undefined && { rowSpan: placement.rowSpan }),
|
|
50
|
+
...(placement.column !== undefined && { column: placement.column }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function blueprintIrVersion(bp: Blueprint): string {
|
|
55
|
+
return bp.irVersion ?? IR_VERSION_LEGACY;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function upgradeLayout(layout: Layout): Layout {
|
|
59
|
+
if (layout.type === "grid") {
|
|
60
|
+
return {
|
|
61
|
+
...layout,
|
|
62
|
+
children: (layout.children as Array<GridCell | Widget | Layout>).map((c) => {
|
|
63
|
+
const k = asCell(c);
|
|
64
|
+
return isWidget(k.node) ? k : { ...k, node: upgradeLayout(k.node) };
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ...layout, children: layout.children.map((c) => (isWidget(c) ? c : upgradeLayout(c))) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every IR version this build reads AS-IS. 2.1.0 (ADR 0098) and 2.2.0 (ADR 0099) only added
|
|
73
|
+
* optional fields, and 2.3.0 (ADR 0100) only a `BindingState` literal (a runtime status value
|
|
74
|
+
* no artifact carries), 2.4.0 (ADR 0101) only an optional layout `id`, and 2.5.0 (ADR 0097
|
|
75
|
+
* amendment) only an optional `source.redacted`, so a 2.0.0 through 2.4.0 artifact is already a valid current-major artifact: re-stamping it would change its
|
|
76
|
+
* content address with no change to its shape.
|
|
77
|
+
*
|
|
78
|
+
* Add the outgoing version here on every minor bump.
|
|
79
|
+
*/
|
|
80
|
+
export const IR_VERSIONS_READ_AS_IS: ReadonlySet<string> = new Set([
|
|
81
|
+
"2.0.0",
|
|
82
|
+
"2.1.0",
|
|
83
|
+
"2.2.0",
|
|
84
|
+
"2.3.0",
|
|
85
|
+
"2.4.0",
|
|
86
|
+
BLUEPRINT_IR_VERSION,
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A current-major artifact from any readable one. Identity (same reference) on 2.0.0, 2.1.0,
|
|
91
|
+
* 2.2.0, 2.3.0, 2.4.0 and 2.5.0 input; a 1.0.0 (or unstamped) artifact is wrapped and stamped. Never
|
|
92
|
+
* mutates its input.
|
|
93
|
+
*/
|
|
94
|
+
export function upgradeBlueprint(bp: Blueprint): Blueprint {
|
|
95
|
+
if (bp.irVersion !== undefined && IR_VERSIONS_READ_AS_IS.has(bp.irVersion)) return bp;
|
|
96
|
+
return { ...bp, irVersion: BLUEPRINT_IR_VERSION, layout: upgradeLayout(bp.layout) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// `empty` since IR 2.3.0 (ADR 0100). A reader built before it rejects `{ state: "empty" }` here,
|
|
100
|
+
// so the status resolves to `undefined` and that reader's matrix falls back to loading/ready —
|
|
101
|
+
// never to the error row (ADR 0100 D4).
|
|
102
|
+
const STATES = new Set(["pending", "ready", "stale", "error", "empty"]);
|
|
103
|
+
export function isBindingStatus(v: unknown): v is BindingStatus {
|
|
104
|
+
if (typeof v !== "object" || v === null) return false;
|
|
105
|
+
const s = v as Record<string, unknown>;
|
|
106
|
+
if (typeof s.state !== "string" || !STATES.has(s.state)) return false;
|
|
107
|
+
if (s.seq !== undefined && typeof s.seq !== "number") return false;
|
|
108
|
+
if (s.finding !== undefined) {
|
|
109
|
+
const f = s.finding as Record<string, unknown> | null;
|
|
110
|
+
if (
|
|
111
|
+
typeof f !== "object" ||
|
|
112
|
+
f === null ||
|
|
113
|
+
typeof f.code !== "string" ||
|
|
114
|
+
typeof f.message !== "string"
|
|
115
|
+
)
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Status lives in the SAME projection state as the value; a malformed value is `undefined`, never a throw. */
|
|
122
|
+
export function resolveWidgetStatus(
|
|
123
|
+
widget: Widget,
|
|
124
|
+
stateMap: Map<string, unknown>
|
|
125
|
+
): BindingStatus | undefined {
|
|
126
|
+
const b = widget.binding;
|
|
127
|
+
if (b?.status === undefined) return undefined;
|
|
128
|
+
const raw = resolvePath(stateMap.get(b.projectionId), b.status.path);
|
|
129
|
+
return isBindingStatus(raw) ? raw : undefined;
|
|
130
|
+
}
|
package/src/pins.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { checkVersionPins } from "@loomwork/core";
|
|
2
|
+
import type { PinReport, VersionPins } from "@loomwork/core";
|
|
3
|
+
import type { Blueprint } from "./types";
|
|
4
|
+
|
|
5
|
+
// ── The blueprint spelling of the binding-semantics pin (ADR 0095) ────────────
|
|
6
|
+
//
|
|
7
|
+
// `validateBlueprint` checks a composition against a catalogue; `preservesNodes` checks one
|
|
8
|
+
// tree against another. Neither can see the failure this covers, because it is not in the
|
|
9
|
+
// tree at all: a `DataBinding` names a projection, the projection is live code registered
|
|
10
|
+
// under that name, and the content address that makes a promoted view "the same view"
|
|
11
|
+
// freezes the NAME.
|
|
12
|
+
//
|
|
13
|
+
// So a view can be byte-identical to the one an operator promoted, hydrate cleanly, validate
|
|
14
|
+
// cleanly, and render a number that means something else — because the fold behind
|
|
15
|
+
// `latency` was edited, or its window moved from rolling to fixed, since the day it was
|
|
16
|
+
// composed.
|
|
17
|
+
//
|
|
18
|
+
// The comparison itself is `@loomwork/core`'s `checkVersionPins`, over `id -> version` maps
|
|
19
|
+
// rather than blueprints: its other consumer (`ReadModelService.checkPins`) has no tree.
|
|
20
|
+
// These two functions are the blueprint case spelled once, the way `blueprintNodeIds` /
|
|
21
|
+
// `preservesBlueprintNodes` spell it for identity.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The versions a blueprint recorded for the projections it binds to.
|
|
25
|
+
*
|
|
26
|
+
* Only ids the blueprint actually DECLARES are returned. A pin for an id absent from
|
|
27
|
+
* `projections` freezes nothing this view reads, and counting it would pad the comparison
|
|
28
|
+
* with agreements that carry no information — the denominator problem `PinReport.compared`
|
|
29
|
+
* exists to make visible.
|
|
30
|
+
*/
|
|
31
|
+
export function blueprintPins(blueprint: Blueprint): VersionPins {
|
|
32
|
+
const recorded = blueprint.projectionVersions;
|
|
33
|
+
if (recorded === undefined) return emptyPins();
|
|
34
|
+
const pins = emptyPins();
|
|
35
|
+
for (const projectionId of blueprint.projections) {
|
|
36
|
+
const version = recordedVersion(recorded, projectionId);
|
|
37
|
+
if (version !== undefined) pins[projectionId] = version;
|
|
38
|
+
}
|
|
39
|
+
return pins;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Does this blueprint still mean what it meant when it was composed?
|
|
44
|
+
*
|
|
45
|
+
* `current` is what the runtime about to hydrate it declares —
|
|
46
|
+
* `projectionRuntime.pins?.() ?? {}`. Findings, never a throw: a drift can be a deliberate
|
|
47
|
+
* re-promotion or a silent re-definition, and only the caller can tell.
|
|
48
|
+
*
|
|
49
|
+
* The comparison is scoped to the blueprint's own `projections` in BOTH directions, and
|
|
50
|
+
* every declared projection is entered on the recorded side even when nothing pinned it
|
|
51
|
+
* (as `""`, which `checkVersionPins` reads as "declared nothing"). That is deliberate:
|
|
52
|
+
* projections this view never binds are none of its business, and a binding that nobody
|
|
53
|
+
* pinned must produce a visible `unpinned` finding rather than vanishing from a report that
|
|
54
|
+
* would then read `ok: true` over an empty comparison.
|
|
55
|
+
*/
|
|
56
|
+
export function checkBlueprintPins(blueprint: Blueprint, current: VersionPins): PinReport {
|
|
57
|
+
const recorded = emptyPins();
|
|
58
|
+
const scoped = emptyPins();
|
|
59
|
+
for (const projectionId of blueprint.projections) {
|
|
60
|
+
recorded[projectionId] = recordedVersion(blueprint.projectionVersions, projectionId) ?? "";
|
|
61
|
+
const now = recordedVersion(current, projectionId);
|
|
62
|
+
if (now !== undefined) scoped[projectionId] = now;
|
|
63
|
+
}
|
|
64
|
+
return checkVersionPins(recorded, scoped);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A pin map with NO prototype, built rather than written into `{}`.
|
|
69
|
+
*
|
|
70
|
+
* A blueprint is data — the whole premise of ADR 0088 is that a model may have composed it
|
|
71
|
+
* — so a `projectionId` is an untrusted string, and `{}["__proto__"] = "1"` sets a
|
|
72
|
+
* prototype instead of a key: the pin vanishes, `Object.keys` never sees it, and the
|
|
73
|
+
* binding drops out of the report entirely with no finding. Silent omission is precisely
|
|
74
|
+
* what this seam exists to prevent, so the maps it builds cannot have a prototype to hit.
|
|
75
|
+
*/
|
|
76
|
+
function emptyPins(): Record<string, string> {
|
|
77
|
+
return Object.create(null) as Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An OWN-property read, so an inherited member never answers for a recorded version. */
|
|
81
|
+
function recordedVersion(pins: VersionPins | undefined, id: string): string | undefined {
|
|
82
|
+
if (pins === undefined || !Object.hasOwn(pins, id)) return undefined;
|
|
83
|
+
return pins[id];
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { bench, describe } from "vitest";
|
|
2
|
+
import { grid } from "./layout";
|
|
3
|
+
import { traverseBlueprint } from "./traverseBlueprint";
|
|
4
|
+
import type { Layout, Widget } from "./types";
|
|
5
|
+
|
|
6
|
+
// ── Regression gate ───────────────────────────────────────────────────────────
|
|
7
|
+
// traverseBlueprint is the synchronous render fast-path. It is deliberately left
|
|
8
|
+
// untouched by the scheduler work; this bench is the committed baseline that CI
|
|
9
|
+
// compares against so any accidental regression to the hot path is caught.
|
|
10
|
+
|
|
11
|
+
function buildTree(nodeCount: number): Layout {
|
|
12
|
+
// Build a balanced grid tree whose leaves are widgets, totalling ~nodeCount
|
|
13
|
+
// widgets. Each level fans out by `branch`.
|
|
14
|
+
const branch = 4;
|
|
15
|
+
let leaves: Array<Widget | Layout> = Array.from({ length: nodeCount }, (_unused, i) => ({
|
|
16
|
+
_tag: "Widget",
|
|
17
|
+
id: `w${i}`,
|
|
18
|
+
kind: "MetricCard",
|
|
19
|
+
props: { label: `metric ${i}` },
|
|
20
|
+
binding: { projectionId: "p", path: "value" },
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
while (leaves.length > 1) {
|
|
24
|
+
const parents: Array<Widget | Layout> = [];
|
|
25
|
+
for (let i = 0; i < leaves.length; i += branch) {
|
|
26
|
+
parents.push(grid({ columns: branch, children: leaves.slice(i, i + branch) }));
|
|
27
|
+
}
|
|
28
|
+
leaves = parents;
|
|
29
|
+
}
|
|
30
|
+
return leaves[0] as Layout;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stateMap = new Map<string, unknown>([["p", { value: 42 }]]);
|
|
34
|
+
|
|
35
|
+
const options = {
|
|
36
|
+
renderWidget: ({ value }: { value: unknown }) => value,
|
|
37
|
+
renderContainer: ({ children }: { children: unknown[] }) => children,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const size of [10, 100, 1000, 10000]) {
|
|
41
|
+
describe(`traverseBlueprint / ${size} nodes`, () => {
|
|
42
|
+
const tree = buildTree(size);
|
|
43
|
+
bench("traverse", () => {
|
|
44
|
+
traverseBlueprint(tree, stateMap, options);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { asCell, isWidget, resolvePath, resolveWidgetStatus } from "./normalize";
|
|
2
|
+
import type { Layout, Widget, WidgetAction, WidgetRendererProps } from "./types";
|
|
3
|
+
|
|
4
|
+
// Re-exported unchanged for existing consumers; it now lives in normalize.ts to avoid an
|
|
5
|
+
// import cycle (normalize.ts's `resolveWidgetStatus` needs it, and this module imports
|
|
6
|
+
// `asCell`/`isWidget`/`resolveWidgetStatus` from normalize.ts as of Task 2).
|
|
7
|
+
export { resolvePath };
|
|
8
|
+
|
|
9
|
+
// ── Context types ─────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface ContainerContext<TNode> {
|
|
12
|
+
layout: Layout;
|
|
13
|
+
children: TNode[];
|
|
14
|
+
/**
|
|
15
|
+
* Grid containers only: each child with the columns it occupies (default 1), in document
|
|
16
|
+
* order, plus its explicit placement (IR 2.1.0) — `row`/`rowSpan`/`column` are present only
|
|
17
|
+
* when the cell sets them.
|
|
18
|
+
*/
|
|
19
|
+
cells?: Array<{ span: number; row?: number; rowSpan?: number; column?: number; child: TNode }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Value resolution ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export function resolveWidgetValue(widget: Widget, stateMap: Map<string, unknown>): unknown {
|
|
25
|
+
if (!widget.binding) return undefined;
|
|
26
|
+
const state = stateMap.get(widget.binding.projectionId);
|
|
27
|
+
return resolvePath(state, widget.binding.path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── traverseBlueprint ─────────────────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// Framework-agnostic recursive tree walker. Given a blueprint layout node and a
|
|
33
|
+
// projection state map, walks the tree calling `renderWidget` for leaf nodes and
|
|
34
|
+
// `renderContainer` for layout nodes. Returns a single TNode representing the
|
|
35
|
+
// whole subtree — whatever type makes sense for the target framework:
|
|
36
|
+
// React: traverseBlueprint<React.ReactNode>(...)
|
|
37
|
+
// DOM: traverseBlueprint<HTMLElement>(...)
|
|
38
|
+
// string: traverseBlueprint<string>(...) // server-side HTML
|
|
39
|
+
//
|
|
40
|
+
// Example (plain DOM adapter):
|
|
41
|
+
// const el = traverseBlueprint<HTMLElement>(blueprint.layout, stateMap, {
|
|
42
|
+
// renderWidget({ widget, value, onAction }) {
|
|
43
|
+
// const div = document.createElement("div");
|
|
44
|
+
// div.dataset.widgetId = widget.id;
|
|
45
|
+
// div.textContent = String(value ?? "—");
|
|
46
|
+
// return div;
|
|
47
|
+
// },
|
|
48
|
+
// renderContainer({ layout, children }) {
|
|
49
|
+
// const div = document.createElement("div");
|
|
50
|
+
// div.style.display = layout.type === "flex" ? "flex" : "grid";
|
|
51
|
+
// children.forEach(c => div.appendChild(c));
|
|
52
|
+
// return div;
|
|
53
|
+
// },
|
|
54
|
+
// });
|
|
55
|
+
|
|
56
|
+
export interface BlueprintTraversalOptions<TNode> {
|
|
57
|
+
renderWidget: (ctx: WidgetRendererProps) => TNode;
|
|
58
|
+
renderContainer: (ctx: ContainerContext<TNode>) => TNode;
|
|
59
|
+
onAction?: (action: WidgetAction) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function traverseBlueprint<TNode>(
|
|
63
|
+
node: Layout | Widget,
|
|
64
|
+
stateMap: Map<string, unknown>,
|
|
65
|
+
options: BlueprintTraversalOptions<TNode>
|
|
66
|
+
): TNode {
|
|
67
|
+
const { renderWidget, renderContainer, onAction } = options;
|
|
68
|
+
|
|
69
|
+
if (isWidget(node)) {
|
|
70
|
+
const status = resolveWidgetStatus(node, stateMap);
|
|
71
|
+
return renderWidget({
|
|
72
|
+
widget: node,
|
|
73
|
+
value: resolveWidgetValue(node, stateMap),
|
|
74
|
+
...(status !== undefined && { status }),
|
|
75
|
+
...(onAction !== undefined && { onAction }),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const layout = node;
|
|
80
|
+
if (layout.type === "grid") {
|
|
81
|
+
const cells = layout.children.map(asCell).map((c) => ({
|
|
82
|
+
span: c.span ?? 1,
|
|
83
|
+
...(c.row !== undefined && { row: c.row }),
|
|
84
|
+
...(c.rowSpan !== undefined && { rowSpan: c.rowSpan }),
|
|
85
|
+
...(c.column !== undefined && { column: c.column }),
|
|
86
|
+
child: traverseBlueprint(c.node, stateMap, options),
|
|
87
|
+
}));
|
|
88
|
+
return renderContainer({ layout, children: cells.map((c) => c.child), cells });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const children = layout.children.map((child) => traverseBlueprint(child, stateMap, options));
|
|
92
|
+
|
|
93
|
+
return renderContainer({ layout, children });
|
|
94
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { throwIfAborted, yieldControl } from "@loomwork/scheduler";
|
|
2
|
+
import { asCell, isWidget, resolveWidgetStatus } from "./normalize";
|
|
3
|
+
import { resolveWidgetValue } from "./traverseBlueprint";
|
|
4
|
+
import type { Layout, Widget, WidgetAction, WidgetRendererProps } from "./types";
|
|
5
|
+
|
|
6
|
+
// ── traverseBlueprintAsync ────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// An interruptible, incremental-commit counterpart to the synchronous
|
|
9
|
+
// traverseBlueprint. The synchronous walker is a PULL model (assemble the whole
|
|
10
|
+
// TNode bottom-up, then hand it back) — fine for renderers that need the complete
|
|
11
|
+
// tree (React, string/SSR). This async walker is a PUSH model for scheduler-less
|
|
12
|
+
// renderers that mutate a target as the tree resolves (direct DOM, canvas, native):
|
|
13
|
+
// it creates each node top-down, appends children as they resolve, and cooperatively
|
|
14
|
+
// yields against a budget so the host stays responsive on large trees or expensive
|
|
15
|
+
// BYOC widgets. It is opt-in and has no effect on the synchronous fast path.
|
|
16
|
+
//
|
|
17
|
+
// No production renderer ships against this yet (all current renderers are pull);
|
|
18
|
+
// it is the validated BYOR extension point. See traverseBlueprintAsync.test.ts for
|
|
19
|
+
// a reference IncrementalRenderer.
|
|
20
|
+
|
|
21
|
+
export interface IncrementalRenderer<TNode> {
|
|
22
|
+
createContainer(layout: Layout): TNode;
|
|
23
|
+
createWidget(ctx: WidgetRendererProps): TNode;
|
|
24
|
+
appendChild(parent: TNode, child: TNode): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AsyncTraversalOptions<TNode> {
|
|
28
|
+
renderer: IncrementalRenderer<TNode>;
|
|
29
|
+
onAction?: (action: WidgetAction) => void;
|
|
30
|
+
/** Abort a stale/interrupted render; checked at every node. */
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
/** Whether to yield before processing the next node. Default: every 64 nodes. */
|
|
33
|
+
shouldYield?: (nodesSinceYield: number) => boolean;
|
|
34
|
+
/** How to yield. Default: @loomwork/scheduler yieldControl (macrotask). */
|
|
35
|
+
yieldTo?: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_YIELD_EVERY = 64;
|
|
39
|
+
|
|
40
|
+
export async function traverseBlueprintAsync<TNode>(
|
|
41
|
+
node: Layout | Widget,
|
|
42
|
+
stateMap: Map<string, unknown>,
|
|
43
|
+
options: AsyncTraversalOptions<TNode>
|
|
44
|
+
): Promise<TNode> {
|
|
45
|
+
const { renderer, onAction, signal } = options;
|
|
46
|
+
const yieldTo = options.yieldTo ?? yieldControl;
|
|
47
|
+
const shouldYield = options.shouldYield ?? ((n) => n >= DEFAULT_YIELD_EVERY);
|
|
48
|
+
|
|
49
|
+
// Counter-based budget (no clock) so behavior is deterministic and testable.
|
|
50
|
+
let nodesSinceYield = 0;
|
|
51
|
+
const tick = async (): Promise<void> => {
|
|
52
|
+
throwIfAborted(signal);
|
|
53
|
+
nodesSinceYield++;
|
|
54
|
+
if (shouldYield(nodesSinceYield)) {
|
|
55
|
+
nodesSinceYield = 0;
|
|
56
|
+
await yieldTo();
|
|
57
|
+
throwIfAborted(signal);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const walk = async (current: Layout | Widget): Promise<TNode> => {
|
|
62
|
+
await tick();
|
|
63
|
+
|
|
64
|
+
if (isWidget(current)) {
|
|
65
|
+
const status = resolveWidgetStatus(current, stateMap);
|
|
66
|
+
return renderer.createWidget({
|
|
67
|
+
widget: current,
|
|
68
|
+
value: resolveWidgetValue(current, stateMap),
|
|
69
|
+
...(status !== undefined && { status }),
|
|
70
|
+
...(onAction !== undefined && { onAction }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const layout = current;
|
|
75
|
+
// Create the container FIRST so children can be committed into it as they
|
|
76
|
+
// resolve (incremental commit / progressive paint between yields).
|
|
77
|
+
const container = renderer.createContainer(layout);
|
|
78
|
+
// Only the iteration source changes for a grid (unwrap each child via `asCell`) — this
|
|
79
|
+
// walker has no `cells`/span surface of its own: the container is created and committed
|
|
80
|
+
// to before its children resolve, so there is nowhere to hand spans alongside them. A
|
|
81
|
+
// renderer that needs them can read `layout.children` itself in `createContainer`.
|
|
82
|
+
const nodes =
|
|
83
|
+
layout.type === "grid" ? layout.children.map((c) => asCell(c).node) : layout.children;
|
|
84
|
+
for (const child of nodes) {
|
|
85
|
+
const childNode = await walk(child);
|
|
86
|
+
renderer.appendChild(container, childNode);
|
|
87
|
+
}
|
|
88
|
+
return container;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return walk(node);
|
|
92
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// ── Built-in widget kinds ────────────────────────────────────────────────────
|
|
2
|
+
// WidgetKind is a convenience union for the built-in widgets shipped with Loom.
|
|
3
|
+
// Widget.kind is widened to `string` so custom renderers can register any kind.
|
|
4
|
+
export type WidgetKind =
|
|
5
|
+
| "MetricCard"
|
|
6
|
+
| "Timeline"
|
|
7
|
+
| "ApprovalCard"
|
|
8
|
+
| "StatusBadge"
|
|
9
|
+
| "ProgressBar"
|
|
10
|
+
| "DataTable"
|
|
11
|
+
| "AlertCard"
|
|
12
|
+
| "StreamingText";
|
|
13
|
+
|
|
14
|
+
import type { DataClass } from "@loomwork/classify";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `empty` (IR 2.3.0, ADR 0100): delivery succeeded and the upstream answered with NO subject to
|
|
18
|
+
* show (e.g. a template indexed past the end of an empty array). It is not an error and triggers
|
|
19
|
+
* nothing; a value written beside it is ignored by every renderer.
|
|
20
|
+
*/
|
|
21
|
+
export type BindingState = "pending" | "ready" | "stale" | "error" | "empty";
|
|
22
|
+
/** A binding's delivery state, read from the same projection state as its value (IR 2.0.0). */
|
|
23
|
+
export interface BindingStatus {
|
|
24
|
+
state: BindingState;
|
|
25
|
+
/** Monotonic per binding; lets a renderer coalesce out-of-order updates. */
|
|
26
|
+
seq?: number;
|
|
27
|
+
finding?: { code: string; message: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DataBinding {
|
|
31
|
+
projectionId: string;
|
|
32
|
+
path: string;
|
|
33
|
+
/** Where, in the SAME projection state, this binding's status lives. Absent ⇒ no status channel. */
|
|
34
|
+
status?: { path: string };
|
|
35
|
+
/** `@loomwork/classify` DataClass. Read by compilers and delivery; never by renderers. */
|
|
36
|
+
sensitivity?: DataClass;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a widget stands for: the semantic type it renders and the degradations that led to `kind`. */
|
|
40
|
+
export interface WidgetSource {
|
|
41
|
+
type: string;
|
|
42
|
+
version: string;
|
|
43
|
+
/** Outermost first. */
|
|
44
|
+
degradedFrom?: Array<{ type: string; version: string }>;
|
|
45
|
+
/**
|
|
46
|
+
* IR 2.5.0 (ADR 0097, amendment 2026-10-11): `true` asserts this widget's content was WITHHELD
|
|
47
|
+
* for the audience the surface was compiled for. A renderer draws NO value and NO label from a
|
|
48
|
+
* redacted widget (not `title`, `props`, `binding` or `kind`) and draws a redaction marker
|
|
49
|
+
* instead. The compiler records the RESULT of resolving audience, never the audience itself.
|
|
50
|
+
* Only `true` is legal (absent = not redacted); with it `binding` and `degradedFrom` MUST be
|
|
51
|
+
* absent, and `type`/`version` are a generic placeholder (`"redacted"`/`"1"`) — the original
|
|
52
|
+
* widget's type may itself be sensitive.
|
|
53
|
+
*/
|
|
54
|
+
redacted?: true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Widget {
|
|
58
|
+
readonly _tag: "Widget";
|
|
59
|
+
id: string;
|
|
60
|
+
// Widened to `string` so custom widget kinds can be registered. Built-in
|
|
61
|
+
// widgets still produce the exact WidgetKind literal via their builder fns.
|
|
62
|
+
kind: string;
|
|
63
|
+
title?: string;
|
|
64
|
+
props: Record<string, unknown>;
|
|
65
|
+
binding?: DataBinding;
|
|
66
|
+
source?: WidgetSource;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One child of a grid: the columns it occupies (`span`, default 1) and, since IR 2.1.0
|
|
71
|
+
* (ADR 0098), an optional explicit placement. Every placement field is independently optional;
|
|
72
|
+
* all absent = flow placement (the grid's auto-placement, exactly as under 2.0.0). Coordinates
|
|
73
|
+
* are 1-based grid lines: `column` is the start column (the cell occupies `column..column+span-1`),
|
|
74
|
+
* `row` the start row, `rowSpan` the rows occupied (default 1).
|
|
75
|
+
*/
|
|
76
|
+
export interface GridCell {
|
|
77
|
+
span?: number;
|
|
78
|
+
row?: number;
|
|
79
|
+
rowSpan?: number;
|
|
80
|
+
column?: number;
|
|
81
|
+
node: Widget | Layout;
|
|
82
|
+
}
|
|
83
|
+
export interface GridLayout {
|
|
84
|
+
type: "grid";
|
|
85
|
+
/**
|
|
86
|
+
* This container's stable identity (IR 2.4.0, ADR 0101) — the same rule as `Widget.id`: a
|
|
87
|
+
* non-empty string, no charset, no length cap, and ONE namespace with widget ids
|
|
88
|
+
* (`duplicate-node-id` spans both). A renderer that reconciles keys the container by it, so a
|
|
89
|
+
* sibling inserted or moved around it keeps its subtree's state. A renderer uses it only as a
|
|
90
|
+
* `Map` key — never markup. Absent or malformed (`layout-id-invalid`) ⇒ keyed positionally, as
|
|
91
|
+
* under 2.3.0.
|
|
92
|
+
*/
|
|
93
|
+
id?: string;
|
|
94
|
+
columns: number;
|
|
95
|
+
gap?: number;
|
|
96
|
+
/** A section heading for this container (IR 2.1.0). Absent ⇒ no heading. */
|
|
97
|
+
title?: string;
|
|
98
|
+
/**
|
|
99
|
+
* What this container IS to a renderer that knows it (IR 2.2.0, ADR 0099) — the open,
|
|
100
|
+
* app-defined axis for containers that `Widget.kind` is for widgets (`"stepped"`, `"tabbed"`,
|
|
101
|
+
* …). `type` stays the geometry. Absent, malformed (see `isLayoutRole`) or unknown to the
|
|
102
|
+
* renderer in use ⇒ the plain container. Never an ARIA role, never display text.
|
|
103
|
+
*/
|
|
104
|
+
role?: string;
|
|
105
|
+
children: GridCell[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FlexLayout {
|
|
109
|
+
type: "flex";
|
|
110
|
+
/**
|
|
111
|
+
* This container's stable identity (IR 2.4.0, ADR 0101) — the same rule as `Widget.id`: a
|
|
112
|
+
* non-empty string, no charset, no length cap, and ONE namespace with widget ids
|
|
113
|
+
* (`duplicate-node-id` spans both). A renderer that reconciles keys the container by it, so a
|
|
114
|
+
* sibling inserted or moved around it keeps its subtree's state. A renderer uses it only as a
|
|
115
|
+
* `Map` key — never markup. Absent or malformed (`layout-id-invalid`) ⇒ keyed positionally, as
|
|
116
|
+
* under 2.3.0.
|
|
117
|
+
*/
|
|
118
|
+
id?: string;
|
|
119
|
+
direction: "row" | "column";
|
|
120
|
+
gap?: number;
|
|
121
|
+
/** A section heading for this container (IR 2.1.0). Absent ⇒ no heading. */
|
|
122
|
+
title?: string;
|
|
123
|
+
/**
|
|
124
|
+
* What this container IS to a renderer that knows it (IR 2.2.0, ADR 0099) — the open,
|
|
125
|
+
* app-defined axis for containers that `Widget.kind` is for widgets (`"stepped"`, `"tabbed"`,
|
|
126
|
+
* …). `type` stays the geometry. Absent, malformed (see `isLayoutRole`) or unknown to the
|
|
127
|
+
* renderer in use ⇒ the plain container. Never an ARIA role, never display text.
|
|
128
|
+
*/
|
|
129
|
+
role?: string;
|
|
130
|
+
children: Array<Widget | Layout>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface StackLayout {
|
|
134
|
+
type: "stack";
|
|
135
|
+
/**
|
|
136
|
+
* This container's stable identity (IR 2.4.0, ADR 0101) — the same rule as `Widget.id`: a
|
|
137
|
+
* non-empty string, no charset, no length cap, and ONE namespace with widget ids
|
|
138
|
+
* (`duplicate-node-id` spans both). A renderer that reconciles keys the container by it, so a
|
|
139
|
+
* sibling inserted or moved around it keeps its subtree's state. A renderer uses it only as a
|
|
140
|
+
* `Map` key — never markup. Absent or malformed (`layout-id-invalid`) ⇒ keyed positionally, as
|
|
141
|
+
* under 2.3.0.
|
|
142
|
+
*/
|
|
143
|
+
id?: string;
|
|
144
|
+
gap?: number;
|
|
145
|
+
/** A section heading for this container (IR 2.1.0). Absent ⇒ no heading. */
|
|
146
|
+
title?: string;
|
|
147
|
+
/**
|
|
148
|
+
* What this container IS to a renderer that knows it (IR 2.2.0, ADR 0099) — the open,
|
|
149
|
+
* app-defined axis for containers that `Widget.kind` is for widgets (`"stepped"`, `"tabbed"`,
|
|
150
|
+
* …). `type` stays the geometry. Absent, malformed (see `isLayoutRole`) or unknown to the
|
|
151
|
+
* renderer in use ⇒ the plain container. Never an ARIA role, never display text.
|
|
152
|
+
*/
|
|
153
|
+
role?: string;
|
|
154
|
+
children: Array<Widget | Layout>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type Layout = GridLayout | FlexLayout | StackLayout;
|
|
158
|
+
|
|
159
|
+
export interface Blueprint {
|
|
160
|
+
readonly _tag: "Blueprint";
|
|
161
|
+
/** The IR shape this artifact was written in. Absent ⇒ "1.0.0". Writers always set it. */
|
|
162
|
+
irVersion?: string;
|
|
163
|
+
id: string;
|
|
164
|
+
title: string;
|
|
165
|
+
layout: Layout;
|
|
166
|
+
projections: string[];
|
|
167
|
+
/**
|
|
168
|
+
* ADR 0095 — the declared semantics of the projections this view binds to, by id, as they
|
|
169
|
+
* were at composition.
|
|
170
|
+
*
|
|
171
|
+
* It lives on the blueprint rather than beside it because a composed surface is
|
|
172
|
+
* content-addressed: a field of the value is part of the address, so this is what makes
|
|
173
|
+
* "the meaning of a view never changes silently" a property of the artifact instead of a
|
|
174
|
+
* paragraph. A binding freezes a NAME; this freezes what that name meant.
|
|
175
|
+
*
|
|
176
|
+
* Read it with `blueprintPins`, compare it with `checkBlueprintPins`.
|
|
177
|
+
*/
|
|
178
|
+
projectionVersions?: Readonly<Record<string, string>>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Framework-agnostic renderer contract ─────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export interface WidgetAction {
|
|
184
|
+
type: string;
|
|
185
|
+
widgetId: string;
|
|
186
|
+
payload?: unknown;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The props passed to any widget renderer regardless of framework.
|
|
190
|
+
export interface WidgetRendererProps {
|
|
191
|
+
widget: Widget;
|
|
192
|
+
value: unknown;
|
|
193
|
+
status?: BindingStatus;
|
|
194
|
+
onAction?: (action: WidgetAction) => void;
|
|
195
|
+
}
|