@getrefino/core 0.1.0-rc.4 → 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
package/dist/api.d.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
|
-
import type { ContentAdapter, SaveResult } from "./adapter.js";
|
|
1
|
+
import type { ContentAdapter, SaveRequest, SaveResult } from "./adapter.js";
|
|
2
2
|
import type { ContentSnapshot } from "./content.js";
|
|
3
|
+
import type { DocumentChangeSet, DocumentId, DocumentSaveResult, DocumentSnapshot } from "./document.js";
|
|
3
4
|
import type { ContentErrorCode } from "./errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* The document half of the content API.
|
|
7
|
+
*
|
|
8
|
+
* `@getrefino/core/documents` provides the implementation; this interface is
|
|
9
|
+
* all the API layer needs, so the browser-safe entry carries no parser.
|
|
10
|
+
*/
|
|
11
|
+
export interface DocumentSource {
|
|
12
|
+
load(documentIds: readonly DocumentId[]): Promise<readonly DocumentSnapshot[]>;
|
|
13
|
+
save(changeSets: readonly DocumentChangeSet[]): Promise<readonly DocumentSaveResult[]>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What one save produced.
|
|
17
|
+
*
|
|
18
|
+
* `documents` is the copy file untouched: an article-only save must not read
|
|
19
|
+
* or write `content/copy.json`, or an unrelated copy edit elsewhere would
|
|
20
|
+
* turn every article save into a spurious conflict.
|
|
21
|
+
*/
|
|
22
|
+
export type ContentSaveResult = (SaveResult & {
|
|
23
|
+
readonly documents?: readonly DocumentSaveResult[];
|
|
24
|
+
}) | {
|
|
25
|
+
readonly status: "documents";
|
|
26
|
+
readonly documents: readonly DocumentSaveResult[];
|
|
27
|
+
};
|
|
4
28
|
/**
|
|
5
29
|
* Wire format between the React editor and the host's authenticated
|
|
6
30
|
* endpoint. Hosts wrap `createContentApi` in their own route handler and
|
|
@@ -9,9 +33,11 @@ import type { ContentErrorCode } from "./errors.js";
|
|
|
9
33
|
export type ContentApiBody = {
|
|
10
34
|
readonly ok: true;
|
|
11
35
|
readonly snapshot: ContentSnapshot;
|
|
36
|
+
/** Present only when the request asked for articles. */
|
|
37
|
+
readonly documents?: readonly DocumentSnapshot[];
|
|
12
38
|
} | {
|
|
13
39
|
readonly ok: true;
|
|
14
|
-
readonly result:
|
|
40
|
+
readonly result: ContentSaveResult;
|
|
15
41
|
} | {
|
|
16
42
|
readonly ok: false;
|
|
17
43
|
readonly error: {
|
|
@@ -25,14 +51,37 @@ export interface ContentApiResponse {
|
|
|
25
51
|
readonly status: number;
|
|
26
52
|
readonly body: ContentApiBody;
|
|
27
53
|
}
|
|
54
|
+
export interface ContentLoadOptions {
|
|
55
|
+
/** Articles rendered on the page being edited. Validated here, resolved by the document source. */
|
|
56
|
+
readonly documents?: readonly string[];
|
|
57
|
+
}
|
|
28
58
|
export interface ContentApi {
|
|
29
|
-
load(): Promise<ContentApiResponse>;
|
|
59
|
+
load(options?: ContentLoadOptions): Promise<ContentApiResponse>;
|
|
30
60
|
save(body: unknown): Promise<ContentApiResponse>;
|
|
31
61
|
}
|
|
32
62
|
export interface ContentApiOptions {
|
|
33
63
|
/** Receives unexpected (non-ContentError) failures for logging. */
|
|
34
64
|
readonly onError?: (error: unknown) => void;
|
|
65
|
+
/** Enables article editing. Without it, `documents` in a request is refused. */
|
|
66
|
+
readonly documents?: DocumentSource;
|
|
35
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Validate the document ids a request names. Ids are checked against the
|
|
70
|
+
* grammar here and against the configured sources by the document source;
|
|
71
|
+
* neither step ever accepts a path.
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseDocumentIds(value: unknown): readonly DocumentId[];
|
|
74
|
+
/** The article half of a save request. Copy is parsed by `parseSaveRequest`, untouched. */
|
|
75
|
+
export declare function parseDocumentChangeSets(value: unknown): readonly DocumentChangeSet[];
|
|
76
|
+
/**
|
|
77
|
+
* Split a request body into its copy half and its article half. The copy
|
|
78
|
+
* half is `null` when the body names no copy revision at all, which is how
|
|
79
|
+
* an article-only save keeps `content/copy.json` out of the transaction.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseContentSaveRequest(body: unknown): {
|
|
82
|
+
readonly copy: SaveRequest | null;
|
|
83
|
+
readonly documents: readonly DocumentChangeSet[];
|
|
84
|
+
};
|
|
36
85
|
/**
|
|
37
86
|
* Framework-agnostic request handling. Returns plain status + JSON body so
|
|
38
87
|
* it can sit behind Next.js, Express, Hono, Remix, or a test harness.
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseSaveRequest } from "./adapter.js";
|
|
2
|
-
import {
|
|
2
|
+
import { MAX_DOCUMENTS_PER_REQUEST, MAX_FIELDS_PER_DOCUMENT, isDocumentId, isFieldId, maxFieldLength, } from "./document.js";
|
|
3
|
+
import { ContentError, isContentConflictError, isContentError } from "./errors.js";
|
|
3
4
|
const STATUS_BY_CODE = {
|
|
4
5
|
INVALID_ID: 400,
|
|
5
6
|
UNKNOWN_ID: 400,
|
|
@@ -7,6 +8,7 @@ const STATUS_BY_CODE = {
|
|
|
7
8
|
INVALID_CONTENT: 500,
|
|
8
9
|
INVALID_REQUEST: 400,
|
|
9
10
|
CONFLICT: 409,
|
|
11
|
+
UNSAFE_SOURCE: 422,
|
|
10
12
|
NOT_FOUND: 404,
|
|
11
13
|
NOT_CONFIGURED: 503,
|
|
12
14
|
ADAPTER_ERROR: 502,
|
|
@@ -36,16 +38,133 @@ function toErrorResponse(error, options) {
|
|
|
36
38
|
},
|
|
37
39
|
};
|
|
38
40
|
}
|
|
41
|
+
function isPlainObject(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Article fields and copy ids share one flat id space, which is what lets
|
|
46
|
+
* the draft, the editor and revert work on both without knowing the
|
|
47
|
+
* difference. The price is that a copy id must never be an article field id
|
|
48
|
+
* as well, and the site is told plainly rather than having one silently win.
|
|
49
|
+
*/
|
|
50
|
+
function assertNoIdCollision(snapshot, documents) {
|
|
51
|
+
for (const document of documents) {
|
|
52
|
+
for (const field of document.fields) {
|
|
53
|
+
const id = `${document.documentId}.${field.id}`;
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(snapshot.content, id)) {
|
|
55
|
+
throw new ContentError("INVALID_CONTENT", `"${id}" is both a copy id and a field of article "${document.documentId}". Rename the copy id.`, { id });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validate the document ids a request names. Ids are checked against the
|
|
62
|
+
* grammar here and against the configured sources by the document source;
|
|
63
|
+
* neither step ever accepts a path.
|
|
64
|
+
*/
|
|
65
|
+
export function parseDocumentIds(value) {
|
|
66
|
+
if (value === undefined || value === null)
|
|
67
|
+
return [];
|
|
68
|
+
// A query string may repeat the parameter, comma-separate the ids, or both.
|
|
69
|
+
const list = (Array.isArray(value) ? value : [value]).flatMap((entry) => typeof entry === "string" ? entry.split(",") : [entry]);
|
|
70
|
+
const ids = [];
|
|
71
|
+
for (const entry of list) {
|
|
72
|
+
const id = typeof entry === "string" ? entry.trim() : entry;
|
|
73
|
+
if (id === "")
|
|
74
|
+
continue;
|
|
75
|
+
if (!isDocumentId(id)) {
|
|
76
|
+
throw new ContentError("INVALID_ID", `Invalid article id ${JSON.stringify(id)}.`);
|
|
77
|
+
}
|
|
78
|
+
if (!ids.includes(id))
|
|
79
|
+
ids.push(id);
|
|
80
|
+
}
|
|
81
|
+
if (ids.length > MAX_DOCUMENTS_PER_REQUEST) {
|
|
82
|
+
throw new ContentError("INVALID_REQUEST", `Too many articles in one request (max ${MAX_DOCUMENTS_PER_REQUEST}).`);
|
|
83
|
+
}
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
/** The article half of a save request. Copy is parsed by `parseSaveRequest`, untouched. */
|
|
87
|
+
export function parseDocumentChangeSets(value) {
|
|
88
|
+
if (value === undefined || value === null)
|
|
89
|
+
return [];
|
|
90
|
+
if (!Array.isArray(value)) {
|
|
91
|
+
throw new ContentError("INVALID_REQUEST", "Save request: documents must be an array.");
|
|
92
|
+
}
|
|
93
|
+
if (value.length > MAX_DOCUMENTS_PER_REQUEST) {
|
|
94
|
+
throw new ContentError("INVALID_REQUEST", `Too many articles in one save (max ${MAX_DOCUMENTS_PER_REQUEST}).`);
|
|
95
|
+
}
|
|
96
|
+
const sets = [];
|
|
97
|
+
for (const entry of value) {
|
|
98
|
+
if (!isPlainObject(entry)) {
|
|
99
|
+
throw new ContentError("INVALID_REQUEST", "Save request: each article must be an object.");
|
|
100
|
+
}
|
|
101
|
+
const { documentId, baseRevision, changes } = entry;
|
|
102
|
+
if (!isDocumentId(documentId)) {
|
|
103
|
+
throw new ContentError("INVALID_ID", `Invalid article id ${JSON.stringify(documentId)}.`);
|
|
104
|
+
}
|
|
105
|
+
if (sets.some((existing) => existing.documentId === documentId)) {
|
|
106
|
+
throw new ContentError("INVALID_REQUEST", `Article "${documentId}" appears twice in one save.`);
|
|
107
|
+
}
|
|
108
|
+
if (typeof baseRevision !== "string" || baseRevision.length === 0 || baseRevision.length > 256) {
|
|
109
|
+
throw new ContentError("INVALID_REQUEST", `Article "${documentId}" is missing baseRevision.`);
|
|
110
|
+
}
|
|
111
|
+
if (!isPlainObject(changes)) {
|
|
112
|
+
throw new ContentError("INVALID_REQUEST", `Article "${documentId}" is missing changes.`);
|
|
113
|
+
}
|
|
114
|
+
const validated = {};
|
|
115
|
+
const fields = Object.entries(changes);
|
|
116
|
+
if (fields.length > MAX_FIELDS_PER_DOCUMENT) {
|
|
117
|
+
throw new ContentError("INVALID_REQUEST", `Too many fields in one article save (max ${MAX_FIELDS_PER_DOCUMENT}).`);
|
|
118
|
+
}
|
|
119
|
+
for (const [fieldId, fieldValue] of fields) {
|
|
120
|
+
if (!isFieldId(fieldId)) {
|
|
121
|
+
throw new ContentError("INVALID_ID", `Invalid field id ${JSON.stringify(fieldId)}.`);
|
|
122
|
+
}
|
|
123
|
+
if (typeof fieldValue !== "string" || fieldValue.length > maxFieldLength(fieldId)) {
|
|
124
|
+
throw new ContentError("INVALID_VALUE", `Value for "${fieldId}" must be a string within its size limit.`);
|
|
125
|
+
}
|
|
126
|
+
validated[fieldId] = fieldValue;
|
|
127
|
+
}
|
|
128
|
+
sets.push({ documentId, baseRevision, changes: validated });
|
|
129
|
+
}
|
|
130
|
+
return sets;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Split a request body into its copy half and its article half. The copy
|
|
134
|
+
* half is `null` when the body names no copy revision at all, which is how
|
|
135
|
+
* an article-only save keeps `content/copy.json` out of the transaction.
|
|
136
|
+
*/
|
|
137
|
+
export function parseContentSaveRequest(body) {
|
|
138
|
+
if (!isPlainObject(body)) {
|
|
139
|
+
throw new ContentError("INVALID_REQUEST", "Save request must be a JSON object.");
|
|
140
|
+
}
|
|
141
|
+
const documents = parseDocumentChangeSets(body.documents);
|
|
142
|
+
if (body.baseRevision === undefined && documents.length > 0) {
|
|
143
|
+
return { copy: null, documents };
|
|
144
|
+
}
|
|
145
|
+
return { copy: parseSaveRequest(body), documents };
|
|
146
|
+
}
|
|
39
147
|
/**
|
|
40
148
|
* Framework-agnostic request handling. Returns plain status + JSON body so
|
|
41
149
|
* it can sit behind Next.js, Express, Hono, Remix, or a test harness.
|
|
42
150
|
*/
|
|
43
151
|
export function createContentApi(adapter, options = {}) {
|
|
152
|
+
function requireDocuments() {
|
|
153
|
+
if (!options.documents) {
|
|
154
|
+
throw new ContentError("NOT_CONFIGURED", 'Article editing is not configured for this site. Add a "content" block to refino.config.json.');
|
|
155
|
+
}
|
|
156
|
+
return options.documents;
|
|
157
|
+
}
|
|
44
158
|
return {
|
|
45
|
-
async load() {
|
|
159
|
+
async load(loadOptions) {
|
|
46
160
|
try {
|
|
161
|
+
const documentIds = parseDocumentIds(loadOptions?.documents);
|
|
47
162
|
const snapshot = await adapter.load();
|
|
48
|
-
|
|
163
|
+
if (documentIds.length === 0)
|
|
164
|
+
return { status: 200, body: { ok: true, snapshot } };
|
|
165
|
+
const documents = await requireDocuments().load(documentIds);
|
|
166
|
+
assertNoIdCollision(snapshot, documents);
|
|
167
|
+
return { status: 200, body: { ok: true, snapshot, documents } };
|
|
49
168
|
}
|
|
50
169
|
catch (error) {
|
|
51
170
|
return toErrorResponse(error, options);
|
|
@@ -53,8 +172,20 @@ export function createContentApi(adapter, options = {}) {
|
|
|
53
172
|
},
|
|
54
173
|
async save(body) {
|
|
55
174
|
try {
|
|
56
|
-
const request =
|
|
57
|
-
|
|
175
|
+
const request = parseContentSaveRequest(body);
|
|
176
|
+
if (request.documents.length === 0) {
|
|
177
|
+
const result = await adapter.save(request.copy ?? parseSaveRequest(body));
|
|
178
|
+
return { status: 200, body: { ok: true, result } };
|
|
179
|
+
}
|
|
180
|
+
const source = requireDocuments();
|
|
181
|
+
// Copy first: it is the established path and fails fast on a stale
|
|
182
|
+
// revision. Articles are planned in full before any of them is
|
|
183
|
+
// written, so an unsafe source never leaves a half-saved article.
|
|
184
|
+
const copy = request.copy ? await adapter.save(request.copy) : null;
|
|
185
|
+
const documents = await source.save(request.documents);
|
|
186
|
+
const result = copy
|
|
187
|
+
? { ...copy, documents }
|
|
188
|
+
: { status: "documents", documents };
|
|
58
189
|
return { status: 200, body: { ok: true, result } };
|
|
59
190
|
}
|
|
60
191
|
catch (error) {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content-source model: identity and wire types for editable content
|
|
3
|
+
* that lives in the customer's own source files rather than in the copy
|
|
4
|
+
* file. Browser-safe and free of parsing, so `@getrefino/react` can hold a
|
|
5
|
+
* draft of article fields without shipping a Markdown parser.
|
|
6
|
+
*
|
|
7
|
+
* The parsing, the registry and the persistence service are server-side and
|
|
8
|
+
* live in `@getrefino/core/documents`.
|
|
9
|
+
*
|
|
10
|
+
* See `docs/content-editing-architecture.md`.
|
|
11
|
+
*/
|
|
12
|
+
import type { ContentId, CopyContent } from "./content.js";
|
|
13
|
+
/** A document id: an ordinary content id with at least two segments, e.g. `blog.hello-world`. */
|
|
14
|
+
export type DocumentId = string;
|
|
15
|
+
/** A field id *inside* a document: `body`, or `meta.<name>`. */
|
|
16
|
+
export type FieldId = string;
|
|
17
|
+
/** Which kind of source file a document was read from. */
|
|
18
|
+
export type ContentSourceType = "markdown" | "mdx";
|
|
19
|
+
/**
|
|
20
|
+
* What part of the source a field is.
|
|
21
|
+
*
|
|
22
|
+
* `frontmatter` a single scalar entry in the document's frontmatter block.
|
|
23
|
+
* `body` everything after the frontmatter block, as raw source.
|
|
24
|
+
*/
|
|
25
|
+
export type ContentFieldKind = "frontmatter" | "body";
|
|
26
|
+
/** The field id of a document's body. */
|
|
27
|
+
export declare const BODY_FIELD_ID: FieldId;
|
|
28
|
+
/** Prefix of every frontmatter field id. */
|
|
29
|
+
export declare const META_FIELD_PREFIX = "meta.";
|
|
30
|
+
/** Bodies are whole articles, not sentences, so they get their own ceiling. */
|
|
31
|
+
export declare const MAX_BODY_LENGTH = 400000;
|
|
32
|
+
/** Frontmatter values are metadata; the copy ceiling is generous for them already. */
|
|
33
|
+
export declare const MAX_FRONTMATTER_VALUE_LENGTH = 20000;
|
|
34
|
+
/**
|
|
35
|
+
* One editable (or deliberately non-editable) field of a document.
|
|
36
|
+
*
|
|
37
|
+
* Together with the document's `path`, `sourceType` and `revision` this is
|
|
38
|
+
* the whole identity of an article field: which file it came from, what kind
|
|
39
|
+
* of source that is, which field it is, what kind of field, and what its
|
|
40
|
+
* value was when it was read. The edited value lives in the draft, never
|
|
41
|
+
* here.
|
|
42
|
+
*/
|
|
43
|
+
export interface DocumentFieldInfo {
|
|
44
|
+
readonly id: FieldId;
|
|
45
|
+
readonly kind: ContentFieldKind;
|
|
46
|
+
/** The frontmatter key this field was read from; null for the body. */
|
|
47
|
+
readonly key: string | null;
|
|
48
|
+
/** The value as it is in the file right now. */
|
|
49
|
+
readonly value: string;
|
|
50
|
+
/** False when Refino can read this field but cannot safely write it back. */
|
|
51
|
+
readonly editable: boolean;
|
|
52
|
+
/** Why it is not editable, in one sentence. Null when it is. */
|
|
53
|
+
readonly reason: string | null;
|
|
54
|
+
}
|
|
55
|
+
/** A document as the editor sees it: identity, revision and fields. */
|
|
56
|
+
export interface DocumentSnapshot {
|
|
57
|
+
readonly documentId: DocumentId;
|
|
58
|
+
readonly sourceType: ContentSourceType;
|
|
59
|
+
/** Repository-relative path of the source file. Resolved by the server, never sent to it. */
|
|
60
|
+
readonly path: string;
|
|
61
|
+
/** Opaque revision of the exact bytes these fields were read from. */
|
|
62
|
+
readonly revision: string;
|
|
63
|
+
readonly fields: readonly DocumentFieldInfo[];
|
|
64
|
+
}
|
|
65
|
+
/** Field edits for one document, on top of the revision they were read from. */
|
|
66
|
+
export interface DocumentChangeSet {
|
|
67
|
+
readonly documentId: DocumentId;
|
|
68
|
+
readonly baseRevision: string;
|
|
69
|
+
readonly changes: Readonly<Record<FieldId, string>>;
|
|
70
|
+
}
|
|
71
|
+
export type DocumentSaveStatus = "saved" | "unchanged";
|
|
72
|
+
export interface DocumentSaveResult {
|
|
73
|
+
readonly documentId: DocumentId;
|
|
74
|
+
readonly status: DocumentSaveStatus;
|
|
75
|
+
readonly snapshot: DocumentSnapshot;
|
|
76
|
+
readonly commit?: {
|
|
77
|
+
readonly sha: string;
|
|
78
|
+
readonly message: string;
|
|
79
|
+
readonly url?: string;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export declare const MAX_DOCUMENTS_PER_REQUEST = 8;
|
|
83
|
+
export declare const MAX_FIELDS_PER_DOCUMENT = 64;
|
|
84
|
+
/** A document id is a content id of two or more segments. */
|
|
85
|
+
export declare function isDocumentId(value: unknown): value is DocumentId;
|
|
86
|
+
export declare function assertDocumentId(value: unknown): asserts value is DocumentId;
|
|
87
|
+
/** `body`, or `meta.` followed by one identifier segment. */
|
|
88
|
+
export declare function isFieldId(value: unknown): value is FieldId;
|
|
89
|
+
export declare function assertFieldId(value: unknown): asserts value is FieldId;
|
|
90
|
+
/** The field id for a canonical metadata field name. */
|
|
91
|
+
export declare function metaFieldId(name: string): FieldId;
|
|
92
|
+
export declare function fieldKindOf(fieldId: FieldId): ContentFieldKind;
|
|
93
|
+
/** The global content id a document field is edited under: `<documentId>.<fieldId>`. */
|
|
94
|
+
export declare function documentFieldId(documentId: DocumentId, fieldId: FieldId): ContentId;
|
|
95
|
+
/**
|
|
96
|
+
* Split a global content id back into a document and a field, using the
|
|
97
|
+
* documents actually in play. Longest prefix wins, so a source id that is a
|
|
98
|
+
* prefix of another (`blog` and `blog-archive`) is never confused.
|
|
99
|
+
* Returns null for a plain copy id.
|
|
100
|
+
*/
|
|
101
|
+
export declare function splitDocumentFieldId(documentIds: readonly DocumentId[], id: ContentId): {
|
|
102
|
+
readonly documentId: DocumentId;
|
|
103
|
+
readonly fieldId: FieldId;
|
|
104
|
+
} | null;
|
|
105
|
+
/** A document's fields as a flat copy map, keyed by their global ids. */
|
|
106
|
+
export declare function documentFieldContent(snapshot: DocumentSnapshot): CopyContent;
|
|
107
|
+
/** The same, for the initial values a page renders with (no revision yet). */
|
|
108
|
+
export declare function documentContent(documentId: DocumentId, fields: Readonly<Record<FieldId, string>>): CopyContent;
|
|
109
|
+
/** The ceiling for one field's value, by kind. */
|
|
110
|
+
export declare function maxFieldLength(fieldId: FieldId): number;
|
package/dist/document.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ContentError } from "./errors.js";
|
|
2
|
+
import { isContentId } from "./ids.js";
|
|
3
|
+
/** The field id of a document's body. */
|
|
4
|
+
export const BODY_FIELD_ID = "body";
|
|
5
|
+
/** Prefix of every frontmatter field id. */
|
|
6
|
+
export const META_FIELD_PREFIX = "meta.";
|
|
7
|
+
/** Bodies are whole articles, not sentences, so they get their own ceiling. */
|
|
8
|
+
export const MAX_BODY_LENGTH = 400_000;
|
|
9
|
+
/** Frontmatter values are metadata; the copy ceiling is generous for them already. */
|
|
10
|
+
export const MAX_FRONTMATTER_VALUE_LENGTH = 20_000;
|
|
11
|
+
export const MAX_DOCUMENTS_PER_REQUEST = 8;
|
|
12
|
+
export const MAX_FIELDS_PER_DOCUMENT = 64;
|
|
13
|
+
/** A document id is a content id of two or more segments. */
|
|
14
|
+
export function isDocumentId(value) {
|
|
15
|
+
return isContentId(value) && value.includes(".");
|
|
16
|
+
}
|
|
17
|
+
export function assertDocumentId(value) {
|
|
18
|
+
if (!isDocumentId(value)) {
|
|
19
|
+
throw new ContentError("INVALID_ID", `Invalid document id ${JSON.stringify(value)}. Use "<source>.<slug>", e.g. "blog.hello-world".`, { documentId: value });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** `body`, or `meta.` followed by one identifier segment. */
|
|
23
|
+
export function isFieldId(value) {
|
|
24
|
+
if (typeof value !== "string")
|
|
25
|
+
return false;
|
|
26
|
+
if (value === BODY_FIELD_ID)
|
|
27
|
+
return true;
|
|
28
|
+
if (!value.startsWith(META_FIELD_PREFIX))
|
|
29
|
+
return false;
|
|
30
|
+
const name = value.slice(META_FIELD_PREFIX.length);
|
|
31
|
+
return /^[A-Za-z0-9_-]+$/.test(name);
|
|
32
|
+
}
|
|
33
|
+
export function assertFieldId(value) {
|
|
34
|
+
if (!isFieldId(value)) {
|
|
35
|
+
throw new ContentError("INVALID_ID", `Invalid field id ${JSON.stringify(value)}. Use "body" or "meta.<name>".`, { fieldId: value });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The field id for a canonical metadata field name. */
|
|
39
|
+
export function metaFieldId(name) {
|
|
40
|
+
return `${META_FIELD_PREFIX}${name}`;
|
|
41
|
+
}
|
|
42
|
+
export function fieldKindOf(fieldId) {
|
|
43
|
+
return fieldId === BODY_FIELD_ID ? "body" : "frontmatter";
|
|
44
|
+
}
|
|
45
|
+
/** The global content id a document field is edited under: `<documentId>.<fieldId>`. */
|
|
46
|
+
export function documentFieldId(documentId, fieldId) {
|
|
47
|
+
return `${documentId}.${fieldId}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Split a global content id back into a document and a field, using the
|
|
51
|
+
* documents actually in play. Longest prefix wins, so a source id that is a
|
|
52
|
+
* prefix of another (`blog` and `blog-archive`) is never confused.
|
|
53
|
+
* Returns null for a plain copy id.
|
|
54
|
+
*/
|
|
55
|
+
export function splitDocumentFieldId(documentIds, id) {
|
|
56
|
+
let best = null;
|
|
57
|
+
for (const documentId of documentIds) {
|
|
58
|
+
if (!id.startsWith(`${documentId}.`))
|
|
59
|
+
continue;
|
|
60
|
+
const fieldId = id.slice(documentId.length + 1);
|
|
61
|
+
if (!isFieldId(fieldId))
|
|
62
|
+
continue;
|
|
63
|
+
if (!best || documentId.length > best.documentId.length)
|
|
64
|
+
best = { documentId, fieldId };
|
|
65
|
+
}
|
|
66
|
+
return best;
|
|
67
|
+
}
|
|
68
|
+
/** A document's fields as a flat copy map, keyed by their global ids. */
|
|
69
|
+
export function documentFieldContent(snapshot) {
|
|
70
|
+
const content = {};
|
|
71
|
+
for (const field of snapshot.fields) {
|
|
72
|
+
content[documentFieldId(snapshot.documentId, field.id)] = field.value;
|
|
73
|
+
}
|
|
74
|
+
return content;
|
|
75
|
+
}
|
|
76
|
+
/** The same, for the initial values a page renders with (no revision yet). */
|
|
77
|
+
export function documentContent(documentId, fields) {
|
|
78
|
+
const content = {};
|
|
79
|
+
for (const [fieldId, value] of Object.entries(fields)) {
|
|
80
|
+
if (!isFieldId(fieldId))
|
|
81
|
+
continue;
|
|
82
|
+
content[documentFieldId(documentId, fieldId)] = value;
|
|
83
|
+
}
|
|
84
|
+
return content;
|
|
85
|
+
}
|
|
86
|
+
/** The ceiling for one field's value, by kind. */
|
|
87
|
+
export function maxFieldLength(fieldId) {
|
|
88
|
+
return fieldKindOf(fieldId) === "body" ? MAX_BODY_LENGTH : MAX_FRONTMATTER_VALUE_LENGTH;
|
|
89
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `content` block of `refino.config.json`: which directories in the
|
|
3
|
+
* repository hold editable articles.
|
|
4
|
+
*
|
|
5
|
+
* This is the allow-list. A document id names a source and a slug, never a
|
|
6
|
+
* path, and this module is the only thing that turns one into the other.
|
|
7
|
+
* Because every segment of a document id has already been proven to match
|
|
8
|
+
* `[A-Za-z0-9_-]+`, traversal and absolute paths are unrepresentable rather
|
|
9
|
+
* than filtered out.
|
|
10
|
+
*/
|
|
11
|
+
import type { ContentSourceType, DocumentId } from "../document.js";
|
|
12
|
+
export declare const SUPPORTED_EXTENSIONS: readonly [".md", ".mdx"];
|
|
13
|
+
export type SupportedExtension = (typeof SUPPORTED_EXTENSIONS)[number];
|
|
14
|
+
export declare const MAX_CONTENT_SOURCES = 16;
|
|
15
|
+
export interface ContentSourceConfig {
|
|
16
|
+
/** Identifier used as the first segment of every document id from this source. */
|
|
17
|
+
readonly id: string;
|
|
18
|
+
/** Directory holding the articles, relative to the directory `refino.config.json` is in. */
|
|
19
|
+
readonly dir: string;
|
|
20
|
+
/** Extensions to try, in order. Defaults to `.md` then `.mdx`. */
|
|
21
|
+
readonly extensions: readonly SupportedExtension[];
|
|
22
|
+
/** Canonical metadata field name -> the exact frontmatter key to use. */
|
|
23
|
+
readonly fields: Readonly<Record<string, string>>;
|
|
24
|
+
}
|
|
25
|
+
export interface ContentConfig {
|
|
26
|
+
readonly sources: readonly ContentSourceConfig[];
|
|
27
|
+
}
|
|
28
|
+
export declare const EMPTY_CONTENT_CONFIG: ContentConfig;
|
|
29
|
+
/** Read and validate the `content` block. An absent block means no articles, not an error. */
|
|
30
|
+
export declare function parseContentConfig(value: unknown): ContentConfig;
|
|
31
|
+
/**
|
|
32
|
+
* Move a configuration's directories from being relative to the config file
|
|
33
|
+
* to being relative to the repository root.
|
|
34
|
+
*
|
|
35
|
+
* Sites declare `dir` next to their own `refino.config.json`, which in a
|
|
36
|
+
* monorepo is not the repository root. The server reads the file from the
|
|
37
|
+
* repository, so it has to say where it found it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function rebaseContentConfig(config: ContentConfig, baseDir: string): ContentConfig;
|
|
40
|
+
export declare function sourceTypeForPath(path: string): ContentSourceType;
|
|
41
|
+
/** One possible source file for a document id: the same slug under each configured extension. */
|
|
42
|
+
export interface DocumentCandidate {
|
|
43
|
+
readonly documentId: DocumentId;
|
|
44
|
+
readonly source: ContentSourceConfig;
|
|
45
|
+
readonly path: string;
|
|
46
|
+
readonly sourceType: ContentSourceType;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Every path a document id could resolve to, in the source's extension
|
|
50
|
+
* order. Returns an empty list when no configured source claims the id.
|
|
51
|
+
*/
|
|
52
|
+
export declare function documentCandidates(config: ContentConfig, documentId: DocumentId): readonly DocumentCandidate[];
|
|
53
|
+
/**
|
|
54
|
+
* The document id for a repository-relative source path, or null when no
|
|
55
|
+
* configured source claims it. This is how an integration turns the file it
|
|
56
|
+
* just rendered into the id it passes to the editor.
|
|
57
|
+
*/
|
|
58
|
+
export declare function documentIdForPath(config: ContentConfig, path: string): DocumentId | null;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { isDocumentId } from "../document.js";
|
|
2
|
+
import { ContentError } from "../errors.js";
|
|
3
|
+
export const SUPPORTED_EXTENSIONS = [".md", ".mdx"];
|
|
4
|
+
export const MAX_CONTENT_SOURCES = 16;
|
|
5
|
+
export const EMPTY_CONTENT_CONFIG = { sources: [] };
|
|
6
|
+
const SOURCE_ID = /^[A-Za-z0-9_-]+$/;
|
|
7
|
+
const PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
8
|
+
function configError(message) {
|
|
9
|
+
return new ContentError("NOT_CONFIGURED", `refino.config.json: ${message}`);
|
|
10
|
+
}
|
|
11
|
+
function validateDir(value, sourceId) {
|
|
12
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
13
|
+
throw configError(`content source "${sourceId}" needs a "dir".`);
|
|
14
|
+
}
|
|
15
|
+
const dir = value.trim().replace(/\/+$/, "");
|
|
16
|
+
if (dir.startsWith("/") || dir.includes("\\") || dir.includes("\0")) {
|
|
17
|
+
throw configError(`content source "${sourceId}" has a "dir" that is not repository-relative.`);
|
|
18
|
+
}
|
|
19
|
+
const segments = dir.split("/");
|
|
20
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
21
|
+
throw configError(`content source "${sourceId}" has a "dir" with invalid segments.`);
|
|
22
|
+
}
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
function validateExtensions(value, sourceId) {
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
return SUPPORTED_EXTENSIONS;
|
|
28
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
29
|
+
throw configError(`content source "${sourceId}" has an empty "extensions".`);
|
|
30
|
+
}
|
|
31
|
+
const extensions = [];
|
|
32
|
+
for (const entry of value) {
|
|
33
|
+
if (typeof entry !== "string" || !SUPPORTED_EXTENSIONS.includes(entry)) {
|
|
34
|
+
throw configError(`content source "${sourceId}" lists extension ${JSON.stringify(entry)}; Refino edits ${SUPPORTED_EXTENSIONS.join(" and ")}.`);
|
|
35
|
+
}
|
|
36
|
+
if (!extensions.includes(entry))
|
|
37
|
+
extensions.push(entry);
|
|
38
|
+
}
|
|
39
|
+
return extensions;
|
|
40
|
+
}
|
|
41
|
+
function validateFields(value, sourceId) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return {};
|
|
44
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
45
|
+
throw configError(`content source "${sourceId}" has a "fields" that is not an object.`);
|
|
46
|
+
}
|
|
47
|
+
const fields = {};
|
|
48
|
+
for (const [field, key] of Object.entries(value)) {
|
|
49
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
50
|
+
throw configError(`content source "${sourceId}" maps "${field}" to something that is not a frontmatter key.`);
|
|
51
|
+
}
|
|
52
|
+
fields[field] = key;
|
|
53
|
+
}
|
|
54
|
+
return fields;
|
|
55
|
+
}
|
|
56
|
+
/** Read and validate the `content` block. An absent block means no articles, not an error. */
|
|
57
|
+
export function parseContentConfig(value) {
|
|
58
|
+
if (value === undefined || value === null)
|
|
59
|
+
return EMPTY_CONTENT_CONFIG;
|
|
60
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
61
|
+
throw configError('"content" must be an object with a "sources" array.');
|
|
62
|
+
}
|
|
63
|
+
const rawSources = value.sources;
|
|
64
|
+
if (rawSources === undefined)
|
|
65
|
+
return EMPTY_CONTENT_CONFIG;
|
|
66
|
+
if (!Array.isArray(rawSources))
|
|
67
|
+
throw configError('"content.sources" must be an array.');
|
|
68
|
+
if (rawSources.length > MAX_CONTENT_SOURCES) {
|
|
69
|
+
throw configError(`"content.sources" lists more than ${MAX_CONTENT_SOURCES} sources.`);
|
|
70
|
+
}
|
|
71
|
+
const sources = [];
|
|
72
|
+
for (const raw of rawSources) {
|
|
73
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
74
|
+
throw configError('every entry in "content.sources" must be an object.');
|
|
75
|
+
}
|
|
76
|
+
const entry = raw;
|
|
77
|
+
const id = entry.id;
|
|
78
|
+
if (typeof id !== "string" || !SOURCE_ID.test(id)) {
|
|
79
|
+
throw configError(`content source id ${JSON.stringify(id)} must be letters, digits, "-" or "_".`);
|
|
80
|
+
}
|
|
81
|
+
if (sources.some((source) => source.id === id)) {
|
|
82
|
+
throw configError(`content source "${id}" is declared twice.`);
|
|
83
|
+
}
|
|
84
|
+
sources.push({
|
|
85
|
+
id,
|
|
86
|
+
dir: validateDir(entry.dir, id),
|
|
87
|
+
extensions: validateExtensions(entry.extensions, id),
|
|
88
|
+
fields: validateFields(entry.fields, id),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return { sources };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Move a configuration's directories from being relative to the config file
|
|
95
|
+
* to being relative to the repository root.
|
|
96
|
+
*
|
|
97
|
+
* Sites declare `dir` next to their own `refino.config.json`, which in a
|
|
98
|
+
* monorepo is not the repository root. The server reads the file from the
|
|
99
|
+
* repository, so it has to say where it found it.
|
|
100
|
+
*/
|
|
101
|
+
export function rebaseContentConfig(config, baseDir) {
|
|
102
|
+
const base = baseDir.replace(/^\/+|\/+$/g, "");
|
|
103
|
+
if (base === "")
|
|
104
|
+
return config;
|
|
105
|
+
return { sources: config.sources.map((source) => ({ ...source, dir: `${base}/${source.dir}` })) };
|
|
106
|
+
}
|
|
107
|
+
export function sourceTypeForPath(path) {
|
|
108
|
+
return path.endsWith(".mdx") ? "mdx" : "markdown";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Every path a document id could resolve to, in the source's extension
|
|
112
|
+
* order. Returns an empty list when no configured source claims the id.
|
|
113
|
+
*/
|
|
114
|
+
export function documentCandidates(config, documentId) {
|
|
115
|
+
if (!isDocumentId(documentId))
|
|
116
|
+
return [];
|
|
117
|
+
const [sourceId, ...segments] = documentId.split(".");
|
|
118
|
+
const source = config.sources.find((candidate) => candidate.id === sourceId);
|
|
119
|
+
if (!source || segments.length === 0)
|
|
120
|
+
return [];
|
|
121
|
+
if (!segments.every((segment) => PATH_SEGMENT.test(segment)))
|
|
122
|
+
return [];
|
|
123
|
+
const stem = `${source.dir}/${segments.join("/")}`;
|
|
124
|
+
return source.extensions.map((extension) => ({
|
|
125
|
+
documentId,
|
|
126
|
+
source,
|
|
127
|
+
path: `${stem}${extension}`,
|
|
128
|
+
sourceType: sourceTypeForPath(`${stem}${extension}`),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The document id for a repository-relative source path, or null when no
|
|
133
|
+
* configured source claims it. This is how an integration turns the file it
|
|
134
|
+
* just rendered into the id it passes to the editor.
|
|
135
|
+
*/
|
|
136
|
+
export function documentIdForPath(config, path) {
|
|
137
|
+
const normalized = path.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
138
|
+
for (const source of config.sources) {
|
|
139
|
+
const prefix = `${source.dir}/`;
|
|
140
|
+
if (!normalized.startsWith(prefix))
|
|
141
|
+
continue;
|
|
142
|
+
const extension = source.extensions.find((candidate) => normalized.endsWith(candidate));
|
|
143
|
+
if (!extension)
|
|
144
|
+
continue;
|
|
145
|
+
const slug = normalized.slice(prefix.length, normalized.length - extension.length);
|
|
146
|
+
const segments = slug.split("/");
|
|
147
|
+
if (segments.length === 0 || !segments.every((segment) => PATH_SEGMENT.test(segment)))
|
|
148
|
+
continue;
|
|
149
|
+
return `${source.id}.${segments.join(".")}`;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|