@orthacms/bootstrap-admin 0.0.0-reserve.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @orthacms/bootstrap-admin
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/bootstrap-admin
7
+ ```
@@ -0,0 +1,3 @@
1
+ export { createAdmin } from './lib/createAdmin';
2
+ export type { AdminPlugin, RouteItem, CreateAdminOptions } from './lib/types/adminPlugin';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EACR,WAAW,EACX,SAAS,EACT,kBAAkB,EACrB,MAAM,yBAAyB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createAdmin } from './lib/createAdmin';
@@ -0,0 +1,40 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
2
+ /** Props for {@link AppErrorBoundary}. */
3
+ type AppErrorBoundaryProps = {
4
+ /** The routed application tree being isolated. */
5
+ children: ReactNode;
6
+ };
7
+ type AppErrorBoundaryState = {
8
+ failed: boolean;
9
+ };
10
+ /**
11
+ * The host's last line of defence: catches a render-phase throw from anywhere
12
+ * in the routed tree.
13
+ *
14
+ * Without it a single misbehaving plugin takes the whole product with it. React
15
+ * unmounts the entire tree when a throw reaches the root, so `#root` is left
16
+ * with **zero children** — no sidebar to navigate away with, no message, nothing
17
+ * to focus, and no hint that reloading is the fix. The two realistic causes are
18
+ * a **lazy chunk that never arrives** (a deploy while the tab was open leaves
19
+ * the browser asking for content-hashed files that no longer exist; `Suspense`
20
+ * handles waiting, not failing, so it re-throws) and a page that reads an
21
+ * unexpected shape out of an API response.
22
+ *
23
+ * Deliberately mounted **outside** `BrowserRouter` so a throw from the router
24
+ * itself is caught too, and outside the `Toaster` so notifications survive the
25
+ * failure. It is not a substitute for a narrower boundary: a plugin that can
26
+ * usefully degrade one region should catch there (as `insights-admin` does
27
+ * per-widget, and `identity-admin` does around the auth screens), because a
28
+ * boundary this high can only offer a reload.
29
+ *
30
+ * A class component because React still offers no hook equivalent of
31
+ * `componentDidCatch`, and a render-phase throw is exactly what needs catching.
32
+ */
33
+ export declare class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
34
+ state: AppErrorBoundaryState;
35
+ static getDerivedStateFromError(): AppErrorBoundaryState;
36
+ componentDidCatch(error: Error, info: ErrorInfo): void;
37
+ render(): ReactNode;
38
+ }
39
+ export {};
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/AppErrorBoundary/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EACH,SAAS,EAGT,KAAK,SAAS,EACd,KAAK,SAAS,EACjB,MAAM,OAAO,CAAC;AA8Ff,0CAA0C;AAC1C,KAAK,qBAAqB,GAAG;IACzB,kDAAkD;IAClD,QAAQ,EAAE,SAAS,CAAC;CACvB,CAAC;AAEF,KAAK,qBAAqB,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,gBAAiB,SAAQ,SAAS,CAC3C,qBAAqB,EACrB,qBAAqB,CACxB;IACY,KAAK,EAAE,qBAAqB,CAAqB;IAE1D,MAAM,CAAC,wBAAwB,IAAI,qBAAqB;IAI/C,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI;IAQtD,MAAM,IAAI,SAAS;CAK/B"}
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Component, useEffect, useRef } from 'react';
3
+ import { defineMessages, useIntl } from 'react-intl';
4
+ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Logo } from '@orthacms/design-system';
5
+ /** Intl descriptors for the fallback, co-located with it. */
6
+ const messages = defineMessages({
7
+ title: {
8
+ id: 'app.errorBoundary.title',
9
+ defaultMessage: 'Something went wrong'
10
+ },
11
+ description: {
12
+ id: 'app.errorBoundary.description',
13
+ defaultMessage: 'This page stopped working. Reloading usually fixes it — if it keeps happening, the last thing you did is worth reporting.'
14
+ },
15
+ reload: {
16
+ id: 'app.errorBoundary.reload',
17
+ defaultMessage: 'Reload the page'
18
+ }
19
+ });
20
+ /**
21
+ * The card shown when the routed tree throws.
22
+ *
23
+ * A function component because the boundary itself must be a class, and because
24
+ * the copy has to go through the host's single `IntlProvider` like every other
25
+ * string in the admin.
26
+ *
27
+ * It takes focus on mount. The failure replaces whatever the user was reading
28
+ * with no navigation the browser would report, so without this focus stays on a
29
+ * control that no longer exists and the browser resets it to `<body>`: a
30
+ * keyboard user tabs from the top of a page nobody told them they had reached,
31
+ * and a screen reader keeps reading a buffer of content that has been unmounted.
32
+ * The heading carries `tabIndex={-1}` so it can receive focus without joining
33
+ * the tab order.
34
+ */
35
+ function AppCrashed() {
36
+ const intl = useIntl();
37
+ const headingRef = useRef(null);
38
+ useEffect(() => {
39
+ headingRef.current?.focus();
40
+ }, []);
41
+ return (_jsxs("div", { className: "flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6", children: [_jsx(Logo, {}), _jsxs(Card, { className: "w-full max-w-md", children: [_jsxs(CardHeader, { className: "text-center", children: [_jsx(CardTitle, { asChild: true, children: _jsx("h1", { ref: headingRef, tabIndex: -1,
42
+ // Programmatic focus on a heading is `:focus-visible`
43
+ // in Chrome, which draws a box around the page title
44
+ // as if it were something to interact with. Safe to
45
+ // suppress only because `tabIndex={-1}` keeps it out
46
+ // of the tab order, so nobody can navigate onto it
47
+ // and need the indicator.
48
+ className: "text-xl font-semibold outline-none", children: intl.formatMessage(messages.title) }) }), _jsx(CardDescription, { children: intl.formatMessage(messages.description) })] }), _jsx(CardContent, { children: _jsx(Button, { type: "button", className: "w-full",
49
+ // A failed lazy chunk is cached as failed by the module
50
+ // registry, and a render that threw on bad state will
51
+ // throw again — re-rendering cannot recover either, so
52
+ // only a fresh document request is offered.
53
+ onClick: () => window.location.reload(), children: intl.formatMessage(messages.reload) }) })] })] }));
54
+ }
55
+ /**
56
+ * The host's last line of defence: catches a render-phase throw from anywhere
57
+ * in the routed tree.
58
+ *
59
+ * Without it a single misbehaving plugin takes the whole product with it. React
60
+ * unmounts the entire tree when a throw reaches the root, so `#root` is left
61
+ * with **zero children** — no sidebar to navigate away with, no message, nothing
62
+ * to focus, and no hint that reloading is the fix. The two realistic causes are
63
+ * a **lazy chunk that never arrives** (a deploy while the tab was open leaves
64
+ * the browser asking for content-hashed files that no longer exist; `Suspense`
65
+ * handles waiting, not failing, so it re-throws) and a page that reads an
66
+ * unexpected shape out of an API response.
67
+ *
68
+ * Deliberately mounted **outside** `BrowserRouter` so a throw from the router
69
+ * itself is caught too, and outside the `Toaster` so notifications survive the
70
+ * failure. It is not a substitute for a narrower boundary: a plugin that can
71
+ * usefully degrade one region should catch there (as `insights-admin` does
72
+ * per-widget, and `identity-admin` does around the auth screens), because a
73
+ * boundary this high can only offer a reload.
74
+ *
75
+ * A class component because React still offers no hook equivalent of
76
+ * `componentDidCatch`, and a render-phase throw is exactly what needs catching.
77
+ */
78
+ export class AppErrorBoundary extends Component {
79
+ state = { failed: false };
80
+ static getDerivedStateFromError() {
81
+ return { failed: true };
82
+ }
83
+ componentDidCatch(error, info) {
84
+ console.error('[bootstrap-admin] the application tree failed to render', error, info.componentStack);
85
+ }
86
+ render() {
87
+ if (!this.state.failed)
88
+ return this.props.children;
89
+ return _jsx(AppCrashed, {});
90
+ }
91
+ }
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Translates the design system's built-in chrome strings and hands them down
4
+ * through its labels context.
5
+ *
6
+ * The library ships English defaults and no i18n runtime — deliberately, it is
7
+ * consumed outside this app too. That left every dialog's and sheet's close
8
+ * button announcing "Close" in a German session, because localizing it meant
9
+ * passing `closeLabel` at each of twenty-odd call sites and no consumer did
10
+ * (`ORT-159`). Doing it once here means a new dialog is localized by existing,
11
+ * rather than by remembering.
12
+ *
13
+ * A call site that passes its own `closeLabel` still wins — this is the default
14
+ * underneath, not a ceiling.
15
+ */
16
+ export declare function DesignSystemLabels({ children }: {
17
+ children: ReactNode;
18
+ }): import("react").JSX.Element;
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/DesignSystemLabels/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAYhD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAevE"}
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { defineMessages, useIntl } from 'react-intl';
4
+ import { DesignSystemLabelsProvider } from '@orthacms/design-system';
5
+ /** Intl descriptors for the design system's own chrome, co-located here. */
6
+ const messages = defineMessages({
7
+ close: {
8
+ id: 'designSystem.close',
9
+ defaultMessage: 'Close'
10
+ }
11
+ });
12
+ /**
13
+ * Translates the design system's built-in chrome strings and hands them down
14
+ * through its labels context.
15
+ *
16
+ * The library ships English defaults and no i18n runtime — deliberately, it is
17
+ * consumed outside this app too. That left every dialog's and sheet's close
18
+ * button announcing "Close" in a German session, because localizing it meant
19
+ * passing `closeLabel` at each of twenty-odd call sites and no consumer did
20
+ * (`ORT-159`). Doing it once here means a new dialog is localized by existing,
21
+ * rather than by remembering.
22
+ *
23
+ * A call site that passes its own `closeLabel` still wins — this is the default
24
+ * underneath, not a ceiling.
25
+ */
26
+ export function DesignSystemLabels({ children }) {
27
+ const intl = useIntl();
28
+ // Memoized because the context value is an object: a fresh one per render
29
+ // would re-render every dialog and sheet in the tree for nothing.
30
+ const labels = useMemo(() => ({ close: intl.formatMessage(messages.close) }), [intl]);
31
+ return (_jsx(DesignSystemLabelsProvider, { labels: labels, children: children }));
32
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Speaks the name of each new view after a client-side navigation.
3
+ *
4
+ * A single-page app changes the whole view without the browser navigating, and a
5
+ * screen reader is given nothing to report: `<Routes>` swaps the matched
6
+ * element, focus stays on the link that was activated, and the tab title is
7
+ * unchanged. The result is that no page change in the admin is announced at all
8
+ * — the archetypal SPA failure of WCAG `4.1.3 Status Messages`.
9
+ *
10
+ * This mounts one visually-hidden polite live region for the life of the app —
11
+ * present before any message exists, which is the precondition for a live region
12
+ * to be announced at all — and writes the new view's name into it on every
13
+ * pathname change after the first. The first is skipped deliberately: a fresh
14
+ * page load is a real navigation the browser already reports, and announcing it
15
+ * again would double up.
16
+ *
17
+ * **It waits for the heading to actually change.** Naively reading the `<h1>` a
18
+ * moment after the location changes announces the page the user just *left*: the
19
+ * incoming route is a lazy chunk behind a `Suspense` skeleton, so for the first
20
+ * frames of a cold navigation the only heading in the DOM is the outgoing one.
21
+ * Measured on a live stack before this guard existed — `/workspaces` →
22
+ * `/activity` announced "Workspaces" — which is worse than silence, because it
23
+ * tells a screen-reader user they are somewhere they have just left. So the
24
+ * announcer remembers the heading it last saw and polls until it finds a
25
+ * *different* one (a different element, or the same element with different text,
26
+ * which is how a page that fills its own title in asynchronously looks). If the
27
+ * heading never changes within {@link POLL_TIMEOUT_MS} it stays quiet rather
28
+ * than guess — the cost is that two routes sharing one heading text are not
29
+ * announced, which is the right way round.
30
+ *
31
+ * It does **not** move focus. Where focus belongs is a decision only the
32
+ * arriving page can make (identity's auth screens focus their own heading; a
33
+ * page opening an editor may want the first field), and taking it at the host
34
+ * would fight them. The keyboard route past the sidebar is the shell's "Skip to
35
+ * main content" link.
36
+ */
37
+ export declare function RouteAnnouncer(): import("react").JSX.Element;
38
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/RouteAnnouncer/index.tsx"],"names":[],"mappings":"AA2CA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,cAAc,gCAiE7B"}
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useLocation } from 'react-router-dom';
4
+ /**
5
+ * How long to keep looking for the new route's heading, and how often. A
6
+ * private route is a lazy chunk behind a `Suspense` skeleton, so the heading
7
+ * that names it does not exist at the moment the location changes — and for the
8
+ * first ~100ms of a cold navigation the heading still in the DOM is the one
9
+ * belonging to the page being *left*.
10
+ */
11
+ const POLL_INTERVAL_MS = 100;
12
+ const POLL_TIMEOUT_MS = 5_000;
13
+ /**
14
+ * What the announcer last saw, so it can tell "the new route has rendered" from
15
+ * "the old route is still on screen".
16
+ *
17
+ * Module state rather than a `useRef` on purpose: React 19's `StrictMode` mounts
18
+ * every effect twice in development, and a ref is re-created by that remount —
19
+ * so the deliberately silent first render would announce the landing page on the
20
+ * second pass. One host runs per document, so a module-level value has the same
21
+ * lifetime as the app.
22
+ */
23
+ let lastPath = null;
24
+ let lastHeading = null;
25
+ let lastHeadingText = null;
26
+ /**
27
+ * The element that names the view currently rendered, and its text.
28
+ *
29
+ * The `<h1>` is the one label every page in the admin already has and keeps
30
+ * accurate — unlike the document title, which most routes never set (they
31
+ * inherit the host HTML's "Admin"). Scoped to the `<main>` landmark when the
32
+ * layout provides one, so the shell's own chrome cannot be mistaken for the
33
+ * page.
34
+ */
35
+ function readHeading() {
36
+ const scope = document.querySelector('main') ?? document;
37
+ const element = scope.querySelector('h1');
38
+ const text = element?.textContent?.trim();
39
+ return element && text ? { element, text } : null;
40
+ }
41
+ /**
42
+ * Speaks the name of each new view after a client-side navigation.
43
+ *
44
+ * A single-page app changes the whole view without the browser navigating, and a
45
+ * screen reader is given nothing to report: `<Routes>` swaps the matched
46
+ * element, focus stays on the link that was activated, and the tab title is
47
+ * unchanged. The result is that no page change in the admin is announced at all
48
+ * — the archetypal SPA failure of WCAG `4.1.3 Status Messages`.
49
+ *
50
+ * This mounts one visually-hidden polite live region for the life of the app —
51
+ * present before any message exists, which is the precondition for a live region
52
+ * to be announced at all — and writes the new view's name into it on every
53
+ * pathname change after the first. The first is skipped deliberately: a fresh
54
+ * page load is a real navigation the browser already reports, and announcing it
55
+ * again would double up.
56
+ *
57
+ * **It waits for the heading to actually change.** Naively reading the `<h1>` a
58
+ * moment after the location changes announces the page the user just *left*: the
59
+ * incoming route is a lazy chunk behind a `Suspense` skeleton, so for the first
60
+ * frames of a cold navigation the only heading in the DOM is the outgoing one.
61
+ * Measured on a live stack before this guard existed — `/workspaces` →
62
+ * `/activity` announced "Workspaces" — which is worse than silence, because it
63
+ * tells a screen-reader user they are somewhere they have just left. So the
64
+ * announcer remembers the heading it last saw and polls until it finds a
65
+ * *different* one (a different element, or the same element with different text,
66
+ * which is how a page that fills its own title in asynchronously looks). If the
67
+ * heading never changes within {@link POLL_TIMEOUT_MS} it stays quiet rather
68
+ * than guess — the cost is that two routes sharing one heading text are not
69
+ * announced, which is the right way round.
70
+ *
71
+ * It does **not** move focus. Where focus belongs is a decision only the
72
+ * arriving page can make (identity's auth screens focus their own heading; a
73
+ * page opening an editor may want the first field), and taking it at the host
74
+ * would fight them. The keyboard route past the sidebar is the shell's "Skip to
75
+ * main content" link.
76
+ */
77
+ export function RouteAnnouncer() {
78
+ const { pathname } = useLocation();
79
+ const [announcement, setAnnouncement] = useState('');
80
+ useEffect(() => {
81
+ const initial = lastPath === null;
82
+ // The landing page, or a re-render on the same path: nothing changed
83
+ // that the user was not already told about. The landing page's heading
84
+ // is still recorded, so it becomes the baseline the first real
85
+ // navigation has to differ from.
86
+ const samePath = lastPath === pathname;
87
+ lastPath = pathname;
88
+ let cancelled = false;
89
+ let timer = 0;
90
+ const startedAt = Date.now();
91
+ // Cleared first so that going A → B → A announces "A" again: an
92
+ // unchanged live region is never re-read.
93
+ if (!initial && !samePath)
94
+ setAnnouncement('');
95
+ const tick = () => {
96
+ if (cancelled)
97
+ return;
98
+ const heading = readHeading();
99
+ const changed = heading &&
100
+ (heading.element !== lastHeading ||
101
+ heading.text !== lastHeadingText);
102
+ if (heading && (initial || samePath)) {
103
+ // Baseline only: remember what is on screen, say nothing.
104
+ lastHeading = heading.element;
105
+ lastHeadingText = heading.text;
106
+ return;
107
+ }
108
+ if (changed) {
109
+ lastHeading = heading.element;
110
+ lastHeadingText = heading.text;
111
+ setAnnouncement(heading.text);
112
+ return;
113
+ }
114
+ if (Date.now() - startedAt >= POLL_TIMEOUT_MS)
115
+ return;
116
+ timer = window.setTimeout(tick, POLL_INTERVAL_MS);
117
+ };
118
+ timer = window.setTimeout(tick, POLL_INTERVAL_MS);
119
+ return () => {
120
+ cancelled = true;
121
+ window.clearTimeout(timer);
122
+ };
123
+ }, [pathname]);
124
+ return (_jsx("p", { "data-testid": "route-announcer", "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: announcement }));
125
+ }
@@ -0,0 +1,13 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Mounts the app-wide unsaved-changes guard and supplies its confirm dialog.
4
+ *
5
+ * The guard itself is copy-free (it lives in `utils-admin`, which owns no
6
+ * strings); this host binds it to the design-system dialog and the app's single
7
+ * `IntlProvider`, so every form in every plugin gets the same prompt without
8
+ * each one re-implementing it.
9
+ */
10
+ export declare function UnsavedChangesGuard({ children }: {
11
+ children: ReactNode;
12
+ }): import("react").JSX.Element;
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/UnsavedChangesGuard/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAyBvC;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAoBxE"}
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { defineMessages, useIntl } from 'react-intl';
3
+ import { ConfirmDialog } from '@orthacms/design-system';
4
+ import { UnsavedChangesProvider } from '@orthacms/utils-admin';
5
+ const messages = defineMessages({
6
+ title: {
7
+ id: 'app.unsaved.title',
8
+ defaultMessage: 'Discard your unsaved changes?'
9
+ },
10
+ body: {
11
+ id: 'app.unsaved.body',
12
+ defaultMessage: 'You have edits on this page that haven’t been saved. Leaving now loses them.'
13
+ },
14
+ confirm: {
15
+ id: 'app.unsaved.confirm',
16
+ defaultMessage: 'Leave and discard'
17
+ },
18
+ cancel: {
19
+ id: 'app.unsaved.cancel',
20
+ defaultMessage: 'Keep editing'
21
+ }
22
+ });
23
+ /**
24
+ * Mounts the app-wide unsaved-changes guard and supplies its confirm dialog.
25
+ *
26
+ * The guard itself is copy-free (it lives in `utils-admin`, which owns no
27
+ * strings); this host binds it to the design-system dialog and the app's single
28
+ * `IntlProvider`, so every form in every plugin gets the same prompt without
29
+ * each one re-implementing it.
30
+ */
31
+ export function UnsavedChangesGuard({ children }) {
32
+ const intl = useIntl();
33
+ return (_jsx(UnsavedChangesProvider, { dialog: ({ open, onOpenChange, onConfirm }) => (_jsx(ConfirmDialog, { open: open, onOpenChange: onOpenChange, title: intl.formatMessage(messages.title), description: intl.formatMessage(messages.body), confirmLabel: intl.formatMessage(messages.confirm), cancelLabel: intl.formatMessage(messages.cancel), confirmVariant: "destructive", onConfirm: onConfirm })), children: children }));
34
+ }
@@ -0,0 +1,24 @@
1
+ import type { CreateAdminOptions } from '../types/adminPlugin';
2
+ /**
3
+ * Bootstraps the Ortha CMS admin app: mounts the React root, wraps it in
4
+ * the data, i18n, and router providers, and renders the routes contributed by
5
+ * every plugin.
6
+ *
7
+ * Plugins author user-facing strings with `react-intl` (`defineMessages` +
8
+ * `useIntl`), so the host provides a single `IntlProvider`. Messages are
9
+ * resolved from each descriptor's `defaultMessage`; a translation catalogue
10
+ * can be wired in here later without touching plugins.
11
+ *
12
+ * Server state is fetched with TanStack Query, so the host also provides one
13
+ * `QueryClient`. Plugins call `useQuery`/`useMutation` (e.g. identity's
14
+ * `useLoginMutation`) without owning a client of their own.
15
+ *
16
+ * Routes split by `public`: public routes mount as top-level siblings, while
17
+ * every other route mounts under a single pathless parent that renders the
18
+ * `layout` a plugin contributed (or a bare `<Outlet/>`). The host is
19
+ * auth-agnostic — it does not know that `layout` may wrap its children in a
20
+ * gate; the contributing plugin (the shell) owns that. A `public:false` route
21
+ * with no gating `layout` therefore renders ungated.
22
+ */
23
+ export declare function createAdmin(options: CreateAdminOptions): void;
24
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/createAdmin/index.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAe,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AA8J5E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CA6G7D"}
@@ -0,0 +1,209 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { StrictMode } from 'react';
3
+ import * as ReactDOM from 'react-dom/client';
4
+ import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
5
+ import { IntlProvider } from 'react-intl';
6
+ import { QueryClientProvider } from '@tanstack/react-query';
7
+ import { queryClient } from '@orthacms/utils-admin';
8
+ import { AppearanceProvider, TooltipProvider, Toaster } from '@orthacms/design-system';
9
+ import { UnsavedChangesGuard } from '../UnsavedChangesGuard';
10
+ import { AppErrorBoundary } from '../AppErrorBoundary';
11
+ import { RouteAnnouncer } from '../RouteAnnouncer';
12
+ import { DesignSystemLabels } from '../DesignSystemLabels';
13
+ /**
14
+ * Warns when more than one plugin contributes a `layout`.
15
+ *
16
+ * Only the first survives — `find(Boolean)` below — and the loser is whichever
17
+ * plugin happens to be registered later, which is a decision nobody made. The
18
+ * shell's layout is what composes identity's `RequireAuth`, so losing it does
19
+ * not merely change the chrome: every private route renders **ungated**, with
20
+ * the sidebar, the skip link and the `<main>` landmark gone with it. The host
21
+ * cannot pick a winner for the app — it has no way to know which layout was
22
+ * meant — but it can refuse to do it silently.
23
+ */
24
+ /** The locale every descriptor's `defaultMessage` is authored in. */
25
+ const DEFAULT_LOCALE = 'en';
26
+ /**
27
+ * Language subtags whose script is written right-to-left, used when the runtime
28
+ * cannot say.
29
+ *
30
+ * A fallback for `Intl.Locale`'s text-info API, which is recent enough that a
31
+ * browser in the support window may not have it. Deliberately a short list of
32
+ * the languages an admin UI is plausibly translated into rather than every RTL
33
+ * script in Unicode: a wrong `dir` is worse than a missing one, and anything not
34
+ * listed simply gets the correct `ltr` it had before.
35
+ */
36
+ const RTL_LANGUAGES = new Set([
37
+ 'ar',
38
+ 'arc',
39
+ 'ckb',
40
+ 'dv',
41
+ 'fa',
42
+ 'he',
43
+ 'ks',
44
+ 'ps',
45
+ 'sd',
46
+ 'ug',
47
+ 'ur',
48
+ 'yi'
49
+ ]);
50
+ /**
51
+ * The writing direction of `locale`, for `<html dir>`.
52
+ *
53
+ * Nothing was setting it, so every `ml-`/`mr-`/`pl-`/`pr-` in the admin was a
54
+ * physical direction with no chance of mirroring, and a bidi run truncated the
55
+ * wrong end of a string at 320px (WCAG 1.3.2, 1.4.10 — `ORT-86`). Setting the
56
+ * attribute is the host's half and the prerequisite for the rest: a component
57
+ * that wants to mirror has nothing to mirror *against* until the document
58
+ * declares a direction.
59
+ *
60
+ * **This is the UI locale, not the content locale.** A content surface showing
61
+ * an Arabic entry inside a German admin is two directions on one page, and the
62
+ * per-field `dir` the entry editor already sets is what resolves that — the
63
+ * document says which way the *chrome* runs.
64
+ */
65
+ function directionOf(locale) {
66
+ try {
67
+ const info = new Intl.Locale(locale);
68
+ const direction = info.getTextInfo?.().direction ?? info.textInfo?.direction;
69
+ if (direction === 'rtl' || direction === 'ltr')
70
+ return direction;
71
+ const language = new Intl.Locale(locale).language;
72
+ return RTL_LANGUAGES.has(language) ? 'rtl' : 'ltr';
73
+ }
74
+ catch {
75
+ // An unparseable tag is the caller's bug, reported by the `IntlProvider`
76
+ // rather than here. Left-to-right is what the document already said.
77
+ return 'ltr';
78
+ }
79
+ }
80
+ function warnOnLayoutCollision(plugins) {
81
+ const contributors = plugins
82
+ .filter((plugin) => plugin.layout)
83
+ .map((plugin) => plugin.name);
84
+ if (contributors.length < 2)
85
+ return;
86
+ const [winner, ...ignored] = contributors;
87
+ console.warn(`[bootstrap-admin] ${contributors.length} plugins contribute a layout; only the first is mounted. ` +
88
+ `Using "${winner}", ignoring ${ignored.map((name) => `"${name}"`).join(', ')}. ` +
89
+ 'Every non-public route renders inside the winner, so if it is not the app shell they are no longer gated.');
90
+ }
91
+ /**
92
+ * Warns when two routes claim the same `path`.
93
+ *
94
+ * React Router matches by rank, not by declaration, so which element renders at
95
+ * a duplicated path is unspecified; React additionally logs a bare
96
+ * "two children with the same key" that names the path but not the plugins
97
+ * behind it. Naming them here is the difference between a five-minute fix and
98
+ * an afternoon.
99
+ */
100
+ function warnOnRouteCollisions(plugins) {
101
+ const owners = new Map();
102
+ for (const plugin of plugins) {
103
+ for (const route of plugin.routes ?? []) {
104
+ owners.set(route.path, [
105
+ ...(owners.get(route.path) ?? []),
106
+ plugin.name
107
+ ]);
108
+ }
109
+ }
110
+ for (const [path, contributors] of owners) {
111
+ if (contributors.length < 2)
112
+ continue;
113
+ console.warn(`[bootstrap-admin] route "${path}" is contributed by ${contributors
114
+ .map((name) => `"${name}"`)
115
+ .join(', ')}; only one of them will ever render.`);
116
+ }
117
+ }
118
+ /**
119
+ * Reports a missing translation once per message id, per locale.
120
+ *
121
+ * `IntlProvider` logs every `MISSING_TRANSLATION` at `error` level, once per
122
+ * render of every descriptor — 460 of them on a single `/activity` load with
123
+ * `locale: 'de'` — and gives the host no way to downgrade, sample or collect
124
+ * them. The volume is not a style complaint: it buries anything real in the
125
+ * console, which is the only place a developer looks (`ORT-141`).
126
+ *
127
+ * Two rules. A locale that *is* the default has nothing to be missing, so the
128
+ * fallback is the expected path and says nothing at all. Otherwise each id is
129
+ * reported once, at `warn` — a missing catalogue entry is a gap to fill, not a
130
+ * failure of this render — and everything that is not a missing translation is
131
+ * passed through untouched, because those are real formatting errors.
132
+ */
133
+ function makeIntlErrorHandler(locale, defaultLocale) {
134
+ const reported = new Set();
135
+ return (error) => {
136
+ const code = error.code;
137
+ if (code !== 'MISSING_TRANSLATION') {
138
+ console.error(error);
139
+ return;
140
+ }
141
+ if (locale === defaultLocale)
142
+ return;
143
+ const id = error
144
+ .descriptor?.id;
145
+ const key = id ?? error.message;
146
+ if (reported.has(key))
147
+ return;
148
+ reported.add(key);
149
+ console.warn(`[bootstrap-admin] no "${locale}" translation for "${key}"; using the default message.`);
150
+ };
151
+ }
152
+ /**
153
+ * Bootstraps the Ortha CMS admin app: mounts the React root, wraps it in
154
+ * the data, i18n, and router providers, and renders the routes contributed by
155
+ * every plugin.
156
+ *
157
+ * Plugins author user-facing strings with `react-intl` (`defineMessages` +
158
+ * `useIntl`), so the host provides a single `IntlProvider`. Messages are
159
+ * resolved from each descriptor's `defaultMessage`; a translation catalogue
160
+ * can be wired in here later without touching plugins.
161
+ *
162
+ * Server state is fetched with TanStack Query, so the host also provides one
163
+ * `QueryClient`. Plugins call `useQuery`/`useMutation` (e.g. identity's
164
+ * `useLoginMutation`) without owning a client of their own.
165
+ *
166
+ * Routes split by `public`: public routes mount as top-level siblings, while
167
+ * every other route mounts under a single pathless parent that renders the
168
+ * `layout` a plugin contributed (or a bare `<Outlet/>`). The host is
169
+ * auth-agnostic — it does not know that `layout` may wrap its children in a
170
+ * gate; the contributing plugin (the shell) owns that. A `public:false` route
171
+ * with no gating `layout` therefore renders ungated.
172
+ */
173
+ export function createAdmin(options) {
174
+ const { plugins, rootElement = 'root', locale = 'en' } = options;
175
+ // The document's language, which nothing was setting: `apps/admin/index.html`
176
+ // ships `lang="en"` and the host never touched it, so a `locale: 'de'` app
177
+ // was German content announced with an English synthesizer — WCAG 3.1.1
178
+ // Language of Page, and the one part of `ORT-141` that is unambiguously the
179
+ // host's to fix. Set before render so assistive tech sees it with the first
180
+ // paint rather than after a reconciliation.
181
+ document.documentElement.lang = locale;
182
+ document.documentElement.dir = directionOf(locale);
183
+ warnOnLayoutCollision(plugins);
184
+ warnOnRouteCollisions(plugins);
185
+ const routes = plugins.flatMap((plugin) => plugin.routes ?? []);
186
+ const publicRoutes = routes.filter((route) => route.public);
187
+ const privateRoutes = routes.filter((route) => !route.public);
188
+ // Wire every plugin's slot contributions into their target slots before
189
+ // render, so consumers (e.g. the shell toolbar) see all contributed items.
190
+ for (const plugin of plugins) {
191
+ for (const contribution of plugin.slots ?? []) {
192
+ contribution.slot._register(contribution.items);
193
+ }
194
+ }
195
+ // The single layout that wraps every private route; falls back to a bare
196
+ // outlet before any layout plugin (the shell) is registered.
197
+ const layout = plugins.map((plugin) => plugin.layout).find(Boolean) ?? (_jsx(Outlet, {}));
198
+ // Checked rather than cast. `createRoot(null)` throws deep inside React with
199
+ // a message about a "target container", which is true but says nothing about
200
+ // *which* id the host was told to mount into — and the page the developer is
201
+ // looking at is blank either way, so the console is all they have.
202
+ const container = document.getElementById(rootElement);
203
+ if (!container) {
204
+ throw new Error(`[bootstrap-admin] no element with id "${rootElement}" to mount into. ` +
205
+ 'The host HTML must contain it (see `apps/admin/index.html`), or pass a different `rootElement` to createAdmin().');
206
+ }
207
+ const root = ReactDOM.createRoot(container);
208
+ root.render(_jsx(StrictMode, { children: _jsx(AppearanceProvider, { children: _jsx(QueryClientProvider, { client: queryClient, children: _jsx(IntlProvider, { locale: locale, defaultLocale: DEFAULT_LOCALE, onError: makeIntlErrorHandler(locale, DEFAULT_LOCALE), children: _jsx(DesignSystemLabels, { children: _jsxs(TooltipProvider, { delayDuration: 200, children: [_jsx(AppErrorBoundary, { children: _jsxs(BrowserRouter, { children: [_jsx(RouteAnnouncer, {}), _jsx(UnsavedChangesGuard, { children: _jsxs(Routes, { children: [publicRoutes.map((route) => (_jsx(Route, { path: route.path, element: route.element }, route.path))), _jsxs(Route, { element: layout, children: [privateRoutes.map((route) => (_jsx(Route, { path: route.path, element: route.element }, route.path))), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/", replace: true }) })] })] }) })] }) }), _jsx(Toaster, {})] }) }) }) }) }) }));
209
+ }
@@ -0,0 +1,66 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { SlotContribution } from '@orthacms/utils-admin';
3
+ /** A route a plugin mounts into the app router. */
4
+ export type RouteItem = {
5
+ /** Route path (e.g. "/users/*"). */
6
+ path: string;
7
+ /** Element rendered at that path. */
8
+ element: ReactNode;
9
+ /**
10
+ * When `true`, the route mounts as a top-level sibling (e.g. the sign-in
11
+ * page). Omitted/`false` means it mounts under the contributed `layout`.
12
+ * The host attaches no auth meaning to this — whether "under the layout"
13
+ * means "gated" is up to the layout (the shell wraps it in identity's
14
+ * `RequireAuth`).
15
+ */
16
+ public?: boolean;
17
+ };
18
+ /**
19
+ * Contract every admin-side plugin must implement: a name, the routes it
20
+ * contributes, and optionally the layout its non-`public` siblings render
21
+ * inside.
22
+ */
23
+ export type AdminPlugin = {
24
+ /** Unique identifier. */
25
+ name: string;
26
+ /** Routes this plugin contributes. */
27
+ routes?: RouteItem[];
28
+ /**
29
+ * The app shell — chrome that renders an `<Outlet/>`. The host mounts the
30
+ * first plugin-provided `layout` as the single parent of every non-`public`
31
+ * route. The host treats it as opaque; the shell plugin composes its auth
32
+ * provider + gate inside it. Omitted by most plugins.
33
+ */
34
+ layout?: ReactNode;
35
+ /**
36
+ * Slot contributions (e.g. toolbar nav items). The host wires these into
37
+ * their target slots before render and attaches no meaning beyond wiring —
38
+ * the consuming plugin (the shell) defines and reads the slot.
39
+ */
40
+ slots?: SlotContribution[];
41
+ };
42
+ /** Options for {@link createAdmin}. */
43
+ export type CreateAdminOptions = {
44
+ /** Plugins to register. */
45
+ plugins: AdminPlugin[];
46
+ /** DOM element id to mount into. Defaults to "root". */
47
+ rootElement?: string;
48
+ /**
49
+ * Active locale for `react-intl`, and the value written to `<html lang>`.
50
+ * Defaults to `'en'`.
51
+ *
52
+ * **It does not translate anything yet.** The admin ships exactly one
53
+ * catalogue — the `defaultMessage` on each descriptor — and no `messages`
54
+ * are passed to `IntlProvider`, so every string resolves to its English
55
+ * default whatever this is set to. What *does* follow it is `Intl`
56
+ * formatting (dates, numbers, plurals) and the document language, which is
57
+ * why setting it is still better than not.
58
+ *
59
+ * It is typed `string` rather than a union of shipped locales because there
60
+ * is no set of shipped locales to name yet; when catalogues land, this
61
+ * should narrow so the option cannot promise something it does not do
62
+ * (`ORT-141`).
63
+ */
64
+ locale?: string;
65
+ };
66
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/types/adminPlugin/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,mDAAmD;AACnD,MAAM,MAAM,SAAS,GAAG;IACpB,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,OAAO,EAAE,SAAS,CAAC;IACnB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG;IACtB,yBAAyB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAC;CAC9B,CAAC;AAEF,uCAAuC;AACvC,MAAM,MAAM,kBAAkB,GAAG;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;;OAeG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@orthacms/bootstrap-admin",
3
+ "version": "0.0.0-reserve.0",
4
+ "description": "@orthacms/bootstrap-admin — part of Ortha CMS.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/bootstrap/admin",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ortha-source/ortha-cms.git",
10
+ "directory": "packages/bootstrap/admin"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ortha-source/ortha-cms/issues"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@orthacms/design-system": "^0.0.1",
29
+ "@orthacms/utils-admin": "^0.0.1",
30
+ "@tanstack/react-query": "^5.0.0",
31
+ "react-intl": "^7.0.0",
32
+ "tslib": "^2.3.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^19.0.0",
36
+ "react-dom": "^19.0.0",
37
+ "react-router-dom": "^6.0.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }