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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- 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, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"');
|
|
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 `>`, which is
|
|
31
|
+
* the one character CommonMark does not allow raw inside it.
|
|
32
|
+
*/
|
|
33
|
+
const DESTINATION = String.raw`(?:<(.*?)>|([^()\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
|
+
* ``, 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 += ` ?? '')})`;
|
|
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 ` ?? '')})`;
|
|
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
|
+
}
|