@pramen/cms-editor 0.0.61 → 0.0.64

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/src/panels.ts ADDED
@@ -0,0 +1,420 @@
1
+ // The panel registry — where a deployment's own React screens are registered, and what
2
+ // they are handed.
3
+ //
4
+ // A panel is a component in a SEPARATE bundle, built by the consuming project, that renders
5
+ // inside this editor's chrome at `/apps/:slug`. The entry itself is a server fact
6
+ // (`adminPanel()` in @pramen/cms): the server owns the slug, label, icon, position and the
7
+ // role filter, and the bundle owns only the component. So this module never decides whether
8
+ // a panel exists — it answers "is there a component for this slug yet, and if not, was one
9
+ // turned away", and nothing more.
10
+ //
11
+ // HOW A BUNDLE GETS LOADED, AND WHY THE EDITOR DOES THE LOADING
12
+ //
13
+ // The obvious shape is for the shell to emit a second `<script type="module">` beside the
14
+ // editor's. It does not work in either order. Placed FIRST, the panel bundle evaluates
15
+ // before the editor has published the React it must share, so its very first import fails.
16
+ // Placed SECOND, it races the editor's own first render, and a deep link to `/apps/:slug`
17
+ // would resolve before the component it needs exists.
18
+ //
19
+ // So the shell declares panel bundle URLs as configuration and the EDITOR imports them,
20
+ // after publishing the runtime and before mounting the router. Ordering stops being a
21
+ // property of script tags and becomes a line of code. It also makes failure containable:
22
+ // a bundle that 404s or throws costs its own panel, and the admin still boots.
23
+ //
24
+ // The loads are NOT awaited before the first paint. Everything else in the chrome already
25
+ // arrives a round trip late (`listAdminPages` is what puts the entry in the nav at all), and
26
+ // blocking `createRoot` on a third-party fetch means one hanging request is a blank admin.
27
+ // Registration therefore notifies subscribers and the panel route re-reads — which is also
28
+ // what makes "still loading" tellable from "the bundle never registered this slug".
29
+ //
30
+ // Deliberately free of React at RUNTIME (the component type below is erased), so every rule
31
+ // here can be exercised without a DOM — the same discipline `mount.ts` keeps.
32
+
33
+ import type { ComponentType } from "react";
34
+ import type { RpcInput } from "./types";
35
+
36
+ /** The authenticated transport a panel is handed.
37
+ *
38
+ * A NARROW view of the editor's `Api`, not the class itself. `Api` carries ~40 typed
39
+ * wrappers for CMS handlers — pages, media, menus, taxonomies — and none of that is a
40
+ * panel's business; handing the object over would make every one of those methods an API to
41
+ * keep. What a panel needs is the ability to call ITS OWN project handlers with the
42
+ * session's credentials attached, plus a way to turn a relative media path into a URL.
43
+ *
44
+ * The session is not exposed and cannot be: no token, no base URL, no tenant. A panel calls
45
+ * through this or it does not call at all, which keeps one answer to "what does an
46
+ * authenticated request from the admin look like".
47
+ */
48
+ export interface PanelApi {
49
+ /** Call a CMS/project RPC handler as the signed-in user. Rejects with the server's error
50
+ * message on a non-`ok` envelope. */
51
+ call<T = unknown>(name: string, input?: RpcInput): Promise<T>;
52
+ /** Absolute URL for a relative media/serving path the backend returned. */
53
+ resolve(path: string): string;
54
+ }
55
+
56
+ /** What a panel component is handed. Four things, and the case for each:
57
+ *
58
+ * - `api` — a panel with no authenticated transport is a static page.
59
+ * - `basePath` — the editor may be mounted under a prefix (`/__admin`), and a panel that
60
+ * builds its own hrefs has no other way to stay inside it. Without this the one thing a
61
+ * panel is FOR — being part of the admin — is the thing it gets wrong.
62
+ * - `theme` — the chrome's light/dark choice. Styling follows automatically (podoba tokens
63
+ * flip on `[data-theme]` at the document root), so this is for the decisions CSS cannot
64
+ * make: a chart's palette, a canvas, an embedded third-party widget.
65
+ * - `setError` — the editor has ONE error surface, the banner in the root layout. A panel
66
+ * that invented a second would put failures in a place the reader has not learned to
67
+ * look, and the two would style differently.
68
+ *
69
+ * What is deliberately NOT here is the identity (`me`). It is one `api.call("me")` away, and
70
+ * a panel that branches on the caller's roles to decide what to show is doing client-side
71
+ * authorization — the gate that counts is `roles` on `adminPanel()`, enforced server-side
72
+ * before the entry is even listed. Making the roles inconvenient to reach is the point.
73
+ */
74
+ export interface PanelProps {
75
+ api: PanelApi;
76
+ basePath: string;
77
+ theme: "light" | "dark";
78
+ setError: (message: string) => void;
79
+ }
80
+
81
+ // --- the runtime contract ----------------------------------------------------------------
82
+ //
83
+ // A panel bundle is COMPILED there and LINKED here. Its JSX calls and its hook usage are
84
+ // fixed at the consumer's build against whichever React they had installed; the React it
85
+ // actually runs on is the one this editor loaded, on a deployment they do not control, at
86
+ // whatever version that deployment is pinned to. Nothing in the loading path notices the
87
+ // difference — the import map resolves, the shims hand over a perfectly good React — so a
88
+ // mismatch surfaces as a missing export or a hook that behaves differently, somewhere inside
89
+ // a stranger's minified bundle, with no message anyone can act on.
90
+ //
91
+ // So a bundle has to SAY which contract it was built against, and be turned away if it is not
92
+ // this one.
93
+
94
+ /**
95
+ * The panel runtime contract this editor implements.
96
+ *
97
+ * WHAT BUMPS IT. Three things, and this is the whole list:
98
+ *
99
+ * 1. **A React major in this package.** A panel's hooks and JSX are compiled against one
100
+ * React's rules and executed against the editor's; going 19 -> 20 here silently moves
101
+ * every panel bundle ever built onto a React it was not written for. This is the rule
102
+ * that gets missed, because a React bump is a line in `package.json` nowhere near this
103
+ * file — hence `PANEL_RUNTIME_REACT_MAJOR` below, which a test pins to the manifest.
104
+ * 2. **A change to what a panel is handed.** A key leaving `PanelProps`, or one changing
105
+ * meaning; the same for `PanelApi`. ADDING a key is not a bump — a panel that does not
106
+ * read it cannot notice.
107
+ * 3. **A change to the published runtime.** A name leaving `panel-runtime.ts`, or the
108
+ * global it is published on being renamed.
109
+ *
110
+ * What does NOT bump it is everything a panel cannot reach: the chrome, the routes, the CMS
111
+ * handlers, podoba's version, this package's own release number. A contract that moved for
112
+ * those would be a release version wearing a check's clothes — every panel refused on an
113
+ * unrelated upgrade, and the only available response would be to edit the literal without
114
+ * rebuilding anything. A check people learn to satisfy blindly is worse than no check.
115
+ */
116
+ export const PANEL_RUNTIME_CONTRACT = 1;
117
+
118
+ /**
119
+ * The React major `PANEL_RUNTIME_CONTRACT` stands for.
120
+ *
121
+ * Written down so rule 1 above has somewhere to be enforced rather than only stated:
122
+ * `test/cms-editor-panels.test.ts` reads the `react` range out of this package's manifest and
123
+ * fails if it has moved past this number. Upgrading React therefore cannot silently leave the
124
+ * contract behind — the bump gets decided, in a red test, by whoever did the upgrade.
125
+ */
126
+ export const PANEL_RUNTIME_REACT_MAJOR = 19;
127
+
128
+ /** What a bundle passes to `registerPanel`: a slug, the contract it was built against, and
129
+ * the component to draw for it.
130
+ *
131
+ * No label, no icon, no position — those travel on the server's `adminPanel()` entry. If the
132
+ * bundle declared them, a bundle that failed to load would take the nav entry with it (so
133
+ * the section would silently cease to exist rather than say what went wrong), and a bundle
134
+ * that loaded would be asserting its own placement with nothing to check it against.
135
+ */
136
+ export interface PanelRegistration {
137
+ /** Must match the `adminPanel()` slug on the server. A slug the server did not register
138
+ * (or did not list for this caller) is simply never routed to. */
139
+ slug: string;
140
+ /**
141
+ * The {@link PANEL_RUNTIME_CONTRACT} this bundle was BUILT against — a literal in the
142
+ * bundle's own source, `contract: 1`.
143
+ *
144
+ * A LITERAL, and it can be nothing else. None of this editor is in a panel bundle at build
145
+ * time: react, react-dom and both JSX runtimes are external and resolve, at runtime, to the
146
+ * copies this editor published. So every value the bundle could look up is this editor's,
147
+ * and a check fed from `globalThis` would be comparing our number to our number and passing
148
+ * for every bundle ever built. The literal is the only fact about the BUILD that survives
149
+ * into the bundle, which is why the published runtime does not carry the number at all —
150
+ * see `panel-runtime.ts`.
151
+ *
152
+ * That makes it the same kind of claim as a `peerDependencies` range: the author asserts it
153
+ * and the host verifies the shape. It can be edited without rebuilding and no check can
154
+ * catch that — but it cannot be edited by ACCIDENT, and it is the one line the refusal
155
+ * message names, right after telling you to rebuild.
156
+ *
157
+ * Required, never defaulted. A default is a value for the bundles that say nothing, and
158
+ * those are exactly the set this exists for: everything built before the field existed
159
+ * would be waved through as current.
160
+ */
161
+ contract: number;
162
+ render: ComponentType<PanelProps>;
163
+ }
164
+
165
+ /** What the registry KEEPS: the slug and the component.
166
+ *
167
+ * A separate type from `PanelRegistration` because the contract is a fact about the CALL, not
168
+ * about the panel — once it has been checked there is nothing left to store, and storing it
169
+ * would leave a field a later reader could imagine still differs from ours. */
170
+ export interface RegisteredPanel {
171
+ slug: string;
172
+ render: ComponentType<PanelProps>;
173
+ }
174
+
175
+ const panels = new Map<string, RegisteredPanel>();
176
+ const listeners = new Set<() => void>();
177
+ /** Bundle imports still in flight. What tells "loading" from "loaded and never registered
178
+ * this slug" — the two look identical from the registry alone, and the second is a
179
+ * diagnostic worth printing rather than a spinner to leave up forever. */
180
+ let inFlight = 0;
181
+ /** Bumped on every registration, every refusal and every change to `inFlight`, and read as
182
+ * the `useSyncExternalStore` snapshot. A number rather than the Map, because a snapshot has to
183
+ * be referentially stable between notifications and a Map that is mutated in place is not. */
184
+ let version = 0;
185
+ /** Why a slug's registration was REFUSED, keyed by slug, so the panel's own route can say it
186
+ * instead of the generic "nothing registered this slug". The bundle IS there and it DID run;
187
+ * a refusal that reached only the console leaves the screen telling the reader something that
188
+ * is true and useless, and points them at the one thing that is not the problem. */
189
+ const refusals = new Map<string, string>();
190
+
191
+ function notify(): void {
192
+ version += 1;
193
+ for (const listener of [...listeners]) listener();
194
+ }
195
+
196
+ /** Turn a registration away.
197
+ *
198
+ * One sentence, two readers: it is rendered by the panel's route for whoever is looking at
199
+ * the admin, and warned for whoever is looking at the build — so it is written to read as
200
+ * prose in both places, with the package prefix added only on the console side. `notify`,
201
+ * because a route already mounted on that slug is showing "Loading…" and has to be told the
202
+ * loading ended in a refusal. */
203
+ function refuse(slug: string, reason: string, warn: (msg: string) => void): void {
204
+ refusals.set(slug, reason);
205
+ warn(`pramen/cms-editor: ${reason}`);
206
+ notify();
207
+ }
208
+
209
+ /**
210
+ * Why a bundle's stated contract is unacceptable, or `undefined` if it is fine.
211
+ *
212
+ * BOTH directions are refusals, because they are different mistakes with different fixes. A
213
+ * bundle BEHIND this editor is stale — built against a React, or a `PanelProps`, that is not
214
+ * what it will be handed. A bundle AHEAD of it is not itself wrong; the deployment is, with a
215
+ * panel and an admin shipped out of step, and rendering it would link it against a runtime
216
+ * missing whatever the newer contract added. Accepting either would trade a message naming
217
+ * the slug for a crash inside someone else's bundle.
218
+ *
219
+ * A separate exported function, and not folded into `registerPanel`, so the sentence a
220
+ * consumer will actually be sent can be asserted on its own — the wording IS the feature
221
+ * here, and a check whose only test is "it refused" pins none of it.
222
+ *
223
+ * `implemented` is a parameter rather than a read of the constant for one reason, and it is
224
+ * not tidiness: at contract 1 there is no legal number BELOW ours, so the stale branch has
225
+ * nothing to be exercised with and would ship as a branch whose only evidence is that it
226
+ * typechecks. It is the same kind of seam as `warn`, and the registry never passes it.
227
+ */
228
+ export function contractRefusal(slug: string, stated: unknown, implemented: number = PANEL_RUNTIME_CONTRACT): string | undefined {
229
+ // `stated` came out of a foreign bundle's object literal, so it is genuinely unknown: `"1"`
230
+ // is what a hand-edited build config produces, `NaN` is what `Number(undefined)` produces,
231
+ // and neither is a version. `Number.isInteger` alone would reject every one of them at
232
+ // RUNTIME — the `typeof` is what narrows the value for the comparisons below, and dropping
233
+ // it as redundant is a type error, not a passing simplification.
234
+ if (typeof stated !== "number" || !Number.isInteger(stated) || stated < 1) {
235
+ return `The '${slug}' panel did not state which panel runtime contract it was built against. Add \`contract: ${implemented}\` to its registerPanel() call and rebuild it against the @pramen/cms-editor this admin serves (React ${PANEL_RUNTIME_REACT_MAJOR}).`;
236
+ }
237
+ if (stated < implemented) {
238
+ return `The '${slug}' panel was built against panel runtime contract ${stated}, and this editor implements ${implemented}. Rebuild the bundle against the @pramen/cms-editor this admin serves (React ${PANEL_RUNTIME_REACT_MAJOR}) and set \`contract: ${implemented}\` in its registerPanel() call.`;
239
+ }
240
+ if (stated > implemented) {
241
+ return `The '${slug}' panel was built against panel runtime contract ${stated}, and this editor implements ${implemented}. Upgrade @pramen/cms-editor — and the shell that serves it — to the release implementing contract ${stated}, or rebuild the panel against this one.`;
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ /**
247
+ * Register a panel component. Called by a panel bundle, through the runtime published on
248
+ * `globalThis` — see `panel-runtime.ts`.
249
+ *
250
+ * THE CONTRACT IS CHECKED HERE, not in the runtime wrapper that publishes this function, and
251
+ * that is a decision rather than a convenience. This is the only chokepoint: the wrapper is
252
+ * one caller of it, and a check living there would leave the registry itself accepting a
253
+ * bundle built for another React — so the invariant would hold on one path and be a comment
254
+ * on every other. It is also the same KIND of judgement as the two refusals beside it (a
255
+ * missing slug, a `render` that is not a component): whether this registration may enter the
256
+ * registry at all. And it is here rather than in the generated shims because a shim resolves
257
+ * one bare specifier and has no registration to inspect — by the time anything knows a
258
+ * contract was stated, it is in this function's argument.
259
+ *
260
+ * A duplicate slug REPLACES rather than throwing. Registration happens inside a dynamic
261
+ * import of code this editor does not own, where a throw is swallowed into the loader's
262
+ * per-bundle catch and shows up as "the panel never registered" — the least informative
263
+ * possible report of "you registered it twice". A warning names both the slug and the fact,
264
+ * and the last registration wins, which is the only rule that makes a dev-time hot reload
265
+ * (re-evaluating the same bundle) behave. A refusal is not a throw for the same reason.
266
+ */
267
+ export function registerPanel(def: PanelRegistration, warn: (msg: string) => void = console.warn): void {
268
+ const slug = typeof def?.slug === "string" ? def.slug.trim() : "";
269
+ if (!slug) {
270
+ // The only refusal with nothing to record it under, and nowhere to render it: without a
271
+ // slug there is no route to put it on. The console is the whole report.
272
+ warn("pramen/cms-editor: ignoring a panel registered with no slug — the slug is what matches it to its adminPanel() entry.");
273
+ return;
274
+ }
275
+ // Before `render`, because the contract is what decides whether the rest of this call means
276
+ // what it appears to: a bundle built for another React may well hand over a function that
277
+ // is not a component this editor can drive.
278
+ const mismatch = contractRefusal(slug, def.contract);
279
+ if (mismatch !== undefined) return refuse(slug, mismatch, warn);
280
+ const rendered = typeof def.render;
281
+ if (rendered !== "function") {
282
+ return refuse(slug, `The '${slug}' panel was ignored: 'render' must be a React component, and this one is of type ${rendered}.`, warn);
283
+ }
284
+ if (panels.has(slug)) warn(`pramen/cms-editor: panel '${slug}' was registered twice — the last registration wins.`);
285
+ // A registration that lands CLEARS the slug's refusal: a dev loop that fixes the contract
286
+ // and re-evaluates the bundle must not leave the old message on the screen.
287
+ refusals.delete(slug);
288
+ panels.set(slug, { slug, render: def.render });
289
+ notify();
290
+ }
291
+
292
+ /** The component registered for a slug, or `undefined`. */
293
+ export function getPanel(slug: string): RegisteredPanel | undefined {
294
+ return panels.get(slug);
295
+ }
296
+
297
+ /** Why this slug's registration was turned away, if it was — read by the panel route, so a
298
+ * refused panel says what happened on the screen it was supposed to be. */
299
+ export function panelRefusal(slug: string): string | undefined {
300
+ return refusals.get(slug);
301
+ }
302
+
303
+ /** Have all declared bundles finished loading (however they finished)? */
304
+ export function panelsSettled(): boolean {
305
+ return inFlight === 0;
306
+ }
307
+
308
+ /** The registry's version — the `useSyncExternalStore` snapshot. */
309
+ export function panelsVersion(): number {
310
+ return version;
311
+ }
312
+
313
+ export function subscribePanels(listener: () => void): () => void {
314
+ listeners.add(listener);
315
+ return () => listeners.delete(listener);
316
+ }
317
+
318
+ /** Drop every registration and every listener. Tests only — the registry is module state,
319
+ * and a test that registered a panel must not leak it into the next one. */
320
+ export function resetPanels(): void {
321
+ panels.clear();
322
+ listeners.clear();
323
+ refusals.clear();
324
+ inFlight = 0;
325
+ version = 0;
326
+ }
327
+
328
+ // --- bundle loading ---------------------------------------------------------------------
329
+
330
+ /** What the shell may set under `window.PRAMEN_CMS_EDITOR.panels`. */
331
+ export interface PanelHost {
332
+ PRAMEN_CMS_EDITOR?: { panels?: unknown };
333
+ }
334
+
335
+ /**
336
+ * Resolve one declared bundle URL, or reject it.
337
+ *
338
+ * These strings are `import()`ed, which is to say EXECUTED, so the check is about what a
339
+ * scheme can do and not about tidiness. `javascript:`, `data:` and `blob:` all parse
340
+ * happily as URLs and all name code with no origin to attribute it to; an http(s) URL is
341
+ * fetched under the page's own CSP and shows up in the network log like every other asset.
342
+ * So the allow-list is the two hierarchical web schemes and nothing else.
343
+ *
344
+ * Resolved against the DOCUMENT rather than the origin, for the reason `opensInSameTab`
345
+ * spells out: a relative specifier is what the browser would itself resolve that way, and
346
+ * resolving against `location.origin` instead would silently load a different file. It also
347
+ * means a fingerprinted asset path emitted by the host's bundler (`/_astro/panel.a1b2.js`)
348
+ * needs no special handling — it is just an absolute path.
349
+ */
350
+ export function panelBundleUrl(raw: unknown, documentUrl: string): string | undefined {
351
+ if (typeof raw !== "string" || raw.trim() === "") return undefined;
352
+ let url: URL;
353
+ try {
354
+ url = new URL(raw.trim(), documentUrl);
355
+ } catch {
356
+ return undefined;
357
+ }
358
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
359
+ return url.href;
360
+ }
361
+
362
+ /**
363
+ * The bundle URLs the shell declared, resolved and filtered.
364
+ *
365
+ * A rejected entry is WARNED about and dropped rather than throwing: the value is
366
+ * server-generated (see `AdminRuntimeConfig` in @pramen/cms-astro), so one that fails this
367
+ * check means something upstream is wrong — and taking the whole admin down over a bad panel
368
+ * URL would be the worst possible place to discover it. `brand.ts` and `mount.ts` fail the
369
+ * same way for the same reason.
370
+ */
371
+ export function readPanelUrls(host: PanelHost | undefined, documentUrl: string, warn: (msg: string) => void = console.warn): string[] {
372
+ const declared = host?.PRAMEN_CMS_EDITOR?.panels;
373
+ if (declared === undefined) return [];
374
+ if (!Array.isArray(declared)) {
375
+ warn("pramen/cms-editor: ignoring `panels` — it must be an array of module URLs.");
376
+ return [];
377
+ }
378
+ const out: string[] = [];
379
+ for (const entry of declared) {
380
+ const url = panelBundleUrl(entry, documentUrl);
381
+ if (url === undefined) warn(`pramen/cms-editor: ignoring unusable panel bundle URL ${JSON.stringify(entry)} — it must be an http(s) URL or a path.`);
382
+ else out.push(url);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ /** How a bundle is fetched. A parameter so the loader can be exercised without a network. */
388
+ export type PanelImporter = (url: string) => Promise<unknown>;
389
+
390
+ const dynamicImport: PanelImporter = (url) => import(/* @vite-ignore */ url);
391
+
392
+ /**
393
+ * Import every declared panel bundle, in parallel.
394
+ *
395
+ * Per-bundle `catch`, not one `try` around the lot: bundles are independent deployments of
396
+ * independent project code, and one that 404s must not take the others' registrations with
397
+ * it. The returned promise settles when all of them have, which is what `panelsSettled`
398
+ * reports — nothing awaits it on the boot path.
399
+ */
400
+ export async function loadPanelBundles(
401
+ urls: readonly string[],
402
+ importer: PanelImporter = dynamicImport,
403
+ warn: (msg: string) => void = console.warn,
404
+ ): Promise<void> {
405
+ if (urls.length === 0) return;
406
+ inFlight += urls.length;
407
+ notify();
408
+ await Promise.all(
409
+ urls.map((url) =>
410
+ importer(url)
411
+ .catch((e: unknown) => {
412
+ warn(`pramen/cms-editor: panel bundle ${url} failed to load — ${String((e as Error)?.message ?? e)}`);
413
+ })
414
+ .finally(() => {
415
+ inFlight -= 1;
416
+ notify();
417
+ }),
418
+ ),
419
+ );
420
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Where a minted preview token is redeemed.
2
+ //
3
+ // A LEAF module, like `chrome.ts`: the page editor and the collection editor both mint
4
+ // links, and the rule for turning a mint into an href has to be one rule.
5
+ //
6
+ // The CMS is headless. `signPagePreview` returns a token plus a relative url that the CMS
7
+ // Worker redeems itself — and that endpoint answers with JSON, because the server has the
8
+ // draft and no idea what it should look like. That is the right default for a machine and
9
+ // the wrong one for the person preview exists for: a stakeholder without an account, sent a
10
+ // link, who opens a wall of braces.
11
+ //
12
+ // So a host may declare where ITS OWN site renders a preview. Same seam as `menuHref` and
13
+ // the sitemap's `pageUrl` — the CMS cannot know how a deployment routes, so the deployment
14
+ // says. Unset, nothing changes: the link still points at the backend.
15
+
16
+ /** What the host may set under `window.PRAMEN_CMS_EDITOR.previewUrl`. */
17
+ interface PreviewHost {
18
+ PRAMEN_CMS_EDITOR?: { previewUrl?: unknown };
19
+ }
20
+
21
+ /** The site's own preview route, if the shell declared a usable one.
22
+ *
23
+ * Anything that is not a non-empty string is ignored rather than coerced — a `previewUrl`
24
+ * of `true` or `0` would otherwise build a link to `"true?token=…"`, which fails as a
25
+ * broken page rather than as a configuration error anyone can see. */
26
+ export function sitePreviewUrl(host: PreviewHost = globalThis as PreviewHost): string | undefined {
27
+ const raw = host.PRAMEN_CMS_EDITOR?.previewUrl;
28
+ if (typeof raw !== "string") return undefined;
29
+ const trimmed = raw.trim();
30
+ return trimmed === "" ? undefined : trimmed;
31
+ }
32
+
33
+ /** Build the href for a minted PAGE preview. ALWAYS ABSOLUTE.
34
+ *
35
+ * `resolve` is the fallback: the backend-relative url the mint returned, made absolute
36
+ * against the CMS origin — exactly what this did before a site route could be declared.
37
+ *
38
+ * Absolute because the result is not only navigated to, it is COPIED and shown: half the
39
+ * reason the button exists is to send the link to someone who has no account. `previewUrl` is
40
+ * normally written as a path (`"/preview"`), so the configured — recommended — path was the
41
+ * one that produced `/preview?token=…` in the clipboard: dead the moment it is pasted into
42
+ * Slack. The new tab hid it, because a blank window opened by this document inherits its base
43
+ * URL and resolves the relative href perfectly well.
44
+ *
45
+ * `origin` is passed rather than read from `location` so this stays a pure function that can
46
+ * be tested; an already-absolute `siteUrl` is left alone by `new URL`. */
47
+ export function pagePreviewHref(
48
+ minted: { url: string; token: string },
49
+ opts: { siteUrl?: string; origin: string; resolve: (path: string) => string },
50
+ ): string {
51
+ const site = opts.siteUrl;
52
+ if (!site) return opts.resolve(minted.url);
53
+ // The declared route may already carry query params (a locale, a layout switch), so the
54
+ // separator is decided rather than assumed — `?` twice is a link that silently drops the
55
+ // token into a parameter name.
56
+ const sep = site.includes("?") ? "&" : "?";
57
+ const href = `${site}${sep}token=${encodeURIComponent(minted.token)}`;
58
+ try {
59
+ return new URL(href, opts.origin).href;
60
+ } catch {
61
+ // An unparseable origin (or a `previewUrl` that is not a URL at all) must not throw in
62
+ // the middle of minting — the relative href is what this returned before and still opens.
63
+ return href;
64
+ }
65
+ }