@pramen/cms-editor 0.0.50 → 0.0.52

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.50",
3
+ "version": "0.0.52",
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": {
@@ -6,7 +6,8 @@
6
6
  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
- import type { CollectionMeta, JsonValue } from "./types";
9
+ import { BRAND, SETUP_TITLE, type BrandConfig } from "./brand";
10
+ import { DEFAULT_CAPABILITIES, type CmsCapabilities, type CollectionMeta, type JsonValue } from "./types";
10
11
 
11
12
  declare global {
12
13
  interface Window {
@@ -20,6 +21,13 @@ declare global {
20
21
  /** Extra top-nav links to companion tools the host serves (e.g. a curation page).
21
22
  * Rendered as plain external `<a>` links after the built-in tabs. */
22
23
  extraNav?: { label: string; href: string }[];
24
+ /** The wordmark in the topbar, on the Setup screen, and in the browser tab.
25
+ *
26
+ * This editor ships as a package an agency deploys FOR ITS CLIENT, so the default
27
+ * put the framework's name where the client's belongs — someone logging into their
28
+ * own CMS was greeted by "pramen". Set `name` (and optionally `suffix`) to the
29
+ * deployment's own; `suffix: null` drops the "· cms" half entirely. */
30
+ brand?: BrandConfig;
23
31
  };
24
32
  }
25
33
  }
@@ -53,6 +61,10 @@ interface AppContextValue {
53
61
  /** Collections registered on the server (from `listCollections`) — drives the nav + the
54
62
  * generic list/edit routes. Empty when the server registers none. */
55
63
  collections: CollectionMeta[];
64
+ /** What the SERVER says this deployment supports (from `listCmsCapabilities`) — today,
65
+ * its declared locales. The editor renders its i18n surface off this rather than a local
66
+ * flag, so the UI and the data can never disagree about whether the site is multilingual. */
67
+ cms: CmsCapabilities;
56
68
  error: string;
57
69
  setError: (s: string) => void;
58
70
  /** Sign out — drop the token so the config gate falls back to Setup. */
@@ -79,6 +91,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
79
91
  const [cfg, setCfg] = useState<Config>(loadConfig());
80
92
  const [me, setMe] = useState<Me | null>(null);
81
93
  const [collections, setCollections] = useState<CollectionMeta[]>([]);
94
+ const [cms, setCms] = useState<CmsCapabilities>(DEFAULT_CAPABILITIES);
82
95
  const [error, setError] = useState("");
83
96
  // A usable session = a base URL + a token that is NOT expired. An expired token counts as
84
97
  // no session: otherwise the editor mounts and every RPC 403s into an error banner.
@@ -118,6 +131,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
118
131
  // Collections drive the nav + list/edit routes. An app that registers none (or an older
119
132
  // server without the handler) just leaves the nav as-is — a failure is non-fatal.
120
133
  api.call<CollectionMeta[]>("listCollections").then(setCollections).catch(() => setCollections([]));
134
+ // A server older than this handler leaves the monolingual default, which is the safe
135
+ // way round: the i18n surface stays hidden rather than half-rendered.
136
+ api.call<CmsCapabilities>("listCmsCapabilities").then(setCms).catch(() => setCms(DEFAULT_CAPABILITIES));
121
137
  }, [api, authValid]);
122
138
 
