@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.
@@ -0,0 +1,30 @@
1
+ // Rich-text helpers for the editor. Local mirrors of the @pramen/cms functions, kept here
2
+ // because the editor is a self-contained browser app with no server-package dependency —
3
+ // it speaks to the CMS purely over HTTP (see types.ts).
4
+
5
+ import type { RichTextDoc, RichTextNode } from "./types";
6
+
7
+ /** Is this value a rich-text document (rather than a legacy HTML string or a plain bag)? */
8
+ export function isRichTextDoc(v: unknown): v is RichTextDoc {
9
+ return typeof v === "object" && v !== null && !Array.isArray(v) && (v as RichTextDoc).type === "doc";
10
+ }
11
+
12
+ /** The block-level node types that end a line when flattening to plain text. */
13
+ const BLOCK_TYPES = new Set(["paragraph", "heading", "listItem", "taskItem", "blockquote", "codeBlock", "horizontalRule"]);
14
+
15
+ /** Flatten a rich-text document to plain text — for list cells and collapsed-block
16
+ * previews, which want the words without the structure. Mirrors `richTextToPlainText`
17
+ * in @pramen/cms. */
18
+ export function richTextToPlainText(value: RichTextDoc | null | undefined): string {
19
+ const parts: string[] = [];
20
+ const walk = (nodes: readonly RichTextNode[]): void => {
21
+ for (const node of nodes) {
22
+ if (node.type === "text") parts.push(node.text ?? "");
23
+ else if (node.type === "hardBreak") parts.push("\n");
24
+ if (node.content) walk(node.content);
25
+ if (BLOCK_TYPES.has(node.type)) parts.push("\n");
26
+ }
27
+ };
28
+ walk(value?.content ?? []);
29
+ return parts.join("").replace(/\n{2,}/g, "\n").trim();
30
+ }
@@ -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
@@ -5,9 +5,32 @@
5
5
  /** Any JSON value — the wire form of everything the CMS stores. */
6
6
  export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
7
7
 
8
+ /** A rich-text document — the editor's structured JSON. Mirrors `RichTextDoc` in
9
+ * @pramen/cms; a `richtext` field is this tree, never an HTML string. */
10
+ export interface RichTextDoc {
11
+ type: "doc";
12
+ content?: RichTextNode[];
13
+ }
14
+
15
+ /** One node in a {@link RichTextDoc}. */
16
+ export interface RichTextNode {
17
+ type: string;
18
+ content?: RichTextNode[];
19
+ text?: string;
20
+ marks?: RichTextMark[];
21
+ attrs?: Record<string, JsonValue>;
22
+ }
23
+
24
+ /** An inline mark on a text node. */
25
+ export interface RichTextMark {
26
+ type: string;
27
+ attrs?: Record<string, JsonValue>;
28
+ }
29
+
8
30
  /** One authored field value. Mirrors `FieldValue` in @pramen/cms: a `"media"` field
9
- * arrives resolved to a `Media`, and `group`/`repeater` fields nest further bags. */
10
- export type FieldValue = JsonValue | Media | FieldValues | FieldValue[];
31
+ * arrives resolved to a `Media`, a `"richtext"` field is a `RichTextDoc`, and
32
+ * `group`/`repeater` fields nest further bags. */
33
+ export type FieldValue = JsonValue | Media | RichTextDoc | FieldValues | FieldValue[];
11
34
 
12
35
  /** A block / collection / page `fields` bag — field name -> authored value. */
13
36
  export interface FieldValues {
@@ -103,8 +126,36 @@ export interface CollectionMeta {
103
126
  * from this to open/save/delete it. */
104
127
  idField: string;
105
128
  orderBy?: { column: string; dir?: "asc" | "desc" };
129
+ /** The workflow features the collection opted into server-side (`supports`), e.g.
130
+ * `["drafts", "scheduling"]`. Carried here so this mirror stays faithful to
131
+ * `CollectionMeta`. The editor renders the matching affordances (publish/unpublish, a
132
+ * schedule picker, a preview link, a revision list) in `CollectionWorkflow`; an empty
133
+ * list renders none of them. */
134
+ supports?: CollectionFeature[];
135
+ }
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;
106
149
  }
107
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
+
156
+ /** Mirror of @pramen/cms `CollectionFeature`. */
157
+ export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
158
+
108
159
  export interface Page {
109
160
  id: string;
110
161
  typeId: string;