@getrefino/core 0.1.0-rc.5 → 0.1.0-rc.6
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/dist/api.d.ts +52 -3
- package/dist/api.js +136 -5
- package/dist/document.d.ts +110 -0
- package/dist/document.js +89 -0
- package/dist/documents/config.d.ts +58 -0
- package/dist/documents/config.js +152 -0
- package/dist/documents/fields.d.ts +44 -0
- package/dist/documents/fields.js +105 -0
- package/dist/documents/file-store.d.ts +45 -0
- package/dist/documents/file-store.js +1 -0
- package/dist/documents/frontmatter.d.ts +67 -0
- package/dist/documents/frontmatter.js +407 -0
- package/dist/documents/index.d.ts +19 -0
- package/dist/documents/index.js +13 -0
- package/dist/documents/markdown.d.ts +59 -0
- package/dist/documents/markdown.js +162 -0
- package/dist/documents/service.d.ts +33 -0
- package/dist/documents/service.js +199 -0
- package/dist/errors.d.ts +7 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/local-file.d.ts +12 -0
- package/dist/local-file.js +59 -0
- package/package.json +5 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing article documents.
|
|
3
|
+
*
|
|
4
|
+
* The shape mirrors `planSave` deliberately: refuse if the revision moved,
|
|
5
|
+
* validate and apply, report `unchanged` when the bytes are identical so no
|
|
6
|
+
* commit is made. What is different is that a document is one of many files,
|
|
7
|
+
* so every save is planned for every document *before* any of them is
|
|
8
|
+
* written. A stale revision or a source Refino cannot round-trip stops the
|
|
9
|
+
* save while the repository is still untouched.
|
|
10
|
+
*/
|
|
11
|
+
import type { DocumentChangeSet, DocumentId, DocumentSaveResult, DocumentSnapshot, FieldId } from "../document.js";
|
|
12
|
+
import type { ContentConfig } from "./config.js";
|
|
13
|
+
import type { SourceFileStore } from "./file-store.js";
|
|
14
|
+
export interface DocumentServiceOptions {
|
|
15
|
+
readonly store: SourceFileStore;
|
|
16
|
+
/**
|
|
17
|
+
* The content configuration for this request. Read per request: Refino
|
|
18
|
+
* never holds a cache of a customer's repository that outlives one call.
|
|
19
|
+
*/
|
|
20
|
+
readonly loadConfig: () => ContentConfig | Promise<ContentConfig>;
|
|
21
|
+
/** Override the commit message. Receives the path and the field ids that changed. */
|
|
22
|
+
readonly commitMessage?: (context: {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly changedFields: readonly FieldId[];
|
|
25
|
+
}) => string;
|
|
26
|
+
}
|
|
27
|
+
export interface DocumentService {
|
|
28
|
+
load(documentIds: readonly DocumentId[]): Promise<readonly DocumentSnapshot[]>;
|
|
29
|
+
save(changeSets: readonly DocumentChangeSet[]): Promise<readonly DocumentSaveResult[]>;
|
|
30
|
+
}
|
|
31
|
+
/** The commit a document save makes: one file, named, with the fields that changed. */
|
|
32
|
+
export declare function buildDocumentCommitMessage(path: string, changedFields: readonly FieldId[]): string;
|
|
33
|
+
export declare function createDocumentService(options: DocumentServiceOptions): DocumentService;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { BODY_FIELD_ID, MAX_DOCUMENTS_PER_REQUEST, MAX_FIELDS_PER_DOCUMENT, assertDocumentId, assertFieldId, fieldKindOf, maxFieldLength, metaFieldId, } from "../document.js";
|
|
2
|
+
import { normalizeValue } from "../content.js";
|
|
3
|
+
import { ContentError } from "../errors.js";
|
|
4
|
+
import { documentCandidates } from "./config.js";
|
|
5
|
+
import { matchMetadataFields } from "./fields.js";
|
|
6
|
+
import { MarkdownEditError, applyMarkdownEdits, findEntry, parseMarkdownSource } from "./markdown.js";
|
|
7
|
+
/** Newline, carriage return and tab are content; nothing else below a space is. */
|
|
8
|
+
function hasControlCharacters(value) {
|
|
9
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
10
|
+
const code = value.charCodeAt(index);
|
|
11
|
+
if (code === 0x0a || code === 0x0d || code === 0x09)
|
|
12
|
+
continue;
|
|
13
|
+
if (code < 0x20 || code === 0x7f)
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
function unsafeSource(message, details = {}) {
|
|
19
|
+
return new ContentError("UNSAFE_SOURCE", message, details);
|
|
20
|
+
}
|
|
21
|
+
/** The commit a document save makes: one file, named, with the fields that changed. */
|
|
22
|
+
export function buildDocumentCommitMessage(path, changedFields) {
|
|
23
|
+
const subject = `Edit article: ${path}`;
|
|
24
|
+
const body = ["Updated via Refino.", ...changedFields.map((field) => `- ${field}`)];
|
|
25
|
+
return `${subject.length <= 72 ? subject : "Edit article"}\n\n${body.join("\n")}\n`;
|
|
26
|
+
}
|
|
27
|
+
function fieldsOf(file) {
|
|
28
|
+
const fields = [];
|
|
29
|
+
for (const match of file.matches) {
|
|
30
|
+
const entry = findEntry(file.source, match.key);
|
|
31
|
+
const editable = entry.style !== "unsupported";
|
|
32
|
+
fields.push({
|
|
33
|
+
id: metaFieldId(match.field),
|
|
34
|
+
kind: "frontmatter",
|
|
35
|
+
key: match.key,
|
|
36
|
+
value: entry.value ?? file.text.slice(entry.valueStart, entry.valueEnd).trim(),
|
|
37
|
+
editable,
|
|
38
|
+
reason: editable ? null : entry.reason,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
fields.push({
|
|
42
|
+
id: BODY_FIELD_ID,
|
|
43
|
+
kind: "body",
|
|
44
|
+
key: null,
|
|
45
|
+
value: file.source.body,
|
|
46
|
+
editable: file.source.bodyUneditableReason === null,
|
|
47
|
+
reason: file.source.bodyUneditableReason,
|
|
48
|
+
});
|
|
49
|
+
return fields;
|
|
50
|
+
}
|
|
51
|
+
function snapshotOf(file) {
|
|
52
|
+
return {
|
|
53
|
+
documentId: file.documentId,
|
|
54
|
+
sourceType: file.sourceType,
|
|
55
|
+
path: file.path,
|
|
56
|
+
revision: file.revision,
|
|
57
|
+
fields: fieldsOf(file),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function createDocumentService(options) {
|
|
61
|
+
const { store } = options;
|
|
62
|
+
async function resolve(documentId, config) {
|
|
63
|
+
assertDocumentId(documentId);
|
|
64
|
+
const candidates = documentCandidates(config, documentId);
|
|
65
|
+
if (candidates.length === 0) {
|
|
66
|
+
throw new ContentError("NOT_FOUND", `No content source in refino.config.json claims "${documentId}".`, { documentId });
|
|
67
|
+
}
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
const file = await store.readFile(candidate.path);
|
|
70
|
+
if (!file)
|
|
71
|
+
continue;
|
|
72
|
+
const source = parseMarkdownSource(file.text);
|
|
73
|
+
const keys = source.frontmatter?.entries.map((entry) => entry.key) ?? [];
|
|
74
|
+
return {
|
|
75
|
+
documentId,
|
|
76
|
+
path: candidate.path,
|
|
77
|
+
sourceType: candidate.sourceType,
|
|
78
|
+
text: file.text,
|
|
79
|
+
revision: file.revision,
|
|
80
|
+
source,
|
|
81
|
+
matches: matchMetadataFields(keys, candidate.source.fields).matches,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
throw new ContentError("NOT_FOUND", `Article "${documentId}" was not found at ${candidates.map((candidate) => candidate.path).join(" or ")}.`, { documentId });
|
|
85
|
+
}
|
|
86
|
+
function assertValue(documentId, fieldId, value) {
|
|
87
|
+
if (typeof value !== "string") {
|
|
88
|
+
throw new ContentError("INVALID_VALUE", `Value for "${fieldId}" must be a string.`, { documentId, fieldId });
|
|
89
|
+
}
|
|
90
|
+
const limit = maxFieldLength(fieldId);
|
|
91
|
+
if (value.length > limit) {
|
|
92
|
+
throw new ContentError("INVALID_VALUE", `Value for "${fieldId}" is longer than ${limit} characters.`, { documentId, fieldId });
|
|
93
|
+
}
|
|
94
|
+
if (hasControlCharacters(value)) {
|
|
95
|
+
throw new ContentError("INVALID_VALUE", `Value for "${fieldId}" contains control characters.`, { documentId, fieldId });
|
|
96
|
+
}
|
|
97
|
+
return normalizeValue(value);
|
|
98
|
+
}
|
|
99
|
+
function plan(file, changeSet) {
|
|
100
|
+
if (file.revision !== changeSet.baseRevision) {
|
|
101
|
+
throw new ContentError("CONFLICT", `${file.path} changed in the repository while you were editing. Reload the latest content and reapply your edits.`, { documentId: file.documentId });
|
|
102
|
+
}
|
|
103
|
+
const entries = Object.entries(changeSet.changes);
|
|
104
|
+
if (entries.length > MAX_FIELDS_PER_DOCUMENT) {
|
|
105
|
+
throw new ContentError("INVALID_REQUEST", `Too many fields in one article save (max ${MAX_FIELDS_PER_DOCUMENT}).`);
|
|
106
|
+
}
|
|
107
|
+
const fields = fieldsOf(file);
|
|
108
|
+
const frontmatter = {};
|
|
109
|
+
const changedFields = [];
|
|
110
|
+
let body;
|
|
111
|
+
for (const [fieldId, rawValue] of entries) {
|
|
112
|
+
assertFieldId(fieldId);
|
|
113
|
+
const info = fields.find((candidate) => candidate.id === fieldId);
|
|
114
|
+
if (!info) {
|
|
115
|
+
throw new ContentError("UNKNOWN_ID", `Article "${file.documentId}" has no field "${fieldId}".`, { documentId: file.documentId, fieldId });
|
|
116
|
+
}
|
|
117
|
+
if (!info.editable) {
|
|
118
|
+
throw unsafeSource(`Refino cannot edit "${fieldId}" in ${file.path}: ${info.reason}.`, { documentId: file.documentId, fieldId });
|
|
119
|
+
}
|
|
120
|
+
const value = assertValue(file.documentId, fieldId, rawValue);
|
|
121
|
+
if (value === info.value)
|
|
122
|
+
continue;
|
|
123
|
+
changedFields.push(fieldId);
|
|
124
|
+
if (fieldKindOf(fieldId) === "body")
|
|
125
|
+
body = value;
|
|
126
|
+
else
|
|
127
|
+
frontmatter[info.key] = value;
|
|
128
|
+
}
|
|
129
|
+
if (changedFields.length === 0)
|
|
130
|
+
return null;
|
|
131
|
+
let applied;
|
|
132
|
+
try {
|
|
133
|
+
applied = applyMarkdownEdits(file.text, body === undefined ? { frontmatter } : { frontmatter, body });
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof MarkdownEditError) {
|
|
137
|
+
throw unsafeSource(`${error.message} (${file.path})`, {
|
|
138
|
+
documentId: file.documentId,
|
|
139
|
+
...(error.key ? { key: error.key } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
if (applied.text === file.text)
|
|
145
|
+
return null;
|
|
146
|
+
return { file, text: applied.text, changedFields };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
async load(documentIds) {
|
|
150
|
+
if (documentIds.length > MAX_DOCUMENTS_PER_REQUEST) {
|
|
151
|
+
throw new ContentError("INVALID_REQUEST", `Too many articles in one request (max ${MAX_DOCUMENTS_PER_REQUEST}).`);
|
|
152
|
+
}
|
|
153
|
+
const config = await options.loadConfig();
|
|
154
|
+
const snapshots = [];
|
|
155
|
+
for (const documentId of documentIds) {
|
|
156
|
+
snapshots.push(snapshotOf(await resolve(documentId, config)));
|
|
157
|
+
}
|
|
158
|
+
return snapshots;
|
|
159
|
+
},
|
|
160
|
+
async save(changeSets) {
|
|
161
|
+
if (changeSets.length > MAX_DOCUMENTS_PER_REQUEST) {
|
|
162
|
+
throw new ContentError("INVALID_REQUEST", `Too many articles in one save (max ${MAX_DOCUMENTS_PER_REQUEST}).`);
|
|
163
|
+
}
|
|
164
|
+
const config = await options.loadConfig();
|
|
165
|
+
// Plan everything first. A stale revision or an article Refino cannot
|
|
166
|
+
// round-trip stops the save before a single byte has been written.
|
|
167
|
+
const planned = [];
|
|
168
|
+
for (const changeSet of changeSets) {
|
|
169
|
+
const file = await resolve(changeSet.documentId, config);
|
|
170
|
+
planned.push({ file, write: plan(file, changeSet) });
|
|
171
|
+
}
|
|
172
|
+
const results = [];
|
|
173
|
+
for (const { file, write } of planned) {
|
|
174
|
+
if (!write) {
|
|
175
|
+
results.push({ documentId: file.documentId, status: "unchanged", snapshot: snapshotOf(file) });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const message = options.commitMessage
|
|
179
|
+
? options.commitMessage({ path: file.path, changedFields: write.changedFields })
|
|
180
|
+
: buildDocumentCommitMessage(file.path, write.changedFields);
|
|
181
|
+
const written = await store.writeFile({
|
|
182
|
+
path: file.path,
|
|
183
|
+
text: write.text,
|
|
184
|
+
baseRevision: file.revision,
|
|
185
|
+
message,
|
|
186
|
+
});
|
|
187
|
+
const source = parseMarkdownSource(write.text);
|
|
188
|
+
const next = { ...file, text: write.text, revision: written.revision, source };
|
|
189
|
+
results.push({
|
|
190
|
+
documentId: file.documentId,
|
|
191
|
+
status: "saved",
|
|
192
|
+
snapshot: snapshotOf(next),
|
|
193
|
+
...(written.commit ? { commit: written.commit } : {}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -3,7 +3,13 @@ import type { ContentSnapshot } from "./content.js";
|
|
|
3
3
|
* Every failure the editor can surface has one of these codes.
|
|
4
4
|
* They are stable strings so hosts and UIs can branch on them.
|
|
5
5
|
*/
|
|
6
|
-
export type ContentErrorCode = "INVALID_ID" | "UNKNOWN_ID" | "INVALID_VALUE" | "INVALID_CONTENT" | "INVALID_REQUEST" | "CONFLICT"
|
|
6
|
+
export type ContentErrorCode = "INVALID_ID" | "UNKNOWN_ID" | "INVALID_VALUE" | "INVALID_CONTENT" | "INVALID_REQUEST" | "CONFLICT"
|
|
7
|
+
/**
|
|
8
|
+
* The source file cannot be edited safely: a frontmatter value Refino
|
|
9
|
+
* does not round-trip, or a write that would not have read back as it was
|
|
10
|
+
* written. The file is never touched when this is raised.
|
|
11
|
+
*/
|
|
12
|
+
| "UNSAFE_SOURCE" | "NOT_FOUND" | "NOT_CONFIGURED" | "ADAPTER_ERROR";
|
|
7
13
|
export declare class ContentError extends Error {
|
|
8
14
|
readonly code: ContentErrorCode;
|
|
9
15
|
readonly details: Readonly<Record<string, unknown>>;
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ export { createDraft, getDirtyIds, getDraftContent, getDraftValue, isDraftDirty,
|
|
|
5
5
|
export type { DraftState, RebaseResult } from "./draft.js";
|
|
6
6
|
export { MAX_CHANGES_PER_SAVE, buildCommitMessage, parseSaveRequest, planSave, } from "./adapter.js";
|
|
7
7
|
export type { CommitInfo, CommitMessageOptions, ContentAdapter, CurrentFile, SavePlan, SaveRequest, SaveResult, } from "./adapter.js";
|
|
8
|
-
export { createContentApi } from "./api.js";
|
|
9
|
-
export type { ContentApi, ContentApiBody, ContentApiOptions, ContentApiResponse, } from "./api.js";
|
|
8
|
+
export { createContentApi, parseContentSaveRequest, parseDocumentChangeSets, parseDocumentIds, } from "./api.js";
|
|
9
|
+
export type { ContentApi, ContentApiBody, ContentApiOptions, ContentApiResponse, ContentLoadOptions, ContentSaveResult, DocumentSource, } from "./api.js";
|
|
10
|
+
export { BODY_FIELD_ID, MAX_BODY_LENGTH, MAX_DOCUMENTS_PER_REQUEST, MAX_FIELDS_PER_DOCUMENT, MAX_FRONTMATTER_VALUE_LENGTH, META_FIELD_PREFIX, assertDocumentId, assertFieldId, documentContent, documentFieldContent, documentFieldId, fieldKindOf, isDocumentId, isFieldId, maxFieldLength, metaFieldId, splitDocumentFieldId, } from "./document.js";
|
|
11
|
+
export type { ContentFieldKind, ContentSourceType, DocumentChangeSet, DocumentFieldInfo, DocumentId, DocumentSaveResult, DocumentSaveStatus, DocumentSnapshot, FieldId, } from "./document.js";
|
|
10
12
|
export { ContentConflictError, ContentError, isContentConflictError, isContentError, } from "./errors.js";
|
|
11
13
|
export type { ContentErrorCode } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -2,5 +2,6 @@ export { CONTENT_ID_PATTERN, MAX_CONTENT_ID_LENGTH, assertContentId, isContentId
|
|
|
2
2
|
export { MAX_VALUE_LENGTH, applyChanges, assertValidValue, contentEquals, diffContent, getCopy, hasCopy, isValidValue, normalizeValue, parseCopy, serializeCopy, validateCopy, } from "./content.js";
|
|
3
3
|
export { createDraft, getDirtyIds, getDraftContent, getDraftValue, isDraftDirty, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, } from "./draft.js";
|
|
4
4
|
export { MAX_CHANGES_PER_SAVE, buildCommitMessage, parseSaveRequest, planSave, } from "./adapter.js";
|
|
5
|
-
export { createContentApi } from "./api.js";
|
|
5
|
+
export { createContentApi, parseContentSaveRequest, parseDocumentChangeSets, parseDocumentIds, } from "./api.js";
|
|
6
|
+
export { BODY_FIELD_ID, MAX_BODY_LENGTH, MAX_DOCUMENTS_PER_REQUEST, MAX_FIELDS_PER_DOCUMENT, MAX_FRONTMATTER_VALUE_LENGTH, META_FIELD_PREFIX, assertDocumentId, assertFieldId, documentContent, documentFieldContent, documentFieldId, fieldKindOf, isDocumentId, isFieldId, maxFieldLength, metaFieldId, splitDocumentFieldId, } from "./document.js";
|
|
6
7
|
export { ContentConflictError, ContentError, isContentConflictError, isContentError, } from "./errors.js";
|
package/dist/local-file.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ContentAdapter } from "./adapter.js";
|
|
2
|
+
import type { SourceFileStore } from "./documents/file-store.js";
|
|
2
3
|
export interface LocalFileContentAdapterOptions {
|
|
3
4
|
/** Absolute path to the copy file, e.g. `path.join(process.cwd(), "content/copy.json")`. */
|
|
4
5
|
readonly filePath: string;
|
|
@@ -6,3 +7,14 @@ export interface LocalFileContentAdapterOptions {
|
|
|
6
7
|
/** Revision for local files: a hash of the exact bytes on disk. */
|
|
7
8
|
export declare function computeTextRevision(text: string): string;
|
|
8
9
|
export declare function createLocalFileContentAdapter(options: LocalFileContentAdapterOptions): ContentAdapter;
|
|
10
|
+
/**
|
|
11
|
+
* A `SourceFileStore` over a directory on disk: the development counterpart
|
|
12
|
+
* of the GitHub store, used for article documents. Paths are
|
|
13
|
+
* repository-relative and are resolved inside `root`; anything that escapes
|
|
14
|
+
* it is refused rather than normalized.
|
|
15
|
+
*/
|
|
16
|
+
export interface LocalFileStoreOptions {
|
|
17
|
+
/** Absolute path of the repository (or app) root that paths are relative to. */
|
|
18
|
+
readonly root: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function createLocalFileStore(options: LocalFileStoreOptions): SourceFileStore;
|
package/dist/local-file.js
CHANGED
|
@@ -70,3 +70,62 @@ export function createLocalFileContentAdapter(options) {
|
|
|
70
70
|
},
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
74
|
+
function resolveInside(root, path) {
|
|
75
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.includes("\0")) {
|
|
76
|
+
throw new ContentError("NOT_CONFIGURED", `Source path must be repository-relative: ${path}`);
|
|
77
|
+
}
|
|
78
|
+
const segments = path.split("/");
|
|
79
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || !SAFE_SEGMENT.test(segment))) {
|
|
80
|
+
throw new ContentError("NOT_CONFIGURED", `Source path contains invalid segments: ${path}`);
|
|
81
|
+
}
|
|
82
|
+
return join(root, ...segments);
|
|
83
|
+
}
|
|
84
|
+
export function createLocalFileStore(options) {
|
|
85
|
+
const { root } = options;
|
|
86
|
+
if (!root) {
|
|
87
|
+
throw new ContentError("NOT_CONFIGURED", "Local file store needs a root.");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
name: "local-file",
|
|
91
|
+
async readFile(path) {
|
|
92
|
+
const absolute = resolveInside(root, path);
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = await readFile(absolute, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT")
|
|
99
|
+
return null;
|
|
100
|
+
throw new ContentError("ADAPTER_ERROR", `Could not read ${path}: ${String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
return { text, revision: computeTextRevision(text) };
|
|
103
|
+
},
|
|
104
|
+
async writeFile(write) {
|
|
105
|
+
const absolute = resolveInside(root, write.path);
|
|
106
|
+
let current;
|
|
107
|
+
try {
|
|
108
|
+
current = await readFile(absolute, "utf8");
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error.code === "ENOENT") {
|
|
112
|
+
throw new ContentError("NOT_FOUND", `${write.path} no longer exists.`);
|
|
113
|
+
}
|
|
114
|
+
throw new ContentError("ADAPTER_ERROR", `Could not read ${write.path}: ${String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
if (computeTextRevision(current) !== write.baseRevision) {
|
|
117
|
+
throw new ContentError("CONFLICT", `${write.path} changed on disk while you were editing. Reload the latest content and reapply your edits.`);
|
|
118
|
+
}
|
|
119
|
+
const tempPath = join(dirname(absolute), `.${process.pid}-${Date.now()}.source.tmp`);
|
|
120
|
+
try {
|
|
121
|
+
await writeFile(tempPath, write.text, "utf8");
|
|
122
|
+
await rename(tempPath, absolute);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
await rm(tempPath, { force: true });
|
|
126
|
+
throw new ContentError("ADAPTER_ERROR", `Could not write ${write.path}: ${String(error)}`);
|
|
127
|
+
}
|
|
128
|
+
return { revision: computeTextRevision(write.text) };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getrefino/core",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.6",
|
|
4
4
|
"description": "Core content model, draft state and persistence contracts for Refino, the in-browser editor for existing website copy. Framework-independent; the repository stays the source of truth.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"refino",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./dist/index.d.ts",
|
|
35
35
|
"default": "./dist/index.js"
|
|
36
36
|
},
|
|
37
|
+
"./documents": {
|
|
38
|
+
"types": "./dist/documents/index.d.ts",
|
|
39
|
+
"default": "./dist/documents/index.js"
|
|
40
|
+
},
|
|
37
41
|
"./local-file": {
|
|
38
42
|
"types": "./dist/local-file.d.ts",
|
|
39
43
|
"default": "./dist/local-file.js"
|