123
139
  if (!authValid) {
@@ -132,6 +148,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
132
148
  me,
133
149
  isAdmin: (me?.roles ?? []).includes("admin"),
134
150
  collections,
151
+ cms,
135
152
  error,
136
153
  setError,
137
154
  confirmNavigation,
@@ -149,8 +166,10 @@ function Setup({ cfg, onSave }: { cfg: Config; onSave: (c: Config) => void }) {
149
166
  return (
150
167
  <div className="mx-auto mt-[14vh] w-full max-w-[520px] px-6">
151
168
  <div className="rounded-panel border border-border bg-surface-card px-10 py-8 shadow-[0_24px_60px_rgba(30,20,10,0.08)]">
169
+ {/* `SETUP_TITLE`, not `BRAND.title`: this screen has always read "… cms editor", and
170
+ the default has to render byte-identical to before the brand seam existed. */}
152
171
  <h1 className="mb-4 text-display text-fg">
153
- pramen <span className="text-fg-subtle">· cms editor</span>
172
+ {BRAND.name} <span className="text-fg-subtle">{SETUP_TITLE}</span>
154
173
  </h1>
155
174
  <p className="mb-6 text-sm text-fg-muted">
156
175
  Point at your Worker and paste an editor/reviewer JWT. CORS must allow this origin (<code>CORS_ORIGINS</code>).
package/src/brand.ts ADDED
@@ -0,0 +1,96 @@
1
+ // The deployment's wordmark, resolved once from the host's /config.js.
2
+ //
3
+ // Deliberately free of React, podoba and DOM-lib imports: it is read at module load (before
4
+ // any component renders) and it is the one piece of chrome a host is expected to change, so
5
+ // it stays a pure function of config that can be exercised without a browser.
6
+
7
+ /** What the host may set under `window.PRAMEN_CMS_EDITOR.brand`. */
8
+ export interface BrandConfig {
9
+ /** The wordmark. Blank/absent keeps the default. */
10
+ name?: string;
11
+ /** The muted half after the middot. Absent keeps the default; `null` drops it. */
12
+ suffix?: string | null;
13
+ }
14
+
15
+ /** A resolved wordmark. */
16
+ export interface Brand {
17
+ name: string;
18
+ suffix: string | null;
19
+ /** Name and suffix joined for `document.title` — where the middot reads correctly. */
20
+ title: string;
21
+ /** The same words with no punctuation, for an accessible name. A screen reader announces
22
+ * "·" as "middle dot" at higher verbosity, so the decorative separator that belongs in a
23
+ * tab title does not belong in an aria-label. */
24
+ spoken: string;
25
+ }
26
+
27
+ export const DEFAULT_BRAND_NAME = "pramen";
28
+ export const DEFAULT_BRAND_SUFFIX = "cms";
29
+
30
+ /** Coerce one config value to a trimmed string, or undefined for anything else.
31
+ *
32
+ * /config.js is hand-edited, untyped, and often templated from an env var, so a value here
33
+ * can be any JSON type. `?.trim()` guards null and undefined ONLY — `suffix: false` (a
34
+ * plausible slip next to `hidePages: true`) or `name: 123` would THROW, and since `BRAND` is
35
+ * resolved at module load in the entry bundle's import graph, that throw aborts evaluation
36
+ * before `createRoot` and renders a blank page with nothing but a console error. Every other
37
+ * field in this config object already fails safe; this one must too. */
38
+ function str(v: unknown): string | undefined {
39
+ if (typeof v !== "string") return undefined;
40
+ const trimmed = v.trim();
41
+ return trimmed === "" ? undefined : trimmed;
42
+ }
43
+
44
+ /** Resolve the configured wordmark, falling back to what used to be hardcoded.
45
+ *
46
+ * The distinction that matters is ABSENT vs. `null` on `suffix`: a host that only renames
47
+ * the product keeps the "· cms" half, while `suffix: null` is the explicit "just our name".
48
+ *
49
+ * A `brand` that is present but yields no usable name is WARNED about rather than silently
50
+ * accepted: `brand: "Acme"` (the shorthand instead of `{ name: "Acme" }`), a misspelled key,
51
+ * or a template that resolved to an empty string all end up shipping the framework's name to
52
+ * the client — the exact outcome this config exists to prevent — and a green deploy is the
53
+ * worst place to discover it. */
54
+ export function resolveBrand(cfg?: BrandConfig): Brand {
55
+ const configured = str(cfg?.name);
56
+ if (cfg !== undefined && cfg !== null && configured === undefined) {
57
+ console.warn(
58
+ `pramen/cms-editor: \`brand\` is set but has no usable \`name\`, so the wordmark stays "${DEFAULT_BRAND_NAME}". Expected \`brand: { name: "Your name" }\`.`,
59
+ );
60
+ }
61
+ const name = configured ?? DEFAULT_BRAND_NAME;
62
+ // `undefined` (not configured) keeps the default; `null` — and any non-string — drops it.
63
+ const suffix = cfg?.suffix === undefined ? DEFAULT_BRAND_SUFFIX : (str(cfg.suffix) ?? null);
64
+ return { name, suffix, title: suffix ? `${name} · ${suffix}` : name, spoken: suffix ? `${name} ${suffix}` : name };
65
+ }
66
+
67
+ /** The global the host's /config.js writes. Declared structurally rather than reaching for
68
+ * `Window`, so this module needs no DOM lib — and so a test can hand it a plain object. */
69
+ export interface BrandHost {
70
+ PRAMEN_CMS_EDITOR?: { brand?: BrandConfig };
71
+ }
72
+
73
+ /** Pull the brand config off a host global, tolerating its absence (SSR, tests, a /config.js
74
+ * that 404'd). Exported so the READ is testable, not just the resolution. */
75
+ export function readBrandConfig(host: BrandHost | undefined): BrandConfig | undefined {
76
+ return host?.PRAMEN_CMS_EDITOR?.brand;
77
+ }
78
+
79
+ /** The wordmark for THIS page load. Read at module load, like `SIGN_IN_URL` — /config.js is
80
+ * a plain script tag ahead of the bundle, so it is already set. */
81
+ export const BRAND: Brand = resolveBrand(readBrandConfig(globalThis as BrandHost));
82
+
83
+ /** The two chrome strings built from the wordmark, in one place so the words that are NOT
84
+ * the client's name can be seen together — and so neither call site re-invents them.
85
+ *
86
+ * Both keep saying "editor" on the DEFAULT wordmark, because that is what the Setup screen
87
+ * and the browser tab have always said; a configured brand replaces the lot rather than
88
+ * having an English noun appended to it. */
89
+ export const SETUP_TITLE: string = BRAND.suffix ? `· ${BRAND.suffix}${isDefault(BRAND) ? " editor" : ""}` : "";
90
+ export const DOCUMENT_TITLE: string = isDefault(BRAND) ? `${BRAND.title} editor` : BRAND.title;
91
+
92
+ /** Whether nothing was configured — the unbranded default, which must render exactly as it
93
+ * did before this module existed. */
94
+ function isDefault(b: Brand): boolean {
95
+ return b.name === DEFAULT_BRAND_NAME && b.suffix === DEFAULT_BRAND_SUFFIX;
96
+ }
@@ -7,13 +7,20 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
7
7
  import { Api, ApiError } from "./api";
8
8
  import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput } from "./fields";
9
9
  import type { Config } from "./api";
10
- import type { Me } from "./app-context";
10
+ import { useApp, type Me } from "./app-context";
11
11
  import { isRichTextDoc, richTextToPlainText } from "./rich-text";
12
12
  import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
13
13
 
14
14
  export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
15
15
  export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
16
16
 
17
+ /** The tabs a deployment actually shows. ONE definition, used by the tab bar, the panel
18
+ * switch and the route's deep-link fallback — three places that previously each re-derived
19
+ * "is i18n visible?" and could disagree. */
20
+ export function visibleTabs(multilingual: boolean): InspectorTab[] {
21
+ return multilingual ? INSPECTOR_TABS : INSPECTOR_TABS.filter((t) => t !== "i18n");
22
+ }
23
+
17
24
  // --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
18
25
 
19
26
  const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
@@ -102,6 +109,8 @@ const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-
102
109
  // --- pages list --------------------------------------------------------------
103
110
 
104
111
  export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
112
+ // From the SERVER (listCmsCapabilities), not a local flag — see `CmsCapabilities`.
113
+ const { cms: { multilingual } } = useApp();
105
114
  const [creating, setCreating] = useState(false);
106
115
  return (
107
116
  <>
@@ -116,7 +125,7 @@ export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }:
116
125
  <div className={`${ROW} cursor-pointer hover:bg-surface-muted`} key={p.id} onClick={() => onOpen(p)}>
117
126
  <span className="flex-1 truncate font-medium">{p.title}</span>
118
127
  <span className="text-fg-subtle">/{p.slug}</span>
119
- <span className="text-fg-subtle">{p.locale}</span>
128
+ {multilingual ? <span className="text-fg-subtle">{p.locale}</span> : null}
120
129
  <Pill status={p.status}>{p.status}</Pill>
121
130
  </div>
122
131
  ))}
