@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,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" | "NOT_FOUND" | "NOT_CONFIGURED" | "ADAPTER_ERROR";
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";
@@ -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;
@@ -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.5",
3
+ "version": "0.1.0",
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"