@pramen/cms-editor 0.0.49 → 0.0.51

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.49",
4
- "description": "Visual block/page editor for @pramen/cms a standalone React SPA that talks to the CMS handlers over HTTP.",
3
+ "version": "0.0.51",
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": {
7
7
  "type": "git",
@@ -11,7 +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
- "files": ["dist", "src"],
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
15
18
  "scripts": {
16
19
  "build": "bun run scripts/build.ts",
17
20
  "dev": "bun run scripts/build.ts --watch",
@@ -20,16 +23,17 @@
20
23
  },
21
24
  "dependencies": {
22
25
  "@buzola/router": "^0.0.12",
23
- "@podoba/react": "^0.0.32",
24
- "@podoba/tokens": "^0.0.32",
25
- "@podoba/tailwind": "^0.0.32",
26
- "@tiptap/react": "^3.27.4",
27
- "@tiptap/pm": "^3.27.4",
28
- "@tiptap/starter-kit": "^3.27.4",
29
- "@tiptap/extension-placeholder": "^3.27.4",
26
+ "@podoba/react": "^0.0.34",
27
+ "@podoba/tailwind": "^0.0.34",
28
+ "@podoba/tokens": "^0.0.34",
29
+ "@tiptap/core": "^3.27.4",
30
30
  "@tiptap/extension-highlight": "^3.27.4",
31
- "@tiptap/extension-task-list": "^3.27.4",
31
+ "@tiptap/extension-placeholder": "^3.27.4",
32
32
  "@tiptap/extension-task-item": "^3.27.4",
33
+ "@tiptap/extension-task-list": "^3.27.4",
34
+ "@tiptap/pm": "^3.27.4",
35
+ "@tiptap/react": "^3.27.4",
36
+ "@tiptap/starter-kit": "^3.27.4",
33
37
  "react": "^19.0.0",
34
38
  "react-dom": "^19.0.0"
35
39
  },
package/src/api.ts CHANGED
@@ -129,6 +129,12 @@ export class Api {
129
129
  getMedia = (id: string) => this.call<Media | null>("getMedia", { id });
130
130
  updateMedia = (id: string, alt: string | null) => this.call<Media>("updateMedia", { id, alt });
131
131
  deleteMedia = (id: string) => this.call<{ ok: true }>("deleteMedia", { id });
132
+ // Trash is not a UI nicety here: deleteMedia no longer removes the R2 object, so without
133
+ // a reachable purge a file can be "deleted" in the library and still be served on the
134
+ // live site — the case a takedown request actually needs.
135
+ listTrash = (limit = 50) => this.call<{ pages: Page[]; media: Media[] }>("listTrash", { limit });
136
+ restoreMedia = (id: string) => this.call<{ ok: true }>("restoreMedia", { id });
137
+ purgeMedia = (id: string) => this.call<{ ok: true }>("purgeMedia", { id });
132
138
 
133
139
  /** Full upload flow: sign → PUT the bytes → persist a `cms_media` row. Returns the row. */
134
140
  async uploadMedia(file: File): Promise<Media> {
@@ -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
+ }