@brendanbank/atrium-host-bundle-utils 0.15.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/LICENCE.md +25 -0
- package/README.md +119 -0
- package/dist/i18n.d.ts +17 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +32 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +87 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +125 -0
- package/dist/react/index.js.map +1 -0
- package/dist/vite/index.d.ts +36 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +85 -0
- package/dist/vite/index.js.map +1 -0
- package/package.json +76 -0
package/LICENCE.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Brendan Bank
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
this list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
18
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
19
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
20
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
21
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
22
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
23
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
24
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
25
|
+
POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# `@brendanbank/atrium-host-bundle-utils`
|
|
2
|
+
|
|
3
|
+
Runtime helpers, Vite preset, and React hooks for [atrium](https://github.com/brendanbank/atrium)
|
|
4
|
+
host bundles. Wraps the dual-tree mount pattern so a host's
|
|
5
|
+
`main.tsx` collapses to ~10 lines of registration calls.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import {
|
|
9
|
+
AtriumRegistry,
|
|
10
|
+
makeWrapperElement,
|
|
11
|
+
} from '@brendanbank/atrium-host-bundle-utils';
|
|
12
|
+
import { MyWidget } from './MyWidget';
|
|
13
|
+
|
|
14
|
+
const reg = window.__ATRIUM_REGISTRY__ as AtriumRegistry;
|
|
15
|
+
reg.registerHomeWidget({
|
|
16
|
+
key: 'my-card',
|
|
17
|
+
render: () => makeWrapperElement(<MyWidget />),
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
The package is published on **GitHub Packages**, not the public npm
|
|
24
|
+
registry. Add an `.npmrc` to your host project that points the
|
|
25
|
+
`@brendanbank` scope at GitHub Packages and supplies a token with
|
|
26
|
+
`read:packages` scope:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
# .npmrc
|
|
30
|
+
@brendanbank:registry=https://npm.pkg.github.com
|
|
31
|
+
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In CI, `GITHUB_TOKEN` is provided automatically by Actions; locally,
|
|
35
|
+
generate a [classic PAT](https://github.com/settings/tokens) with
|
|
36
|
+
`read:packages` and export it. Then:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm add @brendanbank/atrium-host-bundle-utils \
|
|
40
|
+
@brendanbank/atrium-host-types
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Three subpath entry-points
|
|
44
|
+
|
|
45
|
+
| Import | What you get |
|
|
46
|
+
| ---------------------------------------------------------- | ---------------------------------------------------------- |
|
|
47
|
+
| `@brendanbank/atrium-host-bundle-utils` | `makeWrapperElement`, `mountInside`, `unmountInside`, types |
|
|
48
|
+
| `@brendanbank/atrium-host-bundle-utils/vite` | `hostBundleConfig({ entry })` — returns a Vite config |
|
|
49
|
+
| `@brendanbank/atrium-host-bundle-utils/react` | `useMe`, `usePerm`, `useRole`, `useUserContext`, `<AtriumProvider>` |
|
|
50
|
+
|
|
51
|
+
## Vite preset
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// vite.config.ts
|
|
55
|
+
import { hostBundleConfig } from '@brendanbank/atrium-host-bundle-utils/vite';
|
|
56
|
+
|
|
57
|
+
export default hostBundleConfig({ entry: 'src/main.tsx' });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The factory returns a complete Vite library-mode config: emits a
|
|
61
|
+
single `dist/main.js` that atrium's loader dynamic-imports, inlines
|
|
62
|
+
imported `.css` via runtime `<style>` tags, and defines
|
|
63
|
+
`process.env.NODE_ENV` so the externalised React + TanStack Query
|
|
64
|
+
references resolve. Pass `extraConfig` to layer additional plugins
|
|
65
|
+
or overrides on top.
|
|
66
|
+
|
|
67
|
+
`vite` and `vite-plugin-css-injected-by-js` are declared as optional
|
|
68
|
+
peer deps; install them as devDependencies in the host's
|
|
69
|
+
`frontend/package.json` only when building the bundle.
|
|
70
|
+
|
|
71
|
+
## React hooks
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import {
|
|
75
|
+
AtriumProvider,
|
|
76
|
+
useMe,
|
|
77
|
+
usePerm,
|
|
78
|
+
useRole,
|
|
79
|
+
} from '@brendanbank/atrium-host-bundle-utils/react';
|
|
80
|
+
|
|
81
|
+
function CommissionsPage() {
|
|
82
|
+
const { data: me, isLoading } = useMe();
|
|
83
|
+
const hasPerm = usePerm();
|
|
84
|
+
if (isLoading) return <Loader />;
|
|
85
|
+
if (hasPerm('commission.view.all')) return <Admin />;
|
|
86
|
+
return <SelfCommissions userId={me?.id} />;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function App() {
|
|
90
|
+
return (
|
|
91
|
+
<QueryClientProvider client={hostQueryClient}>
|
|
92
|
+
<AtriumProvider apiBase="/api">
|
|
93
|
+
<CommissionsPage />
|
|
94
|
+
</AtriumProvider>
|
|
95
|
+
</QueryClientProvider>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`<AtriumProvider>` reads from your existing `<QueryClientProvider>`
|
|
101
|
+
by default — no second QueryClient. Pass `client={hostQueryClient}`
|
|
102
|
+
to wrap one inline; pass `fetchUserContext={...}` to inject a custom
|
|
103
|
+
fetcher (useful for tests or hosts that want axios-shaped retry).
|
|
104
|
+
|
|
105
|
+
## Versioning
|
|
106
|
+
|
|
107
|
+
The package version tracks atrium's image version. Pin `^0.15` for
|
|
108
|
+
"compatible with atrium 0.15.x"; bump together with the atrium image
|
|
109
|
+
to pick up new registry slots and SDK helpers.
|
|
110
|
+
|
|
111
|
+
## See also
|
|
112
|
+
|
|
113
|
+
- [`@brendanbank/atrium-host-types`](../host-types/) — the typed
|
|
114
|
+
registry declarations this package re-exports.
|
|
115
|
+
- [`docs/published-images.md`](../../docs/published-images.md) — the
|
|
116
|
+
full host-extension contract.
|
|
117
|
+
- [`examples/hello-world/`](../../examples/hello-world/) — the
|
|
118
|
+
canonical worked example exercising every extension slot through
|
|
119
|
+
these helpers.
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Translate a shared atrium key against the running atrium image's
|
|
2
|
+
* i18next instance.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { __atrium_t__ } from '@brendanbank/atrium-host-bundle-utils';
|
|
6
|
+
*
|
|
7
|
+
* <Button>{__atrium_t__('common.save')}</Button>
|
|
8
|
+
* <Text>{__atrium_t__('common.welcome', { name: me.full_name })}</Text>
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* - Missing atrium image (pre-0.14): returns the literal key.
|
|
12
|
+
* - Missing key: returns the literal key (not empty string), so a
|
|
13
|
+
* typo is visible in the rendered output.
|
|
14
|
+
* - Missing translation in the active locale: falls back to English
|
|
15
|
+
* via i18next's ``fallbackLng``. */
|
|
16
|
+
export declare function __atrium_t__(key: string, vars?: Record<string, string | number>): string;
|
|
17
|
+
//# sourceMappingURL=i18n.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAkDA;;;;;;;;;;;;;;uCAcuC;AACvC,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GACrC,MAAM,CAWR"}
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copyright (c) 2026 Brendan Bank
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
/** Translate a shared atrium key against the running atrium image's
|
|
4
|
+
* i18next instance.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { __atrium_t__ } from '@brendanbank/atrium-host-bundle-utils';
|
|
8
|
+
*
|
|
9
|
+
* <Button>{__atrium_t__('common.save')}</Button>
|
|
10
|
+
* <Text>{__atrium_t__('common.welcome', { name: me.full_name })}</Text>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* - Missing atrium image (pre-0.14): returns the literal key.
|
|
14
|
+
* - Missing key: returns the literal key (not empty string), so a
|
|
15
|
+
* typo is visible in the rendered output.
|
|
16
|
+
* - Missing translation in the active locale: falls back to English
|
|
17
|
+
* via i18next's ``fallbackLng``. */
|
|
18
|
+
export function __atrium_t__(key, vars) {
|
|
19
|
+
if (typeof window === 'undefined')
|
|
20
|
+
return key;
|
|
21
|
+
const i18n = window
|
|
22
|
+
.__atrium_i18n__;
|
|
23
|
+
if (!i18n || typeof i18n.t !== 'function')
|
|
24
|
+
return key;
|
|
25
|
+
// ``defaultValue: key`` is the explicit "missing key → key" rule.
|
|
26
|
+
// i18next's default behaviour already returns the key when the
|
|
27
|
+
// lookup misses, but spelling it out makes the intent unambiguous
|
|
28
|
+
// and survives any future i18next config change in atrium.
|
|
29
|
+
const out = i18n.t(key, { ...vars, defaultValue: key });
|
|
30
|
+
return typeof out === 'string' ? out : key;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=i18n.js.map
|
package/dist/i18n.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.js","sourceRoot":"","sources":["../src/i18n.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,wCAAwC;AAiDxC;;;;;;;;;;;;;;uCAcuC;AACvC,MAAM,UAAU,YAAY,CAC1B,GAAW,EACX,IAAsC;IAEtC,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,GAAG,CAAC;IAC9C,MAAM,IAAI,GAAI,MAAuD;SAClE,eAAe,CAAC;IACnB,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,CAAC,CAAC,KAAK,UAAU;QAAE,OAAO,GAAG,CAAC;IACtD,kEAAkE;IAClE,+DAA+D;IAC/D,kEAAkE;IAClE,2DAA2D;IAC3D,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,EAAE,GAAG,IAAI,EAAE,YAAY,EAAE,GAAG,EAAE,CAAC,CAAC;IACxD,OAAO,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;AAC7C,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atrium host-bundle runtime helpers.
|
|
3
|
+
*
|
|
4
|
+
* Atrium loads each host's frontend as a single ES module via
|
|
5
|
+
* `import(system.host_bundle_url)` after the SPA boots. The host
|
|
6
|
+
* bundle calls `window.__ATRIUM_REGISTRY__.register*(...)` at import
|
|
7
|
+
* time. The elements registered are atrium-React elements (created
|
|
8
|
+
* via `window.React.createElement`) whose ref callback mounts the
|
|
9
|
+
* host's *own* React tree inside via `react-dom/client`'s
|
|
10
|
+
* `createRoot`. Two React trees coexist in the DOM:
|
|
11
|
+
*
|
|
12
|
+
* - atrium's React owns the page shell + the wrapper `<div>`
|
|
13
|
+
* - the host's React owns the subtree (Mantine, hook state, query
|
|
14
|
+
* cache)
|
|
15
|
+
*
|
|
16
|
+
* `makeWrapperElement` and `mountInside` package up the dual-tree
|
|
17
|
+
* pattern so the host's `main.tsx` becomes ten lines of registration
|
|
18
|
+
* calls — see `examples/hello-world/frontend/src/main.tsx` for the
|
|
19
|
+
* canonical use.
|
|
20
|
+
*
|
|
21
|
+
* Type re-exports below let a host that only needs the runtime
|
|
22
|
+
* helpers add a single dep instead of two.
|
|
23
|
+
*/
|
|
24
|
+
import type { ReactElement } from 'react';
|
|
25
|
+
export { __atrium_t__ } from './i18n';
|
|
26
|
+
export type { AdminUserRow, AdminTab, AtriumEvent, AtriumEventHandler, AtriumNotification, AtriumRegistry, HomeWidget, HomeWidgetWidth, LocaleOverlay, NavItem, NotificationKindRenderer, ProfileItem, ProfileSlot, RouteEntry, UserContext, } from '@brendanbank/atrium-host-types';
|
|
27
|
+
/** Mount `child` inside `el` using a fresh React root, idempotent on
|
|
28
|
+
* the same `el`/`child` pair.
|
|
29
|
+
*
|
|
30
|
+
* Behaviour:
|
|
31
|
+
*
|
|
32
|
+
* - First call: `createRoot(el).render(child)` and remember the root.
|
|
33
|
+
* - Same `el` + same `child` reference: no-op (handles `<StrictMode>`
|
|
34
|
+
* double-invoke and ref-callback re-fires on remount).
|
|
35
|
+
* - Same `el` + different `child` reference: re-renders into the
|
|
36
|
+
* existing root — cheaper than unmount/remount and preserves
|
|
37
|
+
* component state where the new child's tree matches.
|
|
38
|
+
*
|
|
39
|
+
* Hosts use this directly only when they want to manage their own
|
|
40
|
+
* ref semantics. The common case is `makeWrapperElement(child)`
|
|
41
|
+
* below, which closes over a single child and tracks the mounted
|
|
42
|
+
* node via the ref callback's `null` signal so unmount is exact. */
|
|
43
|
+
export declare function mountInside(el: HTMLElement, child: ReactElement): void;
|
|
44
|
+
/** Unmount the React root previously attached to `el` by `mountInside`.
|
|
45
|
+
* No-op when there is no tracked root. Called automatically by the
|
|
46
|
+
* ref callback returned from `makeWrapperElement`; hosts only call
|
|
47
|
+
* this directly when managing their own ref semantics. */
|
|
48
|
+
export declare function unmountInside(el: HTMLElement): void;
|
|
49
|
+
/** Build an atrium-React element whose ref callback mounts `child` via
|
|
50
|
+
* the host's own React. Returns a `ReactElement` so it slots
|
|
51
|
+
* directly into the registry option-bag types (`render: () =>
|
|
52
|
+
* makeWrapperElement(<MyWidget />)`).
|
|
53
|
+
*
|
|
54
|
+
* Each call captures the supplied `child` in a closure and tracks the
|
|
55
|
+
* mounted DOM node so the React unmount fires when atrium drops the
|
|
56
|
+
* wrapper (route swap, tab change). The closure-style tracking is the
|
|
57
|
+
* correct fix for issue #31 — using a module-level WeakMap alone
|
|
58
|
+
* meant a child swap on a reused DOM node could leave the previous
|
|
59
|
+
* child's tree mounted; pinning state to one closure-per-call
|
|
60
|
+
* guarantees one wrapper element owns one mount.
|
|
61
|
+
*
|
|
62
|
+
* Note: the returned object is created via *atrium's* React, not the
|
|
63
|
+
* host bundle's. The two trees only meet at this `<div>`; everything
|
|
64
|
+
* inside the ref callback runs under the host's React. The
|
|
65
|
+
* `ReactElement` cast is safe because atrium's React exposes the
|
|
66
|
+
* same element shape — the type system can't tell them apart, and
|
|
67
|
+
* the registry consumer treats either as opaque.
|
|
68
|
+
*
|
|
69
|
+
* Tabler icons (and other hooks-free SVG components) can be passed
|
|
70
|
+
* directly to `window.React.createElement(...)` — they don't need
|
|
71
|
+
* the wrapper. The wrapper is only required for trees that use
|
|
72
|
+
* hooks, context, or anything that depends on the host's React copy. */
|
|
73
|
+
export declare function makeWrapperElement(child: ReactElement): ReactElement;
|
|
74
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAG1C,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,YAAY,EACV,YAAY,EACZ,QAAQ,EACR,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,UAAU,EACV,eAAe,EACf,aAAa,EACb,OAAO,EACP,wBAAwB,EACxB,WAAW,EACX,WAAW,EACX,UAAU,EACV,WAAW,GACZ,MAAM,gCAAgC,CAAC;AA2CxC;;;;;;;;;;;;;;;qEAeqE;AACrE,wBAAgB,WAAW,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI,CAWtE;AAED;;;2DAG2D;AAC3D,wBAAgB,aAAa,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,CAKnD;AAED;;;;;;;;;;;;;;;;;;;;;;;yEAuByE;AACzE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,YAAY,CAsBpE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Copyright (c) 2026 Brendan Bank
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
export { __atrium_t__ } from './i18n';
|
|
5
|
+
/** Resolve the React instance atrium exposes on `window.React`. Throws
|
|
6
|
+
* with a clear message when missing — the host bundle's import-time
|
|
7
|
+
* side-effects are running before the SPA mounted, which means the
|
|
8
|
+
* bundle is being loaded outside atrium's own loader. */
|
|
9
|
+
function getAtriumReact() {
|
|
10
|
+
if (typeof window === 'undefined') {
|
|
11
|
+
throw new Error('[atrium-host-bundle-utils] makeWrapperElement requires a browser ' +
|
|
12
|
+
'environment; called from a non-window context');
|
|
13
|
+
}
|
|
14
|
+
const r = window.React;
|
|
15
|
+
if (!r || typeof r.createElement !== 'function') {
|
|
16
|
+
throw new Error('[atrium-host-bundle-utils] window.React is missing — atrium SPA ' +
|
|
17
|
+
'must mount and expose its React on window.React before the host ' +
|
|
18
|
+
'bundle is imported. Check that the bundle is being loaded via ' +
|
|
19
|
+
"atrium's host_bundle_url loader and not by the host's own SPA.");
|
|
20
|
+
}
|
|
21
|
+
return r;
|
|
22
|
+
}
|
|
23
|
+
// WeakMap so a DOM element going away (host route swap, profile card
|
|
24
|
+
// re-render) doesn't pin the React root in memory. The entry is GC'd
|
|
25
|
+
// alongside the element.
|
|
26
|
+
const mounted = new WeakMap();
|
|
27
|
+
/** Mount `child` inside `el` using a fresh React root, idempotent on
|
|
28
|
+
* the same `el`/`child` pair.
|
|
29
|
+
*
|
|
30
|
+
* Behaviour:
|
|
31
|
+
*
|
|
32
|
+
* - First call: `createRoot(el).render(child)` and remember the root.
|
|
33
|
+
* - Same `el` + same `child` reference: no-op (handles `<StrictMode>`
|
|
34
|
+
* double-invoke and ref-callback re-fires on remount).
|
|
35
|
+
* - Same `el` + different `child` reference: re-renders into the
|
|
36
|
+
* existing root — cheaper than unmount/remount and preserves
|
|
37
|
+
* component state where the new child's tree matches.
|
|
38
|
+
*
|
|
39
|
+
* Hosts use this directly only when they want to manage their own
|
|
40
|
+
* ref semantics. The common case is `makeWrapperElement(child)`
|
|
41
|
+
* below, which closes over a single child and tracks the mounted
|
|
42
|
+
* node via the ref callback's `null` signal so unmount is exact. */
|
|
43
|
+
export function mountInside(el, child) {
|
|
44
|
+
const existing = mounted.get(el);
|
|
45
|
+
if (existing) {
|
|
46
|
+
if (existing.child === child)
|
|
47
|
+
return;
|
|
48
|
+
existing.root.render(child);
|
|
49
|
+
existing.child = child;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const root = createRoot(el);
|
|
53
|
+
root.render(child);
|
|
54
|
+
mounted.set(el, { root, child });
|
|
55
|
+
}
|
|
56
|
+
/** Unmount the React root previously attached to `el` by `mountInside`.
|
|
57
|
+
* No-op when there is no tracked root. Called automatically by the
|
|
58
|
+
* ref callback returned from `makeWrapperElement`; hosts only call
|
|
59
|
+
* this directly when managing their own ref semantics. */
|
|
60
|
+
export function unmountInside(el) {
|
|
61
|
+
const existing = mounted.get(el);
|
|
62
|
+
if (!existing)
|
|
63
|
+
return;
|
|
64
|
+
existing.root.unmount();
|
|
65
|
+
mounted.delete(el);
|
|
66
|
+
}
|
|
67
|
+
/** Build an atrium-React element whose ref callback mounts `child` via
|
|
68
|
+
* the host's own React. Returns a `ReactElement` so it slots
|
|
69
|
+
* directly into the registry option-bag types (`render: () =>
|
|
70
|
+
* makeWrapperElement(<MyWidget />)`).
|
|
71
|
+
*
|
|
72
|
+
* Each call captures the supplied `child` in a closure and tracks the
|
|
73
|
+
* mounted DOM node so the React unmount fires when atrium drops the
|
|
74
|
+
* wrapper (route swap, tab change). The closure-style tracking is the
|
|
75
|
+
* correct fix for issue #31 — using a module-level WeakMap alone
|
|
76
|
+
* meant a child swap on a reused DOM node could leave the previous
|
|
77
|
+
* child's tree mounted; pinning state to one closure-per-call
|
|
78
|
+
* guarantees one wrapper element owns one mount.
|
|
79
|
+
*
|
|
80
|
+
* Note: the returned object is created via *atrium's* React, not the
|
|
81
|
+
* host bundle's. The two trees only meet at this `<div>`; everything
|
|
82
|
+
* inside the ref callback runs under the host's React. The
|
|
83
|
+
* `ReactElement` cast is safe because atrium's React exposes the
|
|
84
|
+
* same element shape — the type system can't tell them apart, and
|
|
85
|
+
* the registry consumer treats either as opaque.
|
|
86
|
+
*
|
|
87
|
+
* Tabler icons (and other hooks-free SVG components) can be passed
|
|
88
|
+
* directly to `window.React.createElement(...)` — they don't need
|
|
89
|
+
* the wrapper. The wrapper is only required for trees that use
|
|
90
|
+
* hooks, context, or anything that depends on the host's React copy. */
|
|
91
|
+
export function makeWrapperElement(child) {
|
|
92
|
+
const AtriumReact = getAtriumReact();
|
|
93
|
+
let mountedNode = null;
|
|
94
|
+
let mountedRoot = null;
|
|
95
|
+
return AtriumReact.createElement('div', {
|
|
96
|
+
ref: (el) => {
|
|
97
|
+
// React calls the ref with `null` on unmount and again with the
|
|
98
|
+
// node on mount. Same node twice in a row is the StrictMode /
|
|
99
|
+
// remount case — skip.
|
|
100
|
+
if (el === mountedNode)
|
|
101
|
+
return;
|
|
102
|
+
if (mountedRoot && mountedNode) {
|
|
103
|
+
mountedRoot.unmount();
|
|
104
|
+
mountedRoot = null;
|
|
105
|
+
}
|
|
106
|
+
mountedNode = el;
|
|
107
|
+
if (el) {
|
|
108
|
+
mountedRoot = createRoot(el);
|
|
109
|
+
mountedRoot.render(child);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,wCAAwC;AA0BxC,OAAO,EAAE,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAEzD,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AA4BtC;;;0DAG0D;AAC1D,SAAS,cAAc;IACrB,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CACb,mEAAmE;YACjE,+CAA+C,CAClD,CAAC;IACJ,CAAC;IACD,MAAM,CAAC,GAAI,MAAmD,CAAC,KAAK,CAAC;IACrE,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,aAAa,KAAK,UAAU,EAAE,CAAC;QAChD,MAAM,IAAI,KAAK,CACb,kEAAkE;YAChE,kEAAkE;YAClE,gEAAgE;YAChE,gEAAgE,CACnE,CAAC;IACJ,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC;AAOD,qEAAqE;AACrE,qEAAqE;AACrE,yBAAyB;AACzB,MAAM,OAAO,GAAG,IAAI,OAAO,EAA2B,CAAC;AAEvD;;;;;;;;;;;;;;;qEAeqE;AACrE,MAAM,UAAU,WAAW,CAAC,EAAe,EAAE,KAAmB;IAC9D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACjC,IAAI,QAAQ,EAAE,CAAC;QACb,IAAI,QAAQ,CAAC,KAAK,KAAK,KAAK;YAAE,OAAO;QACrC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC5B,QAAQ,CAAC,KAAK,GAAG,KAAK,CAAC;QACvB,OAAO;IACT,CAAC;IACD,MAAM,IAAI,GAAG,UAAU,CAAC,EAAE,CAAC,CAAC;IAC5B,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACnB,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AACnC,CAAC;AAED;;;2DAG2D;AAC3D,MAAM,UAAU,aAAa,CAAC,EAAe;IAC3C,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACjC,IAAI,CAAC,QAAQ;QAAE,OAAO;IACtB,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;IACxB,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AACrB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;yEAuByE;AACzE,MAAM,UAAU,kBAAkB,CAAC,KAAmB;IACpD,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,IAAI,WAAW,GAAuB,IAAI,CAAC;IAC3C,IAAI,WAAW,GAAgB,IAAI,CAAC;IAEpC,OAAO,WAAW,CAAC,aAAa,CAAC,KAAK,EAAE;QACtC,GAAG,EAAE,CAAC,EAAsB,EAAE,EAAE;YAC9B,gEAAgE;YAChE,8DAA8D;YAC9D,uBAAuB;YACvB,IAAI,EAAE,KAAK,WAAW;gBAAE,OAAO;YAC/B,IAAI,WAAW,IAAI,WAAW,EAAE,CAAC;gBAC/B,WAAW,CAAC,OAAO,EAAE,CAAC;gBACtB,WAAW,GAAG,IAAI,CAAC;YACrB,CAAC;YACD,WAAW,GAAG,EAAE,CAAC;YACjB,IAAI,EAAE,EAAE,CAAC;gBACP,WAAW,GAAG,UAAU,CAAC,EAAE,CAAC,CAAC;gBAC7B,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAC5B,CAAC;QACH,CAAC;KACF,CAAiB,CAAC;AACrB,CAAC"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hooks + provider for atrium host bundles.
|
|
3
|
+
*
|
|
4
|
+
* One TanStack Query subscription serves the whole host tree:
|
|
5
|
+
* `useMe()` fetches `/users/me/context` once and shares the result
|
|
6
|
+
* with every `usePerm()`, `useRole()`, and `useUserContext()` caller.
|
|
7
|
+
* The hooks read the API base URL from `<AtriumProvider>` (default
|
|
8
|
+
* `'/api'`) so a host serving its bundle on a different prefix can
|
|
9
|
+
* point them at the right origin without forking the package.
|
|
10
|
+
*
|
|
11
|
+
* The hooks reuse the host's enclosing `<QueryClientProvider>` if one
|
|
12
|
+
* is already mounted; pass `client={hostQueryClient}` to
|
|
13
|
+
* `<AtriumProvider>` only if you want this provider to wrap the
|
|
14
|
+
* QueryClient too. Two QueryClients (atrium's + the host's) is the
|
|
15
|
+
* intended state — atrium clears its cache on logout via `qc.clear()`,
|
|
16
|
+
* and a shared client would lose host queries the user still wants.
|
|
17
|
+
*/
|
|
18
|
+
import { type ReactNode } from 'react';
|
|
19
|
+
import { type QueryClient, type UseQueryResult } from '@tanstack/react-query';
|
|
20
|
+
import type { UserContext } from '@brendanbank/atrium-host-types';
|
|
21
|
+
export type { UserContext } from '@brendanbank/atrium-host-types';
|
|
22
|
+
export { __atrium_t__ } from '../i18n';
|
|
23
|
+
interface AtriumContextValue {
|
|
24
|
+
apiBase: string;
|
|
25
|
+
fetchUserContext: () => Promise<UserContext | null>;
|
|
26
|
+
}
|
|
27
|
+
export interface AtriumProviderProps {
|
|
28
|
+
/** API base URL the hooks prefix on every fetch. Default `'/api'` —
|
|
29
|
+
* matches atrium's convention of mounting the API behind `/api/*`
|
|
30
|
+
* on the same origin as the SPA. */
|
|
31
|
+
apiBase?: string;
|
|
32
|
+
/** Optional TanStack QueryClient. When supplied the provider wraps
|
|
33
|
+
* children in a `<QueryClientProvider>`; otherwise the hooks
|
|
34
|
+
* inherit the caller's enclosing one. Use this only when the host
|
|
35
|
+
* hasn't already set up its own provider. */
|
|
36
|
+
client?: QueryClient;
|
|
37
|
+
/** Override the fetcher for `/users/me/context`. Useful for tests
|
|
38
|
+
* and for hosts that want to layer an axios interceptor or extra
|
|
39
|
+
* headers on top. The default uses `fetch` with `credentials:
|
|
40
|
+
* 'include'`. */
|
|
41
|
+
fetchUserContext?: () => Promise<UserContext | null>;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
}
|
|
44
|
+
/** Provider that supplies `apiBase` and (optionally) a QueryClient
|
|
45
|
+
* to the atrium hooks below. Hosts that already wrap their tree in
|
|
46
|
+
* `<QueryClientProvider>` can omit `client` — the hooks use the
|
|
47
|
+
* caller's existing QueryClient. */
|
|
48
|
+
export declare function AtriumProvider({ apiBase, client, fetchUserContext, children, }: AtriumProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<AtriumContextValue | null>> | import("react").FunctionComponentElement<import("@tanstack/react-query").QueryClientProviderProps>;
|
|
49
|
+
/** Stable cache key for the `/users/me/context` query. Exported so
|
|
50
|
+
* hosts can `queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY })`
|
|
51
|
+
* after a flow that changed the user's roles or permissions. */
|
|
52
|
+
export declare const ME_QUERY_KEY: readonly ["atrium", "me"];
|
|
53
|
+
/** Fetch the signed-in user's RBAC context. Returns the standard
|
|
54
|
+
* TanStack Query result so callers can surface loading and error
|
|
55
|
+
* states — `data` is `null` when the user is not signed in (atrium
|
|
56
|
+
* responded 401/403) and the typed `UserContext` otherwise.
|
|
57
|
+
*
|
|
58
|
+
* Cache is shared with `usePerm` / `useRole` / `useUserContext` —
|
|
59
|
+
* one network request per host tree. Stale time is 60s, matching
|
|
60
|
+
* atrium's own `useMe` so the host's cache invalidation rhythm
|
|
61
|
+
* doesn't fight atrium's. */
|
|
62
|
+
export declare function useMe(): UseQueryResult<UserContext | null>;
|
|
63
|
+
/** Alias for `useMe()` — present because issue #40 documents both
|
|
64
|
+
* names. The shape is identical; pick whichever reads better at the
|
|
65
|
+
* call site. */
|
|
66
|
+
export declare function useUserContext(): UseQueryResult<UserContext | null>;
|
|
67
|
+
/** Returns a stable predicate `(code) => boolean` over the signed-in
|
|
68
|
+
* user's permissions. While `useMe` is still loading or if the user
|
|
69
|
+
* is signed out, the predicate returns `false` for every code — so
|
|
70
|
+
* callers default to "not allowed" which is the safe fallback for
|
|
71
|
+
* RBAC gating.
|
|
72
|
+
*
|
|
73
|
+
* Returning a function (not a boolean) is deliberate: a single
|
|
74
|
+
* call site can check several codes against one query subscription,
|
|
75
|
+
* matching the pattern in issue #40:
|
|
76
|
+
*
|
|
77
|
+
* ```tsx
|
|
78
|
+
* const hasPerm = usePerm();
|
|
79
|
+
* if (hasPerm('commission.view.all')) return <Admin />;
|
|
80
|
+
* if (hasPerm('commission.view.own')) return <SelfCommissions />;
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare function usePerm(): (code: string) => boolean;
|
|
84
|
+
/** Convenience: does the signed-in user hold the named role? Returns
|
|
85
|
+
* `false` while loading and when signed out, mirroring `usePerm`. */
|
|
86
|
+
export declare function useRole(code: string): boolean;
|
|
87
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAElE,YAAY,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC,UAAU,kBAAkB;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CACrD;AAqBD,MAAM,WAAW,mBAAmB;IAClC;;yCAEqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;kDAG8C;IAC9C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;sBAGkB;IAClB,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACrD,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;qCAGqC;AACrC,wBAAgB,cAAc,CAAC,EAC7B,OAA0B,EAC1B,MAAM,EACN,gBAAgB,EAChB,QAAQ,GACT,EAAE,mBAAmB,2MAYrB;AAED;;iEAEiE;AACjE,eAAO,MAAM,YAAY,2BAA4B,CAAC;AAsBtD;;;;;;;;8BAQ8B;AAC9B,wBAAgB,KAAK,IAAI,cAAc,CAAC,WAAW,GAAG,IAAI,CAAC,CAQ1D;AAED;;iBAEiB;AACjB,wBAAgB,cAAc,IAAI,cAAc,CAAC,WAAW,GAAG,IAAI,CAAC,CAEnE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,OAAO,IAAI,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAMnD;AAED;sEACsE;AACtE,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAG7C"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Copyright (c) 2026 Brendan Bank
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
/**
|
|
4
|
+
* React hooks + provider for atrium host bundles.
|
|
5
|
+
*
|
|
6
|
+
* One TanStack Query subscription serves the whole host tree:
|
|
7
|
+
* `useMe()` fetches `/users/me/context` once and shares the result
|
|
8
|
+
* with every `usePerm()`, `useRole()`, and `useUserContext()` caller.
|
|
9
|
+
* The hooks read the API base URL from `<AtriumProvider>` (default
|
|
10
|
+
* `'/api'`) so a host serving its bundle on a different prefix can
|
|
11
|
+
* point them at the right origin without forking the package.
|
|
12
|
+
*
|
|
13
|
+
* The hooks reuse the host's enclosing `<QueryClientProvider>` if one
|
|
14
|
+
* is already mounted; pass `client={hostQueryClient}` to
|
|
15
|
+
* `<AtriumProvider>` only if you want this provider to wrap the
|
|
16
|
+
* QueryClient too. Two QueryClients (atrium's + the host's) is the
|
|
17
|
+
* intended state — atrium clears its cache on logout via `qc.clear()`,
|
|
18
|
+
* and a shared client would lose host queries the user still wants.
|
|
19
|
+
*/
|
|
20
|
+
import { createContext, createElement, useCallback, useContext, useMemo, } from 'react';
|
|
21
|
+
import { QueryClientProvider, useQuery, } from '@tanstack/react-query';
|
|
22
|
+
export { __atrium_t__ } from '../i18n';
|
|
23
|
+
const DEFAULT_API_BASE = '/api';
|
|
24
|
+
async function defaultFetchUserContext(apiBase) {
|
|
25
|
+
const res = await fetch(`${apiBase}/users/me/context`, {
|
|
26
|
+
credentials: 'include',
|
|
27
|
+
headers: { Accept: 'application/json' },
|
|
28
|
+
});
|
|
29
|
+
if (res.status === 401 || res.status === 403)
|
|
30
|
+
return null;
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const body = await res.text().catch(() => '');
|
|
33
|
+
throw new Error(`/users/me/context: ${res.status} ${body}`.trim());
|
|
34
|
+
}
|
|
35
|
+
return (await res.json());
|
|
36
|
+
}
|
|
37
|
+
const AtriumContext = createContext(null);
|
|
38
|
+
/** Provider that supplies `apiBase` and (optionally) a QueryClient
|
|
39
|
+
* to the atrium hooks below. Hosts that already wrap their tree in
|
|
40
|
+
* `<QueryClientProvider>` can omit `client` — the hooks use the
|
|
41
|
+
* caller's existing QueryClient. */
|
|
42
|
+
export function AtriumProvider({ apiBase = DEFAULT_API_BASE, client, fetchUserContext, children, }) {
|
|
43
|
+
const value = useMemo(() => {
|
|
44
|
+
const fetcher = fetchUserContext ?? (() => defaultFetchUserContext(apiBase));
|
|
45
|
+
return { apiBase, fetchUserContext: fetcher };
|
|
46
|
+
}, [apiBase, fetchUserContext]);
|
|
47
|
+
const inner = createElement(AtriumContext.Provider, { value }, children);
|
|
48
|
+
if (client) {
|
|
49
|
+
return createElement(QueryClientProvider, { client }, inner);
|
|
50
|
+
}
|
|
51
|
+
return inner;
|
|
52
|
+
}
|
|
53
|
+
/** Stable cache key for the `/users/me/context` query. Exported so
|
|
54
|
+
* hosts can `queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY })`
|
|
55
|
+
* after a flow that changed the user's roles or permissions. */
|
|
56
|
+
export const ME_QUERY_KEY = ['atrium', 'me'];
|
|
57
|
+
// Module-level fallback so hooks work without an explicit
|
|
58
|
+
// `<AtriumProvider>` — convenient for hosts that don't need to
|
|
59
|
+
// override the API base or the fetcher. Lazily-allocated so the
|
|
60
|
+
// import has no side effects.
|
|
61
|
+
let DEFAULT_CTX = null;
|
|
62
|
+
function defaultCtx() {
|
|
63
|
+
if (!DEFAULT_CTX) {
|
|
64
|
+
DEFAULT_CTX = {
|
|
65
|
+
apiBase: DEFAULT_API_BASE,
|
|
66
|
+
fetchUserContext: () => defaultFetchUserContext(DEFAULT_API_BASE),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return DEFAULT_CTX;
|
|
70
|
+
}
|
|
71
|
+
function useAtriumContextOrDefault() {
|
|
72
|
+
const ctx = useContext(AtriumContext);
|
|
73
|
+
return ctx ?? defaultCtx();
|
|
74
|
+
}
|
|
75
|
+
/** Fetch the signed-in user's RBAC context. Returns the standard
|
|
76
|
+
* TanStack Query result so callers can surface loading and error
|
|
77
|
+
* states — `data` is `null` when the user is not signed in (atrium
|
|
78
|
+
* responded 401/403) and the typed `UserContext` otherwise.
|
|
79
|
+
*
|
|
80
|
+
* Cache is shared with `usePerm` / `useRole` / `useUserContext` —
|
|
81
|
+
* one network request per host tree. Stale time is 60s, matching
|
|
82
|
+
* atrium's own `useMe` so the host's cache invalidation rhythm
|
|
83
|
+
* doesn't fight atrium's. */
|
|
84
|
+
export function useMe() {
|
|
85
|
+
const { fetchUserContext } = useAtriumContextOrDefault();
|
|
86
|
+
return useQuery({
|
|
87
|
+
queryKey: ME_QUERY_KEY,
|
|
88
|
+
queryFn: fetchUserContext,
|
|
89
|
+
staleTime: 60_000,
|
|
90
|
+
retry: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Alias for `useMe()` — present because issue #40 documents both
|
|
94
|
+
* names. The shape is identical; pick whichever reads better at the
|
|
95
|
+
* call site. */
|
|
96
|
+
export function useUserContext() {
|
|
97
|
+
return useMe();
|
|
98
|
+
}
|
|
99
|
+
/** Returns a stable predicate `(code) => boolean` over the signed-in
|
|
100
|
+
* user's permissions. While `useMe` is still loading or if the user
|
|
101
|
+
* is signed out, the predicate returns `false` for every code — so
|
|
102
|
+
* callers default to "not allowed" which is the safe fallback for
|
|
103
|
+
* RBAC gating.
|
|
104
|
+
*
|
|
105
|
+
* Returning a function (not a boolean) is deliberate: a single
|
|
106
|
+
* call site can check several codes against one query subscription,
|
|
107
|
+
* matching the pattern in issue #40:
|
|
108
|
+
*
|
|
109
|
+
* ```tsx
|
|
110
|
+
* const hasPerm = usePerm();
|
|
111
|
+
* if (hasPerm('commission.view.all')) return <Admin />;
|
|
112
|
+
* if (hasPerm('commission.view.own')) return <SelfCommissions />;
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function usePerm() {
|
|
116
|
+
const { data: me } = useMe();
|
|
117
|
+
return useCallback((code) => (me?.permissions ?? []).includes(code), [me?.permissions]);
|
|
118
|
+
}
|
|
119
|
+
/** Convenience: does the signed-in user hold the named role? Returns
|
|
120
|
+
* `false` while loading and when signed out, mirroring `usePerm`. */
|
|
121
|
+
export function useRole(code) {
|
|
122
|
+
const { data: me } = useMe();
|
|
123
|
+
return (me?.roles ?? []).includes(code);
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface HostBundleConfigOptions {
|
|
2
|
+
/** Path to the host bundle's entry. Resolved against the directory
|
|
3
|
+
* containing the `vite.config.ts` that calls this factory. */
|
|
4
|
+
entry: string;
|
|
5
|
+
/** Output filename for the built bundle. Default `'main.js'` —
|
|
6
|
+
* matches atrium's documented `system.host_bundle_url` of
|
|
7
|
+
* `/host/main.js`. */
|
|
8
|
+
fileName?: string;
|
|
9
|
+
/** Output directory inside the host's frontend project. Default
|
|
10
|
+
* `'dist'` — matches Vite's default and the `COPY --from=frontend-builder
|
|
11
|
+
* /app/dist /opt/atrium/static/host` Dockerfile pattern. */
|
|
12
|
+
outDir?: string;
|
|
13
|
+
/** Include sourcemaps in the build. Default `true` — they are tiny
|
|
14
|
+
* next to the bundle and make production debugging tractable. */
|
|
15
|
+
sourcemap?: boolean;
|
|
16
|
+
/** Override the JS target. Default `'es2022'`. */
|
|
17
|
+
target?: string;
|
|
18
|
+
/** Extra Vite config to merge on top of the defaults. Plugins are
|
|
19
|
+
* appended; everything else uses Vite's standard merge semantics. */
|
|
20
|
+
extraConfig?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
/** Build a Vite config for an atrium host bundle.
|
|
23
|
+
*
|
|
24
|
+
* Usage in the host's `vite.config.ts`:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { hostBundleConfig } from '@brendanbank/atrium-host-bundle-utils/vite';
|
|
28
|
+
*
|
|
29
|
+
* export default hostBundleConfig({ entry: 'src/main.tsx' });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* The factory is async because it lazy-imports `vite` and the css
|
|
33
|
+
* injection plugin so they can stay optional peer deps.
|
|
34
|
+
*/
|
|
35
|
+
export declare function hostBundleConfig(options: HostBundleConfigOptions): Promise<Record<string, unknown>>;
|
|
36
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/vite/index.ts"],"names":[],"mappings":"AAwCA,MAAM,WAAW,uBAAuB;IACtC;mEAC+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd;;2BAEuB;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;iEAE6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;sEACkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,kDAAkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;0EACsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAiDlC"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright (c) 2026 Brendan Bank
|
|
2
|
+
// SPDX-License-Identifier: BSD-2-Clause
|
|
3
|
+
/**
|
|
4
|
+
* Vite preset for atrium host bundles.
|
|
5
|
+
*
|
|
6
|
+
* Imported only by `vite.config.ts`, never by runtime code, so Vite
|
|
7
|
+
* stays out of the host's production bundle. Vite + the css-injection
|
|
8
|
+
* plugin are declared as optional peer deps; install them only when
|
|
9
|
+
* building (typical: as devDependencies in the host's
|
|
10
|
+
* `frontend/package.json`).
|
|
11
|
+
*
|
|
12
|
+
* The preset returns a complete Vite config that:
|
|
13
|
+
*
|
|
14
|
+
* - Builds in library mode (`build.lib`) emitting a single ES module
|
|
15
|
+
* at `dist/main.js`. Atrium's loader does
|
|
16
|
+
* `import(system.host_bundle_url)` with that URL — no IIFE / UMD
|
|
17
|
+
* needed.
|
|
18
|
+
* - Inlines every imported `.css` via a runtime `<style>` tag
|
|
19
|
+
* (`vite-plugin-css-injected-by-js`). Without this Vite extracts
|
|
20
|
+
* CSS to a sibling `main.css` that atrium's dynamic-import never
|
|
21
|
+
* fetches, and Mantine / FullCalendar / any rich-text editor ships
|
|
22
|
+
* silently unstyled.
|
|
23
|
+
* - Defines `process.env.NODE_ENV` so the externalised React +
|
|
24
|
+
* TanStack Query references resolve in lib-mode builds (Vite's
|
|
25
|
+
* app-mode auto-replaces this; lib-mode does not).
|
|
26
|
+
*
|
|
27
|
+
* Hosts can override anything by passing `extraConfig` (merged into
|
|
28
|
+
* the returned object) — typical use: a host needing a SVG plugin or
|
|
29
|
+
* a dev-only proxy. The defaults match the canonical
|
|
30
|
+
* self-contained-bundle pattern documented in
|
|
31
|
+
* `docs/published-images.md`.
|
|
32
|
+
*/
|
|
33
|
+
import { resolve } from 'node:path';
|
|
34
|
+
/** Build a Vite config for an atrium host bundle.
|
|
35
|
+
*
|
|
36
|
+
* Usage in the host's `vite.config.ts`:
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { hostBundleConfig } from '@brendanbank/atrium-host-bundle-utils/vite';
|
|
40
|
+
*
|
|
41
|
+
* export default hostBundleConfig({ entry: 'src/main.tsx' });
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* The factory is async because it lazy-imports `vite` and the css
|
|
45
|
+
* injection plugin so they can stay optional peer deps.
|
|
46
|
+
*/
|
|
47
|
+
export async function hostBundleConfig(options) {
|
|
48
|
+
const { entry, fileName = 'main.js', outDir = 'dist', sourcemap = true, target = 'es2022', extraConfig = {}, } = options;
|
|
49
|
+
const [vite, cssInjectedByJs] = await Promise.all([
|
|
50
|
+
import('vite'),
|
|
51
|
+
import('vite-plugin-css-injected-by-js'),
|
|
52
|
+
]);
|
|
53
|
+
const mergeConfig = vite.mergeConfig;
|
|
54
|
+
const cssPluginFactory = (cssInjectedByJs.default ??
|
|
55
|
+
cssInjectedByJs);
|
|
56
|
+
// Build the config as a plain object — `defineConfig` is only a
|
|
57
|
+
// typing helper, and its strict `Plugin` typing fights the
|
|
58
|
+
// dynamically-imported CSS plugin. The merged config is what Vite
|
|
59
|
+
// actually consumes at build time.
|
|
60
|
+
const base = {
|
|
61
|
+
// Lib-mode builds don't auto-replace `process.env.NODE_ENV` the
|
|
62
|
+
// way Vite's app-mode builds do, and several deps (React internals,
|
|
63
|
+
// TanStack Query) reference it. Without these defines the bundle
|
|
64
|
+
// throws `ReferenceError: process is not defined` the moment the
|
|
65
|
+
// SPA dynamic-imports it.
|
|
66
|
+
define: {
|
|
67
|
+
'process.env.NODE_ENV': JSON.stringify('production'),
|
|
68
|
+
'process.env': '{}',
|
|
69
|
+
},
|
|
70
|
+
plugins: [cssPluginFactory()],
|
|
71
|
+
build: {
|
|
72
|
+
target,
|
|
73
|
+
lib: {
|
|
74
|
+
entry: resolve(process.cwd(), entry),
|
|
75
|
+
formats: ['es'],
|
|
76
|
+
fileName: () => fileName,
|
|
77
|
+
},
|
|
78
|
+
outDir,
|
|
79
|
+
emptyOutDir: true,
|
|
80
|
+
sourcemap,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return mergeConfig(base, extraConfig);
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/vite/index.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,wCAAwC;AAExC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA6BpC;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,OAAgC;IAEhC,MAAM,EACJ,KAAK,EACL,QAAQ,GAAG,SAAS,EACpB,MAAM,GAAG,MAAM,EACf,SAAS,GAAG,IAAI,EAChB,MAAM,GAAG,QAAQ,EACjB,WAAW,GAAG,EAAE,GACjB,GAAG,OAAO,CAAC;IAEZ,MAAM,CAAC,IAAI,EAAE,eAAe,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAChD,MAAM,CAAC,MAAM,CAAC;QACd,MAAM,CAAC,gCAAgC,CAAC;KACzC,CAAC,CAAC;IAEH,MAAM,WAAW,GAAI,IAA6D,CAAC,WAAW,CAAC;IAC/F,MAAM,gBAAgB,GACpB,CAAE,eAA0D,CAAC,OAAO;QACjE,eAA4C,CAAkB,CAAC;IAEpE,gEAAgE;IAChE,2DAA2D;IAC3D,kEAAkE;IAClE,mCAAmC;IACnC,MAAM,IAAI,GAA4B;QACpC,gEAAgE;QAChE,oEAAoE;QACpE,iEAAiE;QACjE,iEAAiE;QACjE,0BAA0B;QAC1B,MAAM,EAAE;YACN,sBAAsB,EAAE,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC;YACpD,aAAa,EAAE,IAAI;SACpB;QACD,OAAO,EAAE,CAAC,gBAAgB,EAAE,CAAC;QAC7B,KAAK,EAAE;YACL,MAAM;YACN,GAAG,EAAE;gBACH,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,KAAK,CAAC;gBACpC,OAAO,EAAE,CAAC,IAAI,CAAC;gBACf,QAAQ,EAAE,GAAG,EAAE,CAAC,QAAQ;aACzB;YACD,MAAM;YACN,WAAW,EAAE,IAAI;YACjB,SAAS;SACV;KACF,CAAC;IAEF,OAAO,WAAW,CAAC,IAAI,EAAE,WAAW,CAA4B,CAAC;AACnE,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brendanbank/atrium-host-bundle-utils",
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"description": "Runtime helpers, Vite preset, and React hooks for atrium host bundles. Wraps the dual-tree mount pattern, the host-bundle Vite library config, and the /users/me/context hook so a host's main.tsx is ~10 lines of registration calls.",
|
|
5
|
+
"license": "BSD-2-Clause",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./vite": {
|
|
19
|
+
"types": "./dist/vite/index.d.ts",
|
|
20
|
+
"default": "./dist/vite/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./react": {
|
|
23
|
+
"types": "./dist/react/index.d.ts",
|
|
24
|
+
"default": "./dist/react/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@brendanbank/atrium-host-types": "0.15.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@tanstack/react-query": "^5.0.0",
|
|
32
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
33
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
34
|
+
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
|
35
|
+
"vite-plugin-css-injected-by-js": "^3.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"vite": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"vite-plugin-css-injected-by-js": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@tanstack/react-query": "^5.100.5",
|
|
47
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
48
|
+
"@testing-library/react": "^16.3.2",
|
|
49
|
+
"@types/node": "^25.6.0",
|
|
50
|
+
"@types/react": "^19.2.14",
|
|
51
|
+
"@types/react-dom": "^19.2.3",
|
|
52
|
+
"jsdom": "^29.0.2",
|
|
53
|
+
"msw": "^2.7.0",
|
|
54
|
+
"react": "^19.2.5",
|
|
55
|
+
"react-dom": "^19.2.5",
|
|
56
|
+
"typescript": "^6.0.3",
|
|
57
|
+
"vite": "^8.0.10",
|
|
58
|
+
"vite-plugin-css-injected-by-js": "^3.5.2",
|
|
59
|
+
"vitest": "^4.1.5"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "https://github.com/brendanbank/atrium.git",
|
|
67
|
+
"directory": "packages/host-bundle-utils"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsc -p tsconfig.json",
|
|
71
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
72
|
+
"test": "vitest run",
|
|
73
|
+
"test:watch": "vitest",
|
|
74
|
+
"clean": "rm -rf dist"
|
|
75
|
+
}
|
|
76
|
+
}
|