@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 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. The editor is styled with Tailwind
61
- utilities: a Tailwind v4 host adds `@source` for this package's
62
- `src/editor` in this workspace, or `dist/editor` when it installs the
63
- package from npm.
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
- setStatus(
1951
- again && again.text !== savedTextRef.current ? "dirty" : "saved"
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) clearTimeout(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) onReload(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: () => leaveToRef.current(),
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
  {