@codepress/blog 0.2.0 → 0.3.0
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 +26 -6
- package/dist/chunks/{BlogEditor-EDAPNNP5.js → BlogEditor-IYNL6C7G.js} +166 -219
- package/dist/chunks/chunk-BPAM7T27.js +315 -0
- package/dist/chunks/{chunk-3GCZFCUK.js → chunk-LFSX43CO.js} +14 -3
- package/dist/chunks/{chunk-WJVZD4UI.js → chunk-PPMNLNLD.js} +8 -4
- package/dist/chunks/{chunk-3WRTL63O.js → chunk-WEBQTRDI.js} +10 -7
- package/dist/cli.js +100 -18
- package/dist/editor/BlogEditorMount.d.ts +3 -1
- package/dist/editor/client.d.ts +7 -1
- package/dist/editor/client.js +1 -1
- package/dist/editor/config.d.ts +6 -0
- package/dist/editor/host.d.ts +92 -0
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/index.js +605 -8
- package/dist/editor.css +3 -0
- package/dist/next/app.d.ts +15 -2
- package/dist/next/config.d.ts +18 -0
- package/dist/next/index.d.ts +1 -1
- package/dist/next/index.js +22 -5
- package/dist/next/pages.d.ts +4 -2
- package/dist/next/pages.js +29 -5
- package/dist/next/routes.js +1 -1
- package/dist/next/views.d.ts +5 -3
- package/package.json +3 -2
- package/dist/chunks/chunk-UA243KAQ.js +0 -104
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ npm install
|
|
|
14
14
|
`/blog/feed.xml` and `/blog/sitemap.xml`, plus `codepress-blog.ts` with the
|
|
15
15
|
config. The pages render inside your root layout and import
|
|
16
16
|
`@codepress/blog/blog.css`. It checks both the `app/` and `pages/` trees
|
|
17
|
-
first and changes nothing if `/blog` is already served. `--force` overwrites the
|
|
17
|
+
first (each where Next.js serves it: a root `app/` hides `src/app/`) and changes nothing if `/blog` is already served. `--force` overwrites the
|
|
18
18
|
files `init` itself writes and keeps other `/blog` routes, but a `/blog` URL
|
|
19
19
|
served from a different file (the other router, a route group,
|
|
20
|
-
`pages/blog.tsx`) still stops it: move or delete that file first. Other options: `--base-path`, `--api-base`, `--revalidate`,
|
|
20
|
+
`pages/blog.tsx`) still stops it: move or delete that file first. Other options: `--base-path`, `--api-base`, `--app-url`, `--revalidate`,
|
|
21
21
|
`--router app|pages`. Pages regenerate every 60 seconds (ISR); if the CodePress
|
|
22
22
|
API is down, the last good page keeps being served. Run `npx @codepress/blog --help`
|
|
23
23
|
for the full list.
|
|
@@ -26,6 +26,25 @@ The package is ESM and uses `html-react-parser`, whose dependencies are
|
|
|
26
26
|
ESM-only. Run Next.js on Node 20.19+ or 22.12+ (these versions can `require()`
|
|
27
27
|
ESM), or add `@codepress/blog` and `html-react-parser` to `transpilePackages`.
|
|
28
28
|
|
|
29
|
+
## Edit posts on your site
|
|
30
|
+
|
|
31
|
+
`init` also writes `codepress-blog-editor.tsx`, a `"use client"` file that
|
|
32
|
+
re-exports the editor, and a `/blog/__cp/callback` route, and adds the editor's
|
|
33
|
+
Tiptap peer dependencies. Nothing shows until your CodePress organization turns
|
|
34
|
+
on **Blog host editing** and an admin adds your site's origin
|
|
35
|
+
(`https://your.site`) to the content site's allowed origins.
|
|
36
|
+
|
|
37
|
+
Then an editor sees **Edit** on each post (and **New post** on the index).
|
|
38
|
+
Edit (or `?cp-edit` on the URL) asks codepress.dev to sign them in and approve
|
|
39
|
+
"Edit <blog> on <your site>?"; CodePress sends them back to the callback with
|
|
40
|
+
a one-time code, which the page trades (with a PKCE verifier it kept) for a
|
|
41
|
+
token that edits this blog's posts only, for 30 minutes, in that tab. The
|
|
42
|
+
editor opens over the post with the same toolbar as codepress.dev/blog, pinned
|
|
43
|
+
below your `[data-blog-header]` element. After Publish it shows the post as
|
|
44
|
+
CodePress now serves it; your page catches up within its ISR window.
|
|
45
|
+
Clicking one of your own links while editing saves first. When the token
|
|
46
|
+
expires, the editor asks for consent again.
|
|
47
|
+
|
|
29
48
|
## Entries
|
|
30
49
|
|
|
31
50
|
- `@codepress/blog` (server-safe, no React)
|
|
@@ -57,10 +76,11 @@ ESM), or add `@codepress/blog` and `html-react-parser` to `transpilePackages`.
|
|
|
57
76
|
`[data-blog-header]`, else the page's first `header`, else the top),
|
|
58
77
|
`column`, `onPublished`, `onLeave`, `slots.askPanel`, `slots.designLink` and
|
|
59
78
|
`actions.askCodePress`. `NewPostForm` starts a post (`new` is reserved).
|
|
60
|
-
Tiptap `3.24.0` is a peer dependency.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
Tiptap `3.24.0` is a peer dependency. `HostEditing` and
|
|
80
|
+
`HostEditorCallback` are the editing on a site's own page described above;
|
|
81
|
+
import them from a `"use client"` file. The editor is styled with Tailwind
|
|
82
|
+
utilities: a site without Tailwind v4 imports `@codepress/blog/editor.css`
|
|
83
|
+
(scoped to the editor, so it cannot restyle the page).
|
|
64
84
|
- `@codepress/blog/editor/client`: `createDraftClient({ apiBase, auth })`, the
|
|
65
85
|
draft API without the editor.
|
|
66
86
|
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import "./chunk-HWR3R5MU.js";
|
|
2
1
|
import "./chunk-QX6276YA.js";
|
|
2
|
+
import {
|
|
3
|
+
BLOG_TOOLBAR_BOTTOM_VAR,
|
|
4
|
+
DEFAULT_COLUMN,
|
|
5
|
+
EDITOR_SCOPE_CLASS,
|
|
6
|
+
EditDesignLink,
|
|
7
|
+
assemblePostFile,
|
|
8
|
+
canonicalBlock,
|
|
9
|
+
parsePostSource,
|
|
10
|
+
resolveTimings,
|
|
11
|
+
useHeaderOffset
|
|
12
|
+
} from "./chunk-BPAM7T27.js";
|
|
13
|
+
import "./chunk-HWR3R5MU.js";
|
|
3
14
|
import {
|
|
4
15
|
COMPONENT_VIEWS
|
|
5
16
|
} from "./chunk-AJVVTSKZ.js";
|
|
@@ -10,13 +21,6 @@ import {
|
|
|
10
21
|
} from "./chunk-EICE5ONB.js";
|
|
11
22
|
import "./chunk-TYX2HJZC.js";
|
|
12
23
|
import "./chunk-AEQDRBA3.js";
|
|
13
|
-
import {
|
|
14
|
-
BLOG_TOOLBAR_BOTTOM_VAR,
|
|
15
|
-
DEFAULT_COLUMN,
|
|
16
|
-
EditDesignLink,
|
|
17
|
-
resolveTimings,
|
|
18
|
-
useHeaderOffset
|
|
19
|
-
} from "./chunk-UA243KAQ.js";
|
|
20
24
|
import {
|
|
21
25
|
BLOCK_ID_ATTRIBUTE,
|
|
22
26
|
CALLOUT_TYPES,
|
|
@@ -29,207 +33,6 @@ import {
|
|
|
29
33
|
import { EditorContent, useEditor } from "@tiptap/react";
|
|
30
34
|
import { useCallback, useEffect as useEffect2, useMemo as useMemo2, useRef as useRef2, useState as useState3 } from "react";
|
|
31
35
|
|
|
32
|
-
// src/editor/document.ts
|
|
33
|
-
import { isTag, isText } from "domhandler";
|
|
34
|
-
import { parseDocument } from "htmlparser2";
|
|
35
|
-
var META_ATTRIBUTE = "data-cp-meta";
|
|
36
|
-
var WHITESPACE = /^\s*$/;
|
|
37
|
-
var ASCII_SPACES = /[ \t\n\f\r]+/g;
|
|
38
|
-
var ASCII_EDGE_START = /^[ \t\n\f\r]+/;
|
|
39
|
-
var ASCII_EDGE_END = /[ \t\n\f\r]+$/;
|
|
40
|
-
var ASCII_BLANK = /^[ \t\n\f\r]*$/;
|
|
41
|
-
function textOf(node) {
|
|
42
|
-
return "data" in node && typeof node.data === "string" ? node.data : "";
|
|
43
|
-
}
|
|
44
|
-
function parseMeta(script) {
|
|
45
|
-
try {
|
|
46
|
-
const value = JSON.parse(script.children.map(textOf).join(""));
|
|
47
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
48
|
-
return { meta: value };
|
|
49
|
-
}
|
|
50
|
-
} catch {
|
|
51
|
-
}
|
|
52
|
-
return { meta: {}, problem: "The metadata block is not a JSON object." };
|
|
53
|
-
}
|
|
54
|
-
function parsePostSource(source) {
|
|
55
|
-
const doc = parseDocument(source, {
|
|
56
|
-
withStartIndices: true,
|
|
57
|
-
withEndIndices: true
|
|
58
|
-
});
|
|
59
|
-
const result = {
|
|
60
|
-
prefix: "",
|
|
61
|
-
metaText: "",
|
|
62
|
-
meta: {},
|
|
63
|
-
blocks: [],
|
|
64
|
-
gaps: [],
|
|
65
|
-
problems: []
|
|
66
|
-
};
|
|
67
|
-
let gap = "";
|
|
68
|
-
let sawMeta = false;
|
|
69
|
-
for (const node of doc.children) {
|
|
70
|
-
const start = node.startIndex ?? 0;
|
|
71
|
-
const end = (node.endIndex ?? start) + 1;
|
|
72
|
-
if (isText(node)) {
|
|
73
|
-
const raw = source.slice(start, end);
|
|
74
|
-
if (!WHITESPACE.test(raw)) {
|
|
75
|
-
result.problems.push(
|
|
76
|
-
`Text outside any block: "${raw.trim().slice(0, 40)}".`
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
gap += raw;
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (!isTag(node)) {
|
|
83
|
-
result.problems.push("The file contains a comment or directive.");
|
|
84
|
-
gap += source.slice(start, end);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (!sawMeta) {
|
|
88
|
-
sawMeta = true;
|
|
89
|
-
if (node.name === "script" && META_ATTRIBUTE in node.attribs) {
|
|
90
|
-
result.prefix = gap;
|
|
91
|
-
result.metaText = source.slice(start, end);
|
|
92
|
-
const parsed = parseMeta(node);
|
|
93
|
-
result.meta = parsed.meta;
|
|
94
|
-
if (parsed.problem) result.problems.push(parsed.problem);
|
|
95
|
-
gap = "";
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
result.problems.push("The file has no metadata block.");
|
|
99
|
-
}
|
|
100
|
-
result.gaps.push(gap);
|
|
101
|
-
gap = "";
|
|
102
|
-
result.blocks.push({
|
|
103
|
-
id: node.attribs[BLOCK_ID_ATTRIBUTE] ?? null,
|
|
104
|
-
tag: node.name,
|
|
105
|
-
text: source.slice(start, end)
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
if (!sawMeta) result.problems.push("The file has no metadata block.");
|
|
109
|
-
result.gaps.push(gap);
|
|
110
|
-
return result;
|
|
111
|
-
}
|
|
112
|
-
function serializeMeta(meta) {
|
|
113
|
-
const json = JSON.stringify(meta, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
|
|
114
|
-
return `<script type="application/json" ${META_ATTRIBUTE}>
|
|
115
|
-
${json}
|
|
116
|
-
</script>`;
|
|
117
|
-
}
|
|
118
|
-
function sameJson(a, b) {
|
|
119
|
-
return JSON.stringify(canonicalJson(a)) === JSON.stringify(canonicalJson(b));
|
|
120
|
-
}
|
|
121
|
-
function canonicalJson(value) {
|
|
122
|
-
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
123
|
-
if (value && typeof value === "object") {
|
|
124
|
-
return Object.fromEntries(
|
|
125
|
-
Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, canonicalJson(item)])
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
return value;
|
|
129
|
-
}
|
|
130
|
-
function assemblePostFile(source, meta, blocks) {
|
|
131
|
-
const keepMeta = source && source.metaText && sameJson(source.meta, meta);
|
|
132
|
-
const metaText = keepMeta ? source.metaText : serializeMeta(meta);
|
|
133
|
-
let out = (source?.prefix ?? "") + metaText;
|
|
134
|
-
let gap = "\n";
|
|
135
|
-
blocks.forEach((block, position) => {
|
|
136
|
-
const origin = block.sourceIndex ?? block.replaces ?? null;
|
|
137
|
-
if (source && origin != null) gap = source.gaps[origin] || "\n";
|
|
138
|
-
else if (source && position === 0) gap = source.gaps[0] || "\n";
|
|
139
|
-
const original = block.sourceIndex != null ? source?.blocks[block.sourceIndex] : void 0;
|
|
140
|
-
out += gap + (original ? original.text : block.html);
|
|
141
|
-
});
|
|
142
|
-
const trailing = source ? source.gaps[source.gaps.length - 1] : "\n";
|
|
143
|
-
return out + (trailing || "\n");
|
|
144
|
-
}
|
|
145
|
-
var BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
146
|
-
"p",
|
|
147
|
-
"h1",
|
|
148
|
-
"h2",
|
|
149
|
-
"h3",
|
|
150
|
-
"h4",
|
|
151
|
-
"h5",
|
|
152
|
-
"h6",
|
|
153
|
-
"ul",
|
|
154
|
-
"ol",
|
|
155
|
-
"li",
|
|
156
|
-
"blockquote",
|
|
157
|
-
"pre",
|
|
158
|
-
"hr",
|
|
159
|
-
"table",
|
|
160
|
-
"thead",
|
|
161
|
-
"tbody",
|
|
162
|
-
"tr",
|
|
163
|
-
"th",
|
|
164
|
-
"td",
|
|
165
|
-
"figure",
|
|
166
|
-
"figcaption",
|
|
167
|
-
"div",
|
|
168
|
-
"cp-callout",
|
|
169
|
-
"cp-figure"
|
|
170
|
-
]);
|
|
171
|
-
var VOID_TAGS = /* @__PURE__ */ new Set(["br", "hr", "img"]);
|
|
172
|
-
function collapse(text) {
|
|
173
|
-
return text.replace(ASCII_SPACES, " ");
|
|
174
|
-
}
|
|
175
|
-
function canonicalAttributes(node) {
|
|
176
|
-
return Object.entries(node.attribs).filter(([name]) => name !== BLOCK_ID_ATTRIBUTE).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, value]) => {
|
|
177
|
-
if (name === PROPS_ATTRIBUTE) {
|
|
178
|
-
try {
|
|
179
|
-
return `${name}=${JSON.stringify(canonicalJson(JSON.parse(value)))}`;
|
|
180
|
-
} catch {
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return `${name}=${JSON.stringify(value)}`;
|
|
184
|
-
}).join(" ");
|
|
185
|
-
}
|
|
186
|
-
function canonicalChildren(children, preformatted, trimEdges) {
|
|
187
|
-
const parts = children.map(
|
|
188
|
-
(child) => isTag(child) ? { tag: child.name, text: canonicalElement(child, preformatted) } : {
|
|
189
|
-
tag: null,
|
|
190
|
-
text: preformatted ? textOf(child) : collapse(textOf(child))
|
|
191
|
-
}
|
|
192
|
-
);
|
|
193
|
-
if (!preformatted && trimEdges) {
|
|
194
|
-
const first = parts[0];
|
|
195
|
-
const last = parts[parts.length - 1];
|
|
196
|
-
if (first && first.tag === null)
|
|
197
|
-
first.text = first.text.replace(ASCII_EDGE_START, "");
|
|
198
|
-
if (last && last.tag === null)
|
|
199
|
-
last.text = last.text.replace(ASCII_EDGE_END, "");
|
|
200
|
-
}
|
|
201
|
-
return parts.filter((part, index) => {
|
|
202
|
-
if (preformatted || part.tag !== null) return true;
|
|
203
|
-
if (part.text === "") return false;
|
|
204
|
-
if (!ASCII_BLANK.test(part.text)) return true;
|
|
205
|
-
const neighbours = [parts[index - 1], parts[index + 1]];
|
|
206
|
-
return !neighbours.some((n) => n?.tag != null && BLOCK_TAGS.has(n.tag));
|
|
207
|
-
}).map((part) => part.text).join("");
|
|
208
|
-
}
|
|
209
|
-
var PARAGRAPH_WRAPPED = /* @__PURE__ */ new Set(["li", "blockquote", "cp-callout"]);
|
|
210
|
-
function comparableChildren(node) {
|
|
211
|
-
if (!PARAGRAPH_WRAPPED.has(node.name)) return node.children;
|
|
212
|
-
const meaningful = node.children.filter(
|
|
213
|
-
(child) => !(isText(child) && ASCII_BLANK.test(child.data))
|
|
214
|
-
);
|
|
215
|
-
const only = meaningful.length === 1 ? meaningful[0] : void 0;
|
|
216
|
-
if (only && isTag(only) && only.name === "p" && Object.keys(only.attribs).every((name) => name === BLOCK_ID_ATTRIBUTE)) {
|
|
217
|
-
return only.children;
|
|
218
|
-
}
|
|
219
|
-
return node.children;
|
|
220
|
-
}
|
|
221
|
-
function canonicalElement(node, preformatted) {
|
|
222
|
-
const attributes = canonicalAttributes(node);
|
|
223
|
-
const open = `<${node.name}${attributes ? ` ${attributes}` : ""}>`;
|
|
224
|
-
if (VOID_TAGS.has(node.name)) return open;
|
|
225
|
-
const inside = preformatted || node.name === "pre";
|
|
226
|
-
const trimEdges = BLOCK_TAGS.has(node.name);
|
|
227
|
-
return `${open}${canonicalChildren(comparableChildren(node), inside, trimEdges)}</${node.name}>`;
|
|
228
|
-
}
|
|
229
|
-
function canonicalBlock(html) {
|
|
230
|
-
return canonicalChildren(parseDocument(html).children, false, true);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
36
|
// src/editor/extensions.ts
|
|
234
37
|
import { Extension as Extension3 } from "@tiptap/react";
|
|
235
38
|
import { Plugin as Plugin2 } from "@tiptap/pm/state";
|
|
@@ -843,6 +646,7 @@ function DropdownMenuContent({
|
|
|
843
646
|
"data-slot": "dropdown-menu-content",
|
|
844
647
|
sideOffset,
|
|
845
648
|
className: twMerge(
|
|
649
|
+
EDITOR_SCOPE_CLASS,
|
|
846
650
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
|
847
651
|
className
|
|
848
652
|
),
|
|
@@ -898,6 +702,7 @@ function TooltipContent({
|
|
|
898
702
|
"data-slot": "tooltip-content",
|
|
899
703
|
sideOffset,
|
|
900
704
|
className: twMerge(
|
|
705
|
+
EDITOR_SCOPE_CLASS,
|
|
901
706
|
"bg-white text-black shadow-lg border border-gray-200 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[10050] w-fit rounded-md px-3 py-1.5 text-xs",
|
|
902
707
|
className
|
|
903
708
|
),
|
|
@@ -1811,6 +1616,21 @@ function revisionOf(draft) {
|
|
|
1811
1616
|
revision: typeof draft.revision === "number" ? draft.revision : null
|
|
1812
1617
|
};
|
|
1813
1618
|
}
|
|
1619
|
+
var unsavedKey = (site, slug) => `cp-blog-unsaved:${site}/${slug}`;
|
|
1620
|
+
function readUnsaved(site, slug) {
|
|
1621
|
+
try {
|
|
1622
|
+
return window.sessionStorage.getItem(unsavedKey(site, slug));
|
|
1623
|
+
} catch {
|
|
1624
|
+
return null;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
function keepUnsaved(site, slug, text) {
|
|
1628
|
+
try {
|
|
1629
|
+
if (text === null) window.sessionStorage.removeItem(unsavedKey(site, slug));
|
|
1630
|
+
else window.sessionStorage.setItem(unsavedKey(site, slug), text);
|
|
1631
|
+
} catch {
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1814
1634
|
var fieldClass = "w-full border-0 bg-transparent p-0 text-[#111110] outline-none placeholder:text-[#B8B4A9] focus:ring-0";
|
|
1815
1635
|
function listField(value) {
|
|
1816
1636
|
return Array.isArray(value) ? value.join(", ") : "";
|
|
@@ -1886,12 +1706,25 @@ function LoadedEditor({
|
|
|
1886
1706
|
const [confirmDiscard, setConfirmDiscard] = useState3(false);
|
|
1887
1707
|
const [published, setPublished] = useState3(false);
|
|
1888
1708
|
const [leaveWarning, setLeaveWarning] = useState3(false);
|
|
1709
|
+
const [recovered, setRecovered] = useState3(() => {
|
|
1710
|
+
const text = readUnsaved(site, slug);
|
|
1711
|
+
return text !== null && text !== savedTextRef.current ? text : null;
|
|
1712
|
+
});
|
|
1713
|
+
const recoveredRef = useRef2(recovered);
|
|
1714
|
+
const remember = useCallback(
|
|
1715
|
+
(text) => {
|
|
1716
|
+
if (recoveredRef.current === null) keepUnsaved(site, slug, text);
|
|
1717
|
+
},
|
|
1718
|
+
[site, slug]
|
|
1719
|
+
);
|
|
1720
|
+
const [restoreError, setRestoreError] = useState3(null);
|
|
1721
|
+
const [restoring, setRestoring] = useState3(false);
|
|
1889
1722
|
const leaveToRef = useRef2(onExit);
|
|
1890
1723
|
const [formattingOff, setFormattingOff] = useState3(false);
|
|
1891
1724
|
const [linkOpen, setLinkOpen] = useState3(false);
|
|
1892
1725
|
const linkShortcutRef = useRef2(() => {
|
|
1893
1726
|
});
|
|
1894
|
-
const frozen = busy !== null || published;
|
|
1727
|
+
const frozen = busy !== null || published || restoring;
|
|
1895
1728
|
const extensions = useMemo2(
|
|
1896
1729
|
() => editorExtensions({
|
|
1897
1730
|
onSlash: setSlash,
|
|
@@ -1947,9 +1780,9 @@ function LoadedEditor({
|
|
|
1947
1780
|
setViolations([]);
|
|
1948
1781
|
setMessage(null);
|
|
1949
1782
|
const again = compose();
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
);
|
|
1783
|
+
const unsaved = again && again.text !== savedTextRef.current ? again.text : null;
|
|
1784
|
+
remember(unsaved);
|
|
1785
|
+
setStatus(unsaved !== null ? "dirty" : "saved");
|
|
1953
1786
|
return true;
|
|
1954
1787
|
}
|
|
1955
1788
|
if (result.kind === "conflict") {
|
|
@@ -1965,7 +1798,7 @@ function LoadedEditor({
|
|
|
1965
1798
|
setStatus("error");
|
|
1966
1799
|
}
|
|
1967
1800
|
return false;
|
|
1968
|
-
}, [client, compose, site, slug]);
|
|
1801
|
+
}, [client, compose, remember, site, slug]);
|
|
1969
1802
|
const save = useCallback(async () => {
|
|
1970
1803
|
while (inflightRef.current) await inflightRef.current;
|
|
1971
1804
|
const run = saveNow();
|
|
@@ -1990,14 +1823,16 @@ function LoadedEditor({
|
|
|
1990
1823
|
if (composed.text === savedTextRef.current) {
|
|
1991
1824
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
1992
1825
|
timerRef.current = null;
|
|
1826
|
+
remember(null);
|
|
1993
1827
|
if (!blockedRef.current)
|
|
1994
1828
|
setStatus(inflightRef.current ? "saving" : "idle");
|
|
1995
1829
|
return;
|
|
1996
1830
|
}
|
|
1831
|
+
remember(composed.text);
|
|
1997
1832
|
if (blockedRef.current) return;
|
|
1998
1833
|
setStatus((current) => current === "saving" ? current : "dirty");
|
|
1999
1834
|
schedule();
|
|
2000
|
-
}, [compose, schedule]);
|
|
1835
|
+
}, [compose, remember, schedule]);
|
|
2001
1836
|
const editor = useEditor({
|
|
2002
1837
|
immediatelyRender: false,
|
|
2003
1838
|
extensions,
|
|
@@ -2027,12 +1862,26 @@ function LoadedEditor({
|
|
|
2027
1862
|
}
|
|
2028
1863
|
};
|
|
2029
1864
|
}, [editor]);
|
|
1865
|
+
const saveRef = useRef2(save);
|
|
1866
|
+
useEffect2(() => {
|
|
1867
|
+
saveRef.current = save;
|
|
1868
|
+
});
|
|
2030
1869
|
useEffect2(
|
|
2031
1870
|
() => () => {
|
|
2032
|
-
if (timerRef.current)
|
|
1871
|
+
if (!timerRef.current) return;
|
|
1872
|
+
clearTimeout(timerRef.current);
|
|
1873
|
+
timerRef.current = null;
|
|
1874
|
+
if (!blockedRef.current && !publishingRef.current) {
|
|
1875
|
+
void saveRef.current();
|
|
1876
|
+
}
|
|
2033
1877
|
},
|
|
2034
1878
|
[]
|
|
2035
1879
|
);
|
|
1880
|
+
useEffect2(() => {
|
|
1881
|
+
if (readUnsaved(site, slug) === savedTextRef.current) {
|
|
1882
|
+
keepUnsaved(site, slug, null);
|
|
1883
|
+
}
|
|
1884
|
+
}, [site, slug]);
|
|
2036
1885
|
useEffect2(() => {
|
|
2037
1886
|
const interval = setInterval(async () => {
|
|
2038
1887
|
if (inflightRef.current || blockedRef.current || publishingRef.current) {
|
|
@@ -2063,6 +1912,20 @@ function LoadedEditor({
|
|
|
2063
1912
|
window.addEventListener("beforeunload", warn);
|
|
2064
1913
|
return () => window.removeEventListener("beforeunload", warn);
|
|
2065
1914
|
}, [status, published]);
|
|
1915
|
+
useEffect2(() => {
|
|
1916
|
+
const flush = () => {
|
|
1917
|
+
if (blockedRef.current || publishingRef.current) return;
|
|
1918
|
+
const composed = compose();
|
|
1919
|
+
if (!composed || composed.text === savedTextRef.current) return;
|
|
1920
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
1921
|
+
timerRef.current = null;
|
|
1922
|
+
void client.saveDraft(site, slug, composed.text, baseShaRef.current, {
|
|
1923
|
+
keepalive: true
|
|
1924
|
+
});
|
|
1925
|
+
};
|
|
1926
|
+
window.addEventListener("pagehide", flush);
|
|
1927
|
+
return () => window.removeEventListener("pagehide", flush);
|
|
1928
|
+
}, [client, compose, site, slug]);
|
|
2066
1929
|
const updateMeta = (patch) => {
|
|
2067
1930
|
const next = { ...metaRef.current, ...patch };
|
|
2068
1931
|
for (const [key, value] of Object.entries(next)) {
|
|
@@ -2081,7 +1944,20 @@ function LoadedEditor({
|
|
|
2081
1944
|
void save();
|
|
2082
1945
|
};
|
|
2083
1946
|
const takeTheirs = () => {
|
|
2084
|
-
if (conflict)
|
|
1947
|
+
if (!conflict) return;
|
|
1948
|
+
keepUnsaved(site, slug, null);
|
|
1949
|
+
onReload(conflict);
|
|
1950
|
+
};
|
|
1951
|
+
const dismissRecovered = () => {
|
|
1952
|
+
recoveredRef.current = null;
|
|
1953
|
+
const composed = compose();
|
|
1954
|
+
keepUnsaved(
|
|
1955
|
+
site,
|
|
1956
|
+
slug,
|
|
1957
|
+
composed && composed.text !== savedTextRef.current ? composed.text : null
|
|
1958
|
+
);
|
|
1959
|
+
setRecovered(null);
|
|
1960
|
+
setRestoreError(null);
|
|
2085
1961
|
};
|
|
2086
1962
|
const flushSave = async () => {
|
|
2087
1963
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
@@ -2089,6 +1965,34 @@ function LoadedEditor({
|
|
|
2089
1965
|
return save();
|
|
2090
1966
|
};
|
|
2091
1967
|
const freeze = (on) => editorRef.current?.setEditable(!on, false);
|
|
1968
|
+
const restoreRecovered = async () => {
|
|
1969
|
+
if (recovered === null || publishingRef.current) return;
|
|
1970
|
+
setRestoreError(null);
|
|
1971
|
+
setRestoring(true);
|
|
1972
|
+
publishingRef.current = true;
|
|
1973
|
+
freeze(true);
|
|
1974
|
+
if (timerRef.current) {
|
|
1975
|
+
clearTimeout(timerRef.current);
|
|
1976
|
+
timerRef.current = null;
|
|
1977
|
+
await save();
|
|
1978
|
+
}
|
|
1979
|
+
while (inflightRef.current) await inflightRef.current;
|
|
1980
|
+
const request = client.saveDraft(site, slug, recovered, baseShaRef.current);
|
|
1981
|
+
inflightRef.current = request.then((result2) => result2.kind === "saved");
|
|
1982
|
+
const result = await request;
|
|
1983
|
+
inflightRef.current = null;
|
|
1984
|
+
if (result.kind === "saved") {
|
|
1985
|
+
keepUnsaved(site, slug, null);
|
|
1986
|
+
onReload(result.draft);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
publishingRef.current = false;
|
|
1990
|
+
freeze(false);
|
|
1991
|
+
setRestoring(false);
|
|
1992
|
+
setRestoreError(
|
|
1993
|
+
result.kind === "conflict" ? "This draft was changed since those edits were made, so they were not restored." : result.kind === "rejected" ? result.detail : result.message
|
|
1994
|
+
);
|
|
1995
|
+
};
|
|
2092
1996
|
const handlePublish = async () => {
|
|
2093
1997
|
setBusy("publishing");
|
|
2094
1998
|
setMessage(null);
|
|
@@ -2140,6 +2044,7 @@ function LoadedEditor({
|
|
|
2140
2044
|
const result = await client.discardDraft(site, slug);
|
|
2141
2045
|
setBusy(null);
|
|
2142
2046
|
if (result.kind === "ok" || result.kind === "error" && result.status === 404) {
|
|
2047
|
+
keepUnsaved(site, slug, null);
|
|
2143
2048
|
setPublished(true);
|
|
2144
2049
|
onDiscarded();
|
|
2145
2050
|
return;
|
|
@@ -2180,7 +2085,7 @@ function LoadedEditor({
|
|
|
2180
2085
|
}, [controller]);
|
|
2181
2086
|
if (!editor) return null;
|
|
2182
2087
|
const canSave = status !== "conflict";
|
|
2183
|
-
return /* @__PURE__ */ jsxs7("div", { "data-testid": "blog-editor", children: [
|
|
2088
|
+
return /* @__PURE__ */ jsxs7("div", { "data-testid": "blog-editor", className: EDITOR_SCOPE_CLASS, children: [
|
|
2184
2089
|
/* @__PURE__ */ jsx7(
|
|
2185
2090
|
Toolbar,
|
|
2186
2091
|
{
|
|
@@ -2240,7 +2145,12 @@ function LoadedEditor({
|
|
|
2240
2145
|
type: "button",
|
|
2241
2146
|
className: "rounded-md border border-[#D9D5CC] px-3 py-1",
|
|
2242
2147
|
"data-testid": "blog-leave-anyway",
|
|
2243
|
-
onClick: () =>
|
|
2148
|
+
onClick: () => {
|
|
2149
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
2150
|
+
timerRef.current = null;
|
|
2151
|
+
keepUnsaved(site, slug, null);
|
|
2152
|
+
leaveToRef.current();
|
|
2153
|
+
},
|
|
2244
2154
|
children: "Leave without saving"
|
|
2245
2155
|
}
|
|
2246
2156
|
)
|
|
@@ -2248,6 +2158,43 @@ function LoadedEditor({
|
|
|
2248
2158
|
]
|
|
2249
2159
|
}
|
|
2250
2160
|
) : null,
|
|
2161
|
+
recovered !== null ? /* @__PURE__ */ jsxs7(
|
|
2162
|
+
"div",
|
|
2163
|
+
{
|
|
2164
|
+
className: "mb-4 rounded-lg border border-[#F1D6A3] bg-[#FFF9EC] px-4 py-3 text-sm",
|
|
2165
|
+
role: "alert",
|
|
2166
|
+
"data-testid": "blog-unsaved-banner",
|
|
2167
|
+
children: [
|
|
2168
|
+
/* @__PURE__ */ jsx7("p", { className: "font-medium", children: "This tab has edits to this post that were never saved." }),
|
|
2169
|
+
/* @__PURE__ */ jsx7("p", { className: "mt-1 text-[#4A4740]", children: "Restoring saves them over the draft as it is now." }),
|
|
2170
|
+
restoreError ? /* @__PURE__ */ jsx7("p", { className: "mt-1 text-red-800", children: restoreError }) : null,
|
|
2171
|
+
/* @__PURE__ */ jsxs7("div", { className: "mt-3 flex gap-2", children: [
|
|
2172
|
+
/* @__PURE__ */ jsx7(
|
|
2173
|
+
"button",
|
|
2174
|
+
{
|
|
2175
|
+
type: "button",
|
|
2176
|
+
className: "rounded-md bg-[#111110] px-3 py-1 text-white",
|
|
2177
|
+
"data-testid": "blog-unsaved-restore",
|
|
2178
|
+
disabled: restoring,
|
|
2179
|
+
onClick: () => void restoreRecovered(),
|
|
2180
|
+
children: "Restore them"
|
|
2181
|
+
}
|
|
2182
|
+
),
|
|
2183
|
+
/* @__PURE__ */ jsx7(
|
|
2184
|
+
"button",
|
|
2185
|
+
{
|
|
2186
|
+
type: "button",
|
|
2187
|
+
className: "rounded-md border border-[#D9D5CC] px-3 py-1",
|
|
2188
|
+
"data-testid": "blog-unsaved-dismiss",
|
|
2189
|
+
disabled: restoring,
|
|
2190
|
+
onClick: dismissRecovered,
|
|
2191
|
+
children: "Discard them"
|
|
2192
|
+
}
|
|
2193
|
+
)
|
|
2194
|
+
] })
|
|
2195
|
+
]
|
|
2196
|
+
}
|
|
2197
|
+
) : null,
|
|
2251
2198
|
published ? /* @__PURE__ */ jsx7(
|
|
2252
2199
|
"p",
|
|
2253
2200
|
{
|