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,121 @@
1
+ /**
2
+ * `.env` document patching — pure string-in/string-out, like the frontmatter
3
+ * patcher, and deliberately NOT a registry `Patcher`: that interface is
4
+ * loc-based (classify/apply against a source annotation), while this targets a
5
+ * named variable. The caller owns all filesystem access.
6
+ *
7
+ * This exists because the Unsplash access key must not live in
8
+ * `.astro-dev-edit.json`. That file sits in the directory Vite serves; a `.env`
9
+ * file is one Vite already refuses to serve, and one every project's ignore
10
+ * rules already expect to hold a secret.
11
+ *
12
+ * **Values are validated, not escaped.** dotenv gives `"`, `'`, `` ` ``, `#`
13
+ * and whitespace their own meanings, and Vite runs dotenv-expand over what it
14
+ * parses, so `$` interpolates. A value needing any of that would not read back
15
+ * as it was written — and quoting it would be this module guessing at intent.
16
+ * Anything outside {@link ENV_VALUE_RE} is refused instead.
17
+ *
18
+ * Everything is done by line slicing, never by re-serializing the document:
19
+ * comments, blank lines, ordering and the quoting style of untouched lines
20
+ * survive exactly. Line *endings* are the one normalization — the document's
21
+ * dominant ending is applied throughout, so a mixed-ending file comes out
22
+ * consistent rather than gaining a third style.
23
+ */
24
+
25
+ /**
26
+ * What a value may contain. Wide enough for every access-key format in play
27
+ * (Unsplash's are URL-safe base64) and narrow enough that no dotenv
28
+ * metacharacter can reach the file.
29
+ */
30
+ export const ENV_VALUE_RE = /^[A-Za-z0-9_-]+$/;
31
+
32
+ /** Conventional shell variable naming. Ours is a constant; assert anyway. */
33
+ const ENV_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
34
+
35
+ /** Shown to the user when a save is refused, so it says what to do. */
36
+ const VALUE_REFUSAL =
37
+ 'An access key may contain only letters, digits, hyphens and underscores. ' +
38
+ 'Check for a stray space or quotation mark in what you pasted.';
39
+
40
+ export type EnvPatchAction = 'created' | 'updated' | 'appended' | 'removed' | 'unchanged';
41
+
42
+ export type EnvPatchResult =
43
+ | { ok: true; text: string; action: EnvPatchAction }
44
+ | { ok: false; reason: string };
45
+
46
+ /** An uncommented `NAME=` assignment, allowing indentation and a `export `
47
+ * prefix (dotenv accepts both). A `#` before it makes the line a comment. */
48
+ function assignmentRe(name: string): RegExp {
49
+ return new RegExp(`^(\\s*)(export\\s+)?${name}\\s*=`);
50
+ }
51
+
52
+ /** A commented-out assignment — not a value, but a hint about where the user
53
+ * expects the variable to live. */
54
+ function commentedRe(name: string): RegExp {
55
+ return new RegExp(`^\\s*#\\s*(export\\s+)?${name}\\s*=`);
56
+ }
57
+
58
+ /**
59
+ * Upsert `NAME=value` into a `.env` document, or remove it when `value` is
60
+ * empty. Returns the new text, or a refusal the caller turns into a 422.
61
+ *
62
+ * Where the line lands, in order: an existing assignment is replaced **in
63
+ * place**; failing that, immediately after a commented-out one; failing that,
64
+ * appended. Earlier duplicate assignments are deleted rather than left behind —
65
+ * dotenv is last-wins, so a stale copy of a rotated key would otherwise sit on
66
+ * disk reading as inert while still being a secret.
67
+ */
68
+ export function upsertEnvVar(source: string, name: string, value: string): EnvPatchResult {
69
+ if (!ENV_NAME_RE.test(name)) return { ok: false, reason: `not a valid environment variable name: ${name}` };
70
+ const next = value.trim();
71
+ if (next && !ENV_VALUE_RE.test(next)) return { ok: false, reason: VALUE_REFUSAL };
72
+
73
+ // Match the document's own line ending rather than imposing one.
74
+ const eol = /\r\n/.test(source) ? '\r\n' : '\n';
75
+ const assignment = assignmentRe(name);
76
+ const commented = commentedRe(name);
77
+
78
+ // Split on either ending so a \r never rides along on the line content and
79
+ // double up when the parts are rejoined with `eol`.
80
+ const lines = source === '' ? [] : source.split(/\r?\n/);
81
+ // A trailing newline yields a final empty element; drop it and re-add at the
82
+ // end, so "append" cannot produce a blank line in the middle.
83
+ const trailingNewline = lines.length > 0 && lines[lines.length - 1] === '';
84
+ if (trailingNewline) lines.pop();
85
+
86
+ const hits: number[] = [];
87
+ let commentedAt = -1;
88
+ lines.forEach((line, i) => {
89
+ if (assignment.test(line)) hits.push(i);
90
+ else if (commentedAt === -1 && commented.test(line)) commentedAt = i;
91
+ });
92
+
93
+ if (!next) {
94
+ if (hits.length === 0) return { ok: true, text: source, action: 'unchanged' };
95
+ const kept = lines.filter((_, i) => !hits.includes(i));
96
+ return { ok: true, text: kept.length === 0 ? '' : kept.join(eol) + eol, action: 'removed' };
97
+ }
98
+
99
+ let action: EnvPatchAction;
100
+ if (hits.length > 0) {
101
+ // Rewrite the last (the one dotenv would use) and drop the earlier ones.
102
+ const last = hits[hits.length - 1];
103
+ const [, indent = '', exported = ''] = assignment.exec(lines[last]) ?? [];
104
+ // Any trailing `# comment` described the old value, so it goes with it.
105
+ lines[last] = `${indent}${exported}${name}=${next}`;
106
+ for (const i of hits.slice(0, -1).reverse()) lines.splice(i, 1);
107
+ action = 'updated';
108
+ } else if (commentedAt !== -1) {
109
+ lines.splice(commentedAt + 1, 0, `${name}=${next}`);
110
+ action = 'appended';
111
+ } else if (lines.length === 0) {
112
+ lines.push('# Written by astro-dev-edit. Keep this file out of version control.');
113
+ lines.push(`${name}=${next}`);
114
+ action = 'created';
115
+ } else {
116
+ lines.push(`${name}=${next}`);
117
+ action = 'appended';
118
+ }
119
+
120
+ return { ok: true, text: lines.join(eol) + eol, action };
121
+ }
@@ -0,0 +1,326 @@
1
+ import type { AstNode } from './astro.ts';
2
+
3
+ /**
4
+ * Tracing `{expression}` text back to the string constant it renders, so the
5
+ * words can be edited where they are read.
6
+ *
7
+ * Two shapes are understood, and only these two:
8
+ *
9
+ * {title} ← const title = 'Hello'
10
+ * {b.title} ← const benefits = [{ title: 'Hello' }, …] via benefits.map((b) => …)
11
+ *
12
+ * **No JS parser is involved, and none is needed.** `@astrojs/compiler` already
13
+ * splits a template expression into its text parts, so the expression source
14
+ * (`b.title`) and the loop head (`benefits.map((b) => (`) arrive as plain
15
+ * strings. What remains is finding one string literal inside the frontmatter,
16
+ * which is done by scanning rather than parsing — deliberately, since a real
17
+ * parser would mean a runtime dependency (`typescript` is absent from ordinary
18
+ * Astro projects) for a job whose failure mode must be *refusal* anyway.
19
+ *
20
+ * Everything here therefore fails closed: any shape not matched exactly returns
21
+ * null, and the caller keeps today's "edit this in the source" refusal.
22
+ *
23
+ * ## Which item, when a loop renders many
24
+ *
25
+ * Every card in a `.map()` shares one source loc, so the loc cannot say which
26
+ * item was clicked. The rendered string does: the client sends the text it
27
+ * showed and the item whose value equals it is the one patched. Two items with
28
+ * the same text refuse rather than guess. A DOM sibling index was considered
29
+ * and rejected — a `.filter()`, `.sort()` or `.slice()` in the chain would
30
+ * silently mis-target, and a silently wrong write is the one failure this
31
+ * project must not have.
32
+ */
33
+
34
+ const IDENT = '[A-Za-z_$][\\w$]*';
35
+ const BARE_IDENT = new RegExp(`^${IDENT}$`);
36
+ const MEMBER = new RegExp(`^(${IDENT})\\.(${IDENT})$`);
37
+
38
+ /**
39
+ * The head of a `.map()` call, up to the arrow: `items.map((it) => (`, or
40
+ * `items.map(it =>`, with an optional index parameter. Anything else — a
41
+ * `.filter().map()` chain, a nested member (`data.items.map`), a `function`
42
+ * callback — does not match, and the expression stays refused.
43
+ */
44
+ const MAP_HEAD = new RegExp(
45
+ `^\\s*(${IDENT})\\s*\\.\\s*map\\s*\\(\\s*(?:\\(\\s*(${IDENT})\\s*(?:,[^)]*)?\\)|(${IDENT}))\\s*=>`,
46
+ );
47
+
48
+ export interface ExpressionTrace {
49
+ /** Frontmatter key holding the string: `title`. */
50
+ property: string;
51
+ /** The array const the item lives in, when the text came from a `.map()`
52
+ * loop. Absent for a plain `{title}` const. */
53
+ array?: string;
54
+ /** How to name the target in the editor: `title` or `benefits[].title`. */
55
+ label: string;
56
+ }
57
+
58
+ /** An expression node's source text, when it is one plain run of text.
59
+ * A nested element or a second text part means it isn't a simple value. */
60
+ function expressionText(node: AstNode): string | null {
61
+ const children = node.children ?? [];
62
+ if (children.length !== 1 || children[0].type !== 'text') return null;
63
+ return (children[0].value ?? '').trim();
64
+ }
65
+
66
+ /** The leading text of an expression — for a `.map()` call, its head. */
67
+ function headText(node: AstNode): string {
68
+ const first = (node.children ?? [])[0];
69
+ return first?.type === 'text' ? (first.value ?? '') : '';
70
+ }
71
+
72
+ /**
73
+ * What the element's `{expression}` renders, when it can be traced. `parentOf`
74
+ * must reach every ancestor of `el`, so a member access can be bound through
75
+ * the loop that introduced it.
76
+ */
77
+ export function traceExpression(
78
+ el: AstNode,
79
+ parentOf: Map<AstNode, AstNode>,
80
+ ): ExpressionTrace | null {
81
+ const children = el.children ?? [];
82
+ if (children.length !== 1 || children[0].type !== 'expression') return null;
83
+
84
+ const expr = expressionText(children[0]);
85
+ if (!expr) return null;
86
+
87
+ if (BARE_IDENT.test(expr)) return { property: expr, label: expr };
88
+
89
+ const member = MEMBER.exec(expr);
90
+ if (!member) return null;
91
+ const [, param, property] = member;
92
+
93
+ // Bind the parameter through the nearest enclosing expression, which for a
94
+ // mapped element is the `.map()` call itself.
95
+ for (let node = parentOf.get(el); node; node = parentOf.get(node)) {
96
+ if (node.type !== 'expression') continue;
97
+ const map = MAP_HEAD.exec(headText(node));
98
+ if (!map) return null; // an enclosing expression we don't understand
99
+ const array = map[1];
100
+ const bound = map[2] ?? map[3];
101
+ if (bound !== param) return null; // shadowed, or bound somewhere else
102
+ return { property, array, label: `${array}[].${property}` };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Locating the string in the frontmatter
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** A string literal's span within the frontmatter, and its decoded value. */
112
+ export interface LiteralSpan {
113
+ /** Index of the opening quote, relative to the frontmatter text. */
114
+ from: number;
115
+ /** Index just past the closing quote. */
116
+ to: number;
117
+ quote: string;
118
+ value: string;
119
+ }
120
+
121
+ const ESCAPES: Record<string, string> = {
122
+ n: '\n', t: '\t', r: '\r', '\\': '\\', "'": "'", '"': '"', '`': '`',
123
+ };
124
+
125
+ /**
126
+ * Read the string literal starting at `i` (which must be its opening quote).
127
+ * Returns null for an unterminated literal, or a template literal carrying an
128
+ * `${…}` interpolation — that is code, not copy. Escapes outside the common
129
+ * set are left encoded, so they simply fail to match and refuse.
130
+ */
131
+ export function readLiteral(text: string, i: number): LiteralSpan | null {
132
+ const quote = text[i];
133
+ if (quote !== '"' && quote !== "'" && quote !== '`') return null;
134
+ let value = '';
135
+ for (let j = i + 1; j < text.length; j++) {
136
+ const ch = text[j];
137
+ if (ch === '\\') {
138
+ const next = text[j + 1];
139
+ if (next === undefined) return null;
140
+ value += ESCAPES[next] ?? `\\${next}`;
141
+ j++;
142
+ continue;
143
+ }
144
+ if (ch === quote) return { from: i, to: j + 1, quote, value };
145
+ if (quote === '`' && ch === '$' && text[j + 1] === '{') return null;
146
+ if (quote !== '`' && ch === '\n') return null; // unterminated
147
+ value += ch;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /** Step over a string literal or a comment, so scanners never mistake their
153
+ * contents for structure. Returns the index just past it, or -1. */
154
+ function skipOpaque(text: string, i: number): number {
155
+ const ch = text[i];
156
+ if (ch === '"' || ch === "'" || ch === '`') {
157
+ const lit = readLiteral(text, i);
158
+ if (lit) return lit.to;
159
+ // An interpolated template still has to be stepped over; find its end
160
+ // naively so the surrounding scan can continue or bail.
161
+ for (let j = i + 1; j < text.length; j++) {
162
+ if (text[j] === '\\') j++;
163
+ else if (text[j] === ch) return j + 1;
164
+ }
165
+ return -1;
166
+ }
167
+ if (ch === '/' && text[i + 1] === '/') {
168
+ const nl = text.indexOf('\n', i);
169
+ return nl < 0 ? text.length : nl;
170
+ }
171
+ if (ch === '/' && text[i + 1] === '*') {
172
+ const end = text.indexOf('*/', i + 2);
173
+ return end < 0 ? -1 : end + 2;
174
+ }
175
+ return -1;
176
+ }
177
+
178
+ const DECL = (name: string) => new RegExp(`(?:^|[\\s;])(?:const|let|var)\\s+${name}\\s*(?::[^=\\n]*)?=\\s*`, 'm');
179
+
180
+ /** Index just past `const <name> = `, or -1. */
181
+ function declarationEnd(frontmatter: string, name: string): number {
182
+ const m = DECL(name).exec(frontmatter);
183
+ return m ? m.index + m[0].length : -1;
184
+ }
185
+
186
+ /**
187
+ * The span of the array literal a const is initialised with — from its `[` to
188
+ * the matching `]`, stepping over strings and comments so a bracket inside one
189
+ * cannot close it early.
190
+ */
191
+ export function arraySpan(frontmatter: string, name: string): { from: number; to: number } | null {
192
+ const start = declarationEnd(frontmatter, name);
193
+ if (start < 0 || frontmatter[start] !== '[') return null;
194
+ let depth = 0;
195
+ for (let i = start; i < frontmatter.length; i++) {
196
+ const past = skipOpaque(frontmatter, i);
197
+ if (past >= 0) {
198
+ i = past - 1;
199
+ continue;
200
+ }
201
+ const ch = frontmatter[i];
202
+ if (ch === '[' || ch === '{' || ch === '(') depth++;
203
+ else if (ch === ']' || ch === '}' || ch === ')') {
204
+ depth--;
205
+ if (depth === 0) return { from: start, to: i + 1 };
206
+ if (depth < 0) return null;
207
+ }
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /** Every `<property>: "…"` string literal within `[from, to)`. */
213
+ export function propertyLiterals(
214
+ frontmatter: string,
215
+ property: string,
216
+ from: number,
217
+ to: number,
218
+ ): LiteralSpan[] {
219
+ const re = new RegExp(`(?:^|[{,\\s])${property}\\s*:\\s*`, 'g');
220
+ const scope = frontmatter.slice(from, to);
221
+ const out: LiteralSpan[] = [];
222
+ let m: RegExpExecArray | null;
223
+ while ((m = re.exec(scope)) !== null) {
224
+ const at = from + m.index + m[0].length;
225
+ const lit = readLiteral(frontmatter, at);
226
+ if (lit) out.push(lit);
227
+ // Overlapping keys can't occur, but keep the scan moving regardless.
228
+ re.lastIndex = m.index + m[0].length;
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Whether the frontmatter holds anything a trace could patch — the const
235
+ * exists and, for a loop, at least one item carries the property as a string.
236
+ * Classification uses this so the page never offers an edit that the write
237
+ * path would have to refuse for structural reasons. It deliberately does *not*
238
+ * value-match: which item was clicked is only known once the text is sent.
239
+ */
240
+ export function hasCandidates(frontmatter: string, trace: ExpressionTrace): boolean {
241
+ if (!trace.array) {
242
+ const at = declarationEnd(frontmatter, trace.property);
243
+ return at >= 0 && readLiteral(frontmatter, at) !== null;
244
+ }
245
+ const span = arraySpan(frontmatter, trace.array);
246
+ return span !== null && propertyLiterals(frontmatter, trace.property, span.from, span.to).length > 0;
247
+ }
248
+
249
+ export type Located =
250
+ | { ok: true; span: LiteralSpan }
251
+ | { ok: false; code: 'untraceable' | 'mismatch' | 'ambiguous'; error: string };
252
+
253
+ /**
254
+ * Find the one string literal the traced expression rendered, matching on the
255
+ * text the client saw. Whitespace-trimmed on both sides, so re-indentation in
256
+ * the source doesn't break the match — items that differ only in whitespace
257
+ * therefore read as duplicates and refuse.
258
+ */
259
+ export function locateValue(
260
+ frontmatter: string,
261
+ trace: ExpressionTrace,
262
+ original: string,
263
+ ): Located {
264
+ const wanted = original.trim();
265
+
266
+ if (!trace.array) {
267
+ const at = declarationEnd(frontmatter, trace.property);
268
+ if (at < 0) {
269
+ return {
270
+ ok: false,
271
+ code: 'untraceable',
272
+ error: `“${trace.property}” isn’t declared in this file’s frontmatter (it may be imported), so it must be edited in the source.`,
273
+ };
274
+ }
275
+ const lit = readLiteral(frontmatter, at);
276
+ if (!lit) {
277
+ return {
278
+ ok: false,
279
+ code: 'untraceable',
280
+ error: `“${trace.property}” isn’t a plain string in the frontmatter, so it must be edited in the source.`,
281
+ };
282
+ }
283
+ if (lit.value.trim() !== wanted) {
284
+ return {
285
+ ok: false,
286
+ code: 'mismatch',
287
+ error: 'The source no longer matches the text on the page (it may have been edited elsewhere). Reload and try again.',
288
+ };
289
+ }
290
+ return { ok: true, span: lit };
291
+ }
292
+
293
+ const span = arraySpan(frontmatter, trace.array);
294
+ if (!span) {
295
+ return {
296
+ ok: false,
297
+ code: 'untraceable',
298
+ error: `“${trace.array}” isn’t an array declared in this file’s frontmatter (it may be imported), so it must be edited in the source.`,
299
+ };
300
+ }
301
+
302
+ const hits = propertyLiterals(frontmatter, trace.property, span.from, span.to).filter(
303
+ (l) => l.value.trim() === wanted,
304
+ );
305
+ if (hits.length === 1) return { ok: true, span: hits[0] };
306
+ if (hits.length === 0) {
307
+ return {
308
+ ok: false,
309
+ code: 'mismatch',
310
+ error: `No “${trace.property}” in ${trace.array} still reads that way — the file may have been edited elsewhere. Reload and try again.`,
311
+ };
312
+ }
313
+ return {
314
+ ok: false,
315
+ code: 'ambiguous',
316
+ error: `Two entries in ${trace.array} have exactly this text, so the right one can’t be identified. Edit it in the source instead.`,
317
+ };
318
+ }
319
+
320
+ /** Re-encode a replacement for the quote style the literal already uses. */
321
+ export function encodeLiteral(value: string, quote: string): string {
322
+ let out = value.replace(/\\/g, '\\\\').split(quote).join(`\\${quote}`);
323
+ if (quote !== '`') out = out.replace(/\r?\n/g, '\\n');
324
+ else out = out.replace(/\$\{/g, '\\${');
325
+ return out;
326
+ }
@@ -0,0 +1,249 @@
1
+ import { Document, isMap, isNode, isPair, isScalar, parseDocument } from 'yaml';
2
+
3
+ /**
4
+ * Frontmatter entry parsing and patching for the CMS entry panel — pure
5
+ * string-in/string-out, like the .astro patcher. Deliberately NOT a registry
6
+ * `Patcher`: that interface is loc-based (classify/apply against a source
7
+ * annotation), while entry edits target named frontmatter keys and the body.
8
+ * The middleware still owns all filesystem access.
9
+ *
10
+ * The YAML block is patched via the `yaml` Document API so that comments, key
11
+ * order, and the quoting style of untouched keys survive a save byte-for-byte.
12
+ * Only the keys the client actually changed are re-emitted.
13
+ */
14
+
15
+ export interface ParsedEntry {
16
+ hasFrontmatter: boolean;
17
+ /** Raw text between the fences ('' when none). \n-normalized. */
18
+ frontmatterText: string;
19
+ /** Parsed frontmatter mapping; {} when absent or not a mapping. */
20
+ data: Record<string, unknown>;
21
+ /** Set when the YAML block failed to parse — frontmatter edits must refuse. */
22
+ yamlError?: string;
23
+ /** Markdown body with the post-fence blank-line run stripped. \n-normalized. */
24
+ body: string;
25
+ /** Newline run that separated the closing fence from the body. */
26
+ bodyGap: string;
27
+ /** Dominant EOL of the original file, restored on write. */
28
+ eol: '\n' | '\r\n';
29
+ /** Leading byte-order mark, restored on write. */
30
+ bom: string;
31
+ }
32
+
33
+ export interface EntryChanges {
34
+ /** Only changed keys. `null` deletes the key. */
35
+ frontmatter?: Record<string, unknown>;
36
+ /** Full replacement body (\n-normalized). */
37
+ body?: string;
38
+ }
39
+
40
+ export type EntryPatchResult =
41
+ | { ok: true; newSource: string }
42
+ | { ok: false; error: string };
43
+
44
+ const CLOSE_FENCE = /^(---|\.\.\.)[ \t]*$/;
45
+
46
+ export function parseEntry(source: string): ParsedEntry {
47
+ const bom = source.startsWith('\uFEFF') ? '\uFEFF' : '';
48
+ const eol: '\n' | '\r\n' = source.includes('\r\n') ? '\r\n' : '\n';
49
+ const text = source.slice(bom.length).replace(/\r\n/g, '\n');
50
+
51
+ const empty: Omit<ParsedEntry, 'body' | 'bodyGap'> = {
52
+ hasFrontmatter: false,
53
+ frontmatterText: '',
54
+ data: {},
55
+ eol,
56
+ bom,
57
+ };
58
+
59
+ const lines = text.split('\n');
60
+ if (lines[0]?.trimEnd() !== '---') {
61
+ const { body, bodyGap } = splitGap(text);
62
+ return { ...empty, body, bodyGap };
63
+ }
64
+
65
+ const closeIdx = lines.findIndex((l, i) => i > 0 && CLOSE_FENCE.test(l));
66
+ if (closeIdx === -1) {
67
+ // Unterminated fence — treat the whole file as body so nothing is lost.
68
+ const { body, bodyGap } = splitGap(text);
69
+ return { ...empty, body, bodyGap };
70
+ }
71
+
72
+ const frontmatterText = lines.slice(1, closeIdx).join('\n') + (closeIdx > 1 ? '\n' : '');
73
+ const rawBody = lines.slice(closeIdx + 1).join('\n');
74
+ const { body, bodyGap } = splitGap(rawBody);
75
+
76
+ let data: Record<string, unknown> = {};
77
+ let yamlError: string | undefined;
78
+ try {
79
+ const doc = parseDocument(frontmatterText);
80
+ if (doc.errors.length > 0) {
81
+ yamlError = doc.errors[0].message;
82
+ } else {
83
+ const js = doc.toJS() as unknown;
84
+ if (js && typeof js === 'object' && !Array.isArray(js)) {
85
+ data = js as Record<string, unknown>;
86
+ }
87
+ }
88
+ } catch (err) {
89
+ yamlError = err instanceof Error ? err.message : String(err);
90
+ }
91
+
92
+ return { hasFrontmatter: true, frontmatterText, data, yamlError, body, bodyGap, eol, bom };
93
+ }
94
+
95
+ /** Split a leading newline run off the body so the textarea starts at content. */
96
+ function splitGap(raw: string): { body: string; bodyGap: string } {
97
+ const m = raw.match(/^\n+/);
98
+ return m ? { body: raw.slice(m[0].length), bodyGap: m[0] } : { body: raw, bodyGap: '' };
99
+ }
100
+
101
+ /** Byte span of one top-level `key: value` pair, from the key's first
102
+ * character to the end of its value — excluding any trailing inline comment
103
+ * and the line break after it. */
104
+ interface PairSpan {
105
+ start: number;
106
+ end: number;
107
+ }
108
+
109
+ /**
110
+ * Index a mapping's top-level pairs by key name. A key that is not a plain
111
+ * string, that appears twice, or whose value carries no source range maps to
112
+ * `null`: without a single unambiguous span there is nothing to copy back.
113
+ */
114
+ function pairSpans(doc: Document): Map<string, PairSpan | null> {
115
+ const spans = new Map<string, PairSpan | null>();
116
+ if (!isMap(doc.contents)) return spans;
117
+ for (const item of doc.contents.items) {
118
+ if (!isPair(item)) continue;
119
+ const key = item.key;
120
+ if (!isScalar(key) || typeof key.value !== 'string') continue;
121
+ if (spans.has(key.value)) {
122
+ spans.set(key.value, null); // duplicate key
123
+ continue;
124
+ }
125
+ const start = key.range?.[0];
126
+ const end = isNode(item.value) ? item.value.range?.[1] : undefined;
127
+ spans.set(
128
+ key.value,
129
+ start !== undefined && end !== undefined && end > start ? { start, end } : null,
130
+ );
131
+ }
132
+ return spans;
133
+ }
134
+
135
+ /**
136
+ * Copy every untouched pair's original bytes back over the re-emitted ones.
137
+ *
138
+ * Re-serializing the document normalizes keys nobody asked about: long plain
139
+ * scalars get re-folded (which `lineWidth: 0` answers), flow collections gain
140
+ * padding inside their brackets (`[a, b]` becomes `[ a, b ]`), a four-space
141
+ * block indent becomes two. Each is a separate stringify option, and chasing
142
+ * them one at a time only ever fixes the instance in front of you — so the
143
+ * bytes are restored wholesale instead, which is what this module's contract
144
+ * has always promised. Keys the caller changed, added or deleted keep the
145
+ * serializer's output, since for those there is no original to preserve.
146
+ */
147
+ function restoreUntouchedPairs(
148
+ before: string,
149
+ beforeSpans: Map<string, PairSpan | null>,
150
+ after: string,
151
+ changed: Set<string>,
152
+ ): string {
153
+ const afterDoc = parseDocument(after);
154
+ if (afterDoc.errors.length > 0) return after;
155
+
156
+ const edits: { start: number; end: number; text: string }[] = [];
157
+ for (const [name, afterSpan] of pairSpans(afterDoc)) {
158
+ if (afterSpan === null || changed.has(name)) continue;
159
+ const beforeSpan = beforeSpans.get(name);
160
+ if (!beforeSpan) continue;
161
+ const original = before.slice(beforeSpan.start, beforeSpan.end);
162
+ const emitted = after.slice(afterSpan.start, afterSpan.end);
163
+ if (original === emitted) continue;
164
+ // A pair's span ends at its last character for a plain scalar, but at the
165
+ // newline closing its final line for a block collection. Keep whichever
166
+ // terminator the emitted text had so the bytes after the span still line up.
167
+ const body = original.replace(/\n$/, '');
168
+ edits.push({
169
+ start: afterSpan.start,
170
+ end: afterSpan.end,
171
+ text: emitted.endsWith('\n') ? `${body}\n` : body,
172
+ });
173
+ }
174
+
175
+ // Back to front, so an earlier edit cannot shift a later one's offsets.
176
+ edits.sort((a, b) => b.start - a.start);
177
+ let out = after;
178
+ for (const edit of edits) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
179
+ return out;
180
+ }
181
+
182
+ /**
183
+ * Apply field/body changes to an entry source. Untouched frontmatter keys,
184
+ * comments, and ordering survive; a body-only change leaves the frontmatter
185
+ * block byte-identical (and vice versa).
186
+ */
187
+ export function applyEntryChanges(source: string, changes: EntryChanges): EntryPatchResult {
188
+ const parsed = parseEntry(source);
189
+ const fmChanges = changes.frontmatter ?? {};
190
+ const fmKeys = Object.keys(fmChanges);
191
+
192
+ if (fmKeys.length === 0 && changes.body === undefined) {
193
+ return { ok: false, error: 'no changes to apply' };
194
+ }
195
+
196
+ let frontmatterText = parsed.frontmatterText;
197
+ let hasFrontmatter = parsed.hasFrontmatter;
198
+
199
+ if (fmKeys.length > 0) {
200
+ if (parsed.yamlError) {
201
+ return { ok: false, error: `frontmatter YAML is invalid: ${parsed.yamlError}` };
202
+ }
203
+ const hadMapping = parsed.hasFrontmatter && frontmatterText.trim() !== '';
204
+ const doc = hadMapping ? parseDocument(frontmatterText) : new Document({});
205
+ // Spans are read before the edits, while every node still carries the
206
+ // range it parsed from.
207
+ const beforeSpans = hadMapping ? pairSpans(doc) : new Map<string, PairSpan | null>();
208
+ for (const [key, value] of Object.entries(fmChanges)) {
209
+ if (value === null) doc.delete(key);
210
+ else doc.set(key, value);
211
+ }
212
+ // lineWidth 0 disables wrapping: without it, untouched long plain scalars
213
+ // get re-folded across lines just by round-tripping through toString().
214
+ frontmatterText = doc.toString({ lineWidth: 0 });
215
+ if (hadMapping) {
216
+ frontmatterText = restoreUntouchedPairs(
217
+ parsed.frontmatterText,
218
+ beforeSpans,
219
+ frontmatterText,
220
+ new Set(fmKeys),
221
+ );
222
+ }
223
+ if (frontmatterText === '{}\n') frontmatterText = ''; // emptied mapping
224
+ hasFrontmatter = true;
225
+ }
226
+
227
+ const body = changes.body !== undefined ? changes.body.replace(/\r\n/g, '\n') : parsed.body;
228
+ // A file with frontmatter conventionally separates fence and body with a
229
+ // blank line; keep whatever the file had, defaulting to one for new fences.
230
+ const gap = parsed.hasFrontmatter ? parsed.bodyGap : (hasFrontmatter ? '\n' : parsed.bodyGap);
231
+
232
+ let text = hasFrontmatter
233
+ ? `---\n${frontmatterText}---\n${gap}${body}`
234
+ : `${gap}${body}`;
235
+ if (text !== '' && !text.endsWith('\n')) text += '\n';
236
+
237
+ if (parsed.eol === '\r\n') text = text.replace(/\n/g, '\r\n');
238
+ return { ok: true, newSource: parsed.bom + text };
239
+ }
240
+
241
+ /** Build a brand-new entry file from scratch (for /entry/create). */
242
+ export function serializeEntry(frontmatter: Record<string, unknown>, body: string): string {
243
+ const doc = new Document(frontmatter);
244
+ const fm = doc.toString({ lineWidth: 0 });
245
+ const normalizedBody = body.replace(/\r\n/g, '\n');
246
+ let text = `---\n${fm}---\n\n${normalizedBody}`;
247
+ if (!text.endsWith('\n')) text += '\n';
248
+ return text;
249
+ }