@aiaiai-pt/design-system 0.33.0 → 0.35.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/components/EChart.svelte +266 -0
- package/components/EChart.svelte.d.ts +63 -0
- package/components/index.js +3 -0
- package/components/renderer/EChartWidget.svelte +78 -0
- package/components/renderer/EChartWidget.svelte.d.ts +4 -0
- package/components/renderer/ResultsChartWidget.svelte +77 -0
- package/components/renderer/ResultsChartWidget.svelte.d.ts +4 -0
- package/components/renderer/StatGridWidget.svelte +33 -0
- package/components/renderer/StatGridWidget.svelte.d.ts +4 -0
- package/components/renderer/aggregate.d.ts +34 -0
- package/components/renderer/aggregate.ts +72 -0
- package/components/renderer/data-provider.d.ts +82 -0
- package/components/renderer/data-provider.ts +162 -0
- package/components/renderer/dispatch.d.ts +63 -0
- package/components/renderer/dispatch.ts +99 -0
- package/components/renderer/registry.d.ts +48 -0
- package/components/renderer/registry.ts +101 -0
- package/components/renderer/resolve-data.d.ts +109 -0
- package/components/renderer/resolve-data.ts +619 -0
- package/components/renderer/types.d.ts +164 -0
- package/components/renderer/types.ts +250 -0
- package/components/renderer/vote.d.ts +121 -0
- package/components/renderer/vote.ts +253 -0
- package/package.json +17 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataProvider — the host-injected data transport seam (#492 P0).
|
|
3
|
+
*
|
|
4
|
+
* Each host (portal, admin) owns its own DataProvider. The portal constructs a
|
|
5
|
+
* `createPublicDataProvider` that routes through the auth-optional `/bff` proxy.
|
|
6
|
+
* A future admin host constructs its own provider over the RLS-authenticated
|
|
7
|
+
* entity-proxy lane. `resolveData` depends only on this interface, never on
|
|
8
|
+
* the proxy prefix or the tenant/app segment directly.
|
|
9
|
+
*/
|
|
10
|
+
import type { Binding } from "./types";
|
|
11
|
+
import type { FetchLike } from "./resolve-data";
|
|
12
|
+
import type { VoteReadConfig } from "./vote";
|
|
13
|
+
export interface DataUrlOpts {
|
|
14
|
+
/** Calendar window injected from `now` (73e) — applied only for the `calendar` kind. */
|
|
15
|
+
calendarWindow?: {
|
|
16
|
+
start: string;
|
|
17
|
+
end: string;
|
|
18
|
+
};
|
|
19
|
+
/** URL facet/search params forwarded from the page URL (#308) — applied for `list`/`map`. */
|
|
20
|
+
filterParams?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* SECURITY INVARIANT (never remove this comment):
|
|
24
|
+
* Each host owns its own DataProvider and its own security boundary. The
|
|
25
|
+
* portal's DataProvider routes through the public /bff proxy (auth-optional,
|
|
26
|
+
* SSRF-pinned to /{app}/public/). A future admin DataProvider routes through
|
|
27
|
+
* the RLS-authenticated entity-proxy lane. NEVER share one transport across
|
|
28
|
+
* both hosts. NEVER let a public widget read through an authed token, nor
|
|
29
|
+
* expose the authed lane through the public proxy. A "simplification" that
|
|
30
|
+
* collapses the two providers is a tenant/RLS leak.
|
|
31
|
+
*/
|
|
32
|
+
export interface DataProvider {
|
|
33
|
+
/**
|
|
34
|
+
* The raw data path for the binding, with calendar window and filter params
|
|
35
|
+
* applied. Returns `null` for unmappable bindings (caller fails closed).
|
|
36
|
+
* The path is the PAGEABLE surface: widgets re-fetch it client-side with
|
|
37
|
+
* `?after=` for "load more". It does NOT include the proxy prefix — the
|
|
38
|
+
* provider's `fetch` adds the prefix on the outbound call.
|
|
39
|
+
*/
|
|
40
|
+
dataUrl(binding: Binding, opts?: DataUrlOpts): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The entity schema path for the binding. Returns `null` for kinds that
|
|
43
|
+
* carry no schema (content, map, calendar, …). The path does NOT include
|
|
44
|
+
* the proxy prefix.
|
|
45
|
+
*/
|
|
46
|
+
schemaUrl(binding: Binding): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* The authed account lane URL for the given path segment.
|
|
49
|
+
* Returns `/me/{segment}` — NO `/bff` prefix. This is a different security
|
|
50
|
+
* boundary (require_auth + OWN-scoped) from the public `/bff` surface.
|
|
51
|
+
*/
|
|
52
|
+
accountUrl(segment: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* The option-view read URL (for subscription scope dropdowns, #252).
|
|
55
|
+
* Returns the FULL URL including the proxy prefix.
|
|
56
|
+
*/
|
|
57
|
+
optionViewUrl(view: string, limit: number): string;
|
|
58
|
+
/** Edition read path for the `vote` ballot composer. Full URL. */
|
|
59
|
+
voteEditionUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
60
|
+
/** Options read path for the `vote` ballot composer. Full URL. */
|
|
61
|
+
voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
62
|
+
/** The fetch transport. Accepts any URL returned by the methods above. */
|
|
63
|
+
fetch: FetchLike;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Construct the portal's public DataProvider.
|
|
67
|
+
*
|
|
68
|
+
* Reproduces today's `resolveData` URL behavior byte-for-byte:
|
|
69
|
+
* - `dataUrl` / `schemaUrl` return the raw `/{app}/public/...` path (no
|
|
70
|
+
* `/bff` prefix). The provider's `fetch` adds `/bff` on the outbound call
|
|
71
|
+
* so the raw path is also the pageable `dataPath` the widgets store.
|
|
72
|
+
* - `accountUrl` returns `/me/{segment}` — the authed lane, never proxied
|
|
73
|
+
* through `/bff`. The provider's `fetch` passes `/me/...` through as-is.
|
|
74
|
+
* - `optionViewUrl`, `voteEditionUrl`, `voteOptionsUrl` return the full URL
|
|
75
|
+
* already including `/bff`. The provider's `fetch` passes them through.
|
|
76
|
+
*
|
|
77
|
+
* The `fetch` wrapper decides by prefix: `/bff/` and `/me/` are passed
|
|
78
|
+
* through unchanged; anything else (raw `/{app}/public/...` paths) gets
|
|
79
|
+
* `/bff` prepended. This keeps the `fetch` self-contained in the provider —
|
|
80
|
+
* `resolveData` never sees or constructs a proxy prefix.
|
|
81
|
+
*/
|
|
82
|
+
export declare function createPublicDataProvider(app: string, fetchImpl: FetchLike): DataProvider;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataProvider — the host-injected data transport seam (#492 P0).
|
|
3
|
+
*
|
|
4
|
+
* Each host (portal, admin) owns its own DataProvider. The portal constructs a
|
|
5
|
+
* `createPublicDataProvider` that routes through the auth-optional `/bff` proxy.
|
|
6
|
+
* A future admin host constructs its own provider over the RLS-authenticated
|
|
7
|
+
* entity-proxy lane. `resolveData` depends only on this interface, never on
|
|
8
|
+
* the proxy prefix or the tenant/app segment directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Binding } from "./types";
|
|
12
|
+
import type { FetchLike } from "./resolve-data";
|
|
13
|
+
import type { VoteReadConfig } from "./vote";
|
|
14
|
+
import {
|
|
15
|
+
bffPathForBinding,
|
|
16
|
+
schemaPathForBinding,
|
|
17
|
+
appendQuery,
|
|
18
|
+
} from "./resolve-data";
|
|
19
|
+
import { editionReadPath, optionsReadPath } from "./vote";
|
|
20
|
+
|
|
21
|
+
export interface DataUrlOpts {
|
|
22
|
+
/** Calendar window injected from `now` (73e) — applied only for the `calendar` kind. */
|
|
23
|
+
calendarWindow?: { start: string; end: string };
|
|
24
|
+
/** URL facet/search params forwarded from the page URL (#308) — applied for `list`/`map`. */
|
|
25
|
+
filterParams?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SECURITY INVARIANT (never remove this comment):
|
|
30
|
+
* Each host owns its own DataProvider and its own security boundary. The
|
|
31
|
+
* portal's DataProvider routes through the public /bff proxy (auth-optional,
|
|
32
|
+
* SSRF-pinned to /{app}/public/). A future admin DataProvider routes through
|
|
33
|
+
* the RLS-authenticated entity-proxy lane. NEVER share one transport across
|
|
34
|
+
* both hosts. NEVER let a public widget read through an authed token, nor
|
|
35
|
+
* expose the authed lane through the public proxy. A "simplification" that
|
|
36
|
+
* collapses the two providers is a tenant/RLS leak.
|
|
37
|
+
*/
|
|
38
|
+
export interface DataProvider {
|
|
39
|
+
/**
|
|
40
|
+
* The raw data path for the binding, with calendar window and filter params
|
|
41
|
+
* applied. Returns `null` for unmappable bindings (caller fails closed).
|
|
42
|
+
* The path is the PAGEABLE surface: widgets re-fetch it client-side with
|
|
43
|
+
* `?after=` for "load more". It does NOT include the proxy prefix — the
|
|
44
|
+
* provider's `fetch` adds the prefix on the outbound call.
|
|
45
|
+
*/
|
|
46
|
+
dataUrl(binding: Binding, opts?: DataUrlOpts): string | null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The entity schema path for the binding. Returns `null` for kinds that
|
|
50
|
+
* carry no schema (content, map, calendar, …). The path does NOT include
|
|
51
|
+
* the proxy prefix.
|
|
52
|
+
*/
|
|
53
|
+
schemaUrl(binding: Binding): string | null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The authed account lane URL for the given path segment.
|
|
57
|
+
* Returns `/me/{segment}` — NO `/bff` prefix. This is a different security
|
|
58
|
+
* boundary (require_auth + OWN-scoped) from the public `/bff` surface.
|
|
59
|
+
*/
|
|
60
|
+
accountUrl(segment: string): string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The option-view read URL (for subscription scope dropdowns, #252).
|
|
64
|
+
* Returns the FULL URL including the proxy prefix.
|
|
65
|
+
*/
|
|
66
|
+
optionViewUrl(view: string, limit: number): string;
|
|
67
|
+
|
|
68
|
+
/** Edition read path for the `vote` ballot composer. Full URL. */
|
|
69
|
+
voteEditionUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
70
|
+
|
|
71
|
+
/** Options read path for the `vote` ballot composer. Full URL. */
|
|
72
|
+
voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string;
|
|
73
|
+
|
|
74
|
+
/** The fetch transport. Accepts any URL returned by the methods above. */
|
|
75
|
+
fetch: FetchLike;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function enc(value: string): string {
|
|
79
|
+
return encodeURIComponent(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Construct the portal's public DataProvider.
|
|
84
|
+
*
|
|
85
|
+
* Reproduces today's `resolveData` URL behavior byte-for-byte:
|
|
86
|
+
* - `dataUrl` / `schemaUrl` return the raw `/{app}/public/...` path (no
|
|
87
|
+
* `/bff` prefix). The provider's `fetch` adds `/bff` on the outbound call
|
|
88
|
+
* so the raw path is also the pageable `dataPath` the widgets store.
|
|
89
|
+
* - `accountUrl` returns `/me/{segment}` — the authed lane, never proxied
|
|
90
|
+
* through `/bff`. The provider's `fetch` passes `/me/...` through as-is.
|
|
91
|
+
* - `optionViewUrl`, `voteEditionUrl`, `voteOptionsUrl` return the full URL
|
|
92
|
+
* already including `/bff`. The provider's `fetch` passes them through.
|
|
93
|
+
*
|
|
94
|
+
* The `fetch` wrapper decides by prefix: `/bff/` and `/me/` are passed
|
|
95
|
+
* through unchanged; anything else (raw `/{app}/public/...` paths) gets
|
|
96
|
+
* `/bff` prepended. This keeps the `fetch` self-contained in the provider —
|
|
97
|
+
* `resolveData` never sees or constructs a proxy prefix.
|
|
98
|
+
*/
|
|
99
|
+
export function createPublicDataProvider(
|
|
100
|
+
app: string,
|
|
101
|
+
fetchImpl: FetchLike,
|
|
102
|
+
): DataProvider {
|
|
103
|
+
/**
|
|
104
|
+
* Portal fetch transport. Paths that already carry their proxy prefix
|
|
105
|
+
* (`/bff/...` for the public lane, `/me/...` for the authed account lane)
|
|
106
|
+
* are forwarded unchanged. Raw `/{app}/public/...` paths (returned by
|
|
107
|
+
* `dataUrl` and `schemaUrl`) are proxied through `/bff`.
|
|
108
|
+
*/
|
|
109
|
+
const fetch: FetchLike = (url: string) => {
|
|
110
|
+
if (url.startsWith("/bff/") || url.startsWith("/me/")) {
|
|
111
|
+
return fetchImpl(url);
|
|
112
|
+
}
|
|
113
|
+
return fetchImpl(`/bff${url}`);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
dataUrl(binding: Binding, opts?: DataUrlOpts): string | null {
|
|
118
|
+
const path = bffPathForBinding(binding, app);
|
|
119
|
+
if (path === null) return null;
|
|
120
|
+
let url = path;
|
|
121
|
+
// Calendar requires start/end (73e); the window is pre-computed in
|
|
122
|
+
// resolveData from `now` and passed in via opts.
|
|
123
|
+
if (binding.kind === "calendar" && opts?.calendarWindow) {
|
|
124
|
+
const { start, end } = opts.calendarWindow;
|
|
125
|
+
url = `${path}?start=${start}&end=${end}`;
|
|
126
|
+
}
|
|
127
|
+
// Forward URL facet/search params (#308) for list + map feeds so the
|
|
128
|
+
// SSR result is server-FILTERED. The BFF drops undeclared params.
|
|
129
|
+
if (
|
|
130
|
+
(binding.kind === "list" || binding.kind === "map") &&
|
|
131
|
+
opts?.filterParams
|
|
132
|
+
) {
|
|
133
|
+
url = appendQuery(url, opts.filterParams);
|
|
134
|
+
}
|
|
135
|
+
return url;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
schemaUrl(binding: Binding): string | null {
|
|
139
|
+
return schemaPathForBinding(binding, app);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
accountUrl(segment: string): string {
|
|
143
|
+
return `/me/${segment}`;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
optionViewUrl(view: string, limit: number): string {
|
|
147
|
+
return `/bff/${enc(app)}/public/${enc(view)}?limit=${limit}`;
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
voteEditionUrl(cfg: VoteReadConfig, editionId: string): string {
|
|
151
|
+
// Delegates to the unchanged vote.ts helper — guarantees byte-identical
|
|
152
|
+
// vote URLs (vote.ts must NOT be modified, spec §P0).
|
|
153
|
+
return editionReadPath(app, cfg, editionId);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string {
|
|
157
|
+
return optionsReadPath(app, cfg, editionId);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
fetch,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget dispatcher — tester/priority, not a flat map (D4, JSONForms model).
|
|
3
|
+
*
|
|
4
|
+
* Generalises the admin app's XSS-hardened layout registry
|
|
5
|
+
* (`admin/src/lib/renderer-layouts/resolve.ts`) from form-row LayoutKeys to
|
|
6
|
+
* widget `kind`s. Each entry carries a `tester(binding, schema) → score`; the
|
|
7
|
+
* highest score wins. A more specific tester (e.g. "list of `occurrence`")
|
|
8
|
+
* outranks the generic "any list" and overrides one widget for one entity
|
|
9
|
+
* WITHOUT editing the catalog (open/closed).
|
|
10
|
+
*
|
|
11
|
+
* The ranking core here is generic over the payload `P` so it stays
|
|
12
|
+
* component-free and fully unit/mutation-testable; the real registry (73b)
|
|
13
|
+
* instantiates it with Svelte `Component`s.
|
|
14
|
+
*
|
|
15
|
+
* TH-08 (R-SEC-07) preserved: the resolved `key` is a known-good literal from
|
|
16
|
+
* the matched entry — the operator's raw `binding.kind`/`entity`/block `type`
|
|
17
|
+
* is NEVER returned as the key, so it never reaches `class`/`data-*`/`style`.
|
|
18
|
+
*/
|
|
19
|
+
import type { Binding, Block, OntologySchema } from "./types";
|
|
20
|
+
/** Tester sentinel — "this widget does not apply to this binding". */
|
|
21
|
+
export declare const NOT_APPLICABLE = -1;
|
|
22
|
+
/**
|
|
23
|
+
* A tester ranks a registry entry against a binding (+ optional schema and the
|
|
24
|
+
* block's authoring `type` hint). JSONForms model: testers see the full
|
|
25
|
+
* element, so a type-specific widget (e.g. `filter-bar`) can out-rank the
|
|
26
|
+
* generic kind widget (`entity-list`) for the SAME `binding.kind` without
|
|
27
|
+
* editing the kind entry. `type` is UNTRUSTED — it only influences ranking; the
|
|
28
|
+
* resolved `key` still comes from the matched entry (TH-08), never from `type`.
|
|
29
|
+
*/
|
|
30
|
+
export type WidgetTester = (binding: Binding, schema: OntologySchema | null, type?: string) => number;
|
|
31
|
+
export interface RegistryEntry<P> {
|
|
32
|
+
/** Known-good literal. Safe to render as `data-widget={key}` (TH-08). */
|
|
33
|
+
key: string;
|
|
34
|
+
payload: P;
|
|
35
|
+
tester: WidgetTester;
|
|
36
|
+
}
|
|
37
|
+
export interface Match<P> {
|
|
38
|
+
key: string;
|
|
39
|
+
payload: P;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run every tester against the binding; the highest applicable score wins.
|
|
43
|
+
* Ties resolve to the first-registered entry (stable, deterministic). Returns
|
|
44
|
+
* `null` when no tester is applicable — the caller fails closed per blast
|
|
45
|
+
* radius; nothing about the binding is interpolated.
|
|
46
|
+
*/
|
|
47
|
+
export declare function selectEntry<P>(entries: ReadonlyArray<RegistryEntry<P>>, binding: Binding, schema: OntologySchema | null, type?: string): Match<P> | null;
|
|
48
|
+
export type RenderDecision = {
|
|
49
|
+
render: "widget";
|
|
50
|
+
} | {
|
|
51
|
+
render: "empty";
|
|
52
|
+
} | {
|
|
53
|
+
render: "error";
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Fail-closed per blast radius (§14.8). Given whether a widget matched AND
|
|
57
|
+
* whether its data resolved, decide how the slot renders:
|
|
58
|
+
* - matched + data ok → render the widget
|
|
59
|
+
* - failed, optional slot → soft-empty (render nothing)
|
|
60
|
+
* - failed, structural slot → visible error (MUST NOT silently vanish)
|
|
61
|
+
* The operator's raw `type`/`kind` is never interpolated in any branch.
|
|
62
|
+
*/
|
|
63
|
+
export declare function decideRender(block: Pick<Block, "importance">, matched: boolean, dataOk: boolean): RenderDecision;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget dispatcher — tester/priority, not a flat map (D4, JSONForms model).
|
|
3
|
+
*
|
|
4
|
+
* Generalises the admin app's XSS-hardened layout registry
|
|
5
|
+
* (`admin/src/lib/renderer-layouts/resolve.ts`) from form-row LayoutKeys to
|
|
6
|
+
* widget `kind`s. Each entry carries a `tester(binding, schema) → score`; the
|
|
7
|
+
* highest score wins. A more specific tester (e.g. "list of `occurrence`")
|
|
8
|
+
* outranks the generic "any list" and overrides one widget for one entity
|
|
9
|
+
* WITHOUT editing the catalog (open/closed).
|
|
10
|
+
*
|
|
11
|
+
* The ranking core here is generic over the payload `P` so it stays
|
|
12
|
+
* component-free and fully unit/mutation-testable; the real registry (73b)
|
|
13
|
+
* instantiates it with Svelte `Component`s.
|
|
14
|
+
*
|
|
15
|
+
* TH-08 (R-SEC-07) preserved: the resolved `key` is a known-good literal from
|
|
16
|
+
* the matched entry — the operator's raw `binding.kind`/`entity`/block `type`
|
|
17
|
+
* is NEVER returned as the key, so it never reaches `class`/`data-*`/`style`.
|
|
18
|
+
*/
|
|
19
|
+
import type { Binding, Block, OntologySchema } from "./types";
|
|
20
|
+
|
|
21
|
+
/** Tester sentinel — "this widget does not apply to this binding". */
|
|
22
|
+
export const NOT_APPLICABLE = -1;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A tester ranks a registry entry against a binding (+ optional schema and the
|
|
26
|
+
* block's authoring `type` hint). JSONForms model: testers see the full
|
|
27
|
+
* element, so a type-specific widget (e.g. `filter-bar`) can out-rank the
|
|
28
|
+
* generic kind widget (`entity-list`) for the SAME `binding.kind` without
|
|
29
|
+
* editing the kind entry. `type` is UNTRUSTED — it only influences ranking; the
|
|
30
|
+
* resolved `key` still comes from the matched entry (TH-08), never from `type`.
|
|
31
|
+
*/
|
|
32
|
+
export type WidgetTester = (
|
|
33
|
+
binding: Binding,
|
|
34
|
+
schema: OntologySchema | null,
|
|
35
|
+
type?: string,
|
|
36
|
+
) => number;
|
|
37
|
+
|
|
38
|
+
export interface RegistryEntry<P> {
|
|
39
|
+
/** Known-good literal. Safe to render as `data-widget={key}` (TH-08). */
|
|
40
|
+
key: string;
|
|
41
|
+
payload: P;
|
|
42
|
+
tester: WidgetTester;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Match<P> {
|
|
46
|
+
key: string;
|
|
47
|
+
payload: P;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run every tester against the binding; the highest applicable score wins.
|
|
52
|
+
* Ties resolve to the first-registered entry (stable, deterministic). Returns
|
|
53
|
+
* `null` when no tester is applicable — the caller fails closed per blast
|
|
54
|
+
* radius; nothing about the binding is interpolated.
|
|
55
|
+
*/
|
|
56
|
+
export function selectEntry<P>(
|
|
57
|
+
entries: ReadonlyArray<RegistryEntry<P>>,
|
|
58
|
+
binding: Binding,
|
|
59
|
+
schema: OntologySchema | null,
|
|
60
|
+
type?: string,
|
|
61
|
+
): Match<P> | null {
|
|
62
|
+
let best: RegistryEntry<P> | null = null;
|
|
63
|
+
let bestScore: number = NOT_APPLICABLE;
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const score = entry.tester(binding, schema, type);
|
|
66
|
+
// Strictly greater → first registered wins ties.
|
|
67
|
+
if (score > bestScore) {
|
|
68
|
+
bestScore = score;
|
|
69
|
+
best = entry;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (best === null) return null;
|
|
73
|
+
// TH-08: key from the matched entry, never from the binding/block.
|
|
74
|
+
return { key: best.key, payload: best.payload };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type RenderDecision =
|
|
78
|
+
| { render: "widget" }
|
|
79
|
+
| { render: "empty" }
|
|
80
|
+
| { render: "error" };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fail-closed per blast radius (§14.8). Given whether a widget matched AND
|
|
84
|
+
* whether its data resolved, decide how the slot renders:
|
|
85
|
+
* - matched + data ok → render the widget
|
|
86
|
+
* - failed, optional slot → soft-empty (render nothing)
|
|
87
|
+
* - failed, structural slot → visible error (MUST NOT silently vanish)
|
|
88
|
+
* The operator's raw `type`/`kind` is never interpolated in any branch.
|
|
89
|
+
*/
|
|
90
|
+
export function decideRender(
|
|
91
|
+
block: Pick<Block, "importance">,
|
|
92
|
+
matched: boolean,
|
|
93
|
+
dataOk: boolean,
|
|
94
|
+
): RenderDecision {
|
|
95
|
+
if (matched && dataOk) return { render: "widget" };
|
|
96
|
+
return block.importance === "structural"
|
|
97
|
+
? { render: "error" }
|
|
98
|
+
: { render: "empty" };
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DS renderer widget registry — the extensible dispatcher layer (#492).
|
|
3
|
+
*
|
|
4
|
+
* Ships a BASE registry with the two clean DS widgets (stat-grid + results-chart)
|
|
5
|
+
* and exposes a `registerWidget` hook so host apps (the portal, the admin) can
|
|
6
|
+
* append their own coupled widgets at startup without modifying this module
|
|
7
|
+
* (open/closed principle, JSONForms model).
|
|
8
|
+
*
|
|
9
|
+
* `dispatch.ts` (the tester/priority core) is the single dispatcher both the DS
|
|
10
|
+
* base registry and host-extended registries share; the registry is generic over
|
|
11
|
+
* the payload so `selectEntry` stays component-free and unit-testable.
|
|
12
|
+
*
|
|
13
|
+
* TH-08 preserved: `resolveWidget` returns the matched entry's known-good `key`
|
|
14
|
+
* literal — the operator's raw block `type`/`binding.kind` is never the key.
|
|
15
|
+
*/
|
|
16
|
+
import type { Component } from "svelte";
|
|
17
|
+
import { type Match, type RegistryEntry } from "./dispatch";
|
|
18
|
+
import type { Block, OntologySchema, WidgetKind, WidgetProps } from "./types";
|
|
19
|
+
export type WidgetComponent = Component<WidgetProps>;
|
|
20
|
+
/**
|
|
21
|
+
* A TYPE-specific widget over a shared kind (JSONForms model): the authoring
|
|
22
|
+
* `type` hint ranks it ABOVE the kind-generic widget (score 20 > 10) for the
|
|
23
|
+
* same binding. `type` is UNTRUSTED — it only influences ranking; the resolved
|
|
24
|
+
* key is the entry literal (TH-08).
|
|
25
|
+
*/
|
|
26
|
+
export declare function byTypeOnKind(key: string, kind: WidgetKind, component: unknown): RegistryEntry<WidgetComponent>;
|
|
27
|
+
/** A widget whose tester is the generic "binding.kind === K" check (score 10). */
|
|
28
|
+
export declare function byKind(key: string, kind: WidgetKind, component: unknown): RegistryEntry<WidgetComponent>;
|
|
29
|
+
/**
|
|
30
|
+
* Register a widget in the host-extensible registry. Call at app startup
|
|
31
|
+
* (before any `resolveWidget` calls) to append host-specific widgets. Each
|
|
32
|
+
* entry is appended in call order; ties within the same score resolve to the
|
|
33
|
+
* first-registered entry (stable, deterministic — base entries win ties).
|
|
34
|
+
*
|
|
35
|
+
* Signature mirrors `byKind` / `byTypeOnKind` so the caller writes:
|
|
36
|
+
* `registerWidget(byKind("entity-list", "list", EntityListWidget))`
|
|
37
|
+
*
|
|
38
|
+
* @param entry - A registry entry built with `byKind`, `byTypeOnKind`, or a
|
|
39
|
+
* hand-crafted `{ key, payload, tester }` for a custom tester.
|
|
40
|
+
*/
|
|
41
|
+
export declare function registerWidget(entry: RegistryEntry<WidgetComponent>): void;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a block to its widget via the tester/priority dispatcher. Searches
|
|
44
|
+
* the full registry (base DS entries + host-registered). Returns `null` when
|
|
45
|
+
* no widget applies — the caller renders per blast radius (`decideRender`),
|
|
46
|
+
* never interpolating the block's raw `type`/`kind`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveWidget(block: Block, schema?: OntologySchema | null): Match<WidgetComponent> | null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DS renderer widget registry — the extensible dispatcher layer (#492).
|
|
3
|
+
*
|
|
4
|
+
* Ships a BASE registry with the two clean DS widgets (stat-grid + results-chart)
|
|
5
|
+
* and exposes a `registerWidget` hook so host apps (the portal, the admin) can
|
|
6
|
+
* append their own coupled widgets at startup without modifying this module
|
|
7
|
+
* (open/closed principle, JSONForms model).
|
|
8
|
+
*
|
|
9
|
+
* `dispatch.ts` (the tester/priority core) is the single dispatcher both the DS
|
|
10
|
+
* base registry and host-extended registries share; the registry is generic over
|
|
11
|
+
* the payload so `selectEntry` stays component-free and unit-testable.
|
|
12
|
+
*
|
|
13
|
+
* TH-08 preserved: `resolveWidget` returns the matched entry's known-good `key`
|
|
14
|
+
* literal — the operator's raw block `type`/`binding.kind` is never the key.
|
|
15
|
+
*/
|
|
16
|
+
import type { Component } from "svelte";
|
|
17
|
+
import {
|
|
18
|
+
NOT_APPLICABLE,
|
|
19
|
+
selectEntry,
|
|
20
|
+
type Match,
|
|
21
|
+
type RegistryEntry,
|
|
22
|
+
} from "./dispatch";
|
|
23
|
+
import type { Block, OntologySchema, WidgetKind, WidgetProps } from "./types";
|
|
24
|
+
|
|
25
|
+
import EChartWidget from "./EChartWidget.svelte";
|
|
26
|
+
import ResultsChartWidget from "./ResultsChartWidget.svelte";
|
|
27
|
+
import StatGridWidget from "./StatGridWidget.svelte";
|
|
28
|
+
|
|
29
|
+
export type WidgetComponent = Component<WidgetProps>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A TYPE-specific widget over a shared kind (JSONForms model): the authoring
|
|
33
|
+
* `type` hint ranks it ABOVE the kind-generic widget (score 20 > 10) for the
|
|
34
|
+
* same binding. `type` is UNTRUSTED — it only influences ranking; the resolved
|
|
35
|
+
* key is the entry literal (TH-08).
|
|
36
|
+
*/
|
|
37
|
+
export function byTypeOnKind(
|
|
38
|
+
key: string,
|
|
39
|
+
kind: WidgetKind,
|
|
40
|
+
component: unknown,
|
|
41
|
+
): RegistryEntry<WidgetComponent> {
|
|
42
|
+
return {
|
|
43
|
+
key,
|
|
44
|
+
payload: component as WidgetComponent,
|
|
45
|
+
tester: (binding, _schema, type) =>
|
|
46
|
+
binding.kind === kind && type === key ? 20 : NOT_APPLICABLE,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A widget whose tester is the generic "binding.kind === K" check (score 10). */
|
|
51
|
+
export function byKind(
|
|
52
|
+
key: string,
|
|
53
|
+
kind: WidgetKind,
|
|
54
|
+
component: unknown,
|
|
55
|
+
): RegistryEntry<WidgetComponent> {
|
|
56
|
+
return {
|
|
57
|
+
key,
|
|
58
|
+
payload: component as WidgetComponent,
|
|
59
|
+
tester: (binding) => (binding.kind === kind ? 10 : NOT_APPLICABLE),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Mutable internal registry — base DS entries first, host-registered entries
|
|
65
|
+
* appended in call order. Exported (readonly view) for introspection / testing.
|
|
66
|
+
*/
|
|
67
|
+
const _entries: RegistryEntry<WidgetComponent>[] = [
|
|
68
|
+
// DS base: the two clean widgets shipped by this package.
|
|
69
|
+
byKind("stat-grid", "kpi", StatGridWidget),
|
|
70
|
+
byTypeOnKind("results-chart", "aggregate", ResultsChartWidget),
|
|
71
|
+
byTypeOnKind("chart", "aggregate", EChartWidget),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register a widget in the host-extensible registry. Call at app startup
|
|
76
|
+
* (before any `resolveWidget` calls) to append host-specific widgets. Each
|
|
77
|
+
* entry is appended in call order; ties within the same score resolve to the
|
|
78
|
+
* first-registered entry (stable, deterministic — base entries win ties).
|
|
79
|
+
*
|
|
80
|
+
* Signature mirrors `byKind` / `byTypeOnKind` so the caller writes:
|
|
81
|
+
* `registerWidget(byKind("entity-list", "list", EntityListWidget))`
|
|
82
|
+
*
|
|
83
|
+
* @param entry - A registry entry built with `byKind`, `byTypeOnKind`, or a
|
|
84
|
+
* hand-crafted `{ key, payload, tester }` for a custom tester.
|
|
85
|
+
*/
|
|
86
|
+
export function registerWidget(entry: RegistryEntry<WidgetComponent>): void {
|
|
87
|
+
_entries.push(entry);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a block to its widget via the tester/priority dispatcher. Searches
|
|
92
|
+
* the full registry (base DS entries + host-registered). Returns `null` when
|
|
93
|
+
* no widget applies — the caller renders per blast radius (`decideRender`),
|
|
94
|
+
* never interpolating the block's raw `type`/`kind`.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveWidget(
|
|
97
|
+
block: Block,
|
|
98
|
+
schema: OntologySchema | null = null,
|
|
99
|
+
): Match<WidgetComponent> | null {
|
|
100
|
+
return selectEntry(_entries, block.binding, schema, block.type);
|
|
101
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveData(block)` — the single data path between a block's pointer and a
|
|
3
|
+
* widget (#73, spec §5). Reads the `binding`, fetches through the host-injected
|
|
4
|
+
* `DataProvider` (#492 P0), and returns normalised `{ schema, data, actionDef }`.
|
|
5
|
+
* The block stores ONLY the pointer — rows are never baked in.
|
|
6
|
+
*
|
|
7
|
+
* Pure + provider-injectable so the security-critical decisions (which paths are
|
|
8
|
+
* reachable, fail-closed on misconfig/upstream error) are unit/mutation-
|
|
9
|
+
* testable without SvelteKit. The route loader is a thin wrapper that passes a
|
|
10
|
+
* `createPublicDataProvider` wrapping its server `fetch` (which carries the
|
|
11
|
+
* tenant + graduated principal).
|
|
12
|
+
*
|
|
13
|
+
* Fail-closed (§14.8): an unmappable binding never fetches; a non-OK upstream
|
|
14
|
+
* yields `{ ok: false }` and the caller renders per blast radius (see
|
|
15
|
+
* `decideRender` in `./dispatch.ts`).
|
|
16
|
+
*/
|
|
17
|
+
import type { Binding, Block, FeedView, OntologySchema, WidgetKind } from "./types";
|
|
18
|
+
import type { DataProvider } from "./data-provider";
|
|
19
|
+
/**
|
|
20
|
+
* Presentational kinds (#75 M5 hero/lookup + #105 Phase 7 landing/content) that
|
|
21
|
+
* render PURELY from their block props — no data path, no fetch. resolveData
|
|
22
|
+
* short-circuits them with `{ data: null }`, which still satisfies decideRender's
|
|
23
|
+
* "matched + dataOk" contract (the widget owns its own props). Keeping them in
|
|
24
|
+
* one set means a new content section never accidentally tries to fetch.
|
|
25
|
+
*/
|
|
26
|
+
export declare const PRESENTATIONAL_KINDS: ReadonlySet<WidgetKind>;
|
|
27
|
+
export interface ResolvedData {
|
|
28
|
+
data: unknown;
|
|
29
|
+
/** Ontology schema for the bound entity — wired in 73d. */
|
|
30
|
+
schema: OntologySchema | null;
|
|
31
|
+
/** Action definition for form bindings — wired in 73f. */
|
|
32
|
+
actionDef: unknown | null;
|
|
33
|
+
/** The BFF path the data came from (#75 M5 slice 4) — widgets that page
|
|
34
|
+
* (list "load more") re-fetch it client-side with `?after=`. */
|
|
35
|
+
dataPath?: string;
|
|
36
|
+
}
|
|
37
|
+
export type ResolveDataResult = {
|
|
38
|
+
ok: true;
|
|
39
|
+
value: ResolvedData;
|
|
40
|
+
} | {
|
|
41
|
+
ok: false;
|
|
42
|
+
status: number;
|
|
43
|
+
reason: string;
|
|
44
|
+
};
|
|
45
|
+
/** The minimal fetch shape we depend on (injectable; SvelteKit `fetch` fits). */
|
|
46
|
+
export type FetchLike = (path: string) => Promise<{
|
|
47
|
+
ok: boolean;
|
|
48
|
+
status: number;
|
|
49
|
+
json: () => Promise<unknown>;
|
|
50
|
+
}>;
|
|
51
|
+
export interface ResolveDataDeps {
|
|
52
|
+
/** Host-injected data transport seam (#492 P0). */
|
|
53
|
+
provider: DataProvider;
|
|
54
|
+
/**
|
|
55
|
+
* "Now", injected by the route loader (`new Date()`), used to compute the
|
|
56
|
+
* calendar's required `start`/`end` window (73e). Optional — only the
|
|
57
|
+
* `calendar` kind reads it. Injected (not read here) so the path stays
|
|
58
|
+
* deterministic in tests.
|
|
59
|
+
*/
|
|
60
|
+
now?: Date;
|
|
61
|
+
/**
|
|
62
|
+
* The active feed view (`?view=` — #308), used only by the `feed` kind to
|
|
63
|
+
* pick which underlying feed (list/map) to resolve. Absent → the block's
|
|
64
|
+
* first configured view.
|
|
65
|
+
*/
|
|
66
|
+
view?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The URL's facet/search params (#308), forwarded to a feed read so the SSR
|
|
69
|
+
* feed is server-FILTERED (and paging rides `dataPath`). The BFF drops any
|
|
70
|
+
* param it doesn't declare (its security contract), so the whole set is safe
|
|
71
|
+
* to pass. Reserved keys (`view`, `reference`) are stripped by the loader.
|
|
72
|
+
*/
|
|
73
|
+
filterParams?: Record<string, string>;
|
|
74
|
+
}
|
|
75
|
+
export declare function feedViews(props: Record<string, unknown> | undefined): FeedView[];
|
|
76
|
+
/** The active view = the requested one when it's an available view, else the
|
|
77
|
+
* default (first). Keeps `?view=` shareable + an unknown value harmless. */
|
|
78
|
+
export declare function activeFeedView(views: FeedView[], requested: string | undefined): FeedView;
|
|
79
|
+
/** Merge facet/search params into a BFF path (handles an existing `?`). Empty
|
|
80
|
+
* values drop; an empty param set returns the path unchanged. */
|
|
81
|
+
export declare function appendQuery(path: string, params: Record<string, string> | undefined): string;
|
|
82
|
+
/**
|
|
83
|
+
* Pure mapping: pointer → BFF public path (#70 surface, app-first). Returns
|
|
84
|
+
* `null` for any binding that doesn't map to a wired read endpoint — the
|
|
85
|
+
* caller fails closed (no fetch, no DOM). `binding.filter` carries the
|
|
86
|
+
* per-route selector (ref/key/id) for the single-resource kinds.
|
|
87
|
+
*/
|
|
88
|
+
export declare function bffPathForBinding(binding: Binding, app: string): string | null;
|
|
89
|
+
/**
|
|
90
|
+
* Pure mapping: pointer → BFF public *schema* path (#73 / 73d). Only the
|
|
91
|
+
* schema-driven kinds (`list`, `detail`) have one; the entity's field shape
|
|
92
|
+
* (key/type/label) lets EntityList/DetailView render columns/fields without a
|
|
93
|
+
* hardcoded entity-type union (§14.6). Returns `null` for every other kind, and
|
|
94
|
+
* for list/detail without an entity. `binding.filter` (the detail row id) is
|
|
95
|
+
* NOT part of the schema path — the schema is per *type*, not per row.
|
|
96
|
+
*/
|
|
97
|
+
export declare function schemaPathForBinding(binding: Binding, app: string): string | null;
|
|
98
|
+
/**
|
|
99
|
+
* The date window the `/public/calendar` fetch requires (73e). The endpoint
|
|
100
|
+
* 422s without `start`/`end`, so the calendar block fetches a window computed
|
|
101
|
+
* from the injected "now": the current month through two months out (covering
|
|
102
|
+
* the default month view plus a buffer for next-month navigation). Pure given
|
|
103
|
+
* `now`, computed in UTC so it doesn't drift with the server timezone.
|
|
104
|
+
*/
|
|
105
|
+
export declare function calendarWindow(now: Date): {
|
|
106
|
+
start: string;
|
|
107
|
+
end: string;
|
|
108
|
+
};
|
|
109
|
+
export declare function resolveData(block: Block, deps: ResolveDataDeps): Promise<ResolveDataResult>;
|