astro-dev-edit 0.11.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,419 @@
1
+ import { canRichEdit, escapeHtml, htmlToMarkdown, markdownToHtml } from '../markdown.ts';
2
+ import { COLOR, FONT, PAPER, RADIUS, basename, hexToRgba, inputEl, isolateScroll, styled, toast } from '../ui.ts';
3
+ import { mountLight } from '../shadow.ts';
4
+ import { icon, type IconName } from '../icons.ts';
5
+ import { buildImageField } from './asset-picker.ts';
6
+ import { webPathToUrl } from '../../shared/asset-path.ts';
7
+
8
+ /**
9
+ * WYSIWYG markdown body editor for the entry drawer: a contenteditable
10
+ * surface with a formatting toolbar (bold/italic/strikethrough, heading
11
+ * level, lists, quote, code, link, image), backed by the subset converter in
12
+ * client/markdown.ts. Bodies outside that subset (tables, raw HTML/MDX, …)
13
+ * open in raw-markdown mode instead — the toolbar's MD/Rich toggle switches
14
+ * views, and the switch to Rich is refused when it would be lossy.
15
+ *
16
+ * Dirty tracking compares against the *normalized* round-trip of the initial
17
+ * body, so merely opening and closing the drawer never rewrites the file.
18
+ */
19
+
20
+ export interface BodyEditor {
21
+ root: HTMLElement;
22
+ /** Current markdown. */
23
+ value(): string;
24
+ /** Whether the user actually changed the body. */
25
+ dirty(): boolean;
26
+ /** Drop the light-DOM contenteditable this editor slots into the drawer.
27
+ * Call from the drawer's onClose — `root.remove()` cannot reach it, because
28
+ * it is parented to the shadow host rather than to `root`. */
29
+ destroy(): void;
30
+ }
31
+
32
+ /**
33
+ * Content styling can't be inlined — the user creates these elements by typing
34
+ * — so the editor injects one class-scoped stylesheet.
35
+ *
36
+ * It lives in the **document**, not in the overlay's shadow stylesheet, because
37
+ * `.atx-rte-content` is the one piece of overlay chrome that stays in the light
38
+ * DOM: Safari's selection and `execCommand` APIs are inert against a node
39
+ * inside a shadow root, which would leave the toolbar doing nothing at all. The
40
+ * node is slotted back into the drawer instead (shadow.ts::mountLight), so it
41
+ * renders in place while remaining light-DOM for selection purposes — and is
42
+ * therefore styled from here, where the document can see it.
43
+ */
44
+ const CONTENT_CSS = `
45
+ /* The writing surface itself. White, like the rendered page rather than a form
46
+ field, and color-scheme: light so native chrome (the scrollbar) matches it.
47
+ Hidden in source mode; [data-on] is the visual half of the MD/Rich toggle. */
48
+ .atx-rte-content {
49
+ display: none;
50
+ box-sizing: border-box;
51
+ width: 100%;
52
+ min-height: 40vh;
53
+ padding: 16px 20px;
54
+ border: none;
55
+ border-radius: ${RADIUS.lg};
56
+ background: ${PAPER.bg};
57
+ color: ${PAPER.fg};
58
+ font: 15px/1.65 ${FONT.ui};
59
+ color-scheme: light;
60
+ outline: none;
61
+ overflow-y: auto;
62
+ cursor: text;
63
+ }
64
+ .atx-rte-content[data-on] { display: block; }
65
+ .atx-rte-content h1, .atx-rte-content h2, .atx-rte-content h3,
66
+ .atx-rte-content h4, .atx-rte-content h5, .atx-rte-content h6 {
67
+ margin: 0.7em 0 0.35em; font-weight: 700; line-height: 1.25; color: inherit;
68
+ }
69
+ .atx-rte-content h1 { font-size: 1.55em; }
70
+ .atx-rte-content h2 { font-size: 1.35em; }
71
+ .atx-rte-content h3 { font-size: 1.18em; }
72
+ .atx-rte-content h4 { font-size: 1.05em; }
73
+ .atx-rte-content h5 { font-size: 0.95em; }
74
+ .atx-rte-content h6 { font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.04em; }
75
+ .atx-rte-content p { margin: 0.5em 0; }
76
+ .atx-rte-content ul, .atx-rte-content ol { margin: 0.5em 0; padding-left: 1.5em; }
77
+ .atx-rte-content li { margin: 0.2em 0; }
78
+ .atx-rte-content blockquote {
79
+ margin: 0.6em 0; padding: 0.3em 0.9em; border-left: 3px solid ${COLOR.brand};
80
+ background: ${hexToRgba(COLOR.brand, 0.07)}; border-radius: 0 ${RADIUS.md} ${RADIUS.md} 0;
81
+ }
82
+ .atx-rte-content pre {
83
+ margin: 0.6em 0; padding: 8px 10px; background: ${PAPER.muted}; border: 1px solid ${PAPER.border};
84
+ border-radius: 6px; font: 12px/1.5 ${FONT.mono}; white-space: pre-wrap; overflow-x: auto;
85
+ }
86
+ .atx-rte-content code {
87
+ background: ${PAPER.muted}; border: 1px solid ${PAPER.border}; border-radius: 4px;
88
+ padding: 1px 4px; font-family: ${FONT.mono}; font-size: 0.9em;
89
+ }
90
+ .atx-rte-content pre code { background: transparent; border: none; padding: 0; }
91
+ .atx-rte-content a { color: ${PAPER.link}; }
92
+ .atx-rte-content img { max-width: 100%; border-radius: 4px; cursor: pointer; }
93
+ .atx-rte-content hr { border: none; border-top: 1px solid ${PAPER.border}; margin: 0.8em 0; }
94
+ `;
95
+
96
+ function ensureContentStyles(): void {
97
+ if (document.getElementById('atx-rte-style')) return;
98
+ const style = document.createElement('style');
99
+ style.id = 'atx-rte-style';
100
+ style.textContent = CONTENT_CSS;
101
+ document.head.append(style);
102
+ }
103
+
104
+ /** A toolbar button. `variant` is an extra class, not a style object: what each
105
+ * button does to its own label — bold, italic, struck through, monospaced — is
106
+ * a fixed choice from a known set, so it belongs in the stylesheet. */
107
+ /**
108
+ * One toolbar key. `label` is either a typographic mark the button *is* — B, I,
109
+ * the list bullets — or an `IconName`, for the three whose meaning no letter
110
+ * carries. Those three were emoji, which arrive in colour and at a different
111
+ * weight and baseline in every platform font: the one place in the overlay
112
+ * where the chrome was the operating system's rather than its own.
113
+ */
114
+ function toolbarButton(
115
+ label: string | IconName,
116
+ title: string,
117
+ onRun: () => void,
118
+ variant = '',
119
+ glyph = false,
120
+ ): HTMLButtonElement {
121
+ const b = styled('button', variant ? `atx-rte-btn ${variant}` : 'atx-rte-btn');
122
+ b.type = 'button';
123
+ if (glyph) b.append(icon(label as IconName, 16));
124
+ else b.textContent = label;
125
+ b.title = title;
126
+ // preventDefault keeps the contenteditable selection alive through the click.
127
+ b.addEventListener('mousedown', (e) => e.preventDefault());
128
+ b.addEventListener('click', onRun);
129
+ return b;
130
+ }
131
+
132
+ function divider(): HTMLElement {
133
+ return styled('span', 'atx-rte-divider');
134
+ }
135
+
136
+ /** Slot names must be unique per editor instance: a drawer hand-off can build
137
+ * the next editor before the previous one's node is gone. */
138
+ let rteSeq = 0;
139
+
140
+ export function buildBodyEditor(initial: string): BodyEditor {
141
+ ensureContentStyles();
142
+
143
+ const root = styled('div', 'atx-rte');
144
+ let mode: 'visual' | 'source' = canRichEdit(initial) ? 'visual' : 'source';
145
+
146
+ // --- the two surfaces ----------------------------------------------------
147
+
148
+ // The one control that can wear neither the shared [data-input] baseline nor
149
+ // a rule from the overlay's stylesheet: it lives in the light DOM, where
150
+ // selectors from the shadow root do not reach and ::slotted() loses to the
151
+ // document. Its whole box is in CONTENT_CSS instead, alongside the rules for
152
+ // the elements the user types into it.
153
+ const content = styled('div', 'atx-rte-content');
154
+ // The editing surface is the one node that does not move into the shadow
155
+ // root — see CONTENT_CSS above. It is parented to the host and composed back
156
+ // into the drawer through this slot, so layout is the drawer's job and
157
+ // selection keeps working in every engine.
158
+ const slotName = `atx-rte-${++rteSeq}`;
159
+ content.slot = slotName;
160
+ const contentSlot = document.createElement('slot');
161
+ contentSlot.name = slotName;
162
+ mountLight(content);
163
+
164
+ isolateScroll(content);
165
+ content.contentEditable = 'true';
166
+ content.addEventListener('focus', () => {
167
+ // Tag-based markup (<b>, <p>…), not styled spans — the serializer's format.
168
+ document.execCommand('styleWithCSS', false, 'false');
169
+ document.execCommand('defaultParagraphSeparator', false, 'p');
170
+ }, { once: true });
171
+
172
+ // Class kept from the old plain-textarea body input, so existing user CSS
173
+ // overrides keep working in source mode.
174
+ const srcInput = inputEl('textarea', 'atx-body-input');
175
+ srcInput.value = initial;
176
+
177
+ let visualBaseline: string | null = null;
178
+ if (mode === 'visual') {
179
+ content.innerHTML = markdownToHtml(initial);
180
+ visualBaseline = htmlToMarkdown(content);
181
+ }
182
+
183
+ // --- commands ------------------------------------------------------------
184
+
185
+ const exec = (cmd: string, val?: string): void => {
186
+ content.focus();
187
+ document.execCommand(cmd, false, val);
188
+ };
189
+
190
+ let savedRange: Range | null = null;
191
+ const saveSelection = (): void => {
192
+ const s = window.getSelection();
193
+ savedRange = s && s.rangeCount > 0 ? s.getRangeAt(0).cloneRange() : null;
194
+ };
195
+ const restoreSelection = (): void => {
196
+ if (!savedRange) return;
197
+ const s = window.getSelection();
198
+ s?.removeAllRanges();
199
+ s?.addRange(savedRange);
200
+ };
201
+
202
+ const insertInlineCode = (): void => {
203
+ const s = window.getSelection();
204
+ if (!s || s.isCollapsed || !content.contains(s.anchorNode)) return;
205
+ exec('insertHTML', `<code>${escapeHtml(s.toString())}</code>`);
206
+ };
207
+
208
+ const insertLink = (): void => {
209
+ saveSelection();
210
+ const url = window.prompt('Link URL', 'https://');
211
+ if (!url) return;
212
+ content.focus();
213
+ restoreSelection();
214
+ const s = window.getSelection();
215
+ if (!s || s.isCollapsed) exec('insertHTML', `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
216
+ else exec('createLink', url);
217
+ };
218
+
219
+ // --- heading dropdown ----------------------------------------------------
220
+
221
+ const headWrap = styled('span', 'atx-rte-heading');
222
+ const headMenu = styled('div', 'atx-rte-heading-menu');
223
+ const hideMenu = (): void => {
224
+ headMenu.toggleAttribute('data-on', false);
225
+ };
226
+ const menuItem = (tag: string, chip: string, name: string, size: string): HTMLButtonElement => {
227
+ const item = styled('button', 'atx-rte-heading-item');
228
+ item.type = 'button';
229
+ const chipEl = styled('span', 'atx-rte-heading-chip');
230
+ chipEl.textContent = chip;
231
+ // The one size the stylesheet cannot know: each row previews its own level.
232
+ const nameEl = styled('span', 'atx-rte-heading-name', { fontSize: size });
233
+ nameEl.textContent = name;
234
+ item.append(chipEl, nameEl);
235
+ item.addEventListener('mousedown', (e) => e.preventDefault());
236
+ item.addEventListener('click', () => {
237
+ hideMenu();
238
+ exec('formatBlock', `<${tag}>`);
239
+ });
240
+ return item;
241
+ };
242
+ for (let i = 1; i <= 6; i++) {
243
+ headMenu.append(menuItem(`h${i}`, `H${i}`, `Heading ${i}`, `${17 - i}px`));
244
+ }
245
+ headMenu.append(menuItem('p', 'P', 'Paragraph', '12px'));
246
+ headWrap.append(
247
+ toolbarButton('Hx', 'Heading level', () => {
248
+ headMenu.toggleAttribute('data-on');
249
+ }),
250
+ headMenu,
251
+ );
252
+
253
+ // --- image insert/replace (reuses the asset-picker field: upload / browse /
254
+ // path). Opened by the toolbar button (insert at caret) or by clicking
255
+ // an image inside the content (replace that image).
256
+
257
+ const imagePanel = styled('div', 'atx-rte-image-panel');
258
+ let imageValue = '';
259
+ let replaceTarget: HTMLImageElement | null = null;
260
+ const imageFieldSlot = styled('div', 'atx-rte-image-slot');
261
+
262
+ // Alt text, auto-suggested from the picked file's name until edited by hand.
263
+ const altFromPath = (path: string): string =>
264
+ (basename(path).replace(/\.[a-z0-9]+$/i, '').replace(/[-_]+/g, ' ')).trim();
265
+ let altTouched = false;
266
+ const altLabel = styled('label', 'atx-rte-image-alt-label');
267
+ altLabel.textContent = 'Alt text';
268
+ const altInput = inputEl('input', 'atx-rte-image-alt');
269
+ altInput.type = 'text';
270
+ altInput.placeholder = 'Describe the image';
271
+ altInput.addEventListener('input', () => (altTouched = true));
272
+
273
+ const imageActions = styled('div', 'atx-rte-image-actions');
274
+ const smallBtn = (label: string, primary: boolean, onClick: () => void): HTMLButtonElement => {
275
+ const kind = primary ? 'default' : 'outline';
276
+ const b = styled('button', `atx-btn atx-btn-${kind} atx-btn-sm atx-rte-image-btn`);
277
+ b.type = 'button';
278
+ b.textContent = label;
279
+ b.addEventListener('click', onClick);
280
+ return b;
281
+ };
282
+ const confirmBtn = smallBtn('Insert', true, () => {
283
+ if (!imageValue) {
284
+ toast('Pick or upload an image first', 'err');
285
+ return;
286
+ }
287
+ imagePanel.toggleAttribute('data-on', false);
288
+ const alt = altInput.value.trim();
289
+ if (replaceTarget && replaceTarget.isConnected) {
290
+ replaceTarget.src = imageValue;
291
+ replaceTarget.alt = alt;
292
+ } else {
293
+ content.focus();
294
+ restoreSelection();
295
+ exec('insertHTML', `<img src="${escapeHtml(imageValue)}" alt="${escapeHtml(alt)}">`);
296
+ }
297
+ });
298
+ imageActions.append(
299
+ smallBtn('Cancel', false, () => imagePanel.toggleAttribute('data-on', false)),
300
+ confirmBtn,
301
+ );
302
+ imagePanel.append(imageFieldSlot, altLabel, altInput, imageActions);
303
+
304
+ /** (Re)builds the field so the path/preview reflect this open, not the last. */
305
+ const openImagePanel = (prefill: string, target: HTMLImageElement | null): void => {
306
+ replaceTarget = target;
307
+ imageValue = prefill;
308
+ // Editing an existing image never rewrites its alt on its own — the source
309
+ // value is authoritative, even when blank. Filename auto-suggest applies
310
+ // only to a brand-new insert, and only until the field is edited by hand.
311
+ altInput.value = target?.getAttribute('alt') ?? '';
312
+ altTouched = target !== null || altInput.value !== '';
313
+ imageFieldSlot.textContent = '';
314
+ // Body images stay web-path shaped: a markdown `![](…)` in a rendered page
315
+ // resolves against the site, not the entry file (unlike an image() field).
316
+ imageFieldSlot.append(buildImageField({
317
+ initial: prefill,
318
+ onChange: (v, origin) => {
319
+ // The picker hands back a path describing a file, so it carries the
320
+ // filename's own characters. What goes into an `<img src>` — and from
321
+ // there into a markdown destination — is a URL, so a picked path is
322
+ // encoded on the way in. A value already stored or typed by hand is
323
+ // taken as written; re-encoding it would turn its `%20` into `%2520`.
324
+ imageValue = origin === 'picked' ? webPathToUrl(v) : v;
325
+ // Alt is suggested from the file's real name, not from the URL.
326
+ if (!altTouched) altInput.value = altFromPath(v);
327
+ },
328
+ }));
329
+ confirmBtn.textContent = target ? 'Replace' : 'Insert';
330
+ imagePanel.toggleAttribute('data-on', true);
331
+ };
332
+
333
+ // Clicking an image in the content opens the panel targeting it.
334
+ content.addEventListener('click', (e) => {
335
+ const t = e.target;
336
+ if (t instanceof HTMLImageElement && content.contains(t)) {
337
+ saveSelection();
338
+ openImagePanel(t.getAttribute('src') ?? '', t);
339
+ }
340
+ });
341
+
342
+ // --- toolbar -------------------------------------------------------------
343
+
344
+ const toolbar = styled('div', 'atx-rte-toolbar');
345
+
346
+ const modeBtn = toolbarButton('MD', 'Switch between rich text and markdown source', () => {
347
+ if (mode === 'visual') {
348
+ srcInput.value = htmlToMarkdown(content);
349
+ setMode('source');
350
+ } else {
351
+ if (!canRichEdit(srcInput.value)) {
352
+ toast('Body uses markdown the rich editor can’t preserve (tables, HTML, …)', 'err');
353
+ return;
354
+ }
355
+ content.innerHTML = markdownToHtml(srcInput.value);
356
+ setMode('visual');
357
+ }
358
+ }, 'atx-rte-mode');
359
+
360
+ const formatButtons = [
361
+ toolbarButton('B', 'Bold', () => exec('bold'), 'atx-rte-btn-bold'),
362
+ toolbarButton('I', 'Italic', () => exec('italic'), 'atx-rte-btn-italic'),
363
+ toolbarButton('S', 'Strikethrough', () => exec('strikeThrough'), 'atx-rte-btn-strike'),
364
+ divider(),
365
+ headWrap,
366
+ divider(),
367
+ toolbarButton('•–', 'Bulleted list', () => exec('insertUnorderedList')),
368
+ toolbarButton('1.', 'Numbered list', () => exec('insertOrderedList')),
369
+ divider(),
370
+ toolbarButton('quote', 'Quote', () => exec('formatBlock', '<blockquote>'), '', true),
371
+ toolbarButton('PRE', 'Code block', () => exec('formatBlock', '<pre>'), 'atx-rte-btn-pre'),
372
+ toolbarButton('`', 'Inline code', insertInlineCode, 'atx-rte-btn-code'),
373
+ divider(),
374
+ toolbarButton('link', 'Insert link', insertLink, '', true),
375
+ toolbarButton('image', 'Insert image', () => {
376
+ if (imagePanel.hasAttribute('data-on')) {
377
+ imagePanel.toggleAttribute('data-on', false);
378
+ return;
379
+ }
380
+ saveSelection();
381
+ openImagePanel('', null);
382
+ }, '', true),
383
+ ];
384
+ toolbar.append(...formatButtons, modeBtn);
385
+
386
+ // Clicking into the text closes the heading menu.
387
+ content.addEventListener('mousedown', hideMenu);
388
+
389
+ const setMode = (next: 'visual' | 'source'): void => {
390
+ mode = next;
391
+ const visual = next === 'visual';
392
+ content.toggleAttribute('data-on', visual);
393
+ imagePanel.toggleAttribute('data-on', false);
394
+ hideMenu();
395
+ srcInput.toggleAttribute('data-on', !visual);
396
+ for (const el of formatButtons) el.toggleAttribute('data-hidden', !visual);
397
+ modeBtn.textContent = visual ? 'MD' : 'Rich';
398
+ };
399
+ setMode(mode);
400
+
401
+ // Toolbar and image panel share one sticky header, so the panel stays in
402
+ // view when it's opened for an image far down a long body.
403
+ const stickyHead = styled('div', 'atx-rte-head');
404
+ stickyHead.append(toolbar, imagePanel);
405
+
406
+ root.append(stickyHead, contentSlot, srcInput);
407
+
408
+ const value = (): string => (mode === 'visual' ? htmlToMarkdown(content) : srcInput.value);
409
+
410
+ return {
411
+ root,
412
+ value,
413
+ dirty: () => {
414
+ const v = value();
415
+ return v !== initial && (visualBaseline === null || v !== visualBaseline);
416
+ },
417
+ destroy: () => content.remove(),
418
+ };
419
+ }