@getrefino/core 0.1.0-rc.5 → 0.1.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.
@@ -0,0 +1,407 @@
1
+ /**
2
+ * A span parser for YAML frontmatter.
3
+ *
4
+ * It does not build a document tree and it cannot serialize one. For each
5
+ * top-level entry it records where the key is, where its value is, and how
6
+ * that value is written. Reading a field is a slice; writing one replaces a
7
+ * single span and leaves every other byte of the file alone.
8
+ *
9
+ * That is the whole reason it exists: key order, unknown keys, comments,
10
+ * indentation, blank lines and quoting style are preserved because they are
11
+ * never in the code path. Anything this parser cannot represent honestly is
12
+ * reported as not editable rather than guessed at.
13
+ *
14
+ * The YAML subset understood here is the one blog frontmatter actually uses:
15
+ * top-level scalar entries in plain, single-quoted, double-quoted, literal
16
+ * (`|`) or folded (`>`) form.
17
+ */
18
+ export function splitLines(text) {
19
+ const lines = [];
20
+ let start = 0;
21
+ for (;;) {
22
+ const index = text.indexOf("\n", start);
23
+ if (index === -1) {
24
+ if (start < text.length || lines.length === 0) {
25
+ lines.push({ start, contentEnd: text.length, end: text.length, text: text.slice(start) });
26
+ }
27
+ return lines;
28
+ }
29
+ const contentEnd = index > start && text[index - 1] === "\r" ? index - 1 : index;
30
+ lines.push({ start, contentEnd, end: index + 1, text: text.slice(start, contentEnd) });
31
+ start = index + 1;
32
+ }
33
+ }
34
+ const FENCE = /^---[ \t]*$/;
35
+ const CLOSING_FENCE = /^(?:---|\.\.\.)[ \t]*$/;
36
+ /** A top-level entry: an unquoted key at column zero followed by a colon. */
37
+ const KEY_LINE = /^([A-Za-z0-9_][A-Za-z0-9_.-]*)[ \t]*:(?=[ \t]|$)/;
38
+ /** A block scalar header we can reproduce byte for byte. `|+` and `|2` are not on the list. */
39
+ const BLOCK_HEADER = /^([|>])(-?)$/;
40
+ const PLAIN_UNSAFE_FIRST = new Set([
41
+ "-", "?", ":", ",", "[", "]", "{", "}", "#", "&", "*", "!", "|", ">", "'", '"', "%", "@", "`",
42
+ ]);
43
+ /** Anything below a space that is not a tab, and DEL. A regex of these is both unreadable and lint-hostile. */
44
+ function hasControlCharacters(value) {
45
+ for (let index = 0; index < value.length; index += 1) {
46
+ const code = value.charCodeAt(index);
47
+ if (code === 0x09)
48
+ continue;
49
+ if (code < 0x20 || code === 0x7f)
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ function isBlank(text) {
55
+ return text.trim().length === 0;
56
+ }
57
+ function isIndented(text) {
58
+ return text.length > 0 && (text[0] === " " || text[0] === "\t");
59
+ }
60
+ function indentOf(text) {
61
+ return /^[ \t]*/.exec(text)[0];
62
+ }
63
+ function plainTrim(value) {
64
+ return value.replace(/[ \t]+$/, "");
65
+ }
66
+ /**
67
+ * Where a plain scalar ends on its line: before a ` #` comment, and before
68
+ * any trailing whitespace. YAML only starts a comment when the `#` is
69
+ * preceded by whitespace, so `a#b` is one scalar.
70
+ */
71
+ function plainScalarEnd(text, from, limit) {
72
+ for (let index = from + 1; index < limit; index += 1) {
73
+ if (text[index] !== "#")
74
+ continue;
75
+ const previous = text[index - 1];
76
+ if (previous !== " " && previous !== "\t")
77
+ continue;
78
+ let end = index - 1;
79
+ while (end > from && (text[end - 1] === " " || text[end - 1] === "\t"))
80
+ end -= 1;
81
+ return end;
82
+ }
83
+ let end = limit;
84
+ while (end > from && (text[end - 1] === " " || text[end - 1] === "\t"))
85
+ end -= 1;
86
+ return end;
87
+ }
88
+ /** Read a quoted token starting at `from`. Returns its end offset, or -1 if unterminated. */
89
+ function quotedEnd(text, from, limit) {
90
+ const quote = text[from];
91
+ for (let index = from + 1; index < limit; index += 1) {
92
+ const char = text[index];
93
+ if (quote === "'") {
94
+ if (char !== "'")
95
+ continue;
96
+ if (text[index + 1] === "'") {
97
+ index += 1;
98
+ continue;
99
+ }
100
+ return index + 1;
101
+ }
102
+ if (char === "\\") {
103
+ index += 1;
104
+ }
105
+ else if (char === '"') {
106
+ return index + 1;
107
+ }
108
+ }
109
+ return -1;
110
+ }
111
+ function decodeSingle(raw) {
112
+ return raw.slice(1, -1).replace(/''/g, "'");
113
+ }
114
+ function decodeDouble(raw) {
115
+ let out = "";
116
+ const body = raw.slice(1, -1);
117
+ for (let index = 0; index < body.length; index += 1) {
118
+ const char = body[index];
119
+ if (char !== "\\") {
120
+ out += char;
121
+ continue;
122
+ }
123
+ const next = body[index + 1];
124
+ index += 1;
125
+ switch (next) {
126
+ case "n":
127
+ out += "\n";
128
+ break;
129
+ case "t":
130
+ out += "\t";
131
+ break;
132
+ case "r":
133
+ out += "\r";
134
+ break;
135
+ case '"':
136
+ out += '"';
137
+ break;
138
+ case "\\":
139
+ out += "\\";
140
+ break;
141
+ case "/":
142
+ out += "/";
143
+ break;
144
+ case " ":
145
+ out += " ";
146
+ break;
147
+ case "u": {
148
+ const hex = body.slice(index + 1, index + 5);
149
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
150
+ return null;
151
+ out += String.fromCharCode(Number.parseInt(hex, 16));
152
+ index += 4;
153
+ break;
154
+ }
155
+ default:
156
+ return null;
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+ export function encodeDouble(value) {
162
+ let out = '"';
163
+ for (const char of value) {
164
+ const code = char.codePointAt(0);
165
+ if (char === '"')
166
+ out += '\\"';
167
+ else if (char === "\\")
168
+ out += "\\\\";
169
+ else if (char === "\n")
170
+ out += "\\n";
171
+ else if (char === "\t")
172
+ out += "\\t";
173
+ else if (char === "\r")
174
+ out += "\\r";
175
+ else if (code < 0x20 || code === 0x7f)
176
+ out += `\\u${code.toString(16).padStart(4, "0")}`;
177
+ else
178
+ out += char;
179
+ }
180
+ return `${out}"`;
181
+ }
182
+ export function encodeSingle(value) {
183
+ return `'${value.replace(/'/g, "''")}'`;
184
+ }
185
+ /** Whether a value can be written as a plain scalar without changing what it means. */
186
+ export function isPlainSafe(value) {
187
+ if (value.length === 0)
188
+ return false;
189
+ if (value !== value.trim())
190
+ return false;
191
+ if (/[\n\r\t]/.test(value))
192
+ return false;
193
+ if (PLAIN_UNSAFE_FIRST.has(value[0]))
194
+ return false;
195
+ if (value.includes(": ") || value.endsWith(":"))
196
+ return false;
197
+ if (/[ \t]#/.test(value))
198
+ return false;
199
+ return !hasControlCharacters(value);
200
+ }
201
+ function unsupported(key, entryStart, entryEnd, valueStart, valueEnd, reason) {
202
+ return { key, entryStart, entryEnd, valueStart, valueEnd, style: "unsupported", value: null, reason, block: null };
203
+ }
204
+ function readBlockScalar(lines, index, last, key, entryStart, entryEnd, colonEnd, header) {
205
+ const contentLines = lines.slice(index + 1, last + 1);
206
+ const headerEnd = lines[index].contentEnd;
207
+ if (contentLines.length === 0) {
208
+ return unsupported(key, entryStart, entryEnd, colonEnd, headerEnd, "the block scalar has no content");
209
+ }
210
+ const indent = indentOf(contentLines[0].text);
211
+ const valueEnd = lines[last].contentEnd;
212
+ if (indent.length === 0) {
213
+ return unsupported(key, entryStart, entryEnd, colonEnd, valueEnd, "the block scalar is not indented");
214
+ }
215
+ const parts = [];
216
+ for (const line of contentLines) {
217
+ if (isBlank(line.text)) {
218
+ parts.push("");
219
+ continue;
220
+ }
221
+ if (!line.text.startsWith(indent)) {
222
+ return unsupported(key, entryStart, entryEnd, colonEnd, valueEnd, "the block scalar's indentation is uneven");
223
+ }
224
+ parts.push(line.text.slice(indent.length));
225
+ }
226
+ if (header.startsWith("|")) {
227
+ return {
228
+ key, entryStart, entryEnd, valueStart: colonEnd, valueEnd,
229
+ style: "literal", value: parts.join("\n"), reason: null, block: { header, indent },
230
+ };
231
+ }
232
+ // Folded: lines join with a space, a blank line becomes a newline. A line
233
+ // indented further than the block keeps its own breaks, which this parser
234
+ // does not reproduce, so it is refused.
235
+ if (parts.some((part) => part.length > 0 && (part[0] === " " || part[0] === "\t"))) {
236
+ return unsupported(key, entryStart, entryEnd, colonEnd, valueEnd, "the folded value contains a more-indented line");
237
+ }
238
+ let folded = "";
239
+ for (const part of parts) {
240
+ if (part === "") {
241
+ folded += "\n";
242
+ continue;
243
+ }
244
+ if (folded.length > 0 && !folded.endsWith("\n"))
245
+ folded += " ";
246
+ folded += part;
247
+ }
248
+ return {
249
+ key, entryStart, entryEnd, valueStart: colonEnd, valueEnd,
250
+ style: "folded", value: folded, reason: null, block: { header, indent },
251
+ };
252
+ }
253
+ function readEntry(text, lines, index, lastLine) {
254
+ const line = lines[index];
255
+ const match = KEY_LINE.exec(line.text);
256
+ const key = match[1];
257
+ const colonEnd = line.start + match[0].length;
258
+ // Continuation lines: indented lines, plus blank lines that are followed by
259
+ // another indented line. A comment at column zero belongs to no entry.
260
+ let last = index;
261
+ for (let scan = index + 1; scan <= lastLine; scan += 1) {
262
+ const candidate = lines[scan];
263
+ if (isBlank(candidate.text))
264
+ continue;
265
+ if (!isIndented(candidate.text))
266
+ break;
267
+ last = scan;
268
+ }
269
+ const entryStart = line.start;
270
+ const entryEnd = lines[last].end;
271
+ const next = last + 1;
272
+ const inlineRaw = text.slice(colonEnd, line.contentEnd);
273
+ const leading = indentOf(inlineRaw);
274
+ const inlineStart = colonEnd + leading.length;
275
+ const inline = inlineRaw.slice(leading.length);
276
+ const commentOnly = inline.startsWith("#");
277
+ const inlineText = commentOnly ? "" : inline;
278
+ if (BLOCK_HEADER.test(plainTrim(inlineText))) {
279
+ const header = plainTrim(inlineText);
280
+ return { entry: readBlockScalar(lines, index, last, key, entryStart, entryEnd, colonEnd, header), next };
281
+ }
282
+ if (last !== index) {
283
+ // Indented lines under something that is not a block scalar: a nested
284
+ // map, a sequence, or a multi-line scalar. Readable, not writable
285
+ // through a single span.
286
+ const reason = inlineText.length === 0
287
+ ? "the value is a nested map or list, not a single value"
288
+ : "the value continues across several lines";
289
+ return { entry: unsupported(key, entryStart, entryEnd, inlineStart, line.contentEnd, reason), next };
290
+ }
291
+ if (inlineText.length === 0) {
292
+ const valueEnd = commentOnly ? inlineStart : line.contentEnd;
293
+ return {
294
+ entry: { key, entryStart, entryEnd, valueStart: inlineStart, valueEnd, style: "empty", value: "", reason: null, block: null },
295
+ next,
296
+ };
297
+ }
298
+ const first = inlineText[0];
299
+ if (first === "'" || first === '"') {
300
+ const end = quotedEnd(text, inlineStart, line.contentEnd);
301
+ if (end === -1) {
302
+ return { entry: unsupported(key, entryStart, entryEnd, inlineStart, line.contentEnd, "the quoted value is not closed on the same line"), next };
303
+ }
304
+ const value = first === "'" ? decodeSingle(text.slice(inlineStart, end)) : decodeDouble(text.slice(inlineStart, end));
305
+ if (value === null) {
306
+ return { entry: unsupported(key, entryStart, entryEnd, inlineStart, end, "the quoted value uses an escape Refino does not reproduce"), next };
307
+ }
308
+ // Anything after the closing quote other than spaces or a comment is not
309
+ // something this parser understands.
310
+ const trailing = text.slice(end, line.contentEnd).trim();
311
+ if (trailing.length > 0 && !trailing.startsWith("#")) {
312
+ return { entry: unsupported(key, entryStart, entryEnd, inlineStart, end, "there is more after the quoted value than a comment"), next };
313
+ }
314
+ return {
315
+ entry: {
316
+ key, entryStart, entryEnd, valueStart: inlineStart, valueEnd: end,
317
+ style: first === "'" ? "single" : "double", value, reason: null, block: null,
318
+ },
319
+ next,
320
+ };
321
+ }
322
+ if (PLAIN_UNSAFE_FIRST.has(first)) {
323
+ return {
324
+ entry: unsupported(key, entryStart, entryEnd, inlineStart, line.contentEnd, `the value starts with "${first}", which Refino does not edit`),
325
+ next,
326
+ };
327
+ }
328
+ const valueEnd = plainScalarEnd(text, inlineStart, line.contentEnd);
329
+ return {
330
+ entry: {
331
+ key, entryStart, entryEnd, valueStart: inlineStart, valueEnd,
332
+ style: "plain", value: text.slice(inlineStart, valueEnd), reason: null, block: null,
333
+ },
334
+ next,
335
+ };
336
+ }
337
+ /**
338
+ * Find and parse the leading frontmatter block. Returns null when the file
339
+ * does not open with one, which is not an error: a Markdown file without
340
+ * frontmatter simply has no metadata fields.
341
+ */
342
+ export function parseFrontmatter(text) {
343
+ const lines = splitLines(text);
344
+ if (lines.length === 0)
345
+ return null;
346
+ const opening = lines[0];
347
+ if (!FENCE.test(opening.text))
348
+ return null;
349
+ let closing = -1;
350
+ for (let index = 1; index < lines.length; index += 1) {
351
+ if (CLOSING_FENCE.test(lines[index].text)) {
352
+ closing = index;
353
+ break;
354
+ }
355
+ }
356
+ if (closing === -1)
357
+ return null;
358
+ const entries = [];
359
+ let index = 1;
360
+ while (index < closing) {
361
+ const line = lines[index];
362
+ if (isBlank(line.text) || line.text.trimStart().startsWith("#") || !KEY_LINE.test(line.text)) {
363
+ index += 1;
364
+ continue;
365
+ }
366
+ const read = readEntry(text, lines, index, closing - 1);
367
+ entries.push(read.entry);
368
+ index = Math.max(read.next, index + 1);
369
+ }
370
+ // A key written twice is ambiguous; neither copy is editable.
371
+ const counts = new Map();
372
+ for (const entry of entries)
373
+ counts.set(entry.key, (counts.get(entry.key) ?? 0) + 1);
374
+ const resolved = entries.map((entry) => (counts.get(entry.key) ?? 0) > 1
375
+ ? unsupported(entry.key, entry.entryStart, entry.entryEnd, entry.valueStart, entry.valueEnd, `"${entry.key}" appears more than once in the frontmatter`)
376
+ : entry);
377
+ return { start: opening.start, end: lines[closing].end, entries: resolved };
378
+ }
379
+ /** The replacement text for one entry's value span. Throws when the style cannot carry the value. */
380
+ export function renderScalar(text, entry, value) {
381
+ switch (entry.style) {
382
+ case "plain":
383
+ case "empty": {
384
+ const rendered = isPlainSafe(value) ? value : encodeDouble(value);
385
+ // `key:` with nothing after it needs a space; `key: x` already has one.
386
+ return text[entry.valueStart - 1] === ":" ? ` ${rendered}` : rendered;
387
+ }
388
+ case "single":
389
+ return value.includes("\n") ? encodeDouble(value) : encodeSingle(value);
390
+ case "double":
391
+ return encodeDouble(value);
392
+ case "literal": {
393
+ const { header, indent } = entry.block;
394
+ const body = value.split("\n").map((line) => (line.length === 0 ? "" : indent + line)).join("\n");
395
+ return ` ${header}\n${body}`;
396
+ }
397
+ case "folded": {
398
+ const { header, indent } = entry.block;
399
+ if (value.includes("\n")) {
400
+ throw new Error(`"${entry.key}" is a folded block scalar and the new value contains a line break`);
401
+ }
402
+ return ` ${header}\n${indent}${value}`;
403
+ }
404
+ default:
405
+ throw new Error(`"${entry.key}" is not an editable frontmatter value`);
406
+ }
407
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Server-side content sources: reading and writing editable fields that live
3
+ * in the customer's own Markdown and MDX files.
4
+ *
5
+ * Imported as `@getrefino/core/documents` so the browser-safe main entry
6
+ * carries only the identity and wire types. See
7
+ * `docs/content-editing-architecture.md`.
8
+ */
9
+ export { EMPTY_CONTENT_CONFIG, MAX_CONTENT_SOURCES, SUPPORTED_EXTENSIONS, documentCandidates, documentIdForPath, parseContentConfig, rebaseContentConfig, sourceTypeForPath, } from "./config.js";
10
+ export type { ContentConfig, ContentSourceConfig, DocumentCandidate, SupportedExtension, } from "./config.js";
11
+ export { METADATA_ALIASES, METADATA_FIELDS, METADATA_LABELS, matchMetadataFields, normalizeKey, } from "./fields.js";
12
+ export type { MetadataFieldName, MetadataMatch, MetadataMatchResult } from "./fields.js";
13
+ export { encodeDouble, encodeSingle, isPlainSafe, parseFrontmatter, renderScalar, } from "./frontmatter.js";
14
+ export type { FrontmatterBlock, FrontmatterEntry, ScalarStyle } from "./frontmatter.js";
15
+ export { MarkdownEditError, applyMarkdownEdits, findEntry, parseMarkdownSource, } from "./markdown.js";
16
+ export type { AppliedMarkdownEdits, Eol, MarkdownEdits, MarkdownSource } from "./markdown.js";
17
+ export { buildDocumentCommitMessage, createDocumentService } from "./service.js";
18
+ export type { DocumentService, DocumentServiceOptions } from "./service.js";
19
+ export type { SourceFile, SourceFileStore, SourceFileWrite, SourceFileWriteResult, } from "./file-store.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Server-side content sources: reading and writing editable fields that live
3
+ * in the customer's own Markdown and MDX files.
4
+ *
5
+ * Imported as `@getrefino/core/documents` so the browser-safe main entry
6
+ * carries only the identity and wire types. See
7
+ * `docs/content-editing-architecture.md`.
8
+ */
9
+ export { EMPTY_CONTENT_CONFIG, MAX_CONTENT_SOURCES, SUPPORTED_EXTENSIONS, documentCandidates, documentIdForPath, parseContentConfig, rebaseContentConfig, sourceTypeForPath, } from "./config.js";
10
+ export { METADATA_ALIASES, METADATA_FIELDS, METADATA_LABELS, matchMetadataFields, normalizeKey, } from "./fields.js";
11
+ export { encodeDouble, encodeSingle, isPlainSafe, parseFrontmatter, renderScalar, } from "./frontmatter.js";
12
+ export { MarkdownEditError, applyMarkdownEdits, findEntry, parseMarkdownSource, } from "./markdown.js";
13
+ export { buildDocumentCommitMessage, createDocumentService } from "./service.js";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Markdown and MDX source handling.
3
+ *
4
+ * A source file is two spans: an optional leading frontmatter block, and
5
+ * everything after it. Refino reads fields by slicing those spans and writes
6
+ * them by replacing one span at a time, so a save touches exactly the bytes
7
+ * the owner changed.
8
+ *
9
+ * Nothing here parses Markdown or MDX. The body is a string that goes out to
10
+ * the editor and comes back; JSX, imports, exports, expressions, fenced
11
+ * code, tables, links and raw HTML survive because they are never
12
+ * interpreted. That is the whole MDX safety strategy, and it is why it does
13
+ * not decay as MDX grows.
14
+ *
15
+ * Every write is re-parsed and checked against what was intended before it
16
+ * is returned. A file Refino cannot round-trip is refused, never rewritten.
17
+ */
18
+ import type { FrontmatterBlock, FrontmatterEntry } from "./frontmatter.js";
19
+ export type Eol = "\n" | "\r\n";
20
+ export interface MarkdownSource {
21
+ /** The file exactly as it is on disk. */
22
+ readonly text: string;
23
+ readonly eol: Eol;
24
+ readonly frontmatter: FrontmatterBlock | null;
25
+ /** The body span, with line endings normalized to `\n` for the editor. */
26
+ readonly body: string;
27
+ readonly bodyStart: number;
28
+ readonly bodyEnd: number;
29
+ /** Null when the body can be written back safely; a reason when it cannot. */
30
+ readonly bodyUneditableReason: string | null;
31
+ }
32
+ export interface MarkdownEdits {
33
+ /** Frontmatter values by key, already resolved from field ids. */
34
+ readonly frontmatter?: Readonly<Record<string, string>>;
35
+ /** The whole body, with `\n` line endings. */
36
+ readonly body?: string;
37
+ }
38
+ export interface AppliedMarkdownEdits {
39
+ readonly text: string;
40
+ /** What the file now holds for each edited field, after normalization. */
41
+ readonly frontmatter: Readonly<Record<string, string>>;
42
+ readonly body: string | null;
43
+ }
44
+ export declare class MarkdownEditError extends Error {
45
+ readonly key: string | null;
46
+ constructor(message: string, key?: string | null);
47
+ }
48
+ export declare function parseMarkdownSource(text: string): MarkdownSource;
49
+ export declare function findEntry(source: MarkdownSource, key: string): FrontmatterEntry | null;
50
+ /**
51
+ * Apply field edits to the source text and prove the result reads back as
52
+ * intended. The proof, not the parser, is what makes this safe:
53
+ *
54
+ * - the frontmatter keys are the same, in the same order;
55
+ * - every entry that was not edited is byte-identical;
56
+ * - every entry that was edited reads back exactly the value that was sent;
57
+ * - the body reads back exactly the body that was sent.
58
+ */
59
+ export declare function applyMarkdownEdits(text: string, edits: MarkdownEdits): AppliedMarkdownEdits;
@@ -0,0 +1,162 @@
1
+ import { parseFrontmatter, renderScalar } from "./frontmatter.js";
2
+ export class MarkdownEditError extends Error {
3
+ key;
4
+ constructor(message, key = null) {
5
+ super(message);
6
+ this.name = "MarkdownEditError";
7
+ this.key = key;
8
+ }
9
+ }
10
+ const BOM = "";
11
+ function detectEol(text) {
12
+ const index = text.indexOf("\n");
13
+ return index > 0 && text[index - 1] === "\r" ? "\r\n" : "\n";
14
+ }
15
+ /**
16
+ * Line endings Refino will not touch: a file that mixes them, or uses a lone
17
+ * carriage return, cannot have its body rewritten without also rewriting
18
+ * lines nobody edited.
19
+ */
20
+ function bodyEolProblem(body, eol) {
21
+ if (/\r(?!\n)/.test(body))
22
+ return "the file uses carriage returns on their own";
23
+ const newlines = (body.match(/\n/g) ?? []).length;
24
+ const crlf = (body.match(/\r\n/g) ?? []).length;
25
+ if (crlf > 0 && crlf < newlines)
26
+ return "the file mixes Windows and Unix line endings";
27
+ if (eol === "\r\n" && crlf === 0 && newlines > 0)
28
+ return "the file mixes Windows and Unix line endings";
29
+ return null;
30
+ }
31
+ export function parseMarkdownSource(text) {
32
+ const eol = detectEol(text);
33
+ if (text.startsWith(BOM)) {
34
+ return {
35
+ text,
36
+ eol,
37
+ frontmatter: null,
38
+ body: text,
39
+ bodyStart: 0,
40
+ bodyEnd: text.length,
41
+ bodyUneditableReason: "the file begins with a byte order mark",
42
+ };
43
+ }
44
+ const frontmatter = parseFrontmatter(text);
45
+ const bodyStart = frontmatter ? frontmatter.end : 0;
46
+ const raw = text.slice(bodyStart);
47
+ const problem = bodyEolProblem(raw, eol);
48
+ return {
49
+ text,
50
+ eol,
51
+ frontmatter,
52
+ body: problem === null && eol === "\r\n" ? raw.replace(/\r\n/g, "\n") : raw,
53
+ bodyStart,
54
+ bodyEnd: text.length,
55
+ bodyUneditableReason: problem,
56
+ };
57
+ }
58
+ export function findEntry(source, key) {
59
+ return source.frontmatter?.entries.find((entry) => entry.key === key) ?? null;
60
+ }
61
+ function splice(text, replacements) {
62
+ const ordered = [...replacements].sort((a, b) => a.start - b.start);
63
+ let out = "";
64
+ let cursor = 0;
65
+ for (const replacement of ordered) {
66
+ if (replacement.start < cursor) {
67
+ throw new MarkdownEditError("Two edits in this article overlap in the source file.");
68
+ }
69
+ out += text.slice(cursor, replacement.start) + replacement.text;
70
+ cursor = replacement.end;
71
+ }
72
+ return out + text.slice(cursor);
73
+ }
74
+ /**
75
+ * Apply field edits to the source text and prove the result reads back as
76
+ * intended. The proof, not the parser, is what makes this safe:
77
+ *
78
+ * - the frontmatter keys are the same, in the same order;
79
+ * - every entry that was not edited is byte-identical;
80
+ * - every entry that was edited reads back exactly the value that was sent;
81
+ * - the body reads back exactly the body that was sent.
82
+ */
83
+ export function applyMarkdownEdits(text, edits) {
84
+ const source = parseMarkdownSource(text);
85
+ const replacements = [];
86
+ const intendedFrontmatter = {};
87
+ for (const [key, value] of Object.entries(edits.frontmatter ?? {})) {
88
+ const entry = findEntry(source, key);
89
+ if (!entry) {
90
+ throw new MarkdownEditError(`The article has no frontmatter field "${key}".`, key);
91
+ }
92
+ if (entry.style === "unsupported") {
93
+ throw new MarkdownEditError(`Refino cannot edit "${key}" in this article: ${entry.reason}.`, key);
94
+ }
95
+ let rendered;
96
+ try {
97
+ rendered = renderScalar(text, entry, value);
98
+ }
99
+ catch (error) {
100
+ throw new MarkdownEditError(`Refino cannot write "${key}" in this article: ${error instanceof Error ? error.message : String(error)}.`, key);
101
+ }
102
+ replacements.push({ start: entry.valueStart, end: entry.valueEnd, text: rendered });
103
+ intendedFrontmatter[key] = value;
104
+ }
105
+ let intendedBody = null;
106
+ if (edits.body !== undefined) {
107
+ if (source.bodyUneditableReason !== null) {
108
+ throw new MarkdownEditError(`Refino cannot edit this article's body: ${source.bodyUneditableReason}.`);
109
+ }
110
+ let body = edits.body;
111
+ // Keep the file's own habit about a final newline rather than letting a
112
+ // textarea decide it.
113
+ const hadTrailingNewline = source.body.endsWith("\n");
114
+ if (hadTrailingNewline && body.length > 0 && !body.endsWith("\n"))
115
+ body += "\n";
116
+ if (!source.frontmatter && /^---[ \t]*\n/.test(body)) {
117
+ throw new MarkdownEditError("The body cannot start with a `---` line in an article that has no frontmatter: it would become frontmatter.");
118
+ }
119
+ intendedBody = body;
120
+ replacements.push({
121
+ start: source.bodyStart,
122
+ end: source.bodyEnd,
123
+ text: source.eol === "\r\n" ? body.replace(/\n/g, "\r\n") : body,
124
+ });
125
+ }
126
+ const next = splice(text, replacements);
127
+ verify(source, next, intendedFrontmatter, intendedBody);
128
+ return { text: next, frontmatter: intendedFrontmatter, body: intendedBody };
129
+ }
130
+ function verify(source, next, intendedFrontmatter, intendedBody) {
131
+ const reparsed = parseMarkdownSource(next);
132
+ const before = source.frontmatter?.entries ?? [];
133
+ const after = reparsed.frontmatter?.entries ?? [];
134
+ if (before.length !== after.length || before.some((entry, index) => entry.key !== after[index].key)) {
135
+ throw new MarkdownEditError("Saving would have changed this article's frontmatter structure, so nothing was written.");
136
+ }
137
+ for (let index = 0; index < before.length; index += 1) {
138
+ const original = before[index];
139
+ const written = after[index];
140
+ const intended = intendedFrontmatter[original.key];
141
+ if (intended === undefined) {
142
+ const originalText = source.text.slice(original.entryStart, original.entryEnd);
143
+ const writtenText = next.slice(written.entryStart, written.entryEnd);
144
+ if (originalText !== writtenText) {
145
+ throw new MarkdownEditError(`Saving would have rewritten the untouched frontmatter field "${original.key}", so nothing was written.`, original.key);
146
+ }
147
+ continue;
148
+ }
149
+ if (written.value !== intended) {
150
+ throw new MarkdownEditError(`Saving "${original.key}" would not have read back as it was written, so nothing was written.`, original.key);
151
+ }
152
+ }
153
+ if (intendedBody === null) {
154
+ if (reparsed.body !== source.body) {
155
+ throw new MarkdownEditError("Saving would have changed this article's body, so nothing was written.");
156
+ }
157
+ return;
158
+ }
159
+ if (reparsed.bodyUneditableReason !== null || reparsed.body !== intendedBody) {
160
+ throw new MarkdownEditError("Saving the body would not have read back as it was written, so nothing was written.");
161
+ }
162
+ }