@@ -538,6 +547,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
538
547
  // --- page editor -------------------------------------------------------------
539
548
 
540
549
  export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
550
+ const { cms: { multilingual } } = useApp();
541
551
  const [ct, setCt] = useState<ContentType | null>(null);
542
552
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
543
553
  const [err, setErr] = useState("");
@@ -776,14 +786,14 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
776
786
 
777
787
  <div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
778
788
  <div className="mb-3 flex gap-1">
779
- {INSPECTOR_TABS.map((t) => (
789
+ {visibleTabs(multilingual).map((t) => (
780
790
  <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
781
791
  ))}
782
792
  </div>
783
793
  {tab === "settings" ? <PageMeta api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
784
794
  {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
785
795
  {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
786
- {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
796
+ {tab === "i18n" && multilingual ? <I18n api={api} page={page} onError={setErr} /> : null}
787
797
  {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
788
798
  </div>
789
799
  </div>
@@ -1044,6 +1054,7 @@ function Inserter({ allowed, btBySlug, onAdd, compact }: { allowed: string[]; bt
1044
1054
  * type has no regions look empty — a wide, blank canvas next to a cramped form.
1045
1055
  */
1046
1056
  function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSaved: (p: Page) => void; onError: (s: string) => void }) {
1057
+ const { cms: { multilingual, locales } } = useApp();
1047
1058
  const [title, setTitle] = useState(page.title);
1048
1059
  const [slug, setSlug] = useState(page.slug);
1049
1060
  const [locale, setLocale] = useState(page.locale);
@@ -1057,7 +1068,12 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
1057
1068
  try {
1058
1069
  // Meta only — `fields` is deliberately omitted so saving here can never clobber
1059
1070
  // content edited in the canvas (updatePage patches only what it is given).
1060
- const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, title, slug, locale });
1071
+ // `locale` is sent ONLY where it is editable. On a single-locale deployment there is
1072
+ // no control for it, so including it would blind-overwrite whatever the row holds
1073
+ // with mount-time state — reverting an import or another editor's change through a
1074
+ // field this user cannot see. `updatePage` treats an absent key as no-change.
1075
+ const patch = multilingual ? { title, slug: slug.trim(), locale } : { title, slug: slug.trim() };
1076
+ const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, ...patch });
1061
1077
  if (r?.page) onSaved(r.page);
1062
1078
  setOk(true);
1063
1079
  setTimeout(() => setOk(false), 1500);
@@ -1074,7 +1090,18 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
1074
1090
  {ok ? <Banner ok>saved</Banner> : null}
1075
1091
  <Input label="Title" value={title} onChange={setTitle} />
1076
1092
  <Input label="Slug" value={slug} onChange={setSlug} />
1077
- <Input label="Locale" value={locale} onChange={setLocale} />
1093
+ {/* A SELECT over the declared locales, not free text: a typo'd or blank locale saves
1094
+ fine, previews fine (the editor round-trips the same string) and then 404s on the
1095
+ live site, which is the hardest kind of wrong to see. */}
1096
+ {multilingual ? (
1097
+ <label className="flex flex-col gap-1 text-sm">
1098
+ <span className="font-medium text-fg">Locale</span>
1099
+ <select className={CONTROL} value={locale} onChange={(e) => setLocale(e.target.value)}>
1100
+ {locales.includes(locale) ? null : <option value={locale}>{locale || "(unset)"} — not a declared locale</option>}
1101
+ {locales.map((l) => <option key={l} value={l}>{l}</option>)}
1102
+ </select>
1103
+ </label>
1104
+ ) : null}
1078
1105
  <Button onPress={save} isDisabled={busy || !title.trim() || !slug.trim()}>{busy ? "Saving…" : "Save"}</Button>
1079
1106
  <KV><span>Status</span><span>{page.status}</span></KV>
1080
1107
  </div>
package/src/main.tsx CHANGED
@@ -3,10 +3,20 @@ import { StrictMode } 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
+ import { DOCUMENT_TITLE } from "./brand";
6
7
 
7
8
  // Styling is podoba: @podoba/tokens/variables.css + the compiled Tailwind (podoba
8
9
  // preset) are <link>ed by index.html (see scripts/build.ts). No more inline CSS.
9
10
 
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.
14
+ //
15
+ // `DOCUMENT_TITLE`, not `${BRAND.title} editor`: appending a fixed English noun to the one
16
+ // string this feature exists to hand over would put a foreign word in a rebranded client's
17
+ // tab, and `suffix: null` ("just our name") could never drop it.
18
+ document.title = DOCUMENT_TITLE;
19
+
10
20
  const el = document.getElementById("app");
11
21
  if (el)
12
22
  createRoot(el).render(
@@ -6,6 +6,7 @@ import { Outlet, useNavigate, useRoute } 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
+ import { BRAND } from "../brand";
9
10
 
10
11
  const THEME_KEY = "pramen.cms.theme";
11
12
 
@@ -57,11 +58,11 @@ export default function RootLayout() {
57
58
  <button
58
59
  type="button"
59
60
  onClick={guarded(() => navigate("home"))}
60
- aria-label="pramen cms — home"
61
+ aria-label={`${BRAND.spoken} — home`}
61
62
  className="flex items-baseline gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-surface-muted"
62
63
  >
63
- <span className="text-callout font-bold tracking-[0.01em] text-fg">pramen</span>
64
- <span className="text-fg-subtle">· cms</span>
64
+ <span className="text-callout font-bold tracking-[0.01em] text-fg">{BRAND.name}</span>
65
+ {BRAND.suffix ? <span className="text-fg-subtle">· {BRAND.suffix}</span> : null}
65
66
  </button>
66
67
  </Topbar.Brand>
67
68
  <Topbar.Nav aria-label="Primary">
@@ -89,8 +90,14 @@ export default function RootLayout() {
89
90
  </Button>
90
91
  {extraNav.map((l) => (
91
92
  // Companion tools live OUTSIDE this SPA (a separate static page/worker route), so
92
- // open them in a new tab. A same-tab click would be caught by the client router
93
- // (Navigation API) and fall to the in-app 404, since the path isn't an SPA route.
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.
97
+ //
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.
94
101
  <a
95
102
  key={l.href}
96
103
  href={l.href}
@@ -6,14 +6,14 @@ import { createPage, useNavigate } from "@buzola/router";
6
6
  import { Button } from "@podoba/react";
7
7
  import { useEffect, useState } from "react";
8
8
  import { useApp } from "../app-context";
9
- import { INSPECTOR_TABS, PageEditor, errMsg, type InspectorTab } from "../components";
9
+ import { PageEditor, errMsg, visibleTabs, type InspectorTab } from "../components";
10
10
  import type { BlockType, Page } from "../types";
11
11
 
12
12
  export default createPage()
13
13
  .params({ pageId: "string", tab: "?string" })
14
14
  .route("/pages/:pageId")
15
15
  .render(function PageEditorRoute({ params }) {
16
- const { api, setError, setNavGuard } = useApp();
16
+ const { api, cms, setError, setNavGuard } = useApp();
17
17
  const navigate = useNavigate();
18
18
  const [page, setPage] = useState<Page | null>(null);
19
19
  const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
@@ -35,8 +35,17 @@ export default createPage()
35
35
  return () => { live = false; };
36
36
  }, [api, params.pageId, setError]);
37
37
 
38
- const tab: InspectorTab = INSPECTOR_TABS.includes(params.tab as InspectorTab) ? (params.tab as InspectorTab) : "settings";
38
+ // The visible set comes from the server (`visibleTabs`), so a deep link to `?tab=i18n`
39
+ // on a single-locale deployment falls back to settings — and the URL is REWRITTEN to
40
+ // match. Rendering one tab under another tab's address means a refresh, a Back, or a
41
+ // shared link all disagree with what is on screen; `setTab` already replaces without
42
+ // adding a history entry, so reconciling costs nothing.
43
+ const shown = visibleTabs(cms.multilingual);
44
+ const tab: InspectorTab = shown.includes(params.tab as InspectorTab) ? (params.tab as InspectorTab) : "settings";
39
45
  const setTab = (t: InspectorTab) => navigate("page", { params: { pageId: params.pageId, tab: t }, replace: true });
46
+ useEffect(() => {
47
+ if (params.tab !== undefined && params.tab !== tab) setTab(tab);
48
+ }, [params.tab, tab]);
40
49
 
41
50
  if (missing) {
42
51
  return (
package/src/types.ts CHANGED
@@ -134,6 +134,25 @@ export interface CollectionMeta {
134
134
  supports?: CollectionFeature[];
135
135
  }
136
136
 
137
+ /** What the server says this deployment supports (mirror of `listCmsCapabilities`).
138
+ *
139
+ * Server-declared, like a collection's `supports: [...]`: the editor asks what exists
140
+ * rather than being configured to hide things locally, so the chrome and the data cannot
141
+ * disagree. `multilingual` is the derived answer to the question the UI actually asks. */
142
+ export interface CmsCapabilities {
143
+ /** Declared locales, most-preferred first. */
144
+ locales: string[];
145
+ /** The locale a page gets when created without one — `locales[0]`. */
146
+ defaultLocale: string;
147
+ /** More than one declared locale. Gates the whole i18n surface. */
148
+ multilingual: boolean;
149
+ }
150
+
151
+ /** Used until `listCmsCapabilities` answers, and when it cannot (an older server). Assumes
152
+ * MONOLINGUAL: a hidden i18n surface on a multilingual site is recoverable by reloading,
153
+ * where a half-rendered one on a single-locale site is what this replaced. */
154
+ export const DEFAULT_CAPABILITIES: CmsCapabilities = { locales: ["en"], defaultLocale: "en", multilingual: false };
155
+
137
156
  /** Mirror of @pramen/cms `CollectionFeature`. */
138
157
  export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
139
158