decap-cms-lib-util 2.16.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +601 -0
- package/LICENSE +22 -0
- package/README.md +22 -0
- package/dist/decap-cms-lib-util.js +3 -0
- package/dist/decap-cms-lib-util.js.LICENSE.txt +15 -0
- package/dist/decap-cms-lib-util.js.map +1 -0
- package/dist/esm/API.js +177 -0
- package/dist/esm/APIError.js +47 -0
- package/dist/esm/APIUtils.js +48 -0
- package/dist/esm/AccessTokenError.js +41 -0
- package/dist/esm/Cursor.js +135 -0
- package/dist/esm/EditorialWorkflowError.js +43 -0
- package/dist/esm/asyncLock.js +45 -0
- package/dist/esm/backendUtil.js +98 -0
- package/dist/esm/getBlobSHA.js +19 -0
- package/dist/esm/git-lfs.js +123 -0
- package/dist/esm/implementation.js +304 -0
- package/dist/esm/index.js +403 -0
- package/dist/esm/loadScript.js +27 -0
- package/dist/esm/localForage.js +25 -0
- package/dist/esm/path.js +93 -0
- package/dist/esm/promise.js +23 -0
- package/dist/esm/types/semaphore.d.js +1 -0
- package/dist/esm/unsentRequest.js +123 -0
- package/package.json +29 -0
- package/src/API.ts +220 -0
- package/src/APIError.ts +17 -0
- package/src/APIUtils.ts +38 -0
- package/src/AccessTokenError.ts +11 -0
- package/src/Cursor.ts +178 -0
- package/src/EditorialWorkflowError.ts +12 -0
- package/src/__tests__/api.spec.js +12 -0
- package/src/__tests__/apiUtils.spec.js +74 -0
- package/src/__tests__/asyncLock.spec.js +85 -0
- package/src/__tests__/backendUtil.spec.js +97 -0
- package/src/__tests__/implementation.spec.js +58 -0
- package/src/__tests__/path.spec.js +53 -0
- package/src/__tests__/unsentRequest.spec.js +19 -0
- package/src/asyncLock.ts +43 -0
- package/src/backendUtil.ts +120 -0
- package/src/getBlobSHA.ts +12 -0
- package/src/git-lfs.ts +133 -0
- package/src/implementation.ts +573 -0
- package/src/index.ts +210 -0
- package/src/loadScript.js +24 -0
- package/src/localForage.ts +21 -0
- package/src/path.ts +86 -0
- package/src/promise.ts +21 -0
- package/src/types/semaphore.d.ts +5 -0
- package/src/unsentRequest.js +133 -0
- package/webpack.config.js +3 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "decap-cms-lib-util",
|
|
3
|
+
"description": "Shared utilities for Decap CMS.",
|
|
4
|
+
"version": "2.16.0-beta.0",
|
|
5
|
+
"repository": "https://github.com/decaporg/decap-cms/tree/master/packages/decap-cms-lib-util",
|
|
6
|
+
"bugs": "https://github.com/decaporg/decap-cms/issues",
|
|
7
|
+
"module": "dist/esm/index.js",
|
|
8
|
+
"main": "dist/decap-cms-lib-util.js",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"decap-cms"
|
|
12
|
+
],
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"scripts": {
|
|
15
|
+
"develop": "yarn build:esm --watch",
|
|
16
|
+
"build": "cross-env NODE_ENV=production webpack",
|
|
17
|
+
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"js-sha256": "^0.9.0",
|
|
21
|
+
"localforage": "^1.7.3",
|
|
22
|
+
"semaphore": "^1.1.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"immutable": "^3.7.6",
|
|
26
|
+
"lodash": "^4.17.11"
|
|
27
|
+
},
|
|
28
|
+
"gitHead": "1bdf716e5655bf088a343cd90210a2d361a9db52"
|
|
29
|
+
}
|
package/src/API.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { asyncLock } from './asyncLock';
|
|
2
|
+
import unsentRequest from './unsentRequest';
|
|
3
|
+
import APIError from './APIError';
|
|
4
|
+
|
|
5
|
+
import type { AsyncLock } from './asyncLock';
|
|
6
|
+
|
|
7
|
+
export interface FetchError extends Error {
|
|
8
|
+
status: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface API {
|
|
12
|
+
rateLimiter?: AsyncLock;
|
|
13
|
+
buildRequest: (req: ApiRequest) => ApiRequest | Promise<ApiRequest>;
|
|
14
|
+
requestFunction?: (req: ApiRequest) => Promise<Response>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ApiRequestObject = {
|
|
18
|
+
url: string;
|
|
19
|
+
params?: Record<string, string | boolean | number>;
|
|
20
|
+
method?: 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'PATCH';
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
body?: string | FormData;
|
|
23
|
+
cache?: 'no-store';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ApiRequest = ApiRequestObject | string;
|
|
27
|
+
|
|
28
|
+
class RateLimitError extends Error {
|
|
29
|
+
resetSeconds: number;
|
|
30
|
+
|
|
31
|
+
constructor(message: string, resetSeconds: number) {
|
|
32
|
+
super(message);
|
|
33
|
+
if (resetSeconds < 0) {
|
|
34
|
+
this.resetSeconds = 1;
|
|
35
|
+
} else if (resetSeconds > 60 * 60) {
|
|
36
|
+
this.resetSeconds = 60 * 60;
|
|
37
|
+
} else {
|
|
38
|
+
this.resetSeconds = resetSeconds;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function requestWithBackoff(
|
|
44
|
+
api: API,
|
|
45
|
+
req: ApiRequest,
|
|
46
|
+
attempt = 1,
|
|
47
|
+
): Promise<Response> {
|
|
48
|
+
if (api.rateLimiter) {
|
|
49
|
+
await api.rateLimiter.acquire();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const builtRequest = await api.buildRequest(req);
|
|
54
|
+
const requestFunction = api.requestFunction || unsentRequest.performRequest;
|
|
55
|
+
const response: Response = await requestFunction(builtRequest);
|
|
56
|
+
if (response.status === 429) {
|
|
57
|
+
// GitLab/Bitbucket too many requests
|
|
58
|
+
const text = await response.text().catch(() => 'Too many requests');
|
|
59
|
+
throw new Error(text);
|
|
60
|
+
} else if (response.status === 403) {
|
|
61
|
+
// GitHub too many requests
|
|
62
|
+
const json = await response.json().catch(() => ({ message: '' }));
|
|
63
|
+
if (json.message.match('API rate limit exceeded')) {
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const nextWindowInSeconds = response.headers.has('X-RateLimit-Reset')
|
|
66
|
+
? parseInt(response.headers.get('X-RateLimit-Reset')!)
|
|
67
|
+
: now.getTime() / 1000 + 60;
|
|
68
|
+
|
|
69
|
+
throw new RateLimitError(json.message, nextWindowInSeconds);
|
|
70
|
+
}
|
|
71
|
+
response.json = () => Promise.resolve(json);
|
|
72
|
+
}
|
|
73
|
+
return response;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (attempt > 5 || err.message === "Can't refresh access token when using implicit auth") {
|
|
76
|
+
throw err;
|
|
77
|
+
} else {
|
|
78
|
+
if (!api.rateLimiter) {
|
|
79
|
+
const timeout = err.resetSeconds || attempt * attempt;
|
|
80
|
+
console.log(
|
|
81
|
+
`Pausing requests for ${timeout} ${
|
|
82
|
+
attempt === 1 ? 'second' : 'seconds'
|
|
83
|
+
} due to fetch failures:`,
|
|
84
|
+
err.message,
|
|
85
|
+
);
|
|
86
|
+
api.rateLimiter = asyncLock();
|
|
87
|
+
api.rateLimiter.acquire();
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
api.rateLimiter?.release();
|
|
90
|
+
api.rateLimiter = undefined;
|
|
91
|
+
console.log(`Done pausing requests`);
|
|
92
|
+
}, 1000 * timeout);
|
|
93
|
+
}
|
|
94
|
+
return requestWithBackoff(api, req, attempt + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function readFile(
|
|
100
|
+
id: string | null | undefined,
|
|
101
|
+
fetchContent: () => Promise<string | Blob>,
|
|
102
|
+
localForage: LocalForage,
|
|
103
|
+
isText: boolean,
|
|
104
|
+
) {
|
|
105
|
+
const key = id ? (isText ? `gh.${id}` : `gh.${id}.blob`) : null;
|
|
106
|
+
const cached = key ? await localForage.getItem<string | Blob>(key) : null;
|
|
107
|
+
if (cached) {
|
|
108
|
+
return cached;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const content = await fetchContent();
|
|
112
|
+
if (key) {
|
|
113
|
+
await localForage.setItem(key, content);
|
|
114
|
+
}
|
|
115
|
+
return content;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type FileMetadata = {
|
|
119
|
+
author: string;
|
|
120
|
+
updatedOn: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function getFileMetadataKey(id: string) {
|
|
124
|
+
return `gh.${id}.meta`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function readFileMetadata(
|
|
128
|
+
id: string | null | undefined,
|
|
129
|
+
fetchMetadata: () => Promise<FileMetadata>,
|
|
130
|
+
localForage: LocalForage,
|
|
131
|
+
) {
|
|
132
|
+
const key = id ? getFileMetadataKey(id) : null;
|
|
133
|
+
const cached = key && (await localForage.getItem<FileMetadata>(key));
|
|
134
|
+
if (cached) {
|
|
135
|
+
return cached;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const metadata = await fetchMetadata();
|
|
139
|
+
if (key) {
|
|
140
|
+
await localForage.setItem<FileMetadata>(key, metadata);
|
|
141
|
+
}
|
|
142
|
+
return metadata;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Keywords for inferring a status that will provide a deploy preview URL.
|
|
147
|
+
*/
|
|
148
|
+
const PREVIEW_CONTEXT_KEYWORDS = ['deploy'];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check a given status context string to determine if it provides a link to a
|
|
152
|
+
* deploy preview. Checks for an exact match against `previewContext` if given,
|
|
153
|
+
* otherwise checks for inclusion of a value from `PREVIEW_CONTEXT_KEYWORDS`.
|
|
154
|
+
*/
|
|
155
|
+
export function isPreviewContext(context: string, previewContext: string) {
|
|
156
|
+
if (previewContext) {
|
|
157
|
+
return context === previewContext;
|
|
158
|
+
}
|
|
159
|
+
return PREVIEW_CONTEXT_KEYWORDS.some(keyword => context.includes(keyword));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export enum PreviewState {
|
|
163
|
+
Other = 'other',
|
|
164
|
+
Success = 'success',
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Retrieve a deploy preview URL from an array of statuses. By default, a
|
|
169
|
+
* matching status is inferred via `isPreviewContext`.
|
|
170
|
+
*/
|
|
171
|
+
export function getPreviewStatus(
|
|
172
|
+
statuses: {
|
|
173
|
+
context: string;
|
|
174
|
+
target_url: string;
|
|
175
|
+
state: PreviewState;
|
|
176
|
+
}[],
|
|
177
|
+
previewContext: string,
|
|
178
|
+
) {
|
|
179
|
+
return statuses.find(({ context }) => {
|
|
180
|
+
return isPreviewContext(context, previewContext);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getConflictingBranches(branchName: string) {
|
|
185
|
+
// for cms/posts/post-1, conflicting branches are cms/posts, cms
|
|
186
|
+
const parts = branchName.split('/');
|
|
187
|
+
parts.pop();
|
|
188
|
+
|
|
189
|
+
const conflictingBranches = parts.reduce((acc, _, index) => {
|
|
190
|
+
acc = [...acc, parts.slice(0, index + 1).join('/')];
|
|
191
|
+
return acc;
|
|
192
|
+
}, [] as string[]);
|
|
193
|
+
|
|
194
|
+
return conflictingBranches;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function throwOnConflictingBranches(
|
|
198
|
+
branchName: string,
|
|
199
|
+
getBranch: (name: string) => Promise<{ name: string }>,
|
|
200
|
+
apiName: string,
|
|
201
|
+
) {
|
|
202
|
+
const possibleConflictingBranches = getConflictingBranches(branchName);
|
|
203
|
+
|
|
204
|
+
const conflictingBranches = await Promise.all(
|
|
205
|
+
possibleConflictingBranches.map(b =>
|
|
206
|
+
getBranch(b)
|
|
207
|
+
.then(b => b.name)
|
|
208
|
+
.catch(() => ''),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const conflictingBranch = conflictingBranches.filter(Boolean)[0];
|
|
213
|
+
if (conflictingBranch) {
|
|
214
|
+
throw new APIError(
|
|
215
|
+
`Failed creating branch '${branchName}' since there is already a branch named '${conflictingBranch}'. Please delete the '${conflictingBranch}' branch and try again`,
|
|
216
|
+
500,
|
|
217
|
+
apiName,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/APIError.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const API_ERROR = 'API_ERROR';
|
|
2
|
+
|
|
3
|
+
export default class APIError extends Error {
|
|
4
|
+
message: string;
|
|
5
|
+
status: null | number;
|
|
6
|
+
api: string;
|
|
7
|
+
meta: {};
|
|
8
|
+
|
|
9
|
+
constructor(message: string, status: null | number, api: string, meta = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.message = message;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.api = api;
|
|
14
|
+
this.name = API_ERROR;
|
|
15
|
+
this.meta = meta;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/APIUtils.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const CMS_BRANCH_PREFIX = 'cms';
|
|
2
|
+
export const DEFAULT_PR_BODY = 'Automatically generated by Decap CMS';
|
|
3
|
+
export const MERGE_COMMIT_MESSAGE = 'Automatically generated. Merged on Decap CMS.';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_DECAP_CMS_LABEL_PREFIX = 'decap-cms/';
|
|
6
|
+
|
|
7
|
+
function getLabelPrefix(labelPrefix: string) {
|
|
8
|
+
return labelPrefix || DEFAULT_DECAP_CMS_LABEL_PREFIX;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isCMSLabel(label: string, labelPrefix: string) {
|
|
12
|
+
return label.startsWith(getLabelPrefix(labelPrefix));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function labelToStatus(label: string, labelPrefix: string) {
|
|
16
|
+
return label.slice(getLabelPrefix(labelPrefix).length);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function statusToLabel(status: string, labelPrefix: string) {
|
|
20
|
+
return `${getLabelPrefix(labelPrefix)}${status}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function generateContentKey(collectionName: string, slug: string) {
|
|
24
|
+
return `${collectionName}/${slug}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseContentKey(contentKey: string) {
|
|
28
|
+
const index = contentKey.indexOf('/');
|
|
29
|
+
return { collection: contentKey.slice(0, index), slug: contentKey.slice(index + 1) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function contentKeyFromBranch(branch: string) {
|
|
33
|
+
return branch.slice(`${CMS_BRANCH_PREFIX}/`.length);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function branchFromContentKey(contentKey: string) {
|
|
37
|
+
return `${CMS_BRANCH_PREFIX}/${contentKey}`;
|
|
38
|
+
}
|
package/src/Cursor.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { fromJS, Map, Set } from 'immutable';
|
|
2
|
+
|
|
3
|
+
type CursorStoreObject = {
|
|
4
|
+
actions: Set<string>;
|
|
5
|
+
data: Map<string, unknown>;
|
|
6
|
+
meta: Map<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CursorStore = {
|
|
10
|
+
get<K extends keyof CursorStoreObject>(
|
|
11
|
+
key: K,
|
|
12
|
+
defaultValue?: CursorStoreObject[K],
|
|
13
|
+
): CursorStoreObject[K];
|
|
14
|
+
getIn<V>(path: string[]): V;
|
|
15
|
+
set<K extends keyof CursorStoreObject, V extends CursorStoreObject[K]>(
|
|
16
|
+
key: K,
|
|
17
|
+
value: V,
|
|
18
|
+
): CursorStoreObject[K];
|
|
19
|
+
setIn(path: string[], value: unknown): CursorStore;
|
|
20
|
+
hasIn(path: string[]): boolean;
|
|
21
|
+
mergeIn(path: string[], value: unknown): CursorStore;
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
update: (...args: any[]) => CursorStore;
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
updateIn: (...args: any[]) => CursorStore;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ActionHandler = (action: string) => unknown;
|
|
29
|
+
|
|
30
|
+
function jsToMap(obj: {}) {
|
|
31
|
+
if (obj === undefined) {
|
|
32
|
+
return Map();
|
|
33
|
+
}
|
|
34
|
+
const immutableObj = fromJS(obj);
|
|
35
|
+
if (!Map.isMap(immutableObj)) {
|
|
36
|
+
throw new Error('Object must be equivalent to a Map.');
|
|
37
|
+
}
|
|
38
|
+
return immutableObj;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const knownMetaKeys = Set([
|
|
42
|
+
'index',
|
|
43
|
+
'page',
|
|
44
|
+
'count',
|
|
45
|
+
'pageSize',
|
|
46
|
+
'pageCount',
|
|
47
|
+
'usingOldPaginationAPI',
|
|
48
|
+
'extension',
|
|
49
|
+
'folder',
|
|
50
|
+
'depth',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function filterUnknownMetaKeys(meta: Map<string, string>) {
|
|
54
|
+
return meta.filter((_v, k) => knownMetaKeys.has(k as string));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
createCursorMap takes one of three signatures:
|
|
59
|
+
- () -> cursor with empty actions, data, and meta
|
|
60
|
+
- (cursorMap: <object/Map with optional actions, data, and meta keys>) -> cursor
|
|
61
|
+
- (actions: <array/List>, data: <object/Map>, meta: <optional object/Map>) -> cursor
|
|
62
|
+
*/
|
|
63
|
+
function createCursorStore(...args: {}[]) {
|
|
64
|
+
const { actions, data, meta } =
|
|
65
|
+
args.length === 1
|
|
66
|
+
? jsToMap(args[0]).toObject()
|
|
67
|
+
: { actions: args[0], data: args[1], meta: args[2] };
|
|
68
|
+
return Map({
|
|
69
|
+
// actions are a Set, rather than a List, to ensure an efficient .has
|
|
70
|
+
actions: Set(actions),
|
|
71
|
+
|
|
72
|
+
// data and meta are Maps
|
|
73
|
+
data: jsToMap(data),
|
|
74
|
+
meta: jsToMap(meta).update(filterUnknownMetaKeys),
|
|
75
|
+
}) as CursorStore;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasAction(store: CursorStore, action: string) {
|
|
79
|
+
return store.hasIn(['actions', action]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getActionHandlers(store: CursorStore, handler: ActionHandler) {
|
|
83
|
+
return store
|
|
84
|
+
.get('actions', Set<string>())
|
|
85
|
+
.toMap()
|
|
86
|
+
.map(action => handler(action as string));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The cursor logic is entirely functional, so this class simply
|
|
90
|
+
// provides a chainable interface
|
|
91
|
+
export default class Cursor {
|
|
92
|
+
store?: CursorStore;
|
|
93
|
+
actions?: Set<string>;
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
data?: Map<string, any>;
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
meta?: Map<string, any>;
|
|
98
|
+
|
|
99
|
+
static create(...args: {}[]) {
|
|
100
|
+
return new Cursor(...args);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
constructor(...args: {}[]) {
|
|
104
|
+
if (args[0] instanceof Cursor) {
|
|
105
|
+
return args[0] as Cursor;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.store = createCursorStore(...args);
|
|
109
|
+
this.actions = this.store.get('actions');
|
|
110
|
+
this.data = this.store.get('data');
|
|
111
|
+
this.meta = this.store.get('meta');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
updateStore(...args: any[]) {
|
|
116
|
+
return new Cursor(this.store!.update(...args));
|
|
117
|
+
}
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
updateInStore(...args: any[]) {
|
|
120
|
+
return new Cursor(this.store!.updateIn(...args));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
hasAction(action: string) {
|
|
124
|
+
return hasAction(this.store!, action);
|
|
125
|
+
}
|
|
126
|
+
addAction(action: string) {
|
|
127
|
+
return this.updateStore('actions', (actions: Set<string>) => actions.add(action));
|
|
128
|
+
}
|
|
129
|
+
removeAction(action: string) {
|
|
130
|
+
return this.updateStore('actions', (actions: Set<string>) => actions.delete(action));
|
|
131
|
+
}
|
|
132
|
+
setActions(actions: Iterable<string>) {
|
|
133
|
+
return this.updateStore((store: CursorStore) => store.set('actions', Set<string>(actions)));
|
|
134
|
+
}
|
|
135
|
+
mergeActions(actions: Set<string>) {
|
|
136
|
+
return this.updateStore('actions', (oldActions: Set<string>) => oldActions.union(actions));
|
|
137
|
+
}
|
|
138
|
+
getActionHandlers(handler: ActionHandler) {
|
|
139
|
+
return getActionHandlers(this.store!, handler);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setData(data: {}) {
|
|
143
|
+
return new Cursor(this.store!.set('data', jsToMap(data)));
|
|
144
|
+
}
|
|
145
|
+
mergeData(data: {}) {
|
|
146
|
+
return new Cursor(this.store!.mergeIn(['data'], jsToMap(data)));
|
|
147
|
+
}
|
|
148
|
+
wrapData(data: {}) {
|
|
149
|
+
return this.updateStore('data', (oldData: Map<string, unknown>) =>
|
|
150
|
+
jsToMap(data).set('wrapped_cursor_data', oldData),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
unwrapData() {
|
|
154
|
+
return [
|
|
155
|
+
this.store!.get('data').delete('wrapped_cursor_data'),
|
|
156
|
+
this.updateStore('data', (data: Map<string, unknown>) => data.get('wrapped_cursor_data')),
|
|
157
|
+
] as [Map<string, unknown>, Cursor];
|
|
158
|
+
}
|
|
159
|
+
clearData() {
|
|
160
|
+
return this.updateStore('data', () => Map());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
setMeta(meta: {}) {
|
|
164
|
+
return this.updateStore((store: CursorStore) => store.set('meta', jsToMap(meta)));
|
|
165
|
+
}
|
|
166
|
+
mergeMeta(meta: {}) {
|
|
167
|
+
return this.updateStore((store: CursorStore) =>
|
|
168
|
+
store.update('meta', (oldMeta: Map<string, unknown>) => oldMeta.merge(jsToMap(meta))),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// This is a temporary hack to allow cursors to be added to the
|
|
174
|
+
// interface between backend.js and backends without modifying old
|
|
175
|
+
// backends at all. This should be removed in favor of wrapping old
|
|
176
|
+
// backends with a compatibility layer, as part of the backend API
|
|
177
|
+
// refactor.
|
|
178
|
+
export const CURSOR_COMPATIBILITY_SYMBOL = Symbol('cursor key for compatibility with old backends');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const EDITORIAL_WORKFLOW_ERROR = 'EDITORIAL_WORKFLOW_ERROR';
|
|
2
|
+
|
|
3
|
+
export default class EditorialWorkflowError extends Error {
|
|
4
|
+
message: string;
|
|
5
|
+
notUnderEditorialWorkflow: boolean;
|
|
6
|
+
constructor(message: string, notUnderEditorialWorkflow: boolean) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.message = message;
|
|
9
|
+
this.notUnderEditorialWorkflow = notUnderEditorialWorkflow;
|
|
10
|
+
this.name = EDITORIAL_WORKFLOW_ERROR;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as api from '../API';
|
|
2
|
+
describe('Api', () => {
|
|
3
|
+
describe('getPreviewStatus', () => {
|
|
4
|
+
it('should return preview status on matching context', () => {
|
|
5
|
+
expect(api.getPreviewStatus([{ context: 'deploy' }])).toEqual({ context: 'deploy' });
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('should return undefined on matching context', () => {
|
|
9
|
+
expect(api.getPreviewStatus([{ context: 'other' }])).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as apiUtils from '../APIUtils';
|
|
2
|
+
describe('APIUtils', () => {
|
|
3
|
+
describe('generateContentKey', () => {
|
|
4
|
+
it('should generate content key', () => {
|
|
5
|
+
expect(apiUtils.generateContentKey('posts', 'dir1/dir2/post-title')).toBe(
|
|
6
|
+
'posts/dir1/dir2/post-title',
|
|
7
|
+
);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('parseContentKey', () => {
|
|
12
|
+
it('should parse content key', () => {
|
|
13
|
+
expect(apiUtils.parseContentKey('posts/dir1/dir2/post-title')).toEqual({
|
|
14
|
+
collection: 'posts',
|
|
15
|
+
slug: 'dir1/dir2/post-title',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('isCMSLabel', () => {
|
|
21
|
+
it('should return true for CMS label', () => {
|
|
22
|
+
expect(apiUtils.isCMSLabel('decap-cms/draft', 'decap-cms/')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return false for non CMS label', () => {
|
|
26
|
+
expect(apiUtils.isCMSLabel('other/label', 'decap-cms/')).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return true if the prefix not provided for CMS label', () => {
|
|
30
|
+
expect(apiUtils.isCMSLabel('decap-cms/draft', '')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return false if a different prefix provided for CMS label', () => {
|
|
34
|
+
expect(apiUtils.isCMSLabel('decap-cms/draft', 'other/')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return true for CMS label when undefined prefix is passed', () => {
|
|
38
|
+
expect(apiUtils.isCMSLabel('decap-cms/draft', undefined)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('labelToStatus', () => {
|
|
43
|
+
it('should get status from label when default prefix is passed', () => {
|
|
44
|
+
expect(apiUtils.labelToStatus('decap-cms/draft', 'decap-cms/')).toBe('draft');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should get status from label when custom prefix is passed', () => {
|
|
48
|
+
expect(apiUtils.labelToStatus('other/draft', 'other/')).toBe('draft');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should get status from label when empty prefix is passed', () => {
|
|
52
|
+
expect(apiUtils.labelToStatus('decap-cms/draft', '')).toBe('draft');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should get status from label when undefined prefix is passed', () => {
|
|
56
|
+
expect(apiUtils.labelToStatus('decap-cms/draft', undefined)).toBe('draft');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('statusToLabel', () => {
|
|
61
|
+
it('should generate label from status when default prefix is passed', () => {
|
|
62
|
+
expect(apiUtils.statusToLabel('draft', 'decap-cms/')).toBe('decap-cms/draft');
|
|
63
|
+
});
|
|
64
|
+
it('should generate label from status when custom prefix is passed', () => {
|
|
65
|
+
expect(apiUtils.statusToLabel('draft', 'other/')).toBe('other/draft');
|
|
66
|
+
});
|
|
67
|
+
it('should generate label from status when empty prefix is passed', () => {
|
|
68
|
+
expect(apiUtils.statusToLabel('draft', '')).toBe('decap-cms/draft');
|
|
69
|
+
});
|
|
70
|
+
it('should generate label from status when undefined prefix is passed', () => {
|
|
71
|
+
expect(apiUtils.statusToLabel('draft', undefined)).toBe('decap-cms/draft');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { asyncLock } from '../asyncLock';
|
|
2
|
+
|
|
3
|
+
jest.useFakeTimers();
|
|
4
|
+
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
5
|
+
|
|
6
|
+
describe('asyncLock', () => {
|
|
7
|
+
it('should be able to acquire a new lock', async () => {
|
|
8
|
+
const lock = asyncLock();
|
|
9
|
+
|
|
10
|
+
const acquired = await lock.acquire();
|
|
11
|
+
|
|
12
|
+
expect(acquired).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should not be able to acquire an acquired lock', async () => {
|
|
16
|
+
const lock = asyncLock();
|
|
17
|
+
await lock.acquire();
|
|
18
|
+
|
|
19
|
+
const promise = lock.acquire();
|
|
20
|
+
|
|
21
|
+
// advance by default lock timeout
|
|
22
|
+
jest.advanceTimersByTime(15000);
|
|
23
|
+
|
|
24
|
+
const acquired = await promise;
|
|
25
|
+
|
|
26
|
+
expect(acquired).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should be able to acquire an acquired lock that was released', async () => {
|
|
30
|
+
const lock = asyncLock();
|
|
31
|
+
await lock.acquire();
|
|
32
|
+
|
|
33
|
+
const promise = lock.acquire();
|
|
34
|
+
|
|
35
|
+
// release the lock in the "future"
|
|
36
|
+
setTimeout(() => lock.release(), 100);
|
|
37
|
+
|
|
38
|
+
// advance to the time where the lock will be released
|
|
39
|
+
jest.advanceTimersByTime(100);
|
|
40
|
+
|
|
41
|
+
const acquired = await promise;
|
|
42
|
+
|
|
43
|
+
expect(acquired).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should accept a timeout for acquire', async () => {
|
|
47
|
+
const lock = asyncLock();
|
|
48
|
+
await lock.acquire();
|
|
49
|
+
|
|
50
|
+
const promise = lock.acquire(50);
|
|
51
|
+
|
|
52
|
+
/// advance by lock timeout
|
|
53
|
+
jest.advanceTimersByTime(50);
|
|
54
|
+
|
|
55
|
+
const acquired = await promise;
|
|
56
|
+
|
|
57
|
+
expect(acquired).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should be able to re-acquire a lock after a timeout', async () => {
|
|
61
|
+
const lock = asyncLock();
|
|
62
|
+
await lock.acquire();
|
|
63
|
+
|
|
64
|
+
const promise = lock.acquire();
|
|
65
|
+
|
|
66
|
+
// advance by default lock timeout
|
|
67
|
+
jest.advanceTimersByTime(15000);
|
|
68
|
+
|
|
69
|
+
let acquired = await promise;
|
|
70
|
+
|
|
71
|
+
expect(acquired).toBe(false);
|
|
72
|
+
|
|
73
|
+
acquired = await lock.acquire();
|
|
74
|
+
expect(acquired).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should suppress "leave called too many times" error', async () => {
|
|
78
|
+
const lock = asyncLock();
|
|
79
|
+
|
|
80
|
+
await expect(() => lock.release()).not.toThrow();
|
|
81
|
+
|
|
82
|
+
expect(console.warn).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(console.warn).toHaveBeenCalledWith('leave called too many times.');
|
|
84
|
+
});
|
|
85
|
+
});
|