@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/README.md +45 -2
- package/dist/app.css +1 -1
- package/dist/config.js +14 -4
- package/dist/index.html +4 -2
- package/dist/main.a0tgw8hg.js +399 -0
- package/package.json +15 -11
- package/src/api.ts +6 -0
- package/src/app-context.tsx +21 -2
- package/src/brand.ts +96 -0
- package/src/components.tsx +296 -16
- package/src/fields.tsx +102 -15
- package/src/main.tsx +10 -0
- package/src/rich-text.ts +30 -0
- package/src/routes/_layout.tsx +12 -5
- package/src/routes/page.tsx +12 -3
- package/src/types.ts +53 -2
- package/dist/main.ykztb0px.js +0 -396
package/src/rich-text.ts
ADDED
|
@@ -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
|
+
}
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -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=
|
|
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">
|
|
64
|
-
<span className="text-fg-subtle">·
|
|
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.
|
|
93
|
-
//
|
|
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}
|
package/src/routes/page.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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`,
|
|
10
|
-
|
|
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;
|