@pramen/cms-editor 0.0.52 → 0.0.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "description": "Visual block/page editor for @pramen/cms \u2014 a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,6 +11,10 @@
11
11
  "homepage": "https://github.com/netvarec/pramen#readme",
12
12
  "bugs": "https://github.com/netvarec/pramen/issues",
13
13
  "type": "module",
14
+ "exports": {
15
+ "./editor.js": "./dist/editor.js",
16
+ "./editor.css": "./dist/editor.css"
17
+ },
14
18
  "files": [
15
19
  "dist",
16
20
  "src"
package/src/api.ts CHANGED
@@ -36,14 +36,26 @@ export function isTokenExpired(token: string): boolean {
36
36
  }
37
37
 
38
38
  const LS = "pramen.cmsEditor";
39
- export function loadConfig(): Config {
39
+
40
+ /**
41
+ * The session for this page load.
42
+ *
43
+ * A backend DECLARED by the shell (see `mount.ts`) wins over anything stored: the server
44
+ * that served the editor knows where its own API is, so there is nothing for a person to
45
+ * type and get wrong, and a stale url from an earlier deployment can't outlive it. Only
46
+ * the token is the browser's to remember.
47
+ */
48
+ export function loadConfig(declared?: { url: string; tenant: string }): Config {
49
+ let stored: Partial<Config> = {};
40
50
  try {
41
51
  const raw = localStorage.getItem(LS);
42
- if (raw) return JSON.parse(raw) as Config;
52
+ if (raw) stored = JSON.parse(raw) as Partial<Config>;
43
53
  } catch {
44
54
  /* ignore */
45
55
  }
46
- return { baseUrl: "http://localhost:8787", token: "", tenant: "main" };
56
+ const token = typeof stored.token === "string" ? stored.token : "";
57
+ if (declared) return { baseUrl: declared.url, tenant: declared.tenant, token };
58
+ return { baseUrl: stored.baseUrl ?? "http://localhost:8787", tenant: stored.tenant ?? "main", token };
47
59
  }
48
60
  export function saveConfig(cfg: Config): void {
49
61
  localStorage.setItem(LS, JSON.stringify(cfg));
@@ -7,12 +7,19 @@ import { Button, Input } from "@podoba/react";
7
7
  import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
9
9
  import { BRAND, SETUP_TITLE, type BrandConfig } from "./brand";
10
+ import { readBackend, type BackendHost } from "./mount";
10
11
  import { DEFAULT_CAPABILITIES, type CmsCapabilities, type CollectionMeta, type JsonValue } from "./types";
11
12
 
12
13
  declare global {
13
14
  interface Window {
14
- /** Runtime config set by the host's /config.js (see @pramen/cms-editor build). */
15
+ /** Runtime config written by the SHELL that served the editor a server-rendered
16
+ * inline script, not a file anyone hand-edits (see `PramenAdmin.astro` in
17
+ * @pramen/cms-astro, and the dev preview in scripts/build.ts). */
15
18
  PRAMEN_CMS_EDITOR?: {
19
+ /** Which CMS Worker to call, and as which tenant. Declared by the shell because the
20
+ * server already knows — it is what makes the Setup screen a token field and
21
+ * nothing else. `url: ""` means the CMS is on this same origin. */
22
+ backend?: { url?: string; tenant?: string };
16
23
  signInUrl?: string;
17
24
  /** Hide the Pages tab for deployments that use collections only — no block/page
18
25
  * building. The tab is otherwise always shown and lands on an empty list, which
@@ -20,7 +27,7 @@ declare global {
20
27
  hidePages?: boolean;
21
28
  /** Extra top-nav links to companion tools the host serves (e.g. a curation page).
22
29
  * Rendered as plain external `<a>` links after the built-in tabs. */
23
- extraNav?: { label: string; href: string }[];
30
+ extraNav?: { label: string; href: string; target?: "_blank" | "_self" }[];
24
31
  /** The wordmark in the topbar, on the Setup screen, and in the browser tab.
25
32
  *
26
33
  * This editor ships as a package an agency deploys FOR ITS CLIENT, so the default
@@ -32,7 +39,11 @@ declare global {
32
39
  }
33
40
  }
34
41
 
35
- /** External sign-in URL, if the host configured one via /config.js. When set, an
42
+ /** The backend the shell declared, or `undefined` when it declared none (the dev preview's
43
+ * standalone mode). Present ⇒ the Setup screen asks for a token and nothing else. */
44
+ const BACKEND = readBackend(globalThis as BackendHost);
45
+
46
+ /** External sign-in URL, if the host configured one. When set, an
36
47
  * unauthenticated OR expired session is redirected here instead of the built-in Setup
37
48
  * screen — for deployments whose auth (magic-link, SSO, …) lives on a separate page.
38
49
  *
@@ -88,14 +99,16 @@ export function useApp(): AppContextValue {
88
99
  }
89
100
 
90
101
  export function AppProvider({ children }: { children: React.ReactNode }) {
91
- const [cfg, setCfg] = useState<Config>(loadConfig());
102
+ const [cfg, setCfg] = useState<Config>(() => loadConfig(BACKEND));
92
103
  const [me, setMe] = useState<Me | null>(null);
93
104
  const [collections, setCollections] = useState<CollectionMeta[]>([]);
94
105
  const [cms, setCms] = useState<CmsCapabilities>(DEFAULT_CAPABILITIES);
95
106
  const [error, setError] = useState("");
96
- // A usable session = a base URL + a token that is NOT expired. An expired token counts as
97
- // no session: otherwise the editor mounts and every RPC 403s into an error banner.
98
- const authValid = Boolean(cfg.baseUrl && cfg.token) && !isTokenExpired(cfg.token);
107
+ // A usable session = somewhere to call + a token that is NOT expired. An expired token
108
+ // counts as no session: otherwise the editor mounts and every RPC 403s into an error
109
+ // banner. "Somewhere to call" is satisfied by a declared backend even when its url is the
110
+ // empty string, which is the same-origin case and a perfectly good place to call.
111
+ const authValid = Boolean(BACKEND || cfg.baseUrl) && Boolean(cfg.token) && !isTokenExpired(cfg.token);
99
112
  const api = useMemo(() => new Api(cfg, SIGN_IN_URL ? redirectToSignIn : undefined), [cfg]);
100
113
 
101
114
  // Unsaved-changes guard for in-app navigation. A ref, not state: the guard is read at
@@ -171,14 +184,28 @@ function Setup({ cfg, onSave }: { cfg: Config; onSave: (c: Config) => void }) {
171
184
  <h1 className="mb-4 text-display text-fg">
172
185
  {BRAND.name} <span className="text-fg-subtle">{SETUP_TITLE}</span>
173
186
  </h1>
187
+ {/* Two screens, because there are two things a deployment can leave unanswered. When
188
+ the shell declared a backend there is nothing to point at — asking for a URL that
189
+ is already known invites someone to type the mount path instead of the origin and
190
+ get a stream of "non-JSON response" back. */}
174
191
  <p className="mb-6 text-sm text-fg-muted">
175
- Point at your Worker and paste an editor/reviewer JWT. CORS must allow this origin (<code>CORS_ORIGINS</code>).
192
+ {BACKEND ? (
193
+ <>Paste an editor/reviewer JWT to sign in.</>
194
+ ) : (
195
+ <>
196
+ Point at your Worker and paste an editor/reviewer JWT. CORS must allow this origin (<code>CORS_ORIGINS</code>).
197
+ </>
198
+ )}
176
199
  </p>
177
200
  <div className="flex flex-col gap-4">
178
- <Input label="Worker base URL" value={c.baseUrl} onChange={(baseUrl) => setC({ ...c, baseUrl })} placeholder="https://your-worker.workers.dev" />
179
- <Input label="Tenant" value={c.tenant} onChange={(tenant) => setC({ ...c, tenant })} placeholder="main" />
201
+ {BACKEND ? null : (
202
+ <>
203
+ <Input label="Worker base URL" value={c.baseUrl} onChange={(baseUrl) => setC({ ...c, baseUrl })} placeholder="https://your-worker.workers.dev" />
204
+ <Input label="Tenant" value={c.tenant} onChange={(tenant) => setC({ ...c, tenant })} placeholder="main" />
205
+ </>
206
+ )}
180
207
  <Input label="Bearer token (editor or reviewer)" value={c.token} onChange={(token) => setC({ ...c, token })} placeholder="eyJ…" />
181
- <Button className="mt-2 w-full" onPress={() => onSave(c)} isDisabled={!c.baseUrl || !c.token}>
208
+ <Button className="mt-2 w-full" onPress={() => onSave(c)} isDisabled={(!BACKEND && !c.baseUrl) || !c.token}>
182
209
  Connect
183
210
  </Button>
184
211
  </div>
package/src/main.tsx CHANGED
@@ -1,16 +1,18 @@
1
- import { BuzolaProvider } from "@buzola/router";
2
- import { StrictMode } from "react";
1
+ import { BuzolaProvider, Router, createBrowserNavigationAdapter } from "@buzola/router";
2
+ import { StrictMode, useRef } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { pageRegistry, routes } from "virtual:buzola/routes";
5
5
  import { AppProvider } from "./app-context";
6
6
  import { DOCUMENT_TITLE } from "./brand";
7
+ import { isWithinBasePath, readBasePath, scopeToBasePath } from "./mount";
7
8
 
8
- // Styling is podoba: @podoba/tokens/variables.css + the compiled Tailwind (podoba
9
- // preset) are <link>ed by index.html (see scripts/build.ts). No more inline CSS.
9
+ // Styling is podoba: the compiled Tailwind (podoba preset, with @podoba/tokens' variables
10
+ // and the web font inlined) is a single stylesheet the SHELL links — see scripts/build.ts
11
+ // for what is built, and @pramen/cms-astro for the shell that serves it.
10
12
 
11
- // The <title> in index.html is baked at build time, before any host config exists, so it
12
- // can only be the default. Re-apply the configured wordmark once /config.js has been read —
13
- // the static tag stays the pre-hydration fallback.
13
+ // The shell's <title> is written before the app boots, so on a deployment that has
14
+ // configured a wordmark it can only be the default. Re-apply the configured one here; the
15
+ // server-rendered tag stays the pre-hydration fallback.
14
16
  //
15
17
  // `DOCUMENT_TITLE`, not `${BRAND.title} editor`: appending a fixed English noun to the one
16
18
  // string this feature exists to hand over would put a foreign word in a rebranded client's
@@ -18,11 +20,42 @@ import { DOCUMENT_TITLE } from "./brand";
18
20
  document.title = DOCUMENT_TITLE;
19
21
 
20
22
  const el = document.getElementById("app");
23
+ const basePath = readBasePath(el);
24
+
25
+ // The prefix and the URL the shell was served at come from the same place, so a mismatch
26
+ // means the shell and the route that rendered it have drifted. Worth one line: the symptom
27
+ // is otherwise a silent 404 on the host site for every click in the topbar, with an empty
28
+ // console — buzola's own "no route matched" warning is dead here, because `_404.tsx`'s
29
+ // catch-all guarantees a match.
30
+ if (!isWithinBasePath(location.pathname, basePath)) {
31
+ console.warn(`pramen/cms-editor: mounted for "${basePath}" but served at "${location.pathname}" — navigation will leave the editor.`);
32
+ }
33
+
34
+ /** The router, built lazily so it exists only once there is a session to route.
35
+ *
36
+ * `AppProvider` renders the Setup screen (or hands off to sign-in) WITHOUT its children
37
+ * when there is no valid session, and `createBrowserNavigationAdapter()` throws outright on
38
+ * a browser with no Navigation API. Constructing at module scope would turn that into a
39
+ * blank page before `createRoot` ever ran; here it stays what it was — the Setup screen,
40
+ * where a first admin can still paste a JWT.
41
+ */
42
+ function EditorRouter({ basePath }: { basePath: string }) {
43
+ const router = useRef<Router | null>(null);
44
+ router.current ??= new Router({
45
+ routes,
46
+ // Scoped, so a co-hosted editor intercepts navigation within its own mount only.
47
+ adapter: scopeToBasePath(createBrowserNavigationAdapter(), basePath),
48
+ pageRegistry,
49
+ basePath,
50
+ });
51
+ return <BuzolaProvider router={router.current} />;
52
+ }
53
+
21
54
  if (el)
22
55
  createRoot(el).render(
23
56
  <StrictMode>
24
57
  <AppProvider>
25
- <BuzolaProvider routes={routes} pageRegistry={pageRegistry} />
58
+ <EditorRouter basePath={basePath} />
26
59
  </AppProvider>
27
60
  </StrictMode>,
28
61
  );
package/src/mount.ts ADDED
@@ -0,0 +1,203 @@
1
+ // Where the editor is mounted, and which backend it talks to — read off the shell that
2
+ // served it.
3
+ //
4
+ // The SPA's routes are authored from "/" ("/media", "/pages/:pageId", …), so an editor
5
+ // served under a path prefix needs buzola's Router to prepend it. That prefix is NOT
6
+ // something a human configures: the server that ROUTED the request is the same one that
7
+ // renders the shell, so it stamps the prefix onto the mount node and the two can never
8
+ // disagree. `@pramen/cms-astro` injects its admin route and writes this attribute from a
9
+ // single constant; the dev preview server does the same.
10
+ //
11
+ // A `data-*` attribute rather than a global, for the one value that must be right:
12
+ // `dataset` is a string or undefined BY CONSTRUCTION, so there is no non-string value to
13
+ // guard against, and it cannot be set for an element other than the one being mounted.
14
+ //
15
+ // Deliberately free of React and of any runtime buzola import (the adapter types below are
16
+ // type-only, hence erased), so every rule here can be exercised without a DOM.
17
+
18
+ import type { BuzolaNavigateEvent, NavigationAdapter } from "@buzola/router";
19
+
20
+ /** A mount path must be a chain of non-empty path segments rooted at the origin, written
21
+ * only in characters that `URL.pathname` returns UNENCODED.
22
+ *
23
+ * Rooted, and required to say so: buzola prepends the base path to every href it builds, so
24
+ * anything that is not already an absolute path resolves somewhere unintended. A leading
25
+ * slash is NOT added for a value missing one — that convenience is what turns a pasted
26
+ * `https://host/admin` into `/https://host/admin`, which is a real path and would be
27
+ * mounted. `//cdn.example.com` is rejected for the same reason: protocol-relative, and the
28
+ * natural product of `"/" + prefix` where the prefix already carried a slash.
29
+ *
30
+ * The character set is not a style choice. Every comparison against a mount path in this
31
+ * file — and buzola's own `stripBasePath`, which we cannot change — is a raw `startsWith`
32
+ * against a `URL.pathname`, which is percent-ENCODED. Admit a character the parser encodes
33
+ * and the two sides can never match: a mount of `/správa` is compared against
34
+ * `/spr%C3%A1va/...` and EVERY in-prefix url reads as off-prefix. So the admitted set is
35
+ * derived from the parser rather than guessed — `"<>^`{}`, backslash, space and everything
36
+ * non-ASCII all encode, and are refused here. */
37
+ const MOUNT_PATH = /^(?:\/[A-Za-z0-9!$%&'()*+,\-.:;=@[\]_|~]+)+$/;
38
+
39
+ /**
40
+ * Normalize the mount node's declared prefix to buzola's shape: `""` for the origin root,
41
+ * else a leading slash and no trailing one.
42
+ *
43
+ * Nothing declared means the root, which is how the editor has always been served. A
44
+ * declared-but-unusable value is WARNED about and falls back to the root rather than
45
+ * routing every link through a stray prefix — the value is server-generated, so if one
46
+ * ever fails this check something upstream is wrong and a green deploy is the worst place
47
+ * to discover it (`brand.ts` warns for the same reason).
48
+ */
49
+ export function resolveBasePath(raw?: string | null): string {
50
+ const trimmed = (raw ?? "").trim();
51
+ // Trailing slashes come off FIRST, so `MOUNT_PATH` only ever judges the canonical form
52
+ // and a value that is nothing but slashes collapses to the root rather than being warned
53
+ // about as malformed.
54
+ const canonical = trimmed.replace(/\/+$/, "");
55
+ if (canonical === "") return "";
56
+ if (!MOUNT_PATH.test(canonical)) {
57
+ console.warn(`pramen/cms-editor: ignoring unusable mount path ${JSON.stringify(trimmed)} — mounting at the origin root.`);
58
+ return "";
59
+ }
60
+ return canonical;
61
+ }
62
+
63
+ /** Read the prefix the shell stamped onto the mount node. */
64
+ export function readBasePath(el: { dataset: DOMStringMap } | null): string {
65
+ return resolveBasePath(el?.dataset.basePath);
66
+ }
67
+
68
+ /**
69
+ * Whether a pathname is INSIDE a mount prefix — the prefix itself, or something below it.
70
+ *
71
+ * Anchored at a segment boundary on purpose. buzola's own `stripBasePath` is a bare
72
+ * `startsWith`, so with a prefix of `/cms` the host's `/cmsmedia` strips to `media` and
73
+ * renders the editor's Media library at a URL that has nothing to do with the editor.
74
+ */
75
+ export function isWithinBasePath(pathname: string, basePath: string): boolean {
76
+ if (!basePath) return true;
77
+ return pathname === basePath || pathname.startsWith(`${basePath}/`);
78
+ }
79
+
80
+ /**
81
+ * Scope a navigation adapter to one mount prefix.
82
+ *
83
+ * `Router.start()` intercepts every navigation that matches a route, and `_404.tsx`
84
+ * registers the catch-all `/:__notFound+` — so EVERY same-origin path matches. At the
85
+ * origin root that is correct (the editor is the whole origin). Co-hosted under a prefix
86
+ * it is not: a click on the host site's own `/blog` would be cancelled by `event.intercept`
87
+ * and render the editor's "Nothing lives here" while the address bar reads `/blog`.
88
+ *
89
+ * Filtering at the adapter means the router never SEES an off-prefix navigation, so the
90
+ * browser handles it natively — no route table changes, no buzola fork.
91
+ */
92
+ export function scopeToBasePath(inner: NavigationAdapter, basePath: string): NavigationAdapter {
93
+ if (!basePath) return inner;
94
+ type Handler = (event: BuzolaNavigateEvent) => void;
95
+ // Keyed by the caller's handler so `removeEventListener` can find the wrapper it added;
96
+ // without this the router's `start()` teardown would leave the listener attached.
97
+ const wrappers = new Map<Handler, Handler>();
98
+ // Spread, not a hand-written list of the seven methods. Enumerating them forwards
99
+ // correctly today but only fails safe by luck: tsc catches a newly REQUIRED member of
100
+ // `NavigationAdapter`, while an OPTIONAL one is silently dropped — and the next planned
101
+ // buzola bump (past ^0.0.12, for `router.leaveApp`) is exactly where such a member would
102
+ // arrive. Overriding the two listener methods is the whole of what this wrapper does.
103
+ return {
104
+ ...inner,
105
+ addEventListener(type, handler) {
106
+ // Adding the same handler twice would attach two wrappers to the inner adapter while
107
+ // the map kept only the second, so the first could never be removed and would keep
108
+ // feeding a torn-down router. Nothing does this today (`Router.start()` mints a fresh
109
+ // closure per call), which is precisely why it should be closed while it is free.
110
+ if (wrappers.has(handler)) return;
111
+ const wrapper: Handler = (event) => {
112
+ // A malformed destination is not ours to claim.
113
+ let pathname: string;
114
+ try {
115
+ pathname = new URL(event.destination.url).pathname;
116
+ } catch {
117
+ return;
118
+ }
119
+ if (isWithinBasePath(pathname, basePath)) handler(event);
120
+ };
121
+ wrappers.set(handler, wrapper);
122
+ inner.addEventListener(type, wrapper);
123
+ },
124
+ removeEventListener(type, handler) {
125
+ const wrapper = wrappers.get(handler);
126
+ if (!wrapper) return;
127
+ wrappers.delete(handler);
128
+ inner.removeEventListener(type, wrapper);
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Whether a host-configured nav link may navigate the CURRENT tab.
135
+ *
136
+ * The default for `extraNav` is a new tab, because `_404.tsx` registers the catch-all
137
+ * `/:__notFound+`: an unmounted editor's router matches every same-origin path, so a
138
+ * same-tab click would land on the in-app 404 instead of the tool. `target: "_self"` asks
139
+ * for the co-hosted case, and is honoured only where it cannot strand the user.
140
+ *
141
+ * Lives here, not in the layout, for two reasons: these are the same containment rules
142
+ * `scopeToBasePath` enforces and they belong beside them, and a predicate that decides
143
+ * whether a click escapes the SPA has to be testable without a DOM. `documentUrl` is a
144
+ * PARAMETER rather than a read of `window.location` for the same reason — and because it
145
+ * is the one input that must not be guessed:
146
+ *
147
+ * - Resolve against `location.origin` and a relative href like `"curate"` is judged as
148
+ * `/curate` while the browser navigates to `<current dir>/curate`. Off-prefix by the
149
+ * check, in-prefix in fact, so the link lands on the very 404 this exists to avoid.
150
+ * `?tab=x` and `#top` are the same mistake with an even wider gap.
151
+ * - So the caller passes the document URL the browser will itself resolve against.
152
+ */
153
+ export function opensInSameTab(href: string, target: string | undefined, basePath: string, documentUrl: string): boolean {
154
+ if (target !== "_self") return false;
155
+ let url: URL;
156
+ let docOrigin: string;
157
+ try {
158
+ url = new URL(href, documentUrl);
159
+ docOrigin = new URL(documentUrl).origin;
160
+ } catch {
161
+ return false;
162
+ }
163
+ // `new URL` happily parses `javascript:` and `data:`, and their `pathname` is an opaque
164
+ // string that trivially fails any containment test — so without this they would take the
165
+ // same-tab branch and shed the `rel` that used to confine them. A url with no hierarchical
166
+ // path cannot be reasoned about as "inside or outside the mount"; the answer is no.
167
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
168
+ // Cross-origin ALWAYS escapes on its own — the catch-all can only claim same-origin paths
169
+ // — so it is safe in the same tab whether or not this editor is mounted. That makes an
170
+ // external tool the one configuration that works at the origin root, which is the opposite
171
+ // of what a bare `basePath` check concludes.
172
+ if (url.origin !== docOrigin) return true;
173
+ // Same origin: safe only where the router will not claim it. At the root `isWithinBasePath`
174
+ // is true for everything, so this correctly refuses every same-origin `_self` there.
175
+ return !isWithinBasePath(url.pathname, basePath);
176
+ }
177
+
178
+ /** The backend the shell declared: which Worker to call, and as which tenant.
179
+ *
180
+ * When present these are NOT read from (or written to) localStorage — the server knows
181
+ * where its own API is, and a stale stored value from an earlier deployment would win
182
+ * forever otherwise. Only the session token is the browser's to remember. */
183
+ export interface DeclaredBackend {
184
+ /** Origin of the CMS Worker. `""` means same-origin. */
185
+ url: string;
186
+ tenant: string;
187
+ }
188
+
189
+ /** What the shell may set under `window.PRAMEN_CMS_EDITOR.backend`. */
190
+ export interface BackendHost {
191
+ PRAMEN_CMS_EDITOR?: { backend?: { url?: unknown; tenant?: unknown } };
192
+ }
193
+
194
+ /** Read the declared backend off a host global, or `undefined` when the shell declared
195
+ * none (the dev preview's standalone mode, where the Setup screen asks for it). */
196
+ export function readBackend(host: BackendHost | undefined): DeclaredBackend | undefined {
197
+ const declared = host?.PRAMEN_CMS_EDITOR?.backend;
198
+ // A url is what makes the declaration meaningful; `""` (same-origin) is a real value, so
199
+ // this tests for the STRING, not for truthiness.
200
+ if (typeof declared?.url !== "string") return undefined;
201
+ const tenant = typeof declared.tenant === "string" && declared.tenant.trim() !== "" ? declared.tenant.trim() : "main";
202
+ return { url: declared.url.replace(/\/+$/, ""), tenant };
203
+ }
@@ -2,11 +2,12 @@
2
2
  // wrapped around every route via <Outlet />. Tab highlighting is derived from the
3
3
  // current path, so a deep link or refresh lands with the right tab lit.
4
4
 
5
- import { Outlet, useNavigate, useRoute } from "@buzola/router";
5
+ import { Outlet, useNavigate, useRoute, useRouter } from "@buzola/router";
6
6
  import { Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
7
7
  import { useEffect, useState } from "react";
8
8
  import { useApp } from "../app-context";
9
9
  import { BRAND } from "../brand";
10
+ import { opensInSameTab } from "../mount";
10
11
 
11
12
  const THEME_KEY = "pramen.cms.theme";
12
13
 
@@ -18,8 +19,11 @@ export default function RootLayout() {
18
19
  // Every chrome action here is a way OUT of the current screen, so it runs through that
19
20
  // screen's unsaved-changes guard first (the page editor registers one; with no guard
20
21
  // registered this is a pass-through). In-app navigation fires no `beforeunload`, so
21
- // without this the topbar silently discards unsaved edits. The external `extraNav` links
22
- // open in a new tab and leave nothing behind, so they stay unguarded.
22
+ // without this the topbar silently discards unsaved edits. An `extraNav` link that opens a
23
+ // NEW tab leaves this document alone and needs no guard; one honoured as `_self` is a real
24
+ // cross-document navigation, so it takes the guard too (below). `beforeunload` is not a
25
+ // fallback for it — only `PageEditor` registers one, so a dirty CollectionEditor form would
26
+ // otherwise be discarded with no prompt of any kind.
23
27
  const guarded = (go: () => void) => () => { if (confirmNavigation()) go(); };
24
28
 
25
29
  // Dark mode: podoba tokens flip under `[data-theme="dark"]` — no `dark:` prefixes.
@@ -47,6 +51,13 @@ export default function RootLayout() {
47
51
  // Collections-only deployments hide the block/page builder entirely.
48
52
  const hidePages = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.hidePages === true : false;
49
53
 
54
+ // See the extraNav comment below. The rules live in `mount.ts` beside the containment they
55
+ // depend on; what this supplies is the URL the BROWSER will resolve a relative href
56
+ // against — the current document, not the origin. Empty when there is no `window`, which
57
+ // makes every href unparseable and so degrades to the safe new-tab default.
58
+ const basePath = useRouter().basePath;
59
+ const documentUrl = typeof window !== "undefined" ? window.location.href : "";
60
+
50
61
  return (
51
62
  // Page-level surface so the whole viewport (not just the topbar + cards) flips
52
63
  // under `[data-theme="dark"]` — otherwise the body stays white in dark mode.
@@ -89,24 +100,21 @@ export default function RootLayout() {
89
100
  Settings
90
101
  </Button>
91
102
  {extraNav.map((l) => (
92
- // Companion tools live OUTSIDE this SPA (a separate static page/worker route), so
93
- // open them in a new tab. NOT a style choice, and `target: "_self"` alone would not
94
- // fix it: `_404.tsx` registers the catch-all `/:__notFound+`, so buzola matches
95
- // EVERY same-origin path and intercepts it — a same-tab click and `location.assign`
96
- // both land on the in-app 404 (verified). Only a cross-origin url escapes on its own.
103
+ // Defaults to a new tab: a companion tool is normally a separate deployment, and
104
+ // `_404.tsx` registers the catch-all `/:__notFound+`, so the router matches every
105
+ // SAME-ORIGIN path a same-tab click would land on the in-app 404 rather than the
106
+ // tool. `target: "_self"` asks for the co-hosted case, and is honoured only where
107
+ // the router provably will not claim the url (`opensInSameTab` in mount.ts):
108
+ //
109
+ // - cross-origin: always, mounted or not. The catch-all cannot reach another
110
+ // origin, so this is the ONE case that also works at the origin root.
111
+ // - same-origin: only outside the mount prefix, where `scopeToBasePath` leaves
112
+ // the navigation to the browser. At the root there is no outside, so never.
97
113
  //
98
- // The supported same-tab route is `router.leaveApp(href)` (@buzola/router >= 0.0.16),
99
- // which releases one navigation to the browser. This package still pins ^0.0.12, so
100
- // adopting it is a version bump plus a `target` option on the config item.
101
- <a
102
- key={l.href}
103
- href={l.href}
104
- target="_blank"
105
- rel="noopener noreferrer"
106
- className="rounded-md px-2.5 py-1.5 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg"
107
- >
108
- {l.label}
109
- </a>
114
+ // Anything else degrades to the new tab rather than stranding the user on a 404.
115
+ // (When @buzola/router moves past ^0.0.12 here, `router.leaveApp(href)` releases
116
+ // one navigation to the browser and makes same-origin `_self` work unmounted too.)
117
+ <NavLink key={`${l.href}|${l.label}`} link={l} sameTab={opensInSameTab(l.href, l.target, basePath, documentUrl)} confirm={confirmNavigation} />
110
118
  ))}
111
119
  </Topbar.Nav>
112
120
  <Topbar.Actions>
@@ -136,3 +144,29 @@ export default function RootLayout() {
136
144
  </div>
137
145
  );
138
146
  }
147
+
148
+ /** One host-configured link to a companion tool.
149
+ *
150
+ * `rel="noreferrer"` is on BOTH branches. `noopener` is genuinely moot in the same tab (no
151
+ * new browsing context is created, so there is no `window.opener` to sever) but `noreferrer`
152
+ * is not: without it a click from `/_pramen/admin/pages/<id>` hands that full url to the
153
+ * destination as `Referer`, and `_self` is honoured for cross-origin destinations.
154
+ *
155
+ * The key pairs href with label, because two entries may legitimately point at the same href
156
+ * and differ only in label or target — keyed on href alone React reconciles them together
157
+ * and the rendered label can end up on the other one's anchor.
158
+ */
159
+ function NavLink({ link, sameTab, confirm }: { link: { label: string; href: string; target?: string }; sameTab: boolean; confirm: () => boolean }) {
160
+ return (
161
+ <a
162
+ href={link.href}
163
+ rel={sameTab ? "noreferrer" : "noopener noreferrer"}
164
+ // Only the same-tab case unloads this document, so only it consults the guard.
165
+ onClick={sameTab ? (e) => { if (!confirm()) e.preventDefault(); } : undefined}
166
+ {...(sameTab ? {} : { target: "_blank" })}
167
+ className="rounded-md px-2.5 py-1.5 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg"
168
+ >
169
+ {link.label}
170
+ </a>
171
+ );
172
+ }
package/dist/config.js DELETED
@@ -1,15 +0,0 @@
1
- // Runtime configuration for the pramen CMS editor. Override this file in your host app —
2
- // no rebuild needed. Every field is optional:
3
- //
4
- // window.PRAMEN_CMS_EDITOR = {
5
- // // Send unauthenticated / expired sessions to your own sign-in page instead of the
6
- // // built-in Setup screen (?setup=1 still forces Setup, to paste a first-admin JWT):
7
- // signInUrl: "/signin/",
8
- // // The wordmark in the topbar, on the Setup screen and in the browser tab. Set this
9
- // // when you deploy the editor for a client — the default says "pramen", which is the
10
- // // framework's name, not theirs. `suffix: null` drops the "· cms" half.
11
- // brand: { name: "Acme", suffix: "cms" },
12
- // hidePages: true, // collections-only deployments
13
- // extraNav: [{ label: "Curation", href: "/curate" }],
14
- // };
15
- window.PRAMEN_CMS_EDITOR = window.PRAMEN_CMS_EDITOR || {};
@@ -1,18 +0,0 @@
1
- # Font binaries
2
-
3
- `@font-face` in [`../fonts.css`](../fonts.css) references the file below by exact
4
- name. Drop the woff2 here and it's bundled and served by any consumer that
5
- imports `@podoba/tokens/fonts.css`.
6
-
7
- NC Fontina is a **variable font** — a single file covers the whole weight axis:
8
-
9
- | File | Axis | Covers |
10
- | ---------------------------- | ------- | ------------------------------------------------- |
11
- | `nc-fontina-variable.woff2` | `wght` | 400 `font-normal` · 500 `font-medium` · 600 `font-semibold` · 700 `font-bold` |
12
-
13
- The `@font-face` declares `font-weight: 100 900` (a range). Narrow it in
14
- `fonts.css` to the font's real `wght` axis if it's smaller (e.g. `400 700`).
15
-
16
- Italics: this upright file only. If NC Fontina has a separate italic file, add a
17
- second `@font-face` with `font-style: italic`; otherwise the browser synthesizes
18
- obliques where `italic` is used (e.g. the rich-text editor).
package/dist/fonts.css DELETED
@@ -1,45 +0,0 @@
1
- /*
2
- * @podoba/tokens/fonts.css — bundled web fonts for the design system.
3
- *
4
- * HAND-AUTHORED (unlike variables.css, which is generated). This file ships the
5
- * actual font binaries via @font-face and points the family-name token at them,
6
- * so any consumer gets the real typeface with a single import:
7
- *
8
- * import "@podoba/tokens/variables.css";
9
- * import "@podoba/tokens/fonts.css"; // ← add this alongside variables.css
10
- *
11
- * NC Fontina is our primary UI sans. The @font-face `src` uses relative URLs, so
12
- * bundlers (Vite/webpack/Next) resolve and fingerprint `./fonts/*.woff2` for you.
13
- * `font-display: swap` renders fallback text immediately and swaps in NC Fontina
14
- * when it loads — no invisible-text flash.
15
- *
16
- * Drop the binary into ./fonts/ as nc-fontina-variable.woff2 (see
17
- * ./fonts/README.md). This is a VARIABLE font: one file covers the whole weight
18
- * axis, so `font-weight` is a RANGE. NC Fontina's wght axis is 200–700 (verified
19
- * via fontTools); the components use font-normal (400), font-medium (500),
20
- * font-semibold (600), font-bold (700) — all inside that range.
21
- */
22
-
23
- @font-face {
24
- font-family: 'NC Fontina';
25
- font-style: normal;
26
- font-weight: 200 700;
27
- font-display: swap;
28
- src: url('./fonts/nc-fontina-variable.woff2') format('woff2');
29
- }
30
-
31
- /*
32
- * Point the family-name token at NC Fontina. This override lives HERE, not in the
33
- * auto-generated variables.css (which would clobber it on the next token sync).
34
- * Because fonts.css is imported after variables.css, this :root rule wins the
35
- * cascade. The fallback stack mirrors the generated --font-sans so metrics stay
36
- * close before NC Fontina loads / if it fails.
37
- *
38
- * font-feature-settings turns on NC Fontina's stylistic sets ss02–ss05 (the
39
- * default letterforms the brand uses: jy/at/g ligatures and the @ alternate).
40
- * It's an inherited property, so declaring it on :root cascades to every element.
41
- */
42
- :root {
43
- --font-sans: 'NC Fontina', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
44
- font-feature-settings: 'ss02' 1, 'ss03' 1, 'ss04' 1, 'ss05' 1;
45
- }
package/dist/index.html DELETED
@@ -1,19 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>pramen · cms editor</title>
7
- <link rel="stylesheet" href="/fonts.css" />
8
- <link rel="stylesheet" href="/app.css" />
9
- <!-- Runtime config, loaded before the app so window.PRAMEN_CMS_EDITOR is set at boot.
10
- A default config.js ships in dist; a host overrides it to set e.g. signInUrl or
11
- the wordmark. The <title> below is the pre-hydration fallback — the app re-applies
12
- the configured brand on boot, since this file is baked before any config exists. -->
13
- <script src="/config.js"></script>
14
- </head>
15
- <body>
16
- <div id="app"></div>
17
- <script type="module" src="/main.a0tgw8hg.js"></script>
18
- </body>
19
- </html>