@swimmesberger/elarion-contributions 0.2.3-preview.71.1 → 0.2.3-preview.72.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 +81 -1
- package/dist/angular.d.ts +32 -0
- package/dist/angular.js +49 -0
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -10,6 +10,11 @@ adding a sidebar item or a cross-module action edits one file in the owning modu
|
|
|
10
10
|
- **React bindings** (`@swimmesberger/elarion-contributions/react`) — `ContributionProvider`,
|
|
11
11
|
`useContributions`, `<ExtensionSlot>`. React is an optional peer dependency; other view frameworks
|
|
12
12
|
can port the bindings in a page of code.
|
|
13
|
+
- **Angular bindings** (`@swimmesberger/elarion-contributions/angular`) — `provideContributions`,
|
|
14
|
+
`injectContributions` (returns a `Signal`). Idiomatic for Angular 20–22: signal-first, standalone,
|
|
15
|
+
no NgModule, no shipped directive — you render with `@for`. `@angular/core` is an optional peer
|
|
16
|
+
dependency, and the bindings are decorator/template-free so they ship in this one package (no
|
|
17
|
+
ng-packagr).
|
|
13
18
|
- **TanStack Router guard** (`@swimmesberger/elarion-contributions/tanstack-router`) —
|
|
14
19
|
`redirectUnless(when, to)` + the vocabulary-bound `createRouteGuards<V>()`: a `beforeLoad` guard
|
|
15
20
|
evaluating the same `when` clause as a contribution, so a route and its nav entry can never gate
|
|
@@ -30,7 +35,8 @@ This package ships the *machinery* with fixed semantics; your application owns t
|
|
|
30
35
|
| `defineExtensionPoint`, `defineModule`, `contribute` | Your extension points (`sidebarItems`, …) and their payload types |
|
|
31
36
|
| `evaluateWhen` + the `when` AND semantics | The kit instantiation binding your generated vocabulary |
|
|
32
37
|
| `createContributionRegistry` (filter + deterministic order) | The app shell that renders each slot |
|
|
33
|
-
| `ContributionProvider` / `useContributions` / `<ExtensionSlot>` | Route composition and module discovery (e.g. `import.meta.glob`) |
|
|
38
|
+
| `ContributionProvider` / `useContributions` / `<ExtensionSlot>` (React) | Route composition and module discovery (e.g. `import.meta.glob`) |
|
|
39
|
+
| `provideContributions` / `injectContributions` (Angular) | Slot rendering — an `@for` block, or a small `*extensionSlot` directive you own (see below) |
|
|
34
40
|
| `redirectUnless` route guard (`/tanstack-router`) | Everything else routing — the router's own API |
|
|
35
41
|
|
|
36
42
|
## Quick start
|
|
@@ -93,6 +99,80 @@ const registry = createContributionRegistry(appModules.map((m) => m.manifest), c
|
|
|
93
99
|
const items = useContributions(sidebarItems)
|
|
94
100
|
```
|
|
95
101
|
|
|
102
|
+
## Angular
|
|
103
|
+
|
|
104
|
+
Same kernel, idiomatic Angular surface. `provideContributions` is an environment provider (shaped like
|
|
105
|
+
`provideRouter`); `injectContributions` returns a `Signal` your templates track, so refreshing the
|
|
106
|
+
capability snapshot re-resolves every slot by setting one signal:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// main.ts — hand the resolved registry to the injector tree
|
|
110
|
+
import { createContributionRegistry } from "@swimmesberger/elarion-contributions"
|
|
111
|
+
import { provideContributions } from "@swimmesberger/elarion-contributions/angular"
|
|
112
|
+
|
|
113
|
+
const registry = createContributionRegistry(appModules.map((m) => m.manifest), capabilities)
|
|
114
|
+
bootstrapApplication(App, { providers: [provideContributions(registry)] })
|
|
115
|
+
// snapshot can change at runtime? provideContributions(signal(registry)) and .set(...) on login.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// any standalone component — read the point as a signal, render with @for
|
|
120
|
+
import { Component } from "@angular/core"
|
|
121
|
+
import { injectContributions } from "@swimmesberger/elarion-contributions/angular"
|
|
122
|
+
import { sidebarItems } from "../platform/points"
|
|
123
|
+
|
|
124
|
+
@Component({
|
|
125
|
+
standalone: true,
|
|
126
|
+
imports: [RouterLink],
|
|
127
|
+
template: `@for (item of items(); track item.id) {
|
|
128
|
+
<a [routerLink]="item.to">{{ item.label }}</a>
|
|
129
|
+
}`,
|
|
130
|
+
})
|
|
131
|
+
export class Sidebar {
|
|
132
|
+
readonly items = injectContributions(sidebarItems)
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Want a `*extensionSlot` directive? Build it on top
|
|
137
|
+
|
|
138
|
+
The package deliberately ships no directive (that would need the Angular compiler / ng-packagr and a second
|
|
139
|
+
build). If you prefer slot sugar over an inline `@for`, a structural directive is a few lines in **your**
|
|
140
|
+
app over `injectContributions` — you own the rendering, the kernel stays framework-agnostic:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// app/extension-slot.directive.ts
|
|
144
|
+
import {
|
|
145
|
+
Directive, effect, inject, Injector, input, runInInjectionContext, TemplateRef, ViewContainerRef,
|
|
146
|
+
} from "@angular/core"
|
|
147
|
+
import { injectContributions } from "@swimmesberger/elarion-contributions/angular"
|
|
148
|
+
import type { Contribution, ExtensionPoint } from "@swimmesberger/elarion-contributions"
|
|
149
|
+
|
|
150
|
+
@Directive({ selector: "[extensionSlot]", standalone: true })
|
|
151
|
+
export class ExtensionSlotDirective<TItem> {
|
|
152
|
+
// *extensionSlot="point" — the point token to render.
|
|
153
|
+
readonly point = input.required<ExtensionPoint<TItem, unknown>>({ alias: "extensionSlot" })
|
|
154
|
+
|
|
155
|
+
private readonly tpl = inject<TemplateRef<{ $implicit: Contribution<TItem> }>>(TemplateRef)
|
|
156
|
+
private readonly vcr = inject(ViewContainerRef)
|
|
157
|
+
private readonly injector = inject(Injector)
|
|
158
|
+
|
|
159
|
+
constructor() {
|
|
160
|
+
// Re-render when the point changes or the snapshot refreshes; each item is the template's $implicit.
|
|
161
|
+
// injectContributions() calls inject(), so it must run in an injection context — hence runInInjectionContext.
|
|
162
|
+
effect(() => {
|
|
163
|
+
const items = runInInjectionContext(this.injector, () => injectContributions(this.point())())
|
|
164
|
+
this.vcr.clear()
|
|
165
|
+
for (const item of items) this.vcr.createEmbeddedView(this.tpl, { $implicit: item })
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```html
|
|
172
|
+
<!-- usage: item is the contribution, typed by the point -->
|
|
173
|
+
<a *extensionSlot="sidebarItems; let item" [routerLink]="item.to">{{ item.label }}</a>
|
|
174
|
+
```
|
|
175
|
+
|
|
96
176
|
## Semantics (the part that must not drift)
|
|
97
177
|
|
|
98
178
|
- **`when` is AND** across its fields (`module`, `permission`, `flag`, `role`), mirroring
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type EnvironmentProviders, type Signal } from "@angular/core";
|
|
2
|
+
import type { Contribution, ContributionRegistry, ExtensionPoint } from "./index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Provides the resolved registry to the injector tree — the idiomatic mirror of React's
|
|
5
|
+
* `<ContributionProvider>`, shaped like `provideRouter`/`provideHttpClient`. Pass a live registry for a
|
|
6
|
+
* static snapshot, or a `Signal<ContributionRegistry>` when the capability snapshot can change at runtime
|
|
7
|
+
* (login, tenant switch): rebuilding the registry and setting the signal re-resolves every slot.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* bootstrapApplication(App, {
|
|
12
|
+
* providers: [provideContributions(createContributionRegistry(manifests, capabilities))],
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function provideContributions(source: ContributionRegistry | Signal<ContributionRegistry>): EnvironmentProviders;
|
|
17
|
+
/**
|
|
18
|
+
* Reads a point's resolved contributions as a `Signal` — call from an injection context (a component field,
|
|
19
|
+
* a `factory`, or inside `runInInjectionContext`). The signal tracks the provided registry, so a snapshot
|
|
20
|
+
* refresh updates every template reading it. Already filtered by `when` and deterministically ordered.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* @Component({
|
|
25
|
+
* template: `@for (item of items(); track item.id) { <a [routerLink]="item.to">{{ item.label }}</a> }`,
|
|
26
|
+
* })
|
|
27
|
+
* export class Sidebar {
|
|
28
|
+
* readonly items = injectContributions(sidebarItems)
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function injectContributions<TItem, TContext>(point: ExtensionPoint<TItem, TContext>): Signal<ReadonlyArray<Contribution<TItem>>>;
|
package/dist/angular.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Angular bindings over the contribution kernel — the `/angular` adapter of ADR-0032, the sibling of the
|
|
2
|
+
// `/react` sub-export. Deliberately decorator- and template-free: it ships only the DI + reactivity seam
|
|
3
|
+
// (`provideContributions`, `injectContributions`) as plain `@angular/core` runtime APIs, so it compiles with
|
|
4
|
+
// the same `tsc` build as the rest of the package — no ng-packagr, no second toolchain, one npm package.
|
|
5
|
+
// Rendering is the app's own `@for` block (idiomatic Angular 20+ control flow); a structural directive is a
|
|
6
|
+
// handful of lines the app writes on top of `injectContributions` (see the README) when it wants slot sugar.
|
|
7
|
+
import { computed, inject, InjectionToken, isSignal, makeEnvironmentProviders, signal, } from "@angular/core";
|
|
8
|
+
// A Signal is held (never a bare registry) so a snapshot refresh — login, context change — re-resolves every
|
|
9
|
+
// slot reactively by setting one signal, the Angular mirror of React swapping the provider value.
|
|
10
|
+
const CONTRIBUTION_REGISTRY = new InjectionToken("elarion.contributions.registry");
|
|
11
|
+
/**
|
|
12
|
+
* Provides the resolved registry to the injector tree — the idiomatic mirror of React's
|
|
13
|
+
* `<ContributionProvider>`, shaped like `provideRouter`/`provideHttpClient`. Pass a live registry for a
|
|
14
|
+
* static snapshot, or a `Signal<ContributionRegistry>` when the capability snapshot can change at runtime
|
|
15
|
+
* (login, tenant switch): rebuilding the registry and setting the signal re-resolves every slot.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* bootstrapApplication(App, {
|
|
20
|
+
* providers: [provideContributions(createContributionRegistry(manifests, capabilities))],
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function provideContributions(source) {
|
|
25
|
+
const registry = isSignal(source) ? source : signal(source);
|
|
26
|
+
return makeEnvironmentProviders([{ provide: CONTRIBUTION_REGISTRY, useValue: registry }]);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reads a point's resolved contributions as a `Signal` — call from an injection context (a component field,
|
|
30
|
+
* a `factory`, or inside `runInInjectionContext`). The signal tracks the provided registry, so a snapshot
|
|
31
|
+
* refresh updates every template reading it. Already filtered by `when` and deterministically ordered.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* @Component({
|
|
36
|
+
* template: `@for (item of items(); track item.id) { <a [routerLink]="item.to">{{ item.label }}</a> }`,
|
|
37
|
+
* })
|
|
38
|
+
* export class Sidebar {
|
|
39
|
+
* readonly items = injectContributions(sidebarItems)
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function injectContributions(point) {
|
|
44
|
+
const registry = inject(CONTRIBUTION_REGISTRY, { optional: true });
|
|
45
|
+
if (registry === null) {
|
|
46
|
+
throw new Error("injectContributions requires provideContributions() in the injector tree.");
|
|
47
|
+
}
|
|
48
|
+
return computed(() => registry().get(point));
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swimmesberger/elarion-contributions",
|
|
3
|
-
"version": "0.2.3-preview.
|
|
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.",
|
|
3
|
+
"version": "0.2.3-preview.72.1",
|
|
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",
|
|
7
7
|
"exports": {
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"types": "./dist/react.d.ts",
|
|
14
14
|
"import": "./dist/react.js"
|
|
15
15
|
},
|
|
16
|
+
"./angular": {
|
|
17
|
+
"types": "./dist/angular.d.ts",
|
|
18
|
+
"import": "./dist/angular.js"
|
|
19
|
+
},
|
|
16
20
|
"./tanstack-router": {
|
|
17
21
|
"types": "./dist/tanstack-router.d.ts",
|
|
18
22
|
"import": "./dist/tanstack-router.js"
|
|
@@ -46,6 +50,7 @@
|
|
|
46
50
|
"node": ">=20.11"
|
|
47
51
|
},
|
|
48
52
|
"peerDependencies": {
|
|
53
|
+
"@angular/core": ">=20",
|
|
49
54
|
"@tanstack/react-router": ">=1",
|
|
50
55
|
"react": ">=18"
|
|
51
56
|
},
|
|
@@ -53,13 +58,18 @@
|
|
|
53
58
|
"react": {
|
|
54
59
|
"optional": true
|
|
55
60
|
},
|
|
61
|
+
"@angular/core": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
56
64
|
"@tanstack/react-router": {
|
|
57
65
|
"optional": true
|
|
58
66
|
}
|
|
59
67
|
},
|
|
60
68
|
"devDependencies": {
|
|
69
|
+
"@angular/core": "^22.0.5",
|
|
61
70
|
"@tanstack/react-router": "^1.170.17",
|
|
62
71
|
"@types/react": "^19.2.17",
|
|
72
|
+
"rxjs": "^7.8.2",
|
|
63
73
|
"typescript": "^5.9.3",
|
|
64
74
|
"vitest": "^4.0.18"
|
|
65
75
|
}
|