@codepress/blog 0.0.0-stage → 0.1.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.
Files changed (61) hide show
  1. package/README.md +118 -2
  2. package/dist/blog.css +227 -0
  3. package/dist/chunks/BlogEditor-VF3UAJB6.js +2527 -0
  4. package/dist/chunks/chunk-3GCZFCUK.js +51 -0
  5. package/dist/chunks/chunk-3WRTL63O.js +106 -0
  6. package/dist/chunks/chunk-AEQDRBA3.js +128 -0
  7. package/dist/chunks/chunk-AJVVTSKZ.js +90 -0
  8. package/dist/chunks/chunk-EICE5ONB.js +197 -0
  9. package/dist/chunks/chunk-HWR3R5MU.js +48 -0
  10. package/dist/chunks/chunk-QX6276YA.js +0 -0
  11. package/dist/chunks/chunk-TYX2HJZC.js +68 -0
  12. package/dist/chunks/chunk-UA243KAQ.js +104 -0
  13. package/dist/chunks/chunk-WJVZD4UI.js +114 -0
  14. package/dist/chunks/chunk-XLJBCIXI.js +93 -0
  15. package/dist/cli.js +440 -0
  16. package/dist/content-source.d.ts +31 -0
  17. package/dist/editor/BlogEditor.d.ts +37 -0
  18. package/dist/editor/BlogEditorMount.d.ts +64 -0
  19. package/dist/editor/DocumentActions.d.ts +39 -0
  20. package/dist/editor/EditDesignLink.d.ts +30 -0
  21. package/dist/editor/NewPostForm.d.ts +10 -0
  22. package/dist/editor/SlashMenu.d.ts +5 -0
  23. package/dist/editor/Toolbar.d.ts +39 -0
  24. package/dist/editor/blocks.d.ts +5 -0
  25. package/dist/editor/client.d.ts +88 -0
  26. package/dist/editor/client.js +6 -0
  27. package/dist/editor/component-node.d.ts +14 -0
  28. package/dist/editor/config.d.ts +24 -0
  29. package/dist/editor/document.d.ts +47 -0
  30. package/dist/editor/extensions.d.ts +19 -0
  31. package/dist/editor/header.d.ts +20 -0
  32. package/dist/editor/index.d.ts +11 -0
  33. package/dist/editor/index.js +209 -0
  34. package/dist/editor/paste-guard.d.ts +14 -0
  35. package/dist/editor/primitives.d.ts +17 -0
  36. package/dist/editor/props-form.d.ts +17 -0
  37. package/dist/editor/serialize.d.ts +34 -0
  38. package/dist/editor/slash-command.d.ts +21 -0
  39. package/dist/feed.d.ts +4 -0
  40. package/dist/index.d.ts +8 -0
  41. package/dist/index.js +72 -0
  42. package/dist/next/app.d.ts +34 -0
  43. package/dist/next/config.d.ts +44 -0
  44. package/dist/next/index.d.ts +2 -0
  45. package/dist/next/index.js +94 -0
  46. package/dist/next/pages.d.ts +33 -0
  47. package/dist/next/pages.js +167 -0
  48. package/dist/next/routes.d.ts +14 -0
  49. package/dist/next/routes.js +37 -0
  50. package/dist/next/views.d.ts +18 -0
  51. package/dist/react/components.d.ts +18 -0
  52. package/dist/react/index.d.ts +2 -0
  53. package/dist/react/index.js +15 -0
  54. package/dist/react/render.d.ts +7 -0
  55. package/dist/registry.d.ts +95 -0
  56. package/dist/sanitize.d.ts +33 -0
  57. package/dist/schema.d.ts +24 -0
  58. package/dist/seo.d.ts +63 -0
  59. package/dist/sitemap.d.ts +4 -0
  60. package/dist/xml.d.ts +1 -0
  61. package/package.json +92 -4
