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,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
|
+
}
|