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,319 @@
1
+ /**
2
+ * Minimal markdown ⇄ HTML conversion for the WYSIWYG body editor.
3
+ *
4
+ * Deliberately a subset: headings, paragraphs, bold/italic/strikethrough,
5
+ * inline code, fenced code blocks, flat lists, blockquotes, links, images,
6
+ * hr. `canRichEdit` gates entry — anything outside the subset (tables, raw
7
+ * HTML/MDX, footnotes, nested lists, indented code, setext headings) keeps
8
+ * the editor in raw-markdown mode so a visual round-trip can never destroy
9
+ * constructs it doesn't understand.
10
+ *
11
+ * `markdownToHtml`/`canRichEdit` are pure string functions (unit-tested);
12
+ * `htmlToMarkdown` walks a live DOM tree and only runs in the browser.
13
+ */
14
+
15
+ export function escapeHtml(s: string): string {
16
+ return s
17
+ .replace(/&/g, '&')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;');
21
+ }
22
+
23
+ /**
24
+ * A link/image destination, in both the forms CommonMark writes it: bare, or
25
+ * wrapped in angle brackets. A bare destination ends at the first space or
26
+ * paren, so a path holding either can only be written wrapped — which is how
27
+ * `/brand/Logo Miramar horizontal.png` survives being a destination at all.
28
+ *
29
+ * `escapeHtml` has already run by the time these match, so the brackets arrive
30
+ * as entities. The wrapped form is read lazily up to the first `&gt;`, which is
31
+ * the one character CommonMark does not allow raw inside it.
32
+ */
33
+ const DESTINATION = String.raw`(?:&lt;(.*?)&gt;|([^()\s]+))`;
34
+ const IMAGE_RE = new RegExp(String.raw`!\[([^\]]*)\]\(${DESTINATION}\)`, 'g');
35
+ const LINK_RE = new RegExp(String.raw`\[([^\]]+)\]\(${DESTINATION}\)`, 'g');
36
+
37
+ /**
38
+ * Write a URL as a markdown destination, wrapping it when it holds a character
39
+ * that would otherwise end it early.
40
+ *
41
+ * Without this an `<img>` whose src has a space serializes to
42
+ * `![alt](/a b.png)`, which is not an image: the next parse reads it as
43
+ * literal text, and the save after that replaces the image with its own
44
+ * markdown source. The image is gone and nothing reported a failure.
45
+ */
46
+ export function mdDestination(url: string): string {
47
+ if (!/[\s()<>]/.test(url)) return url;
48
+ // `<` and `>` are the two characters the wrapper cannot carry raw.
49
+ return `<${url.replace(/</g, '%3C').replace(/>/g, '%3E')}>`;
50
+ }
51
+
52
+ /** Inline markdown → inline HTML. Input is raw text; output is escaped. */
53
+ function inlineHtml(text: string): string {
54
+ let s = escapeHtml(text);
55
+ // Protect code spans first so their contents are never styled.
56
+ const codes: string[] = [];
57
+ s = s.replace(/`([^`]+)`/g, (_, c: string) => {
58
+ codes.push(`<code>${c}</code>`);
59
+ return `\u0000${codes.length - 1}\u0000`;
60
+ });
61
+ s = s.replace(IMAGE_RE, (_, alt: string, wrapped?: string, bare?: string) =>
62
+ `<img alt="${alt}" src="${wrapped ?? bare ?? ''}">`);
63
+ s = s.replace(LINK_RE, (_, text: string, wrapped?: string, bare?: string) =>
64
+ `<a href="${wrapped ?? bare ?? ''}">${text}</a>`);
65
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
66
+ s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>');
67
+ s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
68
+ s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
69
+ // Underscore italics only at word boundaries, so snake_case survives.
70
+ s = s.replace(/(^|[\s([])_([^_]+)_(?=$|[\s).,;:!?])/g, '$1<em>$2</em>');
71
+ s = s.replace(/\u0000(\d+)\u0000/g, (_, i: string) => codes[Number(i)]);
72
+ return s;
73
+ }
74
+
75
+ const FENCE_RE = /^```(\S*)\s*$/;
76
+ const HEADING_RE = /^(#{1,6})\s+(.*)$/;
77
+ const UL_RE = /^[-*+]\s+(.*)$/;
78
+ const OL_RE = /^\d+[.)]\s+(.*)$/;
79
+ const QUOTE_RE = /^>\s?(.*)$/;
80
+ const HR_RE = /^(?:-{3,}|\*{3,}|_{3,})\s*$/;
81
+ const BLANK_RE = /^\s*$/;
82
+
83
+ /**
84
+ * Whether the visual editor can represent this markdown without loss.
85
+ * False sends the drawer to raw-markdown mode.
86
+ */
87
+ export function canRichEdit(md: string): boolean {
88
+ const lines = md.replace(/\r\n?/g, '\n').split('\n');
89
+ let inFence = false;
90
+ let prevWasText = false;
91
+ for (const line of lines) {
92
+ if (FENCE_RE.test(line)) {
93
+ inFence = !inFence;
94
+ prevWasText = false;
95
+ continue;
96
+ }
97
+ if (inFence) continue;
98
+ if (BLANK_RE.test(line)) {
99
+ prevWasText = false;
100
+ continue;
101
+ }
102
+ if (prevWasText && /^(=+|-{1,})\s*$/.test(line)) return false; // setext heading
103
+ if (/^\s*<[a-zA-Z!/]/.test(line)) return false; // raw HTML / MDX component
104
+ if (/^\s*\|/.test(line)) return false; // table row
105
+ if (/^\s*\[\^/.test(line)) return false; // footnote
106
+ if (/^\s*\[[^\]]+\]:\s/.test(line)) return false; // reference link definition
107
+ if (/^(import|export)\s/.test(line)) return false; // MDX
108
+ if (/^\s{2,}([-*+]|\d+[.)])\s/.test(line)) return false; // nested list
109
+ if (/^ {4,}\S/.test(line)) return false; // indented code block
110
+ prevWasText = !HEADING_RE.test(line) && !UL_RE.test(line)
111
+ && !OL_RE.test(line) && !QUOTE_RE.test(line) && !HR_RE.test(line);
112
+ }
113
+ return !inFence; // an unclosed fence means we misread everything after it
114
+ }
115
+
116
+ /** Markdown (the canRichEdit subset) → HTML for the contenteditable. */
117
+ export function markdownToHtml(md: string): string {
118
+ const lines = md.replace(/\r\n?/g, '\n').split('\n');
119
+ const out: string[] = [];
120
+ let para: string[] = [];
121
+ let quote: string[] | null = null;
122
+ let list: { tag: 'ul' | 'ol'; items: string[] } | null = null;
123
+
124
+ const flushPara = (): void => {
125
+ if (para.length) out.push(`<p>${inlineHtml(para.join(' '))}</p>`);
126
+ para = [];
127
+ };
128
+ const flushList = (): void => {
129
+ if (list) {
130
+ out.push(`<${list.tag}>${list.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</${list.tag}>`);
131
+ }
132
+ list = null;
133
+ };
134
+ const flushQuote = (): void => {
135
+ if (quote) {
136
+ const paras = quote.join('\n').split(/\n\s*\n/).filter((p) => p.trim() !== '');
137
+ out.push(`<blockquote>${paras.map((p) => `<p>${inlineHtml(p.replace(/\n/g, ' '))}</p>`).join('')}</blockquote>`);
138
+ }
139
+ quote = null;
140
+ };
141
+ const flushAll = (): void => {
142
+ flushPara();
143
+ flushList();
144
+ flushQuote();
145
+ };
146
+
147
+ for (let i = 0; i < lines.length; i++) {
148
+ const line = lines[i];
149
+ const fence = FENCE_RE.exec(line);
150
+ if (fence) {
151
+ flushAll();
152
+ const body: string[] = [];
153
+ i++;
154
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) body.push(lines[i++]);
155
+ const lang = fence[1] ? ` data-lang="${escapeHtml(fence[1])}"` : '';
156
+ out.push(`<pre${lang}><code>${escapeHtml(body.join('\n'))}</code></pre>`);
157
+ continue;
158
+ }
159
+ if (BLANK_RE.test(line)) {
160
+ flushAll();
161
+ continue;
162
+ }
163
+ const h = HEADING_RE.exec(line);
164
+ if (h) {
165
+ flushAll();
166
+ out.push(`<h${h[1].length}>${inlineHtml(h[2])}</h${h[1].length}>`);
167
+ continue;
168
+ }
169
+ if (HR_RE.test(line) && para.length === 0) {
170
+ flushAll();
171
+ out.push('<hr>');
172
+ continue;
173
+ }
174
+ const q = QUOTE_RE.exec(line);
175
+ if (q) {
176
+ flushPara();
177
+ flushList();
178
+ (quote ??= []).push(q[1]);
179
+ continue;
180
+ }
181
+ const ul = UL_RE.exec(line);
182
+ if (ul) {
183
+ flushPara();
184
+ flushQuote();
185
+ if (!list || list.tag !== 'ul') {
186
+ flushList();
187
+ list = { tag: 'ul', items: [] };
188
+ }
189
+ list.items.push(ul[1]);
190
+ continue;
191
+ }
192
+ const ol = OL_RE.exec(line);
193
+ if (ol) {
194
+ flushPara();
195
+ flushQuote();
196
+ if (!list || list.tag !== 'ol') {
197
+ flushList();
198
+ list = { tag: 'ol', items: [] };
199
+ }
200
+ list.items.push(ol[1]);
201
+ continue;
202
+ }
203
+ flushList();
204
+ flushQuote();
205
+ para.push(line.trim());
206
+ }
207
+ flushAll();
208
+ return out.join('\n');
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // HTML → markdown (DOM walker; browser-only)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /** Inline serialization of an element's children. */
216
+ function inlineMd(node: Node): string {
217
+ let out = '';
218
+ for (const child of Array.from(node.childNodes)) {
219
+ if (child.nodeType === Node.TEXT_NODE) {
220
+ out += (child.nodeValue ?? '').replace(/\s+/g, ' ');
221
+ continue;
222
+ }
223
+ if (child.nodeType !== Node.ELEMENT_NODE) continue;
224
+ const el = child as HTMLElement;
225
+ const tag = el.tagName;
226
+ if (tag === 'BR') {
227
+ out += '\n';
228
+ } else if (tag === 'IMG') {
229
+ out += `![${el.getAttribute('alt') ?? ''}](${mdDestination(el.getAttribute('src') ?? '')})`;
230
+ } else if (tag === 'A') {
231
+ out += `[${inlineMd(el)}](${mdDestination(el.getAttribute('href') ?? '')})`;
232
+ } else if (tag === 'CODE') {
233
+ out += `\`${el.textContent ?? ''}\``;
234
+ } else if (tag === 'STRONG' || tag === 'B') {
235
+ const inner = inlineMd(el).trim();
236
+ if (inner) out += `**${inner}**`;
237
+ } else if (tag === 'EM' || tag === 'I') {
238
+ const inner = inlineMd(el).trim();
239
+ if (inner) out += `*${inner}*`;
240
+ } else if (tag === 'DEL' || tag === 'S' || tag === 'STRIKE') {
241
+ const inner = inlineMd(el).trim();
242
+ if (inner) out += `~~${inner}~~`;
243
+ } else if (tag === 'SPAN') {
244
+ // execCommand sometimes styles spans instead of emitting tags.
245
+ let inner = inlineMd(el);
246
+ const st = el.style;
247
+ const trimmed = inner.trim();
248
+ if (trimmed) {
249
+ if (st.textDecoration.includes('line-through')) inner = `~~${trimmed}~~`;
250
+ if (st.fontStyle === 'italic') inner = `*${inner.trim()}*`;
251
+ if (st.fontWeight === 'bold' || Number(st.fontWeight) >= 600) inner = `**${inner.trim()}**`;
252
+ }
253
+ out += inner;
254
+ } else {
255
+ out += inlineMd(el);
256
+ }
257
+ }
258
+ return out;
259
+ }
260
+
261
+ function listToMd(el: HTMLElement, indent: string): string {
262
+ const lines: string[] = [];
263
+ const ordered = el.tagName === 'OL';
264
+ let n = 1;
265
+ for (const li of Array.from(el.children)) {
266
+ if (li.tagName !== 'LI') continue;
267
+ const nested: string[] = [];
268
+ const clone = li.cloneNode(true) as HTMLElement;
269
+ for (const sub of Array.from(clone.querySelectorAll(':scope > ul, :scope > ol'))) {
270
+ nested.push(listToMd(sub as HTMLElement, indent + ' '));
271
+ sub.remove();
272
+ }
273
+ const marker = ordered ? `${n++}.` : '-';
274
+ lines.push(`${indent}${marker} ${inlineMd(clone).trim()}`);
275
+ lines.push(...nested);
276
+ }
277
+ return lines.join('\n');
278
+ }
279
+
280
+ function blockToMd(node: Node): string | null {
281
+ if (node.nodeType === Node.TEXT_NODE) {
282
+ const t = (node.nodeValue ?? '').trim();
283
+ return t === '' ? null : t;
284
+ }
285
+ if (node.nodeType !== Node.ELEMENT_NODE) return null;
286
+ const el = node as HTMLElement;
287
+ const tag = el.tagName;
288
+ const h = /^H([1-6])$/.exec(tag);
289
+ if (h) return `${'#'.repeat(Number(h[1]))} ${inlineMd(el).trim()}`;
290
+ if (tag === 'UL' || tag === 'OL') return listToMd(el, '');
291
+ if (tag === 'BLOCKQUOTE') {
292
+ const inner = htmlToMarkdown(el).replace(/\n$/, '');
293
+ return inner === '' ? null : inner.split('\n').map((l) => (l === '' ? '>' : `> ${l}`)).join('\n');
294
+ }
295
+ if (tag === 'PRE') {
296
+ const lang = el.getAttribute('data-lang') ?? '';
297
+ const text = (el.textContent ?? '').replace(/\n$/, '');
298
+ return `\`\`\`${lang}\n${text}\n\`\`\``;
299
+ }
300
+ if (tag === 'HR') return '---';
301
+ if (tag === 'BR') return null;
302
+ // A bare image between blocks (no <p> wrapper) is still content.
303
+ if (tag === 'IMG') {
304
+ return `![${el.getAttribute('alt') ?? ''}](${mdDestination(el.getAttribute('src') ?? '')})`;
305
+ }
306
+ // P, DIV, and anything unrecognized serialize as a paragraph.
307
+ const inner = inlineMd(el).trim();
308
+ return inner === '' ? null : inner;
309
+ }
310
+
311
+ /** Serialize the contenteditable's DOM back to subset markdown. */
312
+ export function htmlToMarkdown(root: HTMLElement): string {
313
+ const blocks: string[] = [];
314
+ for (const node of Array.from(root.childNodes)) {
315
+ const b = blockToMd(node);
316
+ if (b !== null) blocks.push(b);
317
+ }
318
+ return blocks.length ? blocks.join('\n\n') + '\n' : '';
319
+ }