@@ -0,0 +1,2527 @@
1
+ import "./chunk-HWR3R5MU.js";
2
+ import "./chunk-QX6276YA.js";
3
+ import {
4
+ COMPONENT_VIEWS
5
+ } from "./chunk-AJVVTSKZ.js";
6
+ import {
7
+ DROP_WITH_CONTENT,
8
+ sanitizeNodes,
9
+ sanitizeUrl
10
+ } from "./chunk-EICE5ONB.js";
11
+ import "./chunk-TYX2HJZC.js";
12
+ 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
+ import {
21
+ BLOCK_ID_ATTRIBUTE,
22
+ CALLOUT_TYPES,
23
+ COMPONENTS,
24
+ PROPS_ATTRIBUTE,
25
+ postMetaSchema
26
+ } from "./chunk-XLJBCIXI.js";
27
+
28
+ // src/editor/BlogEditor.tsx
29
+ import { EditorContent, useEditor } from "@tiptap/react";
30
+ import { useCallback, useEffect as useEffect2, useMemo as useMemo2, useRef as useRef2, useState as useState3 } from "react";
31
+
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
+ // src/editor/extensions.ts
234
+ import { Extension as Extension3 } from "@tiptap/react";
235
+ import { Plugin as Plugin2 } from "@tiptap/pm/state";
236
+ import Image from "@tiptap/extension-image";
237
+ import StarterKit from "@tiptap/starter-kit";
238
+ import UniqueID from "@tiptap/extension-unique-id";
239
+
240
+ // src/editor/component-node.tsx
241
+ import {
242
+ Node,
243
+ NodeViewContent,
244
+ NodeViewWrapper,
245
+ ReactNodeViewRenderer,
246
+ mergeAttributes
247
+ } from "@tiptap/react";
248
+ import { useState } from "react";
249
+
250
+ // src/editor/props-form.tsx
251
+ import { useMemo } from "react";
252
+ import { z } from "zod";
253
+ import { jsx, jsxs } from "react/jsx-runtime";
254
+ function fieldsFor(schema) {
255
+ const json = z.toJSONSchema(schema);
256
+ return Object.entries(json.properties ?? {}).map(([name, property]) => {
257
+ const kind = property.enum ? "enum" : property.type === "integer" || property.type === "number" ? "number" : property.type === "boolean" ? "boolean" : "text";
258
+ return {
259
+ name,
260
+ kind,
261
+ options: (property.enum ?? []).map(String),
262
+ required: json.required?.includes(name) ?? false
263
+ };
264
+ });
265
+ }
266
+ var inputClass = "w-full rounded-md border border-[#D9D5CC] bg-white px-2 py-1 text-sm text-[#111110]";
267
+ function PropsForm({ schema, value, onChange }) {
268
+ const fields = useMemo(() => fieldsFor(schema), [schema]);
269
+ const errors = /* @__PURE__ */ new Map();
270
+ const parsed = schema.safeParse(value);
271
+ if (!parsed.success) {
272
+ for (const issue of parsed.error.issues) {
273
+ errors.set(String(issue.path[0] ?? ""), issue.message);
274
+ }
275
+ }
276
+ const set = (name, next) => {
277
+ const copy = { ...value };
278
+ if (next === "" || next === void 0 || Number.isNaN(next))
279
+ delete copy[name];
280
+ else copy[name] = next;
281
+ onChange(copy);
282
+ };
283
+ return /* @__PURE__ */ jsx("div", { className: "space-y-2", "data-testid": "blog-props-form", children: fields.map((field) => /* @__PURE__ */ jsxs("label", { className: "block text-xs text-[#6B6760]", children: [
284
+ /* @__PURE__ */ jsxs("span", { className: "mb-1 block font-medium", children: [
285
+ field.name,
286
+ field.required ? " *" : ""
287
+ ] }),
288
+ field.kind === "enum" ? /* @__PURE__ */ jsxs(
289
+ "select",
290
+ {
291
+ className: inputClass,
292
+ "data-testid": `blog-prop-${field.name}`,
293
+ value: String(value[field.name] ?? ""),
294
+ onChange: (event) => set(field.name, event.target.value),
295
+ children: [
296
+ field.required ? null : /* @__PURE__ */ jsx("option", { value: "" }),
297
+ field.options.map((option) => /* @__PURE__ */ jsx("option", { value: option, children: option }, option))
298
+ ]
299
+ }
300
+ ) : field.kind === "boolean" ? /* @__PURE__ */ jsx(
301
+ "input",
302
+ {
303
+ type: "checkbox",
304
+ "data-testid": `blog-prop-${field.name}`,
305
+ checked: value[field.name] === true,
306
+ onChange: (event) => set(field.name, event.target.checked)
307
+ }
308
+ ) : /* @__PURE__ */ jsx(
309
+ "input",
310
+ {
311
+ className: inputClass,
312
+ "data-testid": `blog-prop-${field.name}`,
313
+ type: field.kind === "number" ? "number" : "text",
314
+ value: String(value[field.name] ?? ""),
315
+ onChange: (event) => set(
316
+ field.name,
317
+ field.kind === "number" ? event.target.valueAsNumber : event.target.value
318
+ )
319
+ }
320
+ ),
321
+ errors.has(field.name) ? /* @__PURE__ */ jsx("span", { className: "mt-1 block text-red-600", children: errors.get(field.name) }) : null
322
+ ] }, field.name)) });
323
+ }
324
+
325
+ // src/editor/component-node.tsx
326
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
327
+ var DEFAULT_PROPS = {
328
+ "cp-callout": { type: "info" },
329
+ // Invalid on purpose: the props form opens and asks for a real image URL.
330
+ "cp-figure": { src: "", alt: "" }
331
+ };
332
+ function componentNodeName(tag) {
333
+ return tag.replace(/-(\w)/g, (_, letter) => letter.toUpperCase());
334
+ }
335
+ function parseProps(element) {
336
+ try {
337
+ const value = JSON.parse(
338
+ element.getAttribute(PROPS_ATTRIBUTE) ?? "{}"
339
+ );
340
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
341
+ } catch {
342
+ return {};
343
+ }
344
+ }
345
+ function ComponentView({ node, updateAttributes, extension }) {
346
+ const tag = extension.options.tag;
347
+ const definition = COMPONENTS[tag];
348
+ const props = node.attrs.props;
349
+ const parsed = definition.props.safeParse(props);
350
+ const [open, setOpen] = useState(!parsed.success);
351
+ const Component = COMPONENT_VIEWS[tag];
352
+ return /* @__PURE__ */ jsxs2(NodeViewWrapper, { "data-testid": `blog-node-${tag}`, className: "relative", children: [
353
+ parsed.success ? /* @__PURE__ */ jsx2(Component, { ...parsed.data, children: definition.content === "none" ? null : /* @__PURE__ */ jsx2(NodeViewContent, {}) }) : /* @__PURE__ */ jsxs2("div", { className: "my-6 rounded-xl border border-dashed border-red-300 px-5 py-4 text-sm text-red-700", children: [
354
+ "<",
355
+ tag,
356
+ "> needs valid props before it can be saved.",
357
+ definition.content === "none" ? null : /* @__PURE__ */ jsx2(NodeViewContent, {})
358
+ ] }),
359
+ /* @__PURE__ */ jsxs2("div", { contentEditable: false, className: "absolute top-2 right-2 z-10", children: [
360
+ /* @__PURE__ */ jsx2(
361
+ "button",
362
+ {
363
+ type: "button",
364
+ className: "rounded-md border border-[#D9D5CC] bg-white px-2 py-0.5 text-xs text-[#4A4740] shadow-sm",
365
+ "data-testid": "blog-node-props-button",
366
+ onClick: () => setOpen((value) => !value),
367
+ children: "Props"
368
+ }
369
+ ),
370
+ open ? /* @__PURE__ */ jsx2(
371
+ "div",
372
+ {
373
+ className: "mt-1 w-64 rounded-lg border border-[#D9D5CC] bg-white p-3 shadow-lg",
374
+ "data-testid": "blog-node-props-popover",
375
+ children: /* @__PURE__ */ jsx2(
376
+ PropsForm,
377
+ {
378
+ schema: definition.props,
379
+ value: props,
380
+ onChange: (next) => updateAttributes({ props: next })
381
+ }
382
+ )
383
+ }
384
+ ) : null
385
+ ] })
386
+ ] });
387
+ }
388
+ function componentNode(tag) {
389
+ const definition = COMPONENTS[tag];
390
+ return Node.create({
391
+ name: componentNodeName(tag),
392
+ group: "block",
393
+ content: definition.content === "block" ? "block+" : void 0,
394
+ atom: definition.content === "none",
395
+ draggable: true,
396
+ addOptions: () => ({ tag }),
397
+ addAttributes() {
398
+ return {
399
+ props: {
400
+ default: DEFAULT_PROPS[tag],
401
+ parseHTML: parseProps,
402
+ rendered: false
403
+ }
404
+ };
405
+ },
406
+ parseHTML() {
407
+ return [{ tag }];
408
+ },
409
+ renderHTML({ node, HTMLAttributes }) {
410
+ const attributes = mergeAttributes(HTMLAttributes, {
411
+ [PROPS_ATTRIBUTE]: JSON.stringify(node.attrs.props)
412
+ });
413
+ return definition.content === "none" ? [tag, attributes] : [tag, attributes, 0];
414
+ },
415
+ addNodeView() {
416
+ return ReactNodeViewRenderer(ComponentView);
417
+ }
418
+ });
419
+ }
420
+
421
+ // src/editor/paste-guard.ts
422
+ import { Extension } from "@tiptap/react";
423
+ import { Plugin } from "@tiptap/pm/state";
424
+ function pasteRejections(html) {
425
+ return sanitizeNodes(html).issues.filter((issue) => {
426
+ if (issue.kind === "url" || issue.kind === "props") return true;
427
+ if (issue.kind === "attribute") return issue.name?.startsWith("on");
428
+ return DROP_WITH_CONTENT.has(issue.tag) || issue.tag.startsWith("cp-");
429
+ }).map((issue) => issue.message);
430
+ }
431
+ var PasteGuard = Extension.create({
432
+ name: "pasteGuard",
433
+ addOptions: () => ({ onReject: () => {
434
+ } }),
435
+ addProseMirrorPlugins() {
436
+ const { onReject } = this.options;
437
+ return [
438
+ new Plugin({
439
+ props: {
440
+ handlePaste: (_view, event) => {
441
+ const html = event.clipboardData?.getData("text/html") ?? "";
442
+ const reasons = html ? pasteRejections(html) : [];
443
+ if (reasons.length === 0) return false;
444
+ onReject([...new Set(reasons)]);
445
+ return true;
446
+ }
447
+ }
448
+ })
449
+ ];
450
+ }
451
+ });
452
+
453
+ // src/editor/slash-command.ts
454
+ import { Extension as Extension2 } from "@tiptap/react";
455
+ import Suggestion from "@tiptap/suggestion";
456
+ function matches(item, query) {
457
+ const needle = query.trim().toLowerCase();
458
+ return !needle || item.title.toLowerCase().includes(needle) || item.id.includes(needle);
459
+ }
460
+ var SlashCommand = Extension2.create({
461
+ name: "slashCommand",
462
+ addOptions: () => ({ items: [], onState: () => {
463
+ } }),
464
+ addProseMirrorPlugins() {
465
+ const { items, onState } = this.options;
466
+ return [
467
+ Suggestion({
468
+ editor: this.editor,
469
+ char: "/",
470
+ startOfLine: true,
471
+ allowSpaces: false,
472
+ items: ({ query }) => items.filter((item) => matches(item, query)),
473
+ command: ({ editor, range, props }) => props.run(editor, range),
474
+ render: () => {
475
+ let current = null;
476
+ let index = 0;
477
+ const emit = () => {
478
+ const props = current;
479
+ if (!props) return onState(null);
480
+ onState({
481
+ items: props.items,
482
+ index,
483
+ rect: props.clientRect?.() ?? null,
484
+ choose: (choice) => {
485
+ const item = props.items[choice];
486
+ if (item) props.command(item);
487
+ }
488
+ });
489
+ };
490
+ return {
491
+ onStart: (props) => {
492
+ current = props;
493
+ index = 0;
494
+ emit();
495
+ },
496
+ onUpdate: (props) => {
497
+ current = props;
498
+ index = Math.min(index, Math.max(props.items.length - 1, 0));
499
+ emit();
500
+ },
501
+ onKeyDown: ({ event }) => {
502
+ const count = current?.items.length ?? 0;
503
+ if (!current || count === 0) return false;
504
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
505
+ index = (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count;
506
+ emit();
507
+ return true;
508
+ }
509
+ if (event.key === "Enter" || event.key === "Tab") {
510
+ const item = current.items[index];
511
+ if (item) current.command(item);
512
+ return true;
513
+ }
514
+ return false;
515
+ },
516
+ onExit: () => {
517
+ current = null;
518
+ onState(null);
519
+ }
520
+ };
521
+ }
522
+ })
523
+ ];
524
+ }
525
+ });
526
+
527
+ // src/editor/extensions.ts
528
+ var HEADING_LEVELS = [2, 3, 4];
529
+ var HeadingId = Extension3.create({
530
+ name: "headingId",
531
+ addGlobalAttributes() {
532
+ return [
533
+ {
534
+ types: ["heading"],
535
+ attributes: {
536
+ id: {
537
+ default: null,
538
+ parseHTML: (element) => element.getAttribute("id"),
539
+ renderHTML: (attributes) => attributes.id ? { id: attributes.id } : {}
540
+ }
541
+ }
542
+ }
543
+ ];
544
+ }
545
+ });
546
+ var PostImage = Image.extend({
547
+ addAttributes() {
548
+ return { ...this.parent?.(), title: { default: null, rendered: false } };
549
+ }
550
+ });
551
+ var COMPONENT_TAGS = Object.keys(COMPONENTS);
552
+ var ID_TYPES = [
553
+ "paragraph",
554
+ "heading",
555
+ "bulletList",
556
+ "orderedList",
557
+ "blockquote",
558
+ "codeBlock",
559
+ "horizontalRule",
560
+ ...COMPONENT_TAGS.map(componentNodeName)
561
+ ];
562
+ function generateBlockId() {
563
+ return `b${Math.random().toString(36).slice(2, 10)}`;
564
+ }
565
+ var BLOCK_ID_NODE_ATTRIBUTE = BLOCK_ID_ATTRIBUTE.replace(/^data-/, "");
566
+ var FreezeGuard = Extension3.create({
567
+ name: "freezeGuard",
568
+ addOptions: () => ({ isFrozen: void 0 }),
569
+ addProseMirrorPlugins() {
570
+ const { isFrozen } = this.options;
571
+ return [
572
+ new Plugin2({
573
+ filterTransaction: (transaction) => !(isFrozen?.() && transaction.docChanged)
574
+ })
575
+ ];
576
+ }
577
+ });
578
+ var LinkShortcut = Extension3.create({
579
+ name: "linkShortcut",
580
+ addOptions: () => ({ onLink: void 0 }),
581
+ addKeyboardShortcuts() {
582
+ return {
583
+ "Mod-k": () => {
584
+ this.options.onLink?.();
585
+ return true;
586
+ }
587
+ };
588
+ }
589
+ });
590
+ function editorExtensions(hooks = {}) {
591
+ return [
592
+ StarterKit.configure({
593
+ heading: { levels: HEADING_LEVELS },
594
+ strike: false,
595
+ underline: false,
596
+ trailingNode: false,
597
+ link: {
598
+ openOnClick: false,
599
+ autolink: false,
600
+ linkOnPaste: false,
601
+ // The schema has no default target/rel; keep what the source says.
602
+ HTMLAttributes: { target: null, rel: null, class: null }
603
+ }
604
+ }),
605
+ HeadingId,
606
+ PostImage.configure({ inline: true, allowBase64: false }),
607
+ ...COMPONENT_TAGS.map(componentNode),
608
+ UniqueID.configure({
609
+ attributeName: BLOCK_ID_NODE_ATTRIBUTE,
610
+ types: ID_TYPES,
611
+ generateID: generateBlockId
612
+ }),
613
+ FreezeGuard.configure({ isFrozen: hooks.isFrozen }),
614
+ PasteGuard.configure({ onReject: hooks.onPasteRejected }),
615
+ LinkShortcut.configure({ onLink: hooks.onLinkShortcut }),
616
+ SlashCommand.configure({
617
+ items: hooks.slashItems ?? [],
618
+ onState: hooks.onSlash
619
+ })
620
+ ];
621
+ }
622
+
623
+ // src/editor/serialize.ts
624
+ import { getSchema } from "@tiptap/react";
625
+ import {
626
+ DOMParser as ProseMirrorParser,
627
+ DOMSerializer
628
+ } from "@tiptap/pm/model";
629
+ var cachedSchema = null;
630
+ function postSchema() {
631
+ cachedSchema ??= getSchema(editorExtensions());
632
+ return cachedSchema;
633
+ }
634
+ function unwrapListParagraphs(host) {
635
+ host.querySelectorAll("li").forEach((item) => {
636
+ const only = item.children.length === 1 ? item.children[0] : null;
637
+ if (!only || only.tagName !== "P" || item.childNodes.length !== 1 || [...only.attributes].some((a) => a.name !== BLOCK_ID_ATTRIBUTE)) {
638
+ return;
639
+ }
640
+ item.replaceChildren(...Array.from(only.childNodes));
641
+ });
642
+ }
643
+ function quoteProps(html) {
644
+ return html.replace(/ data-props="([^"]*)"/g, (whole, value) => {
645
+ const json = value.replace(/&quot;/g, '"');
646
+ return /['&]/.test(json) ? whole : ` data-props='${json}'`;
647
+ });
648
+ }
649
+ function nodeToHtml(schema, node) {
650
+ const host = document.createElement("div");
651
+ host.appendChild(DOMSerializer.fromSchema(schema).serializeNode(node));
652
+ unwrapListParagraphs(host);
653
+ return quoteProps(host.innerHTML);
654
+ }
655
+ function parseBody(schema, html) {
656
+ const template = document.createElement("template");
657
+ template.innerHTML = html;
658
+ return ProseMirrorParser.fromSchema(schema).parse(template.content);
659
+ }
660
+ function editabilityProblems(source) {
661
+ const problems = [...source.problems];
662
+ const schema = postSchema();
663
+ const doc = parseBody(schema, source.blocks.map((b) => b.text).join("\n"));
664
+ if (doc.childCount !== source.blocks.length) {
665
+ problems.push(
666
+ `The editor reads ${doc.childCount} blocks where the file has ${source.blocks.length}.`
667
+ );
668
+ return problems;
669
+ }
670
+ source.blocks.forEach((block, index) => {
671
+ const written = nodeToHtml(schema, doc.child(index));
672
+ if (canonicalBlock(written) !== canonicalBlock(block.text)) {
673
+ problems.push(
674
+ `Block ${index + 1} (<${block.tag}>${block.id ? ` ${block.id}` : ""}) uses markup the editor cannot represent.`
675
+ );
676
+ }
677
+ });
678
+ return problems;
679
+ }
680
+ function sourceBodyHtml(source) {
681
+ const indexById = /* @__PURE__ */ new Map();
682
+ const html = source.blocks.map((block, index) => {
683
+ const id = block.id ?? generateBlockId();
684
+ indexById.set(id, index);
685
+ return block.id ? block.text : block.text.replace(
686
+ /^<([a-z][\w-]*)/i,
687
+ `<$1 ${BLOCK_ID_ATTRIBUTE}="${id}"`
688
+ );
689
+ }).join("\n");
690
+ return { html, indexById };
691
+ }
692
+ function blockIndexById(doc) {
693
+ const map = /* @__PURE__ */ new Map();
694
+ doc.forEach((child, _offset, index) => {
695
+ const id = child.attrs[BLOCK_ID_NODE_ATTRIBUTE];
696
+ if (id) map.set(id, index);
697
+ });
698
+ return map;
699
+ }
700
+ function editedBlocks(doc, source, indexById) {
701
+ const schema = postSchema();
702
+ const blocks = [];
703
+ doc.forEach((child) => {
704
+ const html = nodeToHtml(schema, child);
705
+ const id = child.attrs[BLOCK_ID_NODE_ATTRIBUTE];
706
+ const index = id ? indexById.get(id) : void 0;
707
+ const original = index === void 0 ? void 0 : source?.blocks[index];
708
+ const unchanged = original && canonicalBlock(original.text) === canonicalBlock(html);
709
+ blocks.push({
710
+ html,
711
+ sourceIndex: unchanged ? index ?? null : null,
712
+ replaces: original ? index ?? null : null
713
+ });
714
+ });
715
+ return blocks;
716
+ }
717
+ function localProblems(meta, blocks) {
718
+ const problems = [];
719
+ const parsedMeta = postMetaSchema.safeParse(meta);
720
+ if (!parsedMeta.success) {
721
+ for (const issue of parsedMeta.error.issues) {
722
+ problems.push({
723
+ message: `${issue.path.join(".") || "metadata"}: ${issue.message}`
724
+ });
725
+ }
726
+ }
727
+ const body = blocks.map((block) => block.html).join("\n");
728
+ for (const issue of sanitizeNodes(body).issues) {
729
+ problems.push({ message: issue.message });
730
+ }
731
+ return problems;
732
+ }
733
+
734
+ // src/editor/blocks.ts
735
+ var TEXT_BLOCK_IDS = /* @__PURE__ */ new Set([
736
+ "h2",
737
+ "h3",
738
+ "bullet-list",
739
+ "ordered-list",
740
+ "quote"
741
+ ]);
742
+ var SLASH_ITEMS = [
743
+ {
744
+ id: "h2",
745
+ title: "Heading 2",
746
+ description: "A section heading",
747
+ run: (e, range) => e.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run()
748
+ },
749
+ {
750
+ id: "h3",
751
+ title: "Heading 3",
752
+ description: "A subsection heading",
753
+ run: (e, range) => e.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run()
754
+ },
755
+ {
756
+ id: "bullet-list",
757
+ title: "Bulleted list",
758
+ description: "A list of points",
759
+ run: (e, range) => e.chain().focus().deleteRange(range).toggleBulletList().run()
760
+ },
761
+ {
762
+ id: "ordered-list",
763
+ title: "Numbered list",
764
+ description: "A list of steps",
765
+ run: (e, range) => e.chain().focus().deleteRange(range).toggleOrderedList().run()
766
+ },
767
+ {
768
+ id: "quote",
769
+ title: "Quote",
770
+ description: "A block quote",
771
+ run: (e, range) => e.chain().focus().deleteRange(range).toggleBlockquote().run()
772
+ },
773
+ {
774
+ id: "code-block",
775
+ title: "Code block",
776
+ description: "Preformatted code",
777
+ run: (e, range) => e.chain().focus().deleteRange(range).toggleCodeBlock().run()
778
+ },
779
+ {
780
+ id: "divider",
781
+ title: "Divider",
782
+ description: "A horizontal rule",
783
+ run: (e, range) => e.chain().focus().deleteRange(range).setHorizontalRule().run()
784
+ },
785
+ ...CALLOUT_TYPES.map(
786
+ (type) => ({
787
+ id: `callout-${type}`,
788
+ title: `Callout (${type})`,
789
+ description: "Highlighted note with its own text",
790
+ run: (e, range) => e.chain().focus().deleteRange(range).insertContent({
791
+ type: "cpCallout",
792
+ attrs: { props: { ...DEFAULT_PROPS["cp-callout"], type } },
793
+ content: [{ type: "paragraph" }]
794
+ }).run()
795
+ })
796
+ ),
797
+ {
798
+ id: "figure",
799
+ title: "Figure",
800
+ description: "An image with a caption",
801
+ run: (e, range) => e.chain().focus().deleteRange(range).insertContent({ type: "cpFigure" }).run()
802
+ }
803
+ ];
804
+ var INSERT_ITEMS = SLASH_ITEMS.filter(
805
+ (item) => !TEXT_BLOCK_IDS.has(item.id)
806
+ );
807
+
808
+ // src/editor/DocumentActions.tsx
809
+ import {
810
+ Ellipsis,
811
+ GitPullRequest,
812
+ History,
813
+ LogOut,
814
+ MonitorPlay,
815
+ Trash2
816
+ } from "lucide-react";
817
+
818
+ // src/editor/primitives.tsx
819
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
820
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
821
+ import { twMerge } from "tailwind-merge";
822
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
823
+ function DropdownMenu(props) {
824
+ return /* @__PURE__ */ jsx3(DropdownMenuPrimitive.Root, { "data-slot": "dropdown-menu", ...props });
825
+ }
826
+ function DropdownMenuTrigger(props) {
827
+ return /* @__PURE__ */ jsx3(
828
+ DropdownMenuPrimitive.Trigger,
829
+ {
830
+ "data-slot": "dropdown-menu-trigger",
831
+ ...props
832
+ }
833
+ );
834
+ }
835
+ function DropdownMenuContent({
836
+ className,
837
+ sideOffset = 4,
838
+ ...props
839
+ }) {
840
+ return /* @__PURE__ */ jsx3(DropdownMenuPrimitive.Portal, { children: /* @__PURE__ */ jsx3(
841
+ DropdownMenuPrimitive.Content,
842
+ {
843
+ "data-slot": "dropdown-menu-content",
844
+ sideOffset,
845
+ className: twMerge(
846
+ "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
+ className
848
+ ),
849
+ ...props
850
+ }
851
+ ) });
852
+ }
853
+ function DropdownMenuItem({
854
+ className,
855
+ ...props
856
+ }) {
857
+ return /* @__PURE__ */ jsx3(
858
+ DropdownMenuPrimitive.Item,
859
+ {
860
+ "data-slot": "dropdown-menu-item",
861
+ "data-variant": "default",
862
+ className: twMerge(
863
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
864
+ className
865
+ ),
866
+ ...props
867
+ }
868
+ );
869
+ }
870
+ function DropdownMenuSeparator({
871
+ className,
872
+ ...props
873
+ }) {
874
+ return /* @__PURE__ */ jsx3(
875
+ DropdownMenuPrimitive.Separator,
876
+ {
877
+ "data-slot": "dropdown-menu-separator",
878
+ className: twMerge("bg-border -mx-1 my-1 h-px", className),
879
+ ...props
880
+ }
881
+ );
882
+ }
883
+ function Tooltip(props) {
884
+ return /* @__PURE__ */ jsx3(TooltipPrimitive.Provider, { "data-slot": "tooltip-provider", delayDuration: 0, children: /* @__PURE__ */ jsx3(TooltipPrimitive.Root, { "data-slot": "tooltip", ...props }) });
885
+ }
886
+ function TooltipTrigger(props) {
887
+ return /* @__PURE__ */ jsx3(TooltipPrimitive.Trigger, { "data-slot": "tooltip-trigger", ...props });
888
+ }
889
+ function TooltipContent({
890
+ className,
891
+ sideOffset = 0,
892
+ children,
893
+ ...props
894
+ }) {
895
+ return /* @__PURE__ */ jsx3(TooltipPrimitive.Portal, { children: /* @__PURE__ */ jsxs3(
896
+ TooltipPrimitive.Content,
897
+ {
898
+ "data-slot": "tooltip-content",
899
+ sideOffset,
900
+ className: twMerge(
901
+ "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
+ className
903
+ ),
904
+ ...props,
905
+ children: [
906
+ children,
907
+ /* @__PURE__ */ jsx3(TooltipPrimitive.Arrow, { className: "bg-white fill-white z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" })
908
+ ]
909
+ }
910
+ ) });
911
+ }
912
+
913
+ // src/editor/Toolbar.tsx
914
+ import { useEditorState } from "@tiptap/react";
915
+ import {
916
+ Bold,
917
+ Check,
918
+ ChevronDown,
919
+ Code,
920
+ Image as ImageIcon,
921
+ Info,
922
+ Italic,
923
+ Lightbulb,
924
+ Link2,
925
+ Link2Off,
926
+ List,
927
+ ListOrdered,
928
+ Minus,
929
+ Plus,
930
+ SquareCode,
931
+ Quote,
932
+ TriangleAlert
933
+ } from "lucide-react";
934
+ import {
935
+ useEffect,
936
+ useLayoutEffect,
937
+ useRef,
938
+ useState as useState2
939
+ } from "react";
940
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
941
+ var toolClass = "inline-flex h-8 min-w-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-[#4A4740] transition-colors hover:bg-[#F1EFE9] hover:text-[#111110] focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40 data-[active=true]:bg-[#E8E5DC] data-[active=true]:text-[#111110] data-[state=open]:bg-[#F1EFE9] data-[state=open]:text-[#111110]";
942
+ var menuClass = "rounded-xl border-[#E6E3DA] bg-white p-1.5 text-[#111110] shadow-[0_8px_24px_-8px_rgba(10,10,10,0.16)]";
943
+ var menuItemClass = "flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] focus:bg-[#F1EFE9] focus:text-[#111110]";
944
+ function ToolbarSeparator({ className = "" }) {
945
+ return /* @__PURE__ */ jsx4(
946
+ "span",
947
+ {
948
+ "aria-hidden": true,
949
+ className: `mx-1 h-5 w-px shrink-0 bg-[#E6E3DA] ${className}`
950
+ }
951
+ );
952
+ }
953
+ function Tip({
954
+ label,
955
+ shortcut,
956
+ children
957
+ }) {
958
+ return /* @__PURE__ */ jsxs4(Tooltip, { delayDuration: 300, children: [
959
+ /* @__PURE__ */ jsx4(TooltipTrigger, { asChild: true, children }),
960
+ /* @__PURE__ */ jsxs4(
961
+ TooltipContent,
962
+ {
963
+ side: "bottom",
964
+ sideOffset: 6,
965
+ className: "flex items-center gap-2 border-[#E6E3DA] px-2 py-1 text-xs text-[#111110]",
966
+ children: [
967
+ /* @__PURE__ */ jsx4("span", { children: label }),
968
+ shortcut ? /* @__PURE__ */ jsx4("kbd", { className: "font-sans text-[11px] text-[#8A867D]", children: shortcut }) : null
969
+ ]
970
+ }
971
+ )
972
+ ] });
973
+ }
974
+ var MAC_KEYS = { Mod: "\u2318", Shift: "\u21E7", Alt: "\u2325" };
975
+ function formatShortcut(combo, mac) {
976
+ const keys = combo.split("-").map((key) => key.length === 1 ? key.toUpperCase() : key);
977
+ return mac ? keys.map((key) => MAC_KEYS[key] ?? key).join("") : keys.map((key) => key === "Mod" ? "Ctrl" : key).join("+");
978
+ }
979
+ function useIsMac() {
980
+ const [mac, setMac] = useState2(false);
981
+ useEffect(() => {
982
+ setMac(
983
+ /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
984
+ );
985
+ }, []);
986
+ return mac;
987
+ }
988
+ var TEXT_STYLES = [
989
+ {
990
+ id: "paragraph",
991
+ label: "Paragraph",
992
+ short: "Text",
993
+ shortcut: "Mod-Alt-0",
994
+ sample: "text-[13px]",
995
+ run: (e) => e.chain().focus().setParagraph().run()
996
+ },
997
+ {
998
+ id: "h2",
999
+ label: "Heading 2",
1000
+ short: "H2",
1001
+ shortcut: "Mod-Alt-2",
1002
+ sample: "text-[17px] font-semibold tracking-[-0.01em]",
1003
+ run: (e) => e.chain().focus().setHeading({ level: 2 }).run()
1004
+ },
1005
+ {
1006
+ id: "h3",
1007
+ label: "Heading 3",
1008
+ short: "H3",
1009
+ shortcut: "Mod-Alt-3",
1010
+ sample: "text-[15px] font-semibold",
1011
+ run: (e) => e.chain().focus().setHeading({ level: 3 }).run()
1012
+ },
1013
+ {
1014
+ id: "h4",
1015
+ label: "Heading 4",
1016
+ short: "H4",
1017
+ shortcut: "Mod-Alt-4",
1018
+ sample: "text-[13px] font-semibold",
1019
+ run: (e) => e.chain().focus().setHeading({ level: 4 }).run()
1020
+ }
1021
+ ];
1022
+ var MARKS = [
1023
+ {
1024
+ id: "bold",
1025
+ label: "Bold",
1026
+ icon: Bold,
1027
+ shortcut: "Mod-B",
1028
+ isActive: (e) => e.isActive("bold"),
1029
+ can: (e) => e.can().toggleBold(),
1030
+ run: (e) => e.chain().focus().toggleBold().run()
1031
+ },
1032
+ {
1033
+ id: "italic",
1034
+ label: "Italic",
1035
+ icon: Italic,
1036
+ shortcut: "Mod-I",
1037
+ isActive: (e) => e.isActive("italic"),
1038
+ can: (e) => e.can().toggleItalic(),
1039
+ run: (e) => e.chain().focus().toggleItalic().run()
1040
+ },
1041
+ {
1042
+ id: "code",
1043
+ label: "Inline code",
1044
+ icon: Code,
1045
+ shortcut: "Mod-E",
1046
+ isActive: (e) => e.isActive("code"),
1047
+ can: (e) => e.can().toggleCode(),
1048
+ run: (e) => e.chain().focus().toggleCode().run()
1049
+ }
1050
+ ];
1051
+ var BLOCKS = [
1052
+ {
1053
+ id: "bullet-list",
1054
+ label: "Bulleted list",
1055
+ icon: List,
1056
+ shortcut: "Mod-Shift-8",
1057
+ isActive: (e) => e.isActive("bulletList"),
1058
+ can: (e) => e.can().toggleBulletList(),
1059
+ run: (e) => e.chain().focus().toggleBulletList().run()
1060
+ },
1061
+ {
1062
+ id: "ordered-list",
1063
+ label: "Numbered list",
1064
+ icon: ListOrdered,
1065
+ shortcut: "Mod-Shift-7",
1066
+ isActive: (e) => e.isActive("orderedList"),
1067
+ can: (e) => e.can().toggleOrderedList(),
1068
+ run: (e) => e.chain().focus().toggleOrderedList().run()
1069
+ },
1070
+ {
1071
+ id: "quote",
1072
+ label: "Quote",
1073
+ icon: Quote,
1074
+ shortcut: "Mod-Shift-B",
1075
+ isActive: (e) => e.isActive("blockquote"),
1076
+ can: (e) => e.can().toggleBlockquote(),
1077
+ run: (e) => e.chain().focus().toggleBlockquote().run()
1078
+ }
1079
+ ];
1080
+ var INSERT_ICONS = {
1081
+ "code-block": SquareCode,
1082
+ divider: Minus,
1083
+ "callout-info": Info,
1084
+ "callout-warn": TriangleAlert,
1085
+ "callout-tip": Lightbulb,
1086
+ figure: ImageIcon
1087
+ };
1088
+ var INSERT_SHORTCUTS = { "code-block": "Mod-Alt-C" };
1089
+ function textStyleOf(editor) {
1090
+ for (const level of [2, 3, 4]) {
1091
+ if (editor.isActive("heading", { level })) return `h${level}`;
1092
+ }
1093
+ return editor.isActive("codeBlock") ? "codeBlock" : "paragraph";
1094
+ }
1095
+ function moveFocus(event) {
1096
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
1097
+ const controls = Array.from(
1098
+ event.currentTarget.querySelectorAll("button:enabled")
1099
+ );
1100
+ const index = controls.indexOf(event.target);
1101
+ if (index < 0) return;
1102
+ event.preventDefault();
1103
+ const step = event.key === "ArrowRight" ? 1 : -1;
1104
+ controls[(index + step + controls.length) % controls.length]?.focus();
1105
+ }
1106
+ function Toolbar({
1107
+ editor,
1108
+ frozen,
1109
+ formattingOff,
1110
+ linkOpen,
1111
+ onLinkOpenChange,
1112
+ actions,
1113
+ header
1114
+ }) {
1115
+ const stripRef = useRef(null);
1116
+ const top = useHeaderOffset(header, stripRef);
1117
+ const mac = useIsMac();
1118
+ const [scrolled, setScrolled] = useState2(false);
1119
+ const state = useEditorState({
1120
+ editor,
1121
+ selector: ({ editor: current }) => ({
1122
+ textStyle: textStyleOf(current),
1123
+ canSetStyle: current.can().setParagraph() || current.can().setHeading({ level: 2 }),
1124
+ tools: Object.fromEntries(
1125
+ [...MARKS, ...BLOCKS].map((tool) => [
1126
+ tool.id,
1127
+ { active: tool.isActive(current), can: tool.can(current) }
1128
+ ])
1129
+ ),
1130
+ link: current.isActive("link"),
1131
+ canLink: !current.state.selection.empty || current.isActive("link")
1132
+ })
1133
+ });
1134
+ const [url, setUrl] = useState2("");
1135
+ const [linkError, setLinkError] = useState2(null);
1136
+ useLayoutEffect(() => {
1137
+ const strip = stripRef.current;
1138
+ if (!strip) return;
1139
+ const root = document.documentElement;
1140
+ const update = () => root.style.setProperty(
1141
+ BLOG_TOOLBAR_BOTTOM_VAR,
1142
+ `${top + strip.offsetHeight}px`
1143
+ );
1144
+ update();
1145
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
1146
+ observer?.observe(strip);
1147
+ return () => {
1148
+ observer?.disconnect();
1149
+ root.style.removeProperty(BLOG_TOOLBAR_BOTTOM_VAR);
1150
+ };
1151
+ }, [top]);
1152
+ useEffect(() => {
1153
+ const update = () => setScrolled(window.scrollY > 0);
1154
+ update();
1155
+ window.addEventListener("scroll", update, { passive: true });
1156
+ return () => window.removeEventListener("scroll", update);
1157
+ }, []);
1158
+ const off = frozen || formattingOff;
1159
+ const shortcut = (combo) => formatShortcut(combo, mac);
1160
+ const closeLink = () => {
1161
+ onLinkOpenChange(false);
1162
+ setUrl("");
1163
+ setLinkError(null);
1164
+ };
1165
+ const applyLink = () => {
1166
+ if (frozen) return;
1167
+ const safe = sanitizeUrl(url);
1168
+ if (!safe) {
1169
+ setLinkError("Use an https, http or mailto URL, or a path like /blog.");
1170
+ return;
1171
+ }
1172
+ editor.chain().focus().extendMarkRange("link").setLink({ href: safe }).run();
1173
+ closeLink();
1174
+ };
1175
+ const toggle = (tool) => {
1176
+ const status = state.tools[tool.id];
1177
+ return /* @__PURE__ */ jsx4(Tip, { label: tool.label, shortcut: shortcut(tool.shortcut), children: /* @__PURE__ */ jsx4(
1178
+ "button",
1179
+ {
1180
+ type: "button",
1181
+ className: toolClass,
1182
+ "aria-label": tool.label,
1183
+ "aria-pressed": status?.active === true,
1184
+ "data-active": !off && status?.active === true,
1185
+ "data-testid": `blog-tool-${tool.id}`,
1186
+ disabled: off || status?.can === false,
1187
+ onMouseDown: (event) => event.preventDefault(),
1188
+ onClick: () => tool.run(editor),
1189
+ children: /* @__PURE__ */ jsx4(tool.icon, { className: "h-4 w-4", strokeWidth: 2.25, "aria-hidden": true })
1190
+ }
1191
+ ) }, tool.id);
1192
+ };
1193
+ const currentStyle = TEXT_STYLES.find(
1194
+ (style) => style.id === state.textStyle
1195
+ );
1196
+ const styleLabel = currentStyle?.label ?? "Code block";
1197
+ return /* @__PURE__ */ jsx4(
1198
+ "div",
1199
+ {
1200
+ className: "sticky z-40 border-b border-[#E5E7EB] bg-white/90 backdrop-blur-md transition-shadow duration-200 data-[scrolled=true]:shadow-[0_10px_24px_-18px_rgba(17,17,16,0.35)]",
1201
+ ref: stripRef,
1202
+ style: { top },
1203
+ "data-scrolled": scrolled,
1204
+ "data-testid": "blog-toolbar",
1205
+ children: /* @__PURE__ */ jsxs4("div", { className: "relative flex h-12 items-center gap-2 pr-3 pl-2.5 sm:gap-3 sm:pr-6 sm:pl-[22px] md:pl-[max(22px,calc(50%-338px))] lg:pr-9", children: [
1206
+ /* @__PURE__ */ jsx4("div", { className: "relative flex min-w-0 flex-1 items-center self-stretch", children: /* @__PURE__ */ jsxs4("fieldset", { disabled: frozen, className: "contents", children: [
1207
+ /* @__PURE__ */ jsxs4(
1208
+ "div",
1209
+ {
1210
+ role: "toolbar",
1211
+ "aria-label": "Formatting",
1212
+ className: "flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] max-lg:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)] [&::-webkit-scrollbar]:hidden",
1213
+ onKeyDown: moveFocus,
1214
+ children: [
1215
+ /* @__PURE__ */ jsxs4(DropdownMenu, { children: [
1216
+ /* @__PURE__ */ jsx4(Tip, { label: "Text style", children: /* @__PURE__ */ jsx4(
1217
+ DropdownMenuTrigger,
1218
+ {
1219
+ asChild: true,
1220
+ disabled: off || !state.canSetStyle,
1221
+ children: /* @__PURE__ */ jsxs4(
1222
+ "button",
1223
+ {
1224
+ type: "button",
1225
+ className: `${toolClass} justify-between px-2.5 sm:w-[7.75rem]`,
1226
+ "aria-label": `Text style: ${styleLabel}`,
1227
+ "data-testid": "blog-text-style",
1228
+ "data-style": state.textStyle,
1229
+ onMouseDown: (event) => event.preventDefault(),
1230
+ children: [
1231
+ /* @__PURE__ */ jsx4("span", { className: "text-[#111110] sm:hidden", children: currentStyle?.short ?? "Code" }),
1232
+ /* @__PURE__ */ jsx4("span", { className: "hidden text-[#111110] sm:inline", children: styleLabel }),
1233
+ /* @__PURE__ */ jsx4(
1234
+ ChevronDown,
1235
+ {
1236
+ className: "h-3.5 w-3.5 text-[#8A867D]",
1237
+ "aria-hidden": true
1238
+ }
1239
+ )
1240
+ ]
1241
+ }
1242
+ )
1243
+ }
1244
+ ) }),
1245
+ /* @__PURE__ */ jsx4(
1246
+ DropdownMenuContent,
1247
+ {
1248
+ align: "start",
1249
+ sideOffset: 8,
1250
+ className: `${menuClass} w-56`,
1251
+ onCloseAutoFocus: (event) => event.preventDefault(),
1252
+ children: TEXT_STYLES.map((style) => /* @__PURE__ */ jsxs4(
1253
+ DropdownMenuItem,
1254
+ {
1255
+ className: menuItemClass,
1256
+ "data-testid": `blog-text-style-${style.id}`,
1257
+ onSelect: () => style.run(editor),
1258
+ children: [
1259
+ /* @__PURE__ */ jsx4(
1260
+ Check,
1261
+ {
1262
+ className: `h-3.5 w-3.5 shrink-0 ${state.textStyle === style.id ? "" : "invisible"}`,
1263
+ "aria-hidden": true
1264
+ }
1265
+ ),
1266
+ /* @__PURE__ */ jsx4("span", { className: `flex-1 ${style.sample}`, children: style.label }),
1267
+ /* @__PURE__ */ jsx4("kbd", { className: "font-sans text-[11px] text-[#8A867D]", children: shortcut(style.shortcut) })
1268
+ ]
1269
+ },
1270
+ style.id
1271
+ ))
1272
+ }
1273
+ )
1274
+ ] }),
1275
+ /* @__PURE__ */ jsx4(ToolbarSeparator, {}),
1276
+ MARKS.map(toggle),
1277
+ /* @__PURE__ */ jsx4(
1278
+ Tip,
1279
+ {
1280
+ label: state.link ? "Remove link" : "Link",
1281
+ shortcut: shortcut("Mod-K"),
1282
+ children: /* @__PURE__ */ jsx4(
1283
+ "button",
1284
+ {
1285
+ type: "button",
1286
+ className: toolClass,
1287
+ "aria-label": state.link ? "Remove link" : "Link",
1288
+ "aria-pressed": state.link || linkOpen,
1289
+ "data-active": state.link || linkOpen,
1290
+ "data-testid": "blog-tool-link",
1291
+ disabled: off || !state.canLink,
1292
+ onMouseDown: (event) => event.preventDefault(),
1293
+ onClick: () => state.link ? editor.chain().focus().extendMarkRange("link").unsetLink().run() : linkOpen ? closeLink() : onLinkOpenChange(true),
1294
+ children: state.link ? /* @__PURE__ */ jsx4(
1295
+ Link2Off,
1296
+ {
1297
+ className: "h-4 w-4",
1298
+ strokeWidth: 2.25,
1299
+ "aria-hidden": true
1300
+ }
1301
+ ) : /* @__PURE__ */ jsx4(Link2, { className: "h-4 w-4", strokeWidth: 2.25, "aria-hidden": true })
1302
+ }
1303
+ )
1304
+ }
1305
+ ),
1306
+ /* @__PURE__ */ jsx4(ToolbarSeparator, {}),
1307
+ BLOCKS.map(toggle),
1308
+ /* @__PURE__ */ jsx4(ToolbarSeparator, {}),
1309
+ /* @__PURE__ */ jsxs4(DropdownMenu, { children: [
1310
+ /* @__PURE__ */ jsx4(Tip, { label: "Insert a block", children: /* @__PURE__ */ jsx4(DropdownMenuTrigger, { asChild: true, disabled: off, children: /* @__PURE__ */ jsxs4(
1311
+ "button",
1312
+ {
1313
+ type: "button",
1314
+ className: `${toolClass} pr-1.5`,
1315
+ "aria-label": "Insert",
1316
+ "data-testid": "blog-insert-menu",
1317
+ onMouseDown: (event) => event.preventDefault(),
1318
+ children: [
1319
+ /* @__PURE__ */ jsx4(
1320
+ Plus,
1321
+ {
1322
+ className: "h-4 w-4",
1323
+ strokeWidth: 2.25,
1324
+ "aria-hidden": true
1325
+ }
1326
+ ),
1327
+ /* @__PURE__ */ jsx4("span", { className: "hidden sm:inline", children: "Insert" }),
1328
+ /* @__PURE__ */ jsx4(
1329
+ ChevronDown,
1330
+ {
1331
+ className: "h-3.5 w-3.5 text-[#8A867D]",
1332
+ "aria-hidden": true
1333
+ }
1334
+ )
1335
+ ]
1336
+ }
1337
+ ) }) }),
1338
+ /* @__PURE__ */ jsx4(
1339
+ DropdownMenuContent,
1340
+ {
1341
+ align: "start",
1342
+ sideOffset: 8,
1343
+ className: `${menuClass} w-72`,
1344
+ onCloseAutoFocus: (event) => event.preventDefault(),
1345
+ children: INSERT_ITEMS.map((item) => {
1346
+ const Icon = INSERT_ICONS[item.id] ?? Plus;
1347
+ const combo = INSERT_SHORTCUTS[item.id];
1348
+ return /* @__PURE__ */ jsxs4(
1349
+ DropdownMenuItem,
1350
+ {
1351
+ className: `${menuItemClass} items-start`,
1352
+ "data-testid": `blog-insert-${item.id}`,
1353
+ onSelect: () => {
1354
+ const at = editor.state.selection.to;
1355
+ editor.chain().focus().setTextSelection(at).run();
1356
+ item.run(editor, { from: at, to: at });
1357
+ },
1358
+ children: [
1359
+ /* @__PURE__ */ jsx4("span", { className: "mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[#E6E3DA] bg-[#FAF9F6] text-[#4A4740]", children: /* @__PURE__ */ jsx4(Icon, { className: "h-3.5 w-3.5", "aria-hidden": true }) }),
1360
+ /* @__PURE__ */ jsxs4("span", { className: "min-w-0 flex-1", children: [
1361
+ /* @__PURE__ */ jsx4("span", { className: "block font-medium", children: item.title }),
1362
+ /* @__PURE__ */ jsx4("span", { className: "block text-xs text-[#6B6760]", children: item.description })
1363
+ ] }),
1364
+ combo ? /* @__PURE__ */ jsx4("kbd", { className: "mt-0.5 font-sans text-[11px] text-[#8A867D]", children: shortcut(combo) }) : null
1365
+ ]
1366
+ },
1367
+ item.id
1368
+ );
1369
+ })
1370
+ }
1371
+ )
1372
+ ] })
1373
+ ]
1374
+ }
1375
+ ),
1376
+ linkOpen ? /* @__PURE__ */ jsxs4(
1377
+ "div",
1378
+ {
1379
+ className: "absolute top-full left-0 z-10 mt-2 w-[min(24rem,calc(100vw-1.5rem))] rounded-xl border border-[#E6E3DA] bg-white p-2 shadow-[0_8px_24px_-8px_rgba(10,10,10,0.16)]",
1380
+ "data-testid": "blog-link-form",
1381
+ children: [
1382
+ /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-2", children: [
1383
+ /* @__PURE__ */ jsx4(
1384
+ "input",
1385
+ {
1386
+ autoFocus: true,
1387
+ className: "h-8 min-w-0 flex-1 rounded-md border border-[#D9D5CC] px-2.5 text-sm text-[#111110] outline-none placeholder:text-[#B8B4A9] focus:border-[#8A867D]",
1388
+ placeholder: "Paste or type a link",
1389
+ "aria-label": "Link address",
1390
+ "data-testid": "blog-link-input",
1391
+ value: url,
1392
+ onChange: (event) => setUrl(event.target.value),
1393
+ onKeyDown: (event) => {
1394
+ if (event.key === "Enter") {
1395
+ event.preventDefault();
1396
+ applyLink();
1397
+ } else if (event.key === "Escape") {
1398
+ event.preventDefault();
1399
+ closeLink();
1400
+ editor.commands.focus();
1401
+ }
1402
+ }
1403
+ }
1404
+ ),
1405
+ /* @__PURE__ */ jsx4(
1406
+ "button",
1407
+ {
1408
+ type: "button",
1409
+ className: "h-8 shrink-0 rounded-md bg-[#111110] px-3 text-[13px] font-medium text-white hover:bg-[#2A2926] disabled:opacity-50",
1410
+ "data-testid": "blog-link-apply",
1411
+ onClick: applyLink,
1412
+ children: "Apply"
1413
+ }
1414
+ )
1415
+ ] }),
1416
+ linkError ? /* @__PURE__ */ jsx4("p", { className: "mt-1.5 px-0.5 text-xs text-red-600", children: linkError }) : null
1417
+ ]
1418
+ }
1419
+ ) : null
1420
+ ] }) }),
1421
+ actions ? /* @__PURE__ */ jsx4("div", { className: "flex shrink-0 items-center gap-1", children: actions }) : null
1422
+ ] })
1423
+ }
1424
+ );
1425
+ }
1426
+
1427
+ // src/editor/DocumentActions.tsx
1428
+ import { Fragment, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1429
+ var STATUS_LABEL = {
1430
+ idle: "No changes",
1431
+ dirty: "Unsaved changes",
1432
+ saving: "Saving\u2026",
1433
+ saved: "Draft saved",
1434
+ conflict: "Conflict",
1435
+ rejected: "Rejected",
1436
+ invalid: "Fix the highlighted problems to save",
1437
+ error: "Save failed"
1438
+ };
1439
+ var STATUS_DOT = {
1440
+ idle: "bg-[#C9C5BC]",
1441
+ dirty: "bg-[#D99A2B]",
1442
+ saving: "bg-[#8A867D] animate-pulse",
1443
+ saved: "bg-[#2F9E62]",
1444
+ conflict: "bg-[#D14343]",
1445
+ rejected: "bg-[#D14343]",
1446
+ invalid: "bg-[#D14343]",
1447
+ error: "bg-[#D14343]"
1448
+ };
1449
+ function statusLabel(status, db) {
1450
+ if (status !== "saved" || db === null) return STATUS_LABEL[status];
1451
+ return db.revision === null ? "Saved" : `Saved \xB7 revision ${db.revision}`;
1452
+ }
1453
+ function CodePressMark({ src }) {
1454
+ return (
1455
+ // eslint-disable-next-line @next/next/no-img-element
1456
+ /* @__PURE__ */ jsx5("img", { src, alt: "", className: "h-[18px] w-[18px] object-contain" })
1457
+ );
1458
+ }
1459
+ function DocumentActions({
1460
+ status,
1461
+ prUrl,
1462
+ db = null,
1463
+ historyHref = null,
1464
+ designHref,
1465
+ onAsk,
1466
+ askHref = null,
1467
+ askIconSrc = "/logo-mark-square.png",
1468
+ askOpen,
1469
+ canPublish,
1470
+ busy,
1471
+ publishDisabled,
1472
+ confirmDiscard,
1473
+ onConfirmDiscardChange,
1474
+ onDiscard,
1475
+ onDone,
1476
+ onPublish
1477
+ }) {
1478
+ const label = statusLabel(status, db);
1479
+ const history = db !== null ? historyHref : null;
1480
+ return /* @__PURE__ */ jsxs5(Fragment, { children: [
1481
+ /* @__PURE__ */ jsxs5(
1482
+ "span",
1483
+ {
1484
+ className: "inline-flex h-8 min-w-0 items-center gap-2 px-1.5 text-[13px] text-[#6B6760]",
1485
+ "data-testid": "blog-save-status",
1486
+ "data-status": status,
1487
+ role: "status",
1488
+ title: label,
1489
+ children: [
1490
+ /* @__PURE__ */ jsx5(
1491
+ "span",
1492
+ {
1493
+ "aria-hidden": true,
1494
+ className: `h-2 w-2 shrink-0 rounded-full ${STATUS_DOT[status]}`
1495
+ }
1496
+ ),
1497
+ /* @__PURE__ */ jsx5("span", { className: "sr-only lg:not-sr-only lg:max-w-[13rem] lg:truncate", children: label })
1498
+ ]
1499
+ }
1500
+ ),
1501
+ prUrl ? /* @__PURE__ */ jsx5(Tip, { label: "Open the draft pull request", children: /* @__PURE__ */ jsx5(
1502
+ "a",
1503
+ {
1504
+ href: prUrl,
1505
+ target: "_blank",
1506
+ rel: "noopener noreferrer",
1507
+ className: `${toolClass} max-xl:hidden`,
1508
+ "aria-label": "Draft pull request",
1509
+ "data-testid": "blog-draft-pr-link",
1510
+ children: /* @__PURE__ */ jsx5(GitPullRequest, { className: "h-4 w-4", "aria-hidden": true })
1511
+ }
1512
+ ) }) : null,
1513
+ history ? /* @__PURE__ */ jsx5(Tip, { label: "Open the revision history", children: /* @__PURE__ */ jsx5(
1514
+ "a",
1515
+ {
1516
+ href: history,
1517
+ target: "_blank",
1518
+ rel: "noopener noreferrer",
1519
+ className: `${toolClass} max-xl:hidden`,
1520
+ "aria-label": "Revision history",
1521
+ "data-testid": "blog-history-link",
1522
+ children: /* @__PURE__ */ jsx5(History, { className: "h-4 w-4", "aria-hidden": true })
1523
+ }
1524
+ ) }) : null,
1525
+ /* @__PURE__ */ jsx5(ToolbarSeparator, { className: "max-sm:hidden" }),
1526
+ onAsk ? /* @__PURE__ */ jsx5(Tip, { label: "Ask CodePress", children: /* @__PURE__ */ jsx5(
1527
+ "button",
1528
+ {
1529
+ type: "button",
1530
+ className: `${toolClass} data-[active=true]:bg-[#E8E5DC] data-[active=true]:ring-1 data-[active=true]:ring-[#CFCABF]`,
1531
+ "aria-label": "Ask CodePress",
1532
+ "aria-pressed": askOpen,
1533
+ "aria-haspopup": "dialog",
1534
+ "data-active": askOpen,
1535
+ "data-testid": "blog-ask-codepress",
1536
+ onClick: onAsk,
1537
+ children: /* @__PURE__ */ jsx5(CodePressMark, { src: askIconSrc })
1538
+ }
1539
+ ) }) : askHref ? /* @__PURE__ */ jsx5(Tip, { label: "Ask CodePress", children: /* @__PURE__ */ jsx5(
1540
+ "a",
1541
+ {
1542
+ href: askHref,
1543
+ target: "_blank",
1544
+ rel: "noopener noreferrer",
1545
+ className: toolClass,
1546
+ "aria-label": "Ask CodePress",
1547
+ "data-testid": "blog-ask-codepress",
1548
+ children: /* @__PURE__ */ jsx5(CodePressMark, { src: askIconSrc })
1549
+ }
1550
+ ) }) : null,
1551
+ designHref ? /* @__PURE__ */ jsx5(Tip, { label: "Edit design", children: /* @__PURE__ */ jsx5(
1552
+ EditDesignLink,
1553
+ {
1554
+ href: designHref,
1555
+ className: `${toolClass} max-xl:hidden`,
1556
+ "aria-label": "Edit design",
1557
+ children: /* @__PURE__ */ jsx5(MonitorPlay, { className: "h-4 w-4", "aria-hidden": true })
1558
+ }
1559
+ ) }) : null,
1560
+ /* @__PURE__ */ jsx5(ToolbarSeparator, { className: "max-xl:hidden" }),
1561
+ /* @__PURE__ */ jsx5(
1562
+ "button",
1563
+ {
1564
+ type: "button",
1565
+ className: `${toolClass} px-2.5 max-xl:hidden`,
1566
+ "aria-expanded": confirmDiscard,
1567
+ "data-active": confirmDiscard,
1568
+ "data-testid": "blog-discard-button",
1569
+ onClick: () => onConfirmDiscardChange(!confirmDiscard),
1570
+ children: "Discard"
1571
+ }
1572
+ ),
1573
+ /* @__PURE__ */ jsx5(
1574
+ "button",
1575
+ {
1576
+ type: "button",
1577
+ className: `${toolClass} px-2.5 max-xl:hidden`,
1578
+ "data-testid": "blog-editor-done",
1579
+ disabled: busy !== null,
1580
+ onClick: onDone,
1581
+ children: "Done"
1582
+ }
1583
+ ),
1584
+ /* @__PURE__ */ jsxs5(DropdownMenu, { children: [
1585
+ /* @__PURE__ */ jsx5(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx5(
1586
+ "button",
1587
+ {
1588
+ type: "button",
1589
+ className: `${toolClass} xl:hidden`,
1590
+ "aria-label": "More actions",
1591
+ "data-testid": "blog-more-menu",
1592
+ children: /* @__PURE__ */ jsx5(Ellipsis, { className: "h-4 w-4", "aria-hidden": true })
1593
+ }
1594
+ ) }),
1595
+ /* @__PURE__ */ jsxs5(
1596
+ DropdownMenuContent,
1597
+ {
1598
+ align: "end",
1599
+ sideOffset: 8,
1600
+ className: `${menuClass} w-56`,
1601
+ children: [
1602
+ designHref ? /* @__PURE__ */ jsx5(DropdownMenuItem, { asChild: true, className: menuItemClass, children: /* @__PURE__ */ jsxs5(
1603
+ "a",
1604
+ {
1605
+ href: designHref,
1606
+ target: "_blank",
1607
+ rel: "noopener noreferrer",
1608
+ "data-testid": "blog-more-edit-design",
1609
+ children: [
1610
+ /* @__PURE__ */ jsx5(MonitorPlay, { className: "h-4 w-4 text-[#6B6760]", "aria-hidden": true }),
1611
+ "Edit design"
1612
+ ]
1613
+ }
1614
+ ) }) : null,
1615
+ prUrl ? /* @__PURE__ */ jsx5(DropdownMenuItem, { asChild: true, className: menuItemClass, children: /* @__PURE__ */ jsxs5("a", { href: prUrl, target: "_blank", rel: "noopener noreferrer", children: [
1616
+ /* @__PURE__ */ jsx5(
1617
+ GitPullRequest,
1618
+ {
1619
+ className: "h-4 w-4 text-[#6B6760]",
1620
+ "aria-hidden": true
1621
+ }
1622
+ ),
1623
+ "Draft pull request"
1624
+ ] }) }) : null,
1625
+ history ? /* @__PURE__ */ jsx5(DropdownMenuItem, { asChild: true, className: menuItemClass, children: /* @__PURE__ */ jsxs5(
1626
+ "a",
1627
+ {
1628
+ href: history,
1629
+ target: "_blank",
1630
+ rel: "noopener noreferrer",
1631
+ "data-testid": "blog-more-history",
1632
+ children: [
1633
+ /* @__PURE__ */ jsx5(History, { className: "h-4 w-4 text-[#6B6760]", "aria-hidden": true }),
1634
+ "Revision history"
1635
+ ]
1636
+ }
1637
+ ) }) : null,
1638
+ designHref || prUrl || history ? /* @__PURE__ */ jsx5(DropdownMenuSeparator, { className: "my-1 bg-[#EFECE6]" }) : null,
1639
+ /* @__PURE__ */ jsxs5(
1640
+ DropdownMenuItem,
1641
+ {
1642
+ className: menuItemClass,
1643
+ "data-testid": "blog-more-discard",
1644
+ onSelect: () => onConfirmDiscardChange(true),
1645
+ children: [
1646
+ /* @__PURE__ */ jsx5(Trash2, { className: "h-4 w-4 text-[#6B6760]", "aria-hidden": true }),
1647
+ "Discard draft\u2026"
1648
+ ]
1649
+ }
1650
+ ),
1651
+ /* @__PURE__ */ jsxs5(
1652
+ DropdownMenuItem,
1653
+ {
1654
+ className: menuItemClass,
1655
+ "data-testid": "blog-more-done",
1656
+ disabled: busy !== null,
1657
+ onSelect: onDone,
1658
+ children: [
1659
+ /* @__PURE__ */ jsx5(LogOut, { className: "h-4 w-4 text-[#6B6760]", "aria-hidden": true }),
1660
+ "Done editing"
1661
+ ]
1662
+ }
1663
+ )
1664
+ ]
1665
+ }
1666
+ )
1667
+ ] }),
1668
+ canPublish ? /* @__PURE__ */ jsx5(
1669
+ "button",
1670
+ {
1671
+ type: "button",
1672
+ className: "ml-1 inline-flex h-8 shrink-0 items-center rounded-md bg-[#111110] px-3.5 text-[13px] font-medium text-white transition-colors hover:bg-[#2A2926] focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50",
1673
+ "data-testid": "blog-publish-button",
1674
+ disabled: publishDisabled,
1675
+ onClick: onPublish,
1676
+ children: busy === "publishing" ? "Publishing\u2026" : "Publish"
1677
+ }
1678
+ ) : /* @__PURE__ */ jsx5(Tip, { label: "A publisher must publish this draft.", children: /* @__PURE__ */ jsx5(
1679
+ "span",
1680
+ {
1681
+ className: "ml-1 inline-flex h-8 shrink-0 items-center rounded-md border border-[#E6E3DA] px-2.5 text-xs text-[#6B6760]",
1682
+ "data-testid": "blog-publish-hint",
1683
+ tabIndex: 0,
1684
+ children: "Needs a publisher"
1685
+ }
1686
+ ) }),
1687
+ confirmDiscard ? /* @__PURE__ */ jsxs5(
1688
+ "div",
1689
+ {
1690
+ role: "alertdialog",
1691
+ "aria-labelledby": "blog-discard-title",
1692
+ className: "absolute top-full right-3 z-10 mt-2 w-[min(20rem,calc(100vw-1.5rem))] rounded-xl border border-[#E6E3DA] bg-white p-3 shadow-[0_8px_24px_-8px_rgba(10,10,10,0.16)] sm:right-6 lg:right-9",
1693
+ "data-testid": "blog-discard-panel",
1694
+ onKeyDown: (event) => {
1695
+ if (event.key === "Escape") onConfirmDiscardChange(false);
1696
+ },
1697
+ children: [
1698
+ /* @__PURE__ */ jsx5(
1699
+ "p",
1700
+ {
1701
+ id: "blog-discard-title",
1702
+ className: "text-sm font-medium text-[#111110]",
1703
+ children: "Discard this draft?"
1704
+ }
1705
+ ),
1706
+ /* @__PURE__ */ jsx5("p", { className: "mt-1 text-[13px] leading-5 text-[#6B6760]", children: db !== null ? "This deletes the draft. The live post does not change." : "This deletes the draft and closes its pull request. The live post does not change." }),
1707
+ /* @__PURE__ */ jsxs5("div", { className: "mt-3 flex justify-end gap-2", children: [
1708
+ /* @__PURE__ */ jsx5(
1709
+ "button",
1710
+ {
1711
+ type: "button",
1712
+ className: `${toolClass} px-3`,
1713
+ onClick: () => onConfirmDiscardChange(false),
1714
+ children: "Cancel"
1715
+ }
1716
+ ),
1717
+ /* @__PURE__ */ jsx5(
1718
+ "button",
1719
+ {
1720
+ type: "button",
1721
+ autoFocus: true,
1722
+ className: "inline-flex h-8 items-center rounded-md bg-[#C9372C] px-3 text-[13px] font-medium text-white hover:bg-[#B02F25] focus-visible:ring-2 focus-visible:ring-[#C9372C]/40 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50",
1723
+ "data-testid": "blog-discard-confirm",
1724
+ disabled: busy !== null,
1725
+ onClick: onDiscard,
1726
+ children: busy === "discarding" ? "Discarding\u2026" : "Discard"
1727
+ }
1728
+ )
1729
+ ] })
1730
+ ]
1731
+ }
1732
+ ) : null
1733
+ ] });
1734
+ }
1735
+
1736
+ // src/editor/SlashMenu.tsx
1737
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1738
+ function SlashMenu({ state }) {
1739
+ if (!state || state.items.length === 0) return null;
1740
+ const { rect } = state;
1741
+ return /* @__PURE__ */ jsx6(
1742
+ "ul",
1743
+ {
1744
+ role: "listbox",
1745
+ className: "fixed z-50 w-64 overflow-hidden rounded-lg border border-[#D9D5CC] bg-white py-1 shadow-lg",
1746
+ style: { top: (rect?.bottom ?? 0) + 6, left: rect?.left ?? 0 },
1747
+ "data-testid": "blog-slash-menu",
1748
+ children: state.items.map((item, index) => /* @__PURE__ */ jsxs6(
1749
+ "li",
1750
+ {
1751
+ role: "option",
1752
+ "aria-selected": index === state.index,
1753
+ "data-testid": `blog-slash-item-${item.id}`,
1754
+ className: "cursor-pointer px-3 py-1.5 text-sm aria-selected:bg-[#F1EFE9]",
1755
+ onMouseDown: (event) => {
1756
+ event.preventDefault();
1757
+ state.choose(index);
1758
+ },
1759
+ children: [
1760
+ /* @__PURE__ */ jsx6("div", { className: "font-medium text-[#111110]", children: item.title }),
1761
+ /* @__PURE__ */ jsx6("div", { className: "text-xs text-[#6B6760]", children: item.description })
1762
+ ]
1763
+ },
1764
+ item.id
1765
+ ))
1766
+ }
1767
+ );
1768
+ }
1769
+
1770
+ // src/editor/BlogEditor.tsx
1771
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1772
+ function prepare(draft) {
1773
+ if (draft.source === "new") {
1774
+ return {
1775
+ source: null,
1776
+ meta: {
1777
+ title: "",
1778
+ description: "",
1779
+ date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1780
+ },
1781
+ bodyHtml: "<p></p>",
1782
+ indexById: /* @__PURE__ */ new Map(),
1783
+ problems: []
1784
+ };
1785
+ }
1786
+ const source = parsePostSource(draft.html);
1787
+ const problems = editabilityProblems(source);
1788
+ const body = sourceBodyHtml(source);
1789
+ return {
1790
+ source,
1791
+ meta: source.meta,
1792
+ bodyHtml: body.html || "<p></p>",
1793
+ indexById: body.indexById,
1794
+ problems
1795
+ };
1796
+ }
1797
+ function revisionOf(draft) {
1798
+ if (draft.storage !== "db") return null;
1799
+ return {
1800
+ revision: typeof draft.revision === "number" ? draft.revision : null
1801
+ };
1802
+ }
1803
+ var fieldClass = "w-full border-0 bg-transparent p-0 text-[#111110] outline-none placeholder:text-[#B8B4A9] focus:ring-0";
1804
+ function listField(value) {
1805
+ return Array.isArray(value) ? value.join(", ") : "";
1806
+ }
1807
+ function splitList(text) {
1808
+ return text.split(",").map((item) => item.trim()).filter(Boolean);
1809
+ }
1810
+ function ListField({
1811
+ value,
1812
+ onCommit,
1813
+ ...rest
1814
+ }) {
1815
+ const [text, setText] = useState3(() => listField(value));
1816
+ return /* @__PURE__ */ jsx7(
1817
+ "input",
1818
+ {
1819
+ className: `${fieldClass} mt-1 block`,
1820
+ placeholder: "Comma separated",
1821
+ value: text,
1822
+ onChange: (event) => {
1823
+ setText(event.target.value);
1824
+ onCommit(splitList(event.target.value));
1825
+ },
1826
+ ...rest
1827
+ }
1828
+ );
1829
+ }
1830
+ function LoadedEditor({
1831
+ site,
1832
+ slug,
1833
+ client,
1834
+ timings,
1835
+ column,
1836
+ header,
1837
+ draft,
1838
+ prepared,
1839
+ canPublish,
1840
+ onAsk,
1841
+ askHref,
1842
+ askIconSrc,
1843
+ askOpen = false,
1844
+ designHref,
1845
+ historyHref,
1846
+ onReload,
1847
+ onExit,
1848
+ onPublished,
1849
+ onDiscarded
1850
+ }) {
1851
+ const sourceRef = useRef2(prepared.source);
1852
+ const indexByIdRef = useRef2(prepared.indexById);
1853
+ const baseShaRef = useRef2(draft.base_sha);
1854
+ const headShaRef = useRef2(draft.head_sha);
1855
+ const savedTextRef = useRef2(draft.source === "new" ? "" : draft.html);
1856
+ const metaRef = useRef2(prepared.meta);
1857
+ const inflightRef = useRef2(null);
1858
+ const savesRef = useRef2(0);
1859
+ const blockedRef = useRef2(false);
1860
+ const publishingRef = useRef2(false);
1861
+ const timerRef = useRef2(null);
1862
+ const editorRef = useRef2(null);
1863
+ const [meta, setMeta] = useState3(prepared.meta);
1864
+ const [status, setStatus] = useState3("idle");
1865
+ const [prUrl, setPrUrl] = useState3(draft.pr_url);
1866
+ const [db, setDb] = useState3(() => revisionOf(draft));
1867
+ const [problems, setProblems] = useState3([]);
1868
+ const [violations, setViolations] = useState3([]);
1869
+ const [conflict, setConflict] = useState3(null);
1870
+ const [pasteRejected, setPasteRejected] = useState3(null);
1871
+ const [message, setMessage] = useState3(null);
1872
+ const [slash, setSlash] = useState3(null);
1873
+ const [busy, setBusy] = useState3(null);
1874
+ const [confirmDiscard, setConfirmDiscard] = useState3(false);
1875
+ const [published, setPublished] = useState3(false);
1876
+ const [leaveWarning, setLeaveWarning] = useState3(false);
1877
+ const [formattingOff, setFormattingOff] = useState3(false);
1878
+ const [linkOpen, setLinkOpen] = useState3(false);
1879
+ const linkShortcutRef = useRef2(() => {
1880
+ });
1881
+ const frozen = busy !== null || published;
1882
+ const extensions = useMemo2(
1883
+ () => editorExtensions({
1884
+ onSlash: setSlash,
1885
+ onPasteRejected: setPasteRejected,
1886
+ slashItems: SLASH_ITEMS,
1887
+ isFrozen: () => publishingRef.current,
1888
+ onLinkShortcut: () => linkShortcutRef.current()
1889
+ }),
1890
+ []
1891
+ );
1892
+ const compose = useCallback(() => {
1893
+ const editor2 = editorRef.current;
1894
+ if (!editor2) return null;
1895
+ const blocks = editedBlocks(
1896
+ editor2.state.doc,
1897
+ sourceRef.current,
1898
+ indexByIdRef.current
1899
+ );
1900
+ const text = assemblePostFile(sourceRef.current, metaRef.current, blocks);
1901
+ return { text, blocks };
1902
+ }, []);
1903
+ const saveNow = useCallback(async () => {
1904
+ const composed = compose();
1905
+ if (!composed) return false;
1906
+ if (composed.text === savedTextRef.current) {
1907
+ if (!blockedRef.current) setStatus("saved");
1908
+ return !blockedRef.current;
1909
+ }
1910
+ const found = localProblems(metaRef.current, composed.blocks);
1911
+ if (found.length > 0) {
1912
+ setProblems(found.map((problem) => problem.message));
1913
+ setStatus("invalid");
1914
+ return false;
1915
+ }
1916
+ setProblems([]);
1917
+ setStatus("saving");
1918
+ const result = await client.saveDraft(
1919
+ site,
1920
+ slug,
1921
+ composed.text,
1922
+ baseShaRef.current
1923
+ );
1924
+ savesRef.current += 1;
1925
+ if (result.kind === "saved") {
1926
+ baseShaRef.current = result.draft.base_sha;
1927
+ headShaRef.current = result.draft.head_sha;
1928
+ savedTextRef.current = result.draft.html;
1929
+ sourceRef.current = parsePostSource(result.draft.html);
1930
+ const editor2 = editorRef.current;
1931
+ if (editor2) indexByIdRef.current = blockIndexById(editor2.state.doc);
1932
+ setPrUrl(result.draft.pr_url);
1933
+ setDb(revisionOf(result.draft));
1934
+ setViolations([]);
1935
+ setMessage(null);
1936
+ const again = compose();
1937
+ setStatus(
1938
+ again && again.text !== savedTextRef.current ? "dirty" : "saved"
1939
+ );
1940
+ return true;
1941
+ }
1942
+ if (result.kind === "conflict") {
1943
+ blockedRef.current = true;
1944
+ setConflict(result.current);
1945
+ setStatus("conflict");
1946
+ } else if (result.kind === "rejected") {
1947
+ setViolations(result.violations);
1948
+ setMessage(result.detail);
1949
+ setStatus("rejected");
1950
+ } else {
1951
+ setMessage(result.message);
1952
+ setStatus("error");
1953
+ }
1954
+ return false;
1955
+ }, [client, compose, site, slug]);
1956
+ const save = useCallback(async () => {
1957
+ while (inflightRef.current) await inflightRef.current;
1958
+ const run = saveNow();
1959
+ inflightRef.current = run;
1960
+ try {
1961
+ return await run;
1962
+ } finally {
1963
+ inflightRef.current = null;
1964
+ }
1965
+ }, [saveNow]);
1966
+ const schedule = useCallback(() => {
1967
+ if (timerRef.current) clearTimeout(timerRef.current);
1968
+ timerRef.current = setTimeout(() => {
1969
+ timerRef.current = null;
1970
+ void save();
1971
+ }, timings.autosaveMs);
1972
+ }, [save, timings.autosaveMs]);
1973
+ const handleChange = useCallback(() => {
1974
+ if (publishingRef.current) return;
1975
+ const composed = compose();
1976
+ if (!composed) return;
1977
+ if (composed.text === savedTextRef.current) {
1978
+ if (timerRef.current) clearTimeout(timerRef.current);
1979
+ timerRef.current = null;
1980
+ if (!blockedRef.current)
1981
+ setStatus(inflightRef.current ? "saving" : "idle");
1982
+ return;
1983
+ }
1984
+ if (blockedRef.current) return;
1985
+ setStatus((current) => current === "saving" ? current : "dirty");
1986
+ schedule();
1987
+ }, [compose, schedule]);
1988
+ const editor = useEditor({
1989
+ immediatelyRender: false,
1990
+ extensions,
1991
+ content: prepared.bodyHtml,
1992
+ editorProps: {
1993
+ attributes: {
1994
+ class: "prose prose-neutral max-w-none min-h-[320px] focus:outline-none prose-code:before:content-none prose-code:after:content-none",
1995
+ "data-testid": "blog-editor-body"
1996
+ }
1997
+ },
1998
+ onUpdate: handleChange,
1999
+ onFocus: () => setFormattingOff(false)
2000
+ });
2001
+ useEffect2(() => {
2002
+ editorRef.current = editor;
2003
+ }, [editor]);
2004
+ useEffect2(() => {
2005
+ editor?.setEditable(!frozen, false);
2006
+ }, [editor, frozen]);
2007
+ useEffect2(() => {
2008
+ linkShortcutRef.current = () => {
2009
+ if (!editor || publishingRef.current) return;
2010
+ if (editor.isActive("link")) {
2011
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
2012
+ } else if (!editor.state.selection.empty) {
2013
+ setLinkOpen(true);
2014
+ }
2015
+ };
2016
+ }, [editor]);
2017
+ useEffect2(
2018
+ () => () => {
2019
+ if (timerRef.current) clearTimeout(timerRef.current);
2020
+ },
2021
+ []
2022
+ );
2023
+ useEffect2(() => {
2024
+ const interval = setInterval(async () => {
2025
+ if (inflightRef.current || blockedRef.current || publishingRef.current) {
2026
+ return;
2027
+ }
2028
+ const savesBefore = savesRef.current;
2029
+ const result = await client.getDraft(site, slug);
2030
+ if (!("draft" in result) || publishingRef.current) return;
2031
+ if (savesRef.current !== savesBefore) return;
2032
+ if (result.draft.base_sha === baseShaRef.current) return;
2033
+ if (inflightRef.current) return;
2034
+ const composed = compose();
2035
+ if (composed && composed.text === savedTextRef.current) {
2036
+ onReload(result.draft);
2037
+ return;
2038
+ }
2039
+ blockedRef.current = true;
2040
+ setConflict(result.draft);
2041
+ setStatus("conflict");
2042
+ }, timings.draftPollMs);
2043
+ return () => clearInterval(interval);
2044
+ }, [client, compose, onReload, site, slug, timings.draftPollMs]);
2045
+ useEffect2(() => {
2046
+ const warn = (event) => {
2047
+ if (status === "idle" || status === "saved" || published) return;
2048
+ event.preventDefault();
2049
+ };
2050
+ window.addEventListener("beforeunload", warn);
2051
+ return () => window.removeEventListener("beforeunload", warn);
2052
+ }, [status, published]);
2053
+ const updateMeta = (patch) => {
2054
+ const next = { ...metaRef.current, ...patch };
2055
+ for (const [key, value] of Object.entries(next)) {
2056
+ if (Array.isArray(value) && value.length === 0) delete next[key];
2057
+ }
2058
+ metaRef.current = next;
2059
+ setMeta(next);
2060
+ handleChange();
2061
+ };
2062
+ const keepMine = () => {
2063
+ if (!conflict) return;
2064
+ baseShaRef.current = conflict.base_sha;
2065
+ headShaRef.current = conflict.head_sha;
2066
+ setConflict(null);
2067
+ blockedRef.current = false;
2068
+ void save();
2069
+ };
2070
+ const takeTheirs = () => {
2071
+ if (conflict) onReload(conflict);
2072
+ };
2073
+ const flushSave = async () => {
2074
+ if (timerRef.current) clearTimeout(timerRef.current);
2075
+ timerRef.current = null;
2076
+ return save();
2077
+ };
2078
+ const freeze = (on) => editorRef.current?.setEditable(!on, false);
2079
+ const handlePublish = async () => {
2080
+ setBusy("publishing");
2081
+ setMessage(null);
2082
+ publishingRef.current = true;
2083
+ freeze(true);
2084
+ if (!await flushSave()) {
2085
+ publishingRef.current = false;
2086
+ freeze(false);
2087
+ setBusy(null);
2088
+ return;
2089
+ }
2090
+ const result = await client.publishDraft(site, slug);
2091
+ setBusy(null);
2092
+ if (result.kind !== "ok") {
2093
+ publishingRef.current = false;
2094
+ freeze(false);
2095
+ }
2096
+ if (result.kind === "ok") {
2097
+ setPublished(true);
2098
+ setTimeout(onPublished, timings.publishReloadMs);
2099
+ return;
2100
+ }
2101
+ if (result.kind === "rejected") setViolations(result.violations);
2102
+ setMessage(result.kind === "rejected" ? result.detail : result.message);
2103
+ setStatus(result.kind === "rejected" ? "rejected" : "error");
2104
+ };
2105
+ const handleDiscard = async () => {
2106
+ setBusy("discarding");
2107
+ publishingRef.current = true;
2108
+ freeze(true);
2109
+ if (timerRef.current) clearTimeout(timerRef.current);
2110
+ timerRef.current = null;
2111
+ while (inflightRef.current) await inflightRef.current;
2112
+ const latest = await client.getDraft(site, slug);
2113
+ if (!("draft" in latest) || latest.draft.base_sha !== baseShaRef.current || latest.draft.head_sha !== headShaRef.current) {
2114
+ setBusy(null);
2115
+ publishingRef.current = false;
2116
+ freeze(false);
2117
+ if ("draft" in latest) {
2118
+ blockedRef.current = true;
2119
+ setConflict(latest.draft);
2120
+ setStatus("conflict");
2121
+ } else {
2122
+ setMessage(latest.error);
2123
+ setStatus("error");
2124
+ }
2125
+ return;
2126
+ }
2127
+ const result = await client.discardDraft(site, slug);
2128
+ setBusy(null);
2129
+ if (result.kind === "ok" || result.kind === "error" && result.status === 404) {
2130
+ setPublished(true);
2131
+ onDiscarded();
2132
+ return;
2133
+ }
2134
+ publishingRef.current = false;
2135
+ freeze(false);
2136
+ setMessage(result.kind === "error" ? result.message : "Could not discard.");
2137
+ setStatus("error");
2138
+ };
2139
+ const isPersisted = () => {
2140
+ const composed = compose();
2141
+ return !composed || composed.text === savedTextRef.current;
2142
+ };
2143
+ const handleDone = async () => {
2144
+ setLeaveWarning(false);
2145
+ while (inflightRef.current) await inflightRef.current;
2146
+ if (!isPersisted()) {
2147
+ if (!blockedRef.current) await flushSave();
2148
+ if (!isPersisted()) {
2149
+ setLeaveWarning(true);
2150
+ return;
2151
+ }
2152
+ }
2153
+ onExit();
2154
+ };
2155
+ if (!editor) return null;
2156
+ const canSave = status !== "conflict";
2157
+ return /* @__PURE__ */ jsxs7("div", { "data-testid": "blog-editor", children: [
2158
+ /* @__PURE__ */ jsx7(
2159
+ Toolbar,
2160
+ {
2161
+ editor,
2162
+ frozen,
2163
+ formattingOff,
2164
+ linkOpen,
2165
+ onLinkOpenChange: setLinkOpen,
2166
+ header,
2167
+ actions: /* @__PURE__ */ jsx7(
2168
+ DocumentActions,
2169
+ {
2170
+ status,
2171
+ prUrl,
2172
+ db,
2173
+ historyHref,
2174
+ designHref: designHref ?? null,
2175
+ onAsk,
2176
+ askHref,
2177
+ askIconSrc,
2178
+ askOpen,
2179
+ canPublish,
2180
+ busy,
2181
+ publishDisabled: busy !== null || !canSave || published,
2182
+ confirmDiscard,
2183
+ onConfirmDiscardChange: setConfirmDiscard,
2184
+ onDiscard: handleDiscard,
2185
+ onDone: handleDone,
2186
+ onPublish: handlePublish
2187
+ }
2188
+ )
2189
+ }
2190
+ ),
2191
+ /* @__PURE__ */ jsxs7("div", { className: `${column} pt-8 pb-20 sm:pt-12`, children: [
2192
+ leaveWarning ? /* @__PURE__ */ jsxs7(
2193
+ "div",
2194
+ {
2195
+ className: "mb-4 rounded-lg border border-[#F1D6A3] bg-[#FFF9EC] px-4 py-3 text-sm",
2196
+ role: "alert",
2197
+ "data-testid": "blog-leave-warning",
2198
+ children: [
2199
+ /* @__PURE__ */ jsx7("p", { className: "font-medium", children: "Your latest edits are not saved, so leaving now would lose them." }),
2200
+ /* @__PURE__ */ jsxs7("div", { className: "mt-3 flex gap-2", children: [
2201
+ /* @__PURE__ */ jsx7(
2202
+ "button",
2203
+ {
2204
+ type: "button",
2205
+ className: "rounded-md bg-[#111110] px-3 py-1 text-white",
2206
+ "data-testid": "blog-leave-keep-editing",
2207
+ onClick: () => setLeaveWarning(false),
2208
+ children: "Keep editing"
2209
+ }
2210
+ ),
2211
+ /* @__PURE__ */ jsx7(
2212
+ "button",
2213
+ {
2214
+ type: "button",
2215
+ className: "rounded-md border border-[#D9D5CC] px-3 py-1",
2216
+ "data-testid": "blog-leave-anyway",
2217
+ onClick: onExit,
2218
+ children: "Leave without saving"
2219
+ }
2220
+ )
2221
+ ] })
2222
+ ]
2223
+ }
2224
+ ) : null,
2225
+ published ? /* @__PURE__ */ jsx7(
2226
+ "p",
2227
+ {
2228
+ className: "mb-4 rounded-lg border border-[#BFE3CC] bg-[#F1FAF4] px-4 py-3 text-sm",
2229
+ "data-testid": "blog-published-notice",
2230
+ children: "Done. The live page updates in a few seconds."
2231
+ }
2232
+ ) : null,
2233
+ conflict ? /* @__PURE__ */ jsxs7(
2234
+ "div",
2235
+ {
2236
+ className: "mb-4 rounded-lg border border-[#F1D6A3] bg-[#FFF9EC] px-4 py-3 text-sm",
2237
+ role: "alert",
2238
+ "data-testid": "blog-conflict-banner",
2239
+ children: [
2240
+ /* @__PURE__ */ jsx7("p", { className: "font-medium", children: "This draft was changed somewhere else (another tab or an agent)." }),
2241
+ /* @__PURE__ */ jsx7("p", { className: "mt-1 text-[#4A4740]", children: "Keep your version to save it on top of the newer one, or take theirs and discard your unsaved edits." }),
2242
+ /* @__PURE__ */ jsxs7("div", { className: "mt-3 flex gap-2", children: [
2243
+ /* @__PURE__ */ jsx7(
2244
+ "button",
2245
+ {
2246
+ type: "button",
2247
+ className: "rounded-md bg-[#111110] px-3 py-1 text-white",
2248
+ "data-testid": "blog-conflict-keep-mine",
2249
+ onClick: keepMine,
2250
+ children: "Keep mine"
2251
+ }
2252
+ ),
2253
+ /* @__PURE__ */ jsx7(
2254
+ "button",
2255
+ {
2256
+ type: "button",
2257
+ className: "rounded-md border border-[#D9D5CC] px-3 py-1",
2258
+ "data-testid": "blog-conflict-take-theirs",
2259
+ onClick: takeTheirs,
2260
+ children: "Take theirs"
2261
+ }
2262
+ )
2263
+ ] })
2264
+ ]
2265
+ }
2266
+ ) : null,
2267
+ status === "rejected" ? /* @__PURE__ */ jsxs7(
2268
+ "div",
2269
+ {
2270
+ className: "mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800",
2271
+ role: "alert",
2272
+ "data-testid": "blog-rejected-banner",
2273
+ children: [
2274
+ /* @__PURE__ */ jsx7("p", { className: "font-medium", children: message ?? "The content was rejected." }),
2275
+ /* @__PURE__ */ jsx7("ul", { className: "mt-2 list-disc pl-5", children: violations.map((violation, index) => /* @__PURE__ */ jsx7("li", { children: violation.reason }, index)) })
2276
+ ]
2277
+ }
2278
+ ) : null,
2279
+ status === "invalid" && problems.length > 0 ? /* @__PURE__ */ jsxs7(
2280
+ "div",
2281
+ {
2282
+ className: "mb-4 rounded-lg border border-[#F1D6A3] bg-[#FFF9EC] px-4 py-3 text-sm",
2283
+ role: "alert",
2284
+ "data-testid": "blog-invalid-banner",
2285
+ children: [
2286
+ /* @__PURE__ */ jsx7("p", { className: "font-medium", children: "This draft cannot be saved yet:" }),
2287
+ /* @__PURE__ */ jsx7("ul", { className: "mt-2 list-disc pl-5", children: problems.map((problem, index) => /* @__PURE__ */ jsx7("li", { children: problem }, index)) })
2288
+ ]
2289
+ }
2290
+ ) : null,
2291
+ status === "error" && message ? /* @__PURE__ */ jsxs7(
2292
+ "p",
2293
+ {
2294
+ className: "mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800",
2295
+ role: "alert",
2296
+ "data-testid": "blog-error-banner",
2297
+ children: [
2298
+ message,
2299
+ " ",
2300
+ /* @__PURE__ */ jsx7(
2301
+ "button",
2302
+ {
2303
+ type: "button",
2304
+ className: "underline",
2305
+ "data-testid": "blog-retry-save",
2306
+ onClick: () => void save(),
2307
+ children: "Retry"
2308
+ }
2309
+ )
2310
+ ]
2311
+ }
2312
+ ) : null,
2313
+ pasteRejected ? /* @__PURE__ */ jsxs7(
2314
+ "div",
2315
+ {
2316
+ className: "mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800",
2317
+ role: "alert",
2318
+ "data-testid": "blog-paste-rejected",
2319
+ children: [
2320
+ /* @__PURE__ */ jsx7("p", { className: "font-medium", children: "That paste was rejected and nothing was inserted:" }),
2321
+ /* @__PURE__ */ jsx7("ul", { className: "mt-2 list-disc pl-5", children: pasteRejected.map((reason, index) => /* @__PURE__ */ jsx7("li", { children: reason }, index)) }),
2322
+ /* @__PURE__ */ jsx7(
2323
+ "button",
2324
+ {
2325
+ type: "button",
2326
+ className: "mt-2 underline",
2327
+ onClick: () => setPasteRejected(null),
2328
+ children: "Dismiss"
2329
+ }
2330
+ )
2331
+ ]
2332
+ }
2333
+ ) : null,
2334
+ /* @__PURE__ */ jsxs7(
2335
+ "header",
2336
+ {
2337
+ className: "mb-8",
2338
+ "data-testid": "blog-editor-header",
2339
+ onFocus: () => setFormattingOff(true),
2340
+ children: [
2341
+ /* @__PURE__ */ jsx7(
2342
+ "input",
2343
+ {
2344
+ className: `${fieldClass} text-[34px] leading-[1.08] font-medium tracking-[-0.03em] sm:text-[46px]`,
2345
+ placeholder: "Title",
2346
+ "aria-label": "Title",
2347
+ "data-testid": "blog-meta-title",
2348
+ disabled: frozen,
2349
+ value: String(meta.title ?? ""),
2350
+ onChange: (event) => updateMeta({ title: event.target.value })
2351
+ }
2352
+ ),
2353
+ /* @__PURE__ */ jsx7(
2354
+ "textarea",
2355
+ {
2356
+ className: `${fieldClass} mt-4 block resize-none text-[17px] leading-[1.55] text-[#4A4740]`,
2357
+ placeholder: "One-line description",
2358
+ "aria-label": "Description",
2359
+ rows: 2,
2360
+ "data-testid": "blog-meta-description",
2361
+ disabled: frozen,
2362
+ value: String(meta.description ?? ""),
2363
+ onChange: (event) => updateMeta({ description: event.target.value })
2364
+ }
2365
+ ),
2366
+ /* @__PURE__ */ jsxs7("div", { className: "mt-4 grid gap-2 text-sm text-[#6B6760] sm:grid-cols-3", children: [
2367
+ /* @__PURE__ */ jsxs7("label", { children: [
2368
+ "Date",
2369
+ /* @__PURE__ */ jsx7(
2370
+ "input",
2371
+ {
2372
+ type: "date",
2373
+ className: `${fieldClass} mt-1 block`,
2374
+ "data-testid": "blog-meta-date",
2375
+ disabled: frozen,
2376
+ value: String(meta.date ?? ""),
2377
+ onChange: (event) => updateMeta({ date: event.target.value })
2378
+ }
2379
+ )
2380
+ ] }),
2381
+ /* @__PURE__ */ jsxs7("label", { children: [
2382
+ "Authors",
2383
+ /* @__PURE__ */ jsx7(
2384
+ ListField,
2385
+ {
2386
+ "data-testid": "blog-meta-authors",
2387
+ disabled: frozen,
2388
+ value: meta.authors,
2389
+ onCommit: (items) => updateMeta({ authors: items })
2390
+ }
2391
+ )
2392
+ ] }),
2393
+ /* @__PURE__ */ jsxs7("label", { children: [
2394
+ "Tags",
2395
+ /* @__PURE__ */ jsx7(
2396
+ ListField,
2397
+ {
2398
+ "data-testid": "blog-meta-tags",
2399
+ disabled: frozen,
2400
+ value: meta.tags,
2401
+ onCommit: (items) => updateMeta({ tags: items })
2402
+ }
2403
+ )
2404
+ ] })
2405
+ ] })
2406
+ ]
2407
+ }
2408
+ ),
2409
+ /* @__PURE__ */ jsx7("fieldset", { disabled: frozen, className: "contents", children: /* @__PURE__ */ jsx7(EditorContent, { editor }) })
2410
+ ] }),
2411
+ /* @__PURE__ */ jsx7(SlashMenu, { state: slash })
2412
+ ] });
2413
+ }
2414
+ function UnsupportedContent({
2415
+ problems,
2416
+ html,
2417
+ onExit
2418
+ }) {
2419
+ return /* @__PURE__ */ jsxs7("div", { "data-testid": "blog-unsupported", children: [
2420
+ /* @__PURE__ */ jsxs7(
2421
+ "div",
2422
+ {
2423
+ className: "mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800",
2424
+ role: "alert",
2425
+ children: [
2426
+ /* @__PURE__ */ jsx7("p", { className: "font-medium", children: "This post uses content the editor cannot edit without losing it, so it is shown read-only." }),
2427
+ /* @__PURE__ */ jsx7("ul", { className: "mt-2 list-disc pl-5", "data-testid": "blog-unsupported-list", children: problems.map((problem, index) => /* @__PURE__ */ jsx7("li", { children: problem }, index)) }),
2428
+ /* @__PURE__ */ jsx7(
2429
+ "button",
2430
+ {
2431
+ type: "button",
2432
+ className: "mt-3 rounded-md border border-red-300 bg-white px-3 py-1",
2433
+ "data-testid": "blog-editor-done",
2434
+ onClick: onExit,
2435
+ children: "Close"
2436
+ }
2437
+ )
2438
+ ]
2439
+ }
2440
+ ),
2441
+ /* @__PURE__ */ jsx7(
2442
+ "pre",
2443
+ {
2444
+ className: "overflow-x-auto rounded-lg border border-[#E6E3DA] bg-[#FAF9F6] p-4 text-xs",
2445
+ "data-testid": "blog-source-view",
2446
+ children: html
2447
+ }
2448
+ )
2449
+ ] });
2450
+ }
2451
+ function BlogEditor({
2452
+ timings: timingOverrides,
2453
+ column = DEFAULT_COLUMN,
2454
+ ...props
2455
+ }) {
2456
+ const { site, slug, client } = props;
2457
+ const autosaveMs = timingOverrides?.autosaveMs;
2458
+ const draftPollMs = timingOverrides?.draftPollMs;
2459
+ const publishReloadMs = timingOverrides?.publishReloadMs;
2460
+ const timings = useMemo2(
2461
+ () => resolveTimings({ autosaveMs, draftPollMs, publishReloadMs }),
2462
+ [autosaveMs, draftPollMs, publishReloadMs]
2463
+ );
2464
+ const [loaded, setLoaded] = useState3({ state: "loading" });
2465
+ useEffect2(() => {
2466
+ let cancelled = false;
2467
+ void client.getDraft(site, slug).then((result) => {
2468
+ if (cancelled) return;
2469
+ setLoaded(
2470
+ "draft" in result ? { state: "ready", draft: result.draft, version: 0 } : { state: "error", message: result.error }
2471
+ );
2472
+ });
2473
+ return () => {
2474
+ cancelled = true;
2475
+ };
2476
+ }, [client, site, slug]);
2477
+ const handleReload = useCallback((draft2) => {
2478
+ setLoaded((current) => ({
2479
+ state: "ready",
2480
+ draft: draft2,
2481
+ version: current.state === "ready" ? current.version + 1 : 0
2482
+ }));
2483
+ }, []);
2484
+ const draft = loaded.state === "ready" ? loaded.draft : null;
2485
+ const prepared = useMemo2(() => draft ? prepare(draft) : null, [draft]);
2486
+ const padded = `${column} pt-10 pb-20 sm:pt-14`;
2487
+ if (loaded.state === "loading") {
2488
+ return /* @__PURE__ */ jsx7("p", { className: padded, "data-testid": "blog-editor-loading", children: "Loading the draft\u2026" });
2489
+ }
2490
+ if (loaded.state === "error" || !draft || !prepared) {
2491
+ return /* @__PURE__ */ jsx7("div", { className: padded, children: /* @__PURE__ */ jsx7(
2492
+ "p",
2493
+ {
2494
+ className: "rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800",
2495
+ role: "alert",
2496
+ "data-testid": "blog-editor-error",
2497
+ children: loaded.state === "error" ? loaded.message : "Could not load the draft."
2498
+ }
2499
+ ) });
2500
+ }
2501
+ if (prepared.problems.length > 0) {
2502
+ return /* @__PURE__ */ jsx7("div", { className: padded, children: /* @__PURE__ */ jsx7(
2503
+ UnsupportedContent,
2504
+ {
2505
+ problems: prepared.problems,
2506
+ html: draft.html,
2507
+ onExit: props.onExit
2508
+ }
2509
+ ) });
2510
+ }
2511
+ return /* @__PURE__ */ jsx7(
2512
+ LoadedEditor,
2513
+ {
2514
+ ...props,
2515
+ timings,
2516
+ column,
2517
+ draft,
2518
+ prepared,
2519
+ onReload: handleReload
2520
+ },
2521
+ loaded.version
2522
+ );
2523
+ }
2524
+ export {
2525
+ BlogEditor as default,
2526
+ revisionOf
2527
+ };