@swimmesberger/elarion-contributions 0.2.3-preview.72.1 → 0.2.3-preview.74.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.
- package/README.md +44 -9
- package/dist/index.d.ts +77 -14
- package/dist/index.js +56 -3
- package/dist/react.d.ts +21 -3
- package/dist/react.js +6 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -6,9 +6,14 @@ typed extension-point tokens, declarative module manifests, and capability-gated
|
|
|
6
6
|
It extends the backend's review-isolation rule — *a module only touches its own code* — to the frontend:
|
|
7
7
|
adding a sidebar item or a cross-module action edits one file in the owning module, never the shell.
|
|
8
8
|
|
|
9
|
-
- **Framework-free core** (`@swimmesberger/elarion-contributions`) — no dependencies, no React.
|
|
9
|
+
- **Framework-free core** (`@swimmesberger/elarion-contributions`) — no dependencies, no React. `when`
|
|
10
|
+
clauses are typed **strictly** against your vocabulary (a typo'd permission is a compile error, and
|
|
11
|
+
vocabulary axes are optional — a no-auth app binds `{ module }` only and any stray axis use fails to
|
|
12
|
+
compile), `ItemOf`/`ContextOf` extract a point's declared types, and `createStaticCapabilities` is
|
|
13
|
+
the `CapabilityReader` for apps without a session snapshot yet.
|
|
10
14
|
- **React bindings** (`@swimmesberger/elarion-contributions/react`) — `ContributionProvider`,
|
|
11
|
-
`useContributions`, `<ExtensionSlot
|
|
15
|
+
`useContributions`, `<ExtensionSlot>` (its `context` prop is type-checked against the point's
|
|
16
|
+
`TContext` and handed to the render prop). React is an optional peer dependency; other view frameworks
|
|
12
17
|
can port the bindings in a page of code.
|
|
13
18
|
- **Angular bindings** (`@swimmesberger/elarion-contributions/angular`) — `provideContributions`,
|
|
14
19
|
`injectContributions` (returns a `Signal`). Idiomatic for Angular 20–22: signal-first, standalone,
|
|
@@ -25,6 +30,14 @@ adding a sidebar item or a cross-module action edits one file in the owning modu
|
|
|
25
30
|
npm install @swimmesberger/elarion-contributions
|
|
26
31
|
```
|
|
27
32
|
|
|
33
|
+
> [!IMPORTANT]
|
|
34
|
+
> **Vite users:** the `/react` bindings call hooks, so they must resolve to your app's **single** React
|
|
35
|
+
> instance. If the first `useContributions` throws `Invalid hook call … Cannot read properties of null
|
|
36
|
+
> (reading 'useContext')`, Vite's dependency optimizer pre-bundled the package against a second React
|
|
37
|
+
> copy. Add `resolve: { dedupe: ["react", "react-dom"] }` plus an `optimizeDeps.include` entry for the
|
|
38
|
+
> package (and its `/react` sub-export) to `vite.config.ts`, then clear `node_modules/.vite`. Vite does
|
|
39
|
+
> not consult peer-dependency metadata when pre-bundling, so there is no package-side fix.
|
|
40
|
+
|
|
28
41
|
## What you import vs. what you own
|
|
29
42
|
|
|
30
43
|
This package ships the *machinery* with fixed semantics; your application owns the *points* and the shell
|
|
@@ -32,10 +45,11 @@ This package ships the *machinery* with fixed semantics; your application owns t
|
|
|
32
45
|
|
|
33
46
|
| You import (fixed semantics) | You own (copy from the sample) |
|
|
34
47
|
| --- | --- |
|
|
35
|
-
| `defineExtensionPoint`, `defineModule`, `contribute` | Your extension points (`sidebarItems`, …) and their payload types |
|
|
36
|
-
| `evaluateWhen` + the `when` AND semantics | The kit instantiation binding your generated vocabulary |
|
|
37
|
-
| `createContributionRegistry` (filter + deterministic order) | The app shell that renders each slot |
|
|
38
|
-
| `
|
|
48
|
+
| `defineExtensionPoint`, `defineModule`, `contribute`, `ItemOf`/`ContextOf` | Your extension points (`sidebarItems`, …) and their payload types |
|
|
49
|
+
| `evaluateWhen` + the strict `when` AND semantics | The kit instantiation binding your (generated or hand-authored) vocabulary |
|
|
50
|
+
| `createContributionRegistry` (filter + deterministic order + id validation) | The app shell that renders each slot |
|
|
51
|
+
| `createStaticCapabilities` (the no-snapshot `CapabilityReader`) | The real snapshot wiring once `elarion.session` exists |
|
|
52
|
+
| `ContributionProvider` / `useContributions` / `<ExtensionSlot context=…>` (React) | Route composition and module discovery (e.g. `import.meta.glob`) |
|
|
39
53
|
| `provideContributions` / `injectContributions` (Angular) | Slot rendering — an `@for` block, or a small `*extensionSlot` directive you own (see below) |
|
|
40
54
|
| `redirectUnless` route guard (`/tanstack-router`) | Everything else routing — the router's own API |
|
|
41
55
|
|
|
@@ -59,6 +73,12 @@ export type AppManifest = ModuleManifest<AppVocabulary>
|
|
|
59
73
|
export const { defineModule, defineExtensionPoint, contribute } = createContributionKit<AppVocabulary>()
|
|
60
74
|
```
|
|
61
75
|
|
|
76
|
+
Declare only the axes your application has — they are all optional, and `when` clauses are checked
|
|
77
|
+
**strictly**: a typo'd name or a clause on an undeclared axis is a compile error, not a silently hidden
|
|
78
|
+
item. No auth and no generated `session-client.ts` yet? Bind `{ module: ModuleName }` with a hand-authored
|
|
79
|
+
union and use `createStaticCapabilities()` as the reader (modules/permissions/roles default `"all"`, flags
|
|
80
|
+
default none); swap in the generated `SessionCapabilities` later — same structural interface.
|
|
81
|
+
|
|
62
82
|
Declare a point where the *owner* of the slot lives (the shell for a sidebar, a module for its own
|
|
63
83
|
extension surface), and export the token from the owner's public entry. The payload shape is yours —
|
|
64
84
|
whatever your shell needs to render:
|
|
@@ -99,6 +119,16 @@ const registry = createContributionRegistry(appModules.map((m) => m.manifest), c
|
|
|
99
119
|
const items = useContributions(sidebarItems)
|
|
100
120
|
```
|
|
101
121
|
|
|
122
|
+
When a point declares a slot context (`defineExtensionPoint<TItem, TContext>`), the slot site supplies it
|
|
123
|
+
through `<ExtensionSlot context=…>` — type-checked against `TContext` and handed to the render prop — and
|
|
124
|
+
payloads pin their signatures to the declaration with `ContextOf`:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// the point: export const stackDetailTabs = defineExtensionPoint<StackTab, { stack: Stack }>("stacks.detailTabs")
|
|
128
|
+
// the payload type: component: (context: ContextOf<typeof stackDetailTabs>) => ReactNode
|
|
129
|
+
<ExtensionSlot point={stackDetailTabs} context={{ stack }} render={(tab, ctx) => tab.component(ctx)} />
|
|
130
|
+
```
|
|
131
|
+
|
|
102
132
|
## Angular
|
|
103
133
|
|
|
104
134
|
Same kernel, idiomatic Angular surface. `provideContributions` is an environment provider (shaped like
|
|
@@ -178,9 +208,14 @@ export class ExtensionSlotDirective<TItem> {
|
|
|
178
208
|
- **`when` is AND** across its fields (`module`, `permission`, `flag`, `role`), mirroring
|
|
179
209
|
`[RequirePermission]`/`[RequireRole]`/`[FeatureGate]`; a manifest-level `when` is ANDed into every
|
|
180
210
|
contribution. Absent capabilities fail closed.
|
|
181
|
-
-
|
|
182
|
-
|
|
183
|
-
|
|
211
|
+
- **`when` is strictly typed** against the kit's vocabulary: out-of-vocabulary names do not compile
|
|
212
|
+
(fail-closed evaluation would otherwise turn a typo into invisibly hidden UI). Only a manifest's
|
|
213
|
+
`name` accepts free strings — it is an identity, not a lookup.
|
|
214
|
+
- **Resolution is pure and deterministic**: contributions sort by `order` (default 0), then `id`, using
|
|
215
|
+
code-unit comparison — the same input renders the same UI on server and client. Two co-visible
|
|
216
|
+
contributions to one point sharing an id throw at resolution (ids double as render keys); prefix ids
|
|
217
|
+
with the module name (`"invoicing.create-invoice"`). Refreshing the snapshot means building a new
|
|
218
|
+
registry.
|
|
184
219
|
- **UX projection, never security.** A hidden contribution is not a secured operation — the backend
|
|
185
220
|
handler's own gates are the enforcement on every call.
|
|
186
221
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,24 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The capability vocabulary a contribution kit is typed against — supplied by the application from the
|
|
3
|
-
* generated literal unions in `session-client.ts` (ModuleName/PermissionName/FlagName/RoleName).
|
|
3
|
+
* generated literal unions in `session-client.ts` (ModuleName/PermissionName/FlagName/RoleName). Declare
|
|
4
|
+
* only the axes the application actually has: an omitted (or `never`) axis makes every `when` use of it
|
|
5
|
+
* a compile error, so a no-auth app binding `{ module: ModuleName }` gets a real error on a stray
|
|
6
|
+
* permission/flag/role clause instead of a silently hidden item.
|
|
4
7
|
*/
|
|
5
8
|
export interface Vocabulary {
|
|
6
|
-
module
|
|
7
|
-
permission
|
|
8
|
-
flag
|
|
9
|
-
role
|
|
9
|
+
module?: string;
|
|
10
|
+
permission?: string;
|
|
11
|
+
flag?: string;
|
|
12
|
+
role?: string;
|
|
10
13
|
}
|
|
11
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Keeps literal-union autocomplete while accepting out-of-vocabulary names. Used only where a name is an
|
|
16
|
+
* *identity* (a UI-only module's `name`), never where it is a capability lookup — a lookup outside the
|
|
17
|
+
* vocabulary is exactly the drift `when` typing exists to catch.
|
|
18
|
+
*/
|
|
12
19
|
type Hint<T extends string> = T | (string & {});
|
|
20
|
+
/**
|
|
21
|
+
* One `when`-clause axis: exactly the vocabulary's names. `Extract` keeps the unbound default permissive
|
|
22
|
+
* (`Vocabulary`'s optional `string` axes stay plain `string`) while an omitted or `never` axis reduces to
|
|
23
|
+
* `never` — unusable, by design.
|
|
24
|
+
*/
|
|
25
|
+
type Axis<V extends Vocabulary, K extends keyof Vocabulary> = Extract<V[K], string>;
|
|
13
26
|
/**
|
|
14
27
|
* The declarative visibility condition of a contribution — the frontend mirror of
|
|
15
28
|
* `[RequirePermission]`/`[RequireRole]`/`[FeatureGate]`: every present field must hold (AND).
|
|
29
|
+
*
|
|
30
|
+
* Axes are checked strictly against the vocabulary: a typo'd or out-of-vocabulary name is a compile
|
|
31
|
+
* error, not a silently hidden item (the evaluator fails closed, so a wrong name would never surface at
|
|
32
|
+
* runtime either). Deliberately stricter than the generated snapshot accessors, which accept
|
|
33
|
+
* `Name | (string & {})`.
|
|
16
34
|
*/
|
|
17
35
|
export interface WhenClause<V extends Vocabulary = Vocabulary> {
|
|
18
|
-
readonly module?:
|
|
19
|
-
readonly permission?:
|
|
20
|
-
readonly flag?:
|
|
21
|
-
readonly role?:
|
|
36
|
+
readonly module?: Axis<V, "module">;
|
|
37
|
+
readonly permission?: Axis<V, "permission">;
|
|
38
|
+
readonly flag?: Axis<V, "flag">;
|
|
39
|
+
readonly role?: Axis<V, "role">;
|
|
22
40
|
}
|
|
23
41
|
/**
|
|
24
42
|
* The snapshot surface resolution reads — structurally satisfied by the `SessionCapabilities` class the
|
|
@@ -31,6 +49,34 @@ export interface CapabilityReader {
|
|
|
31
49
|
isFlagEnabled(name: string): boolean;
|
|
32
50
|
}
|
|
33
51
|
export declare function evaluateWhen(when: WhenClause | undefined, capabilities: CapabilityReader): boolean;
|
|
52
|
+
/** One axis of {@link createStaticCapabilities}: everything, an allow-list, or an explicit on/off map. */
|
|
53
|
+
export type StaticCapabilitySet = "all" | ReadonlyArray<string> | Readonly<Record<string, boolean>>;
|
|
54
|
+
/**
|
|
55
|
+
* A fixed {@link CapabilityReader} for applications without a session snapshot — self-hosted/no-auth
|
|
56
|
+
* deployments, tests, stories. Modules, permissions, and roles default to `"all"` (a no-auth app shows
|
|
57
|
+
* everything it ships); flags default to none, because a flag-gated contribution should stay hidden until
|
|
58
|
+
* the flag system that owns it exists (`when` stays fail-closed).
|
|
59
|
+
*
|
|
60
|
+
* This is a static snapshot by design, resolved once at composition. When the backend ships the
|
|
61
|
+
* `elarion.session` operation, swap it for the generated `SessionCapabilities` — same structural
|
|
62
|
+
* interface, nothing else changes.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* // Everything on (e.g. behind an authenticating reverse proxy):
|
|
67
|
+
* const capabilities = createStaticCapabilities()
|
|
68
|
+
* // Env-driven module toggles; permissions/roles open, flags off:
|
|
69
|
+
* const capabilities = createStaticCapabilities({
|
|
70
|
+
* modules: { core: true, "ai-agent": import.meta.env.VITE_MODULE_AI_AGENT_ENABLED !== "false" },
|
|
71
|
+
* })
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function createStaticCapabilities(init?: {
|
|
75
|
+
readonly modules?: StaticCapabilitySet;
|
|
76
|
+
readonly permissions?: StaticCapabilitySet;
|
|
77
|
+
readonly roles?: StaticCapabilitySet;
|
|
78
|
+
readonly flags?: StaticCapabilitySet;
|
|
79
|
+
}): CapabilityReader;
|
|
34
80
|
declare const CONTRIBUTION: unique symbol;
|
|
35
81
|
declare const SLOT_CONTEXT: unique symbol;
|
|
36
82
|
/**
|
|
@@ -49,11 +95,24 @@ export interface ExtensionPoint<TItem, TContext = void> {
|
|
|
49
95
|
readonly [SLOT_CONTEXT]?: TContext;
|
|
50
96
|
}
|
|
51
97
|
export declare function defineExtensionPoint<TItem, TContext = void>(id: string): ExtensionPoint<TItem, TContext>;
|
|
98
|
+
/** The payload type an extension point accepts — `ItemOf<typeof sidebarItems>`. */
|
|
99
|
+
export type ItemOf<P extends ExtensionPoint<unknown, unknown>> = P extends ExtensionPoint<infer TItem, infer _TContext> ? TItem : never;
|
|
100
|
+
/**
|
|
101
|
+
* The slot context an extension point declares — `ContextOf<typeof stackDetailTabs>`. Reference it in the
|
|
102
|
+
* point's payload signatures (`component: (context: ContextOf<typeof stackDetailTabs>) => ReactNode`) and
|
|
103
|
+
* receive it from the slot (`<ExtensionSlot context={…}>` in the React bindings), so the payload, the
|
|
104
|
+
* point, and the slot site can never drift apart.
|
|
105
|
+
*/
|
|
106
|
+
export type ContextOf<P extends ExtensionPoint<unknown, unknown>> = P extends ExtensionPoint<infer _TItem, infer TContext> ? TContext : never;
|
|
52
107
|
/** A single contribution: the point's payload plus identity, ordering, and visibility. */
|
|
53
108
|
export type Contribution<TItem, V extends Vocabulary = Vocabulary> = TItem & {
|
|
54
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Unique within the point among co-visible contributions (enforced when the registry resolves — ids
|
|
111
|
+
* double as render keys). Prefix with the contributing module's name, e.g. `"invoicing.create-invoice"`,
|
|
112
|
+
* to stay collision-free across modules.
|
|
113
|
+
*/
|
|
55
114
|
readonly id: string;
|
|
56
|
-
/** Ascending sort rank (default 0); ties break by id
|
|
115
|
+
/** Ascending sort rank (default 0); ties break by id. */
|
|
57
116
|
readonly order?: number;
|
|
58
117
|
/** Visibility condition, ANDed with the contributing module's manifest-level `when`. */
|
|
59
118
|
readonly when?: WhenClause<V>;
|
|
@@ -72,7 +131,7 @@ export declare function contribute<TItem, TContext, V extends Vocabulary = Vocab
|
|
|
72
131
|
*/
|
|
73
132
|
export interface ModuleManifest<V extends Vocabulary = Vocabulary> {
|
|
74
133
|
/** The module's name — its backend [AppModule] name when it mirrors one, any unique name when UI-only. */
|
|
75
|
-
readonly name: Hint<V
|
|
134
|
+
readonly name: Hint<Axis<V, "module">>;
|
|
76
135
|
/**
|
|
77
136
|
* Module-level visibility, ANDed into every contribution. A backend-paired module gates itself here once
|
|
78
137
|
* — `when: { module: Modules.X }` — so disabling the backend module removes the whole frontend module;
|
|
@@ -91,8 +150,12 @@ export interface ContributionRegistry {
|
|
|
91
150
|
* (manifests, capabilities) input yields the same registry, so a server render and the client hydration
|
|
92
151
|
* produce identical trees. Refreshing the snapshot (login, context change) means building a new registry,
|
|
93
152
|
* the same contract as the generated session provider.
|
|
153
|
+
*
|
|
154
|
+
* Resolution validates that no two co-visible contributions to one point share an id (ids double as
|
|
155
|
+
* render keys and as the deterministic tiebreak) and throws on a collision — manifests are static data,
|
|
156
|
+
* so a collision is a data bug that fails fast here rather than corrupting keyed rendering downstream.
|
|
94
157
|
*/
|
|
95
|
-
export declare function createContributionRegistry(manifests: ReadonlyArray<ModuleManifest
|
|
158
|
+
export declare function createContributionRegistry<V extends Vocabulary = Vocabulary>(manifests: ReadonlyArray<ModuleManifest<V>>, capabilities: CapabilityReader): ContributionRegistry;
|
|
96
159
|
/** The vocabulary-bound facade of the kernel — created once by the application, imported by every module. */
|
|
97
160
|
export interface ContributionKit<V extends Vocabulary> {
|
|
98
161
|
defineModule(manifest: ModuleManifest<V>): ModuleManifest<V>;
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,47 @@ export function evaluateWhen(when, capabilities) {
|
|
|
22
22
|
return false;
|
|
23
23
|
return true;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A fixed {@link CapabilityReader} for applications without a session snapshot — self-hosted/no-auth
|
|
27
|
+
* deployments, tests, stories. Modules, permissions, and roles default to `"all"` (a no-auth app shows
|
|
28
|
+
* everything it ships); flags default to none, because a flag-gated contribution should stay hidden until
|
|
29
|
+
* the flag system that owns it exists (`when` stays fail-closed).
|
|
30
|
+
*
|
|
31
|
+
* This is a static snapshot by design, resolved once at composition. When the backend ships the
|
|
32
|
+
* `elarion.session` operation, swap it for the generated `SessionCapabilities` — same structural
|
|
33
|
+
* interface, nothing else changes.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* // Everything on (e.g. behind an authenticating reverse proxy):
|
|
38
|
+
* const capabilities = createStaticCapabilities()
|
|
39
|
+
* // Env-driven module toggles; permissions/roles open, flags off:
|
|
40
|
+
* const capabilities = createStaticCapabilities({
|
|
41
|
+
* modules: { core: true, "ai-agent": import.meta.env.VITE_MODULE_AI_AGENT_ENABLED !== "false" },
|
|
42
|
+
* })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function createStaticCapabilities(init = {}) {
|
|
46
|
+
return {
|
|
47
|
+
isModuleEnabled: toPredicate(init.modules, true),
|
|
48
|
+
hasPermission: toPredicate(init.permissions, true),
|
|
49
|
+
hasRole: toPredicate(init.roles, true),
|
|
50
|
+
isFlagEnabled: toPredicate(init.flags, false),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function toPredicate(set, enabledByDefault) {
|
|
54
|
+
if (set === undefined)
|
|
55
|
+
return () => enabledByDefault;
|
|
56
|
+
if (set === "all")
|
|
57
|
+
return () => true;
|
|
58
|
+
if (Array.isArray(set)) {
|
|
59
|
+
const names = new Set(set);
|
|
60
|
+
return (name) => names.has(name);
|
|
61
|
+
}
|
|
62
|
+
// Array.isArray does not remove ReadonlyArray from the negated union (TS#17002), hence the assertion.
|
|
63
|
+
const map = set;
|
|
64
|
+
return (name) => map[name] === true;
|
|
65
|
+
}
|
|
25
66
|
export function defineExtensionPoint(id) {
|
|
26
67
|
return { id };
|
|
27
68
|
}
|
|
@@ -46,6 +87,10 @@ const EMPTY = [];
|
|
|
46
87
|
* (manifests, capabilities) input yields the same registry, so a server render and the client hydration
|
|
47
88
|
* produce identical trees. Refreshing the snapshot (login, context change) means building a new registry,
|
|
48
89
|
* the same contract as the generated session provider.
|
|
90
|
+
*
|
|
91
|
+
* Resolution validates that no two co-visible contributions to one point share an id (ids double as
|
|
92
|
+
* render keys and as the deterministic tiebreak) and throws on a collision — manifests are static data,
|
|
93
|
+
* so a collision is a data bug that fails fast here rather than corrupting keyed rendering downstream.
|
|
49
94
|
*/
|
|
50
95
|
export function createContributionRegistry(manifests, capabilities) {
|
|
51
96
|
const byPoint = new Map();
|
|
@@ -68,9 +113,17 @@ export function createContributionRegistry(manifests, capabilities) {
|
|
|
68
113
|
}
|
|
69
114
|
const resolved = new Map();
|
|
70
115
|
for (const [pointId, entries] of byPoint) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
116
|
+
const contributors = new Map();
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const previous = contributors.get(entry.item.id);
|
|
119
|
+
if (previous !== undefined) {
|
|
120
|
+
throw new Error(`Duplicate contribution id "${entry.item.id}" on extension point "${pointId}" ` +
|
|
121
|
+
`(contributed by module "${previous}" and module "${entry.module}"). Contribution ids must ` +
|
|
122
|
+
`be unique within a point — prefix with the module name, e.g. "${entry.module}.${entry.item.id}".`);
|
|
123
|
+
}
|
|
124
|
+
contributors.set(entry.item.id, entry.module);
|
|
125
|
+
}
|
|
126
|
+
entries.sort((a, b) => (a.item.order ?? 0) - (b.item.order ?? 0) || compareStrings(a.item.id, b.item.id));
|
|
74
127
|
resolved.set(pointId, entries.map((entry) => entry.item));
|
|
75
128
|
}
|
|
76
129
|
return {
|
package/dist/react.d.ts
CHANGED
|
@@ -6,8 +6,26 @@ export declare function ContributionProvider({ registry, children, }: {
|
|
|
6
6
|
}): import("react").JSX.Element;
|
|
7
7
|
/** The resolved contributions for a point — already filtered by `when` and deterministically ordered. */
|
|
8
8
|
export declare function useContributions<TItem, TContext>(point: ExtensionPoint<TItem, TContext>): ReadonlyArray<Contribution<TItem>>;
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Renders a point's contributions through a render prop — sugar over {@link useContributions} for inline
|
|
11
|
+
* slots. When the point declares a slot context (`TContext`), pass it as `context` and the render prop
|
|
12
|
+
* receives it, typed, as its second argument — so what the slot site supplies can never drift from what
|
|
13
|
+
* the point declares:
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <ExtensionSlot point={stackDetailTabs} context={{ stack }} render={(tab, ctx) => tab.component(ctx)} />
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Without `context`, the render prop takes only the item — for slots that render inert parts (buttons,
|
|
20
|
+
* menu entries) and hand the payload its context later, at invocation time.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ExtensionSlot<TItem, TContext>(props: {
|
|
23
|
+
point: ExtensionPoint<TItem, TContext>;
|
|
24
|
+
/** The slot context the point declares — handed to `render` as the second argument. */
|
|
25
|
+
context: TContext;
|
|
26
|
+
render: (item: Contribution<TItem>, context: TContext) => ReactNode;
|
|
27
|
+
}): ReactNode;
|
|
28
|
+
export declare function ExtensionSlot<TItem, TContext>(props: {
|
|
11
29
|
point: ExtensionPoint<TItem, TContext>;
|
|
12
30
|
render: (item: Contribution<TItem>) => ReactNode;
|
|
13
|
-
}):
|
|
31
|
+
}): ReactNode;
|
package/dist/react.js
CHANGED
|
@@ -15,8 +15,10 @@ export function useContributions(point) {
|
|
|
15
15
|
throw new Error("useContributions requires a <ContributionProvider> above it.");
|
|
16
16
|
return registry.get(point);
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// Overloads rather than a props union: JSX contextually types the render prop per overload, where a
|
|
19
|
+
// union would leave the render parameters implicitly `any`. The context form must come first — JSX
|
|
20
|
+
// falls through cleanly on its *missing* `context` prop, but would not fall through past an *excess* one.
|
|
21
|
+
export function ExtensionSlot(props) {
|
|
22
|
+
const items = useContributions(props.point);
|
|
23
|
+
return (_jsx(_Fragment, { children: items.map((item) => (_jsx(Fragment, { children: props.render(item, props.context) }, item.id))) }));
|
|
22
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swimmesberger/elarion-contributions",
|
|
3
|
-
"version": "0.2.3-preview.
|
|
3
|
+
"version": "0.2.3-preview.74.1",
|
|
4
4
|
"description": "The Elarion frontend contribution model: typed extension points, declarative module manifests, and capability-gated resolution — framework-free core with React bindings under /react and Angular bindings under /angular.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc -p tsconfig.json",
|
|
31
|
-
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
|
-
"test": "vitest run",
|
|
31
|
+
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
|
32
|
+
"test": "npm run typecheck && vitest run",
|
|
33
33
|
"prepack": "npm run build",
|
|
34
34
|
"prepare": "npm run build"
|
|
35
35
|
},
|