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.
Files changed (51) hide show
  1. package/CHANGELOG.md +601 -0
  2. package/LICENSE +22 -0
  3. package/README.md +22 -0
  4. package/dist/decap-cms-lib-util.js +3 -0
  5. package/dist/decap-cms-lib-util.js.LICENSE.txt +15 -0
  6. package/dist/decap-cms-lib-util.js.map +1 -0
  7. package/dist/esm/API.js +177 -0
  8. package/dist/esm/APIError.js +47 -0
  9. package/dist/esm/APIUtils.js +48 -0
  10. package/dist/esm/AccessTokenError.js +41 -0
  11. package/dist/esm/Cursor.js +135 -0
  12. package/dist/esm/EditorialWorkflowError.js +43 -0
  13. package/dist/esm/asyncLock.js +45 -0
  14. package/dist/esm/backendUtil.js +98 -0
  15. package/dist/esm/getBlobSHA.js +19 -0
  16. package/dist/esm/git-lfs.js +123 -0
  17. package/dist/esm/implementation.js +304 -0
  18. package/dist/esm/index.js +403 -0
  19. package/dist/esm/loadScript.js +27 -0
  20. package/dist/esm/localForage.js +25 -0
  21. package/dist/esm/path.js +93 -0
  22. package/dist/esm/promise.js +23 -0
  23. package/dist/esm/types/semaphore.d.js +1 -0
  24. package/dist/esm/unsentRequest.js +123 -0
  25. package/package.json +29 -0
  26. package/src/API.ts +220 -0
  27. package/src/APIError.ts +17 -0
  28. package/src/APIUtils.ts +38 -0
  29. package/src/AccessTokenError.ts +11 -0
  30. package/src/Cursor.ts +178 -0
  31. package/src/EditorialWorkflowError.ts +12 -0
  32. package/src/__tests__/api.spec.js +12 -0
  33. package/src/__tests__/apiUtils.spec.js +74 -0
  34. package/src/__tests__/asyncLock.spec.js +85 -0
  35. package/src/__tests__/backendUtil.spec.js +97 -0
  36. package/src/__tests__/implementation.spec.js +58 -0
  37. package/src/__tests__/path.spec.js +53 -0
  38. package/src/__tests__/unsentRequest.spec.js +19 -0
  39. package/src/asyncLock.ts +43 -0
  40. package/src/backendUtil.ts +120 -0
  41. package/src/getBlobSHA.ts +12 -0
  42. package/src/git-lfs.ts +133 -0
  43. package/src/implementation.ts +573 -0
  44. package/src/index.ts +210 -0
  45. package/src/loadScript.js +24 -0
  46. package/src/localForage.ts +21 -0
  47. package/src/path.ts +86 -0
  48. package/src/promise.ts +21 -0
  49. package/src/types/semaphore.d.ts +5 -0
  50. package/src/unsentRequest.js +133 -0
  51. package/webpack.config.js +3 -0
@@ -0,0 +1,97 @@
1
+ import { oneLine } from 'common-tags';
2
+ import nock from 'nock';
3
+
4
+ import { parseLinkHeader, getAllResponses, getPathDepth, filterByExtension } from '../backendUtil';
5
+
6
+ describe('parseLinkHeader', () => {
7
+ it('should return the right rel urls', () => {
8
+ const url = 'https://api.github.com/resource';
9
+ const link = oneLine`
10
+ <${url}?page=1>; rel="first",
11
+ <${url}?page=2>; rel="prev",
12
+ <${url}?page=4>; rel="next",
13
+ <${url}?page=5>; rel="last"
14
+ `;
15
+ const linkHeader = parseLinkHeader(link);
16
+
17
+ expect(linkHeader.next).toBe(`${url}?page=4`);
18
+ expect(linkHeader.last).toBe(`${url}?page=5`);
19
+ expect(linkHeader.first).toBe(`${url}?page=1`);
20
+ expect(linkHeader.prev).toBe(`${url}?page=2`);
21
+ });
22
+ });
23
+
24
+ describe('getAllResponses', () => {
25
+ function generatePulls(length) {
26
+ return Array.from({ length }, (_, id) => {
27
+ return { id: id + 1, number: `134${id}`, state: 'open' };
28
+ });
29
+ }
30
+
31
+ function createLinkHeaders({ page, pageCount }) {
32
+ const pageNum = parseInt(page, 10);
33
+ const pageCountNum = parseInt(pageCount, 10);
34
+ const url = 'https://api.github.com/pulls';
35
+
36
+ function link(linkPage) {
37
+ return `<${url}?page=${linkPage}>`;
38
+ }
39
+
40
+ const linkHeader = oneLine`
41
+ ${pageNum === 1 ? '' : `${link(1)}; rel="first",`}
42
+ ${pageNum === pageCountNum ? '' : `${link(pageCount)}; rel="last",`}
43
+ ${pageNum === 1 ? '' : `${link(pageNum - 1)}; rel="prev",`}
44
+ ${pageNum === pageCountNum ? '' : `${link(pageNum + 1)}; rel="next",`}
45
+ `.slice(0, -1);
46
+
47
+ return { Link: linkHeader };
48
+ }
49
+
50
+ function interceptCall({ perPage = 30, repeat = 1, data = [] } = {}) {
51
+ nock('https://api.github.com')
52
+ .get('/pulls')
53
+ .query(true)
54
+ .times(repeat)
55
+ .reply(uri => {
56
+ const searchParams = new URLSearchParams(uri.split('?')[1]);
57
+ const page = searchParams.get('page') || 1;
58
+ const pageCount = data.length <= perPage ? 1 : Math.ceil(data.length / perPage);
59
+ const pageLastIndex = page * perPage;
60
+ const pageFirstIndex = pageLastIndex - perPage;
61
+ const resp = data.slice(pageFirstIndex, pageLastIndex);
62
+ return [200, resp, createLinkHeaders({ page, pageCount })];
63
+ });
64
+ }
65
+
66
+ it('should return all paged response', async () => {
67
+ interceptCall({ repeat: 3, data: generatePulls(70) });
68
+ const res = await getAllResponses('https://api.github.com/pulls', {}, 'next', url => url);
69
+ const pages = await Promise.all(res.map(res => res.json()));
70
+
71
+ expect(pages[0]).toHaveLength(30);
72
+ expect(pages[1]).toHaveLength(30);
73
+ expect(pages[2]).toHaveLength(10);
74
+ });
75
+ });
76
+
77
+ describe('getPathDepth', () => {
78
+ it('should return 1 for empty string', () => {
79
+ expect(getPathDepth('')).toBe(1);
80
+ });
81
+
82
+ it('should return 2 for path of one nested folder', () => {
83
+ expect(getPathDepth('{{year}}/{{slug}}')).toBe(2);
84
+ });
85
+ });
86
+
87
+ describe('filterByExtension', () => {
88
+ it('should return true when extension matches', () => {
89
+ expect(filterByExtension({ path: 'file.html.md' }, '.html.md')).toBe(true);
90
+ expect(filterByExtension({ path: 'file.html.md' }, 'html.md')).toBe(true);
91
+ });
92
+
93
+ it("should return false when extension doesn't match", () => {
94
+ expect(filterByExtension({ path: 'file.json' }, '.html.md')).toBe(false);
95
+ expect(filterByExtension({ path: 'file.json' }, 'html.md')).toBe(false);
96
+ });
97
+ });
@@ -0,0 +1,58 @@
1
+ import { getMediaAsBlob, getMediaDisplayURL } from '../implementation';
2
+
3
+ describe('implementation', () => {
4
+ describe('getMediaAsBlob', () => {
5
+ it('should return response blob on non svg file', async () => {
6
+ const blob = {};
7
+ const readFile = jest.fn().mockResolvedValue(blob);
8
+
9
+ await expect(getMediaAsBlob('static/media/image.png', 'sha', readFile)).resolves.toBe(blob);
10
+
11
+ expect(readFile).toHaveBeenCalledTimes(1);
12
+ expect(readFile).toHaveBeenCalledWith('static/media/image.png', 'sha', {
13
+ parseText: false,
14
+ });
15
+ });
16
+
17
+ it('should return text blob on svg file', async () => {
18
+ const text = 'svg';
19
+ const readFile = jest.fn().mockResolvedValue(text);
20
+
21
+ await expect(getMediaAsBlob('static/media/logo.svg', 'sha', readFile)).resolves.toEqual(
22
+ new Blob([text], { type: 'image/svg+xml' }),
23
+ );
24
+
25
+ expect(readFile).toHaveBeenCalledTimes(1);
26
+ expect(readFile).toHaveBeenCalledWith('static/media/logo.svg', 'sha', {
27
+ parseText: true,
28
+ });
29
+ });
30
+ });
31
+
32
+ describe('getMediaDisplayURL', () => {
33
+ it('should return createObjectURL result', async () => {
34
+ const blob = {};
35
+ const readFile = jest.fn().mockResolvedValue(blob);
36
+ const semaphore = { take: jest.fn(callback => callback()), leave: jest.fn() };
37
+
38
+ global.URL.createObjectURL = jest
39
+ .fn()
40
+ .mockResolvedValue('blob:http://localhost:8080/blob-id');
41
+
42
+ await expect(
43
+ getMediaDisplayURL({ path: 'static/media/image.png', id: 'sha' }, readFile, semaphore),
44
+ ).resolves.toBe('blob:http://localhost:8080/blob-id');
45
+
46
+ expect(semaphore.take).toHaveBeenCalledTimes(1);
47
+ expect(semaphore.leave).toHaveBeenCalledTimes(1);
48
+
49
+ expect(readFile).toHaveBeenCalledTimes(1);
50
+ expect(readFile).toHaveBeenCalledWith('static/media/image.png', 'sha', {
51
+ parseText: false,
52
+ });
53
+
54
+ expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
55
+ expect(global.URL.createObjectURL).toHaveBeenCalledWith(blob);
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,53 @@
1
+ import { fileExtensionWithSeparator, fileExtension } from '../path';
2
+
3
+ describe('fileExtensionWithSeparator', () => {
4
+ it('should return the extension of a file', () => {
5
+ expect(fileExtensionWithSeparator('index.html')).toEqual('.html');
6
+ });
7
+
8
+ it('should return the extension of a file path', () => {
9
+ expect(fileExtensionWithSeparator('/src/main/index.html')).toEqual('.html');
10
+ });
11
+
12
+ it('should return the extension of a file path with trailing slash', () => {
13
+ expect(fileExtensionWithSeparator('/src/main/index.html/')).toEqual('.html');
14
+ });
15
+
16
+ it('should return the extension for an extension with two ..', () => {
17
+ expect(fileExtensionWithSeparator('/src/main/index..html')).toEqual('.html');
18
+ });
19
+
20
+ it('should return an empty string for the parent path ..', () => {
21
+ expect(fileExtensionWithSeparator('..')).toEqual('');
22
+ });
23
+
24
+ it('should return an empty string if the file has no extension', () => {
25
+ expect(fileExtensionWithSeparator('/src/main/index')).toEqual('');
26
+ });
27
+ });
28
+
29
+ describe('fileExtension', () => {
30
+ it('should return the extension of a file', () => {
31
+ expect(fileExtension('index.html')).toEqual('html');
32
+ });
33
+
34
+ it('should return the extension of a file path', () => {
35
+ expect(fileExtension('/src/main/index.html')).toEqual('html');
36
+ });
37
+
38
+ it('should return the extension of a file path with trailing slash', () => {
39
+ expect(fileExtension('/src/main/index.html/')).toEqual('html');
40
+ });
41
+
42
+ it('should return the extension for an extension with two ..', () => {
43
+ expect(fileExtension('/src/main/index..html')).toEqual('html');
44
+ });
45
+
46
+ it('should return an empty string for the parent path ..', () => {
47
+ expect(fileExtension('..')).toEqual('');
48
+ });
49
+
50
+ it('should return an empty string if the file has no extension', () => {
51
+ expect(fileExtension('/src/main/index')).toEqual('');
52
+ });
53
+ });
@@ -0,0 +1,19 @@
1
+ import unsentRequest from '../unsentRequest';
2
+
3
+ describe('unsentRequest', () => {
4
+ describe('withHeaders', () => {
5
+ it('should create new request with headers', () => {
6
+ expect(unsentRequest.withHeaders({ Authorization: 'token' })('path').toJS()).toEqual({
7
+ url: 'path',
8
+ headers: { Authorization: 'token' },
9
+ });
10
+ });
11
+
12
+ it('should add headers to existing request', () => {
13
+ expect(unsentRequest.withHeaders({ Authorization: 'token' }, 'path').toJS()).toEqual({
14
+ url: 'path',
15
+ headers: { Authorization: 'token' },
16
+ });
17
+ });
18
+ });
19
+ });
@@ -0,0 +1,43 @@
1
+ import semaphore from 'semaphore';
2
+
3
+ export type AsyncLock = { release: () => void; acquire: () => Promise<boolean> };
4
+
5
+ export function asyncLock(): AsyncLock {
6
+ let lock = semaphore(1);
7
+
8
+ function acquire(timeout = 15000) {
9
+ const promise = new Promise<boolean>(resolve => {
10
+ // this makes sure a caller doesn't gets stuck forever awaiting on the lock
11
+ const timeoutId = setTimeout(() => {
12
+ // we reset the lock in that case to allow future consumers to use it without being blocked
13
+ lock = semaphore(1);
14
+ resolve(false);
15
+ }, timeout);
16
+
17
+ lock.take(() => {
18
+ clearTimeout(timeoutId);
19
+ resolve(true);
20
+ });
21
+ });
22
+
23
+ return promise;
24
+ }
25
+
26
+ function release() {
27
+ try {
28
+ // suppress too many calls to leave error
29
+ lock.leave();
30
+ } catch (e) {
31
+ // calling 'leave' too many times might not be good behavior
32
+ // but there is no reason to completely fail on it
33
+ if (e.message !== 'leave called too many times.') {
34
+ throw e;
35
+ } else {
36
+ console.warn('leave called too many times.');
37
+ lock = semaphore(1);
38
+ }
39
+ }
40
+ }
41
+
42
+ return { acquire, release };
43
+ }
@@ -0,0 +1,120 @@
1
+ import { flow, fromPairs } from 'lodash';
2
+ import { map } from 'lodash/fp';
3
+ import { fromJS } from 'immutable';
4
+
5
+ import unsentRequest from './unsentRequest';
6
+ import APIError from './APIError';
7
+
8
+ type Formatter = (res: Response) => Promise<string | Blob | unknown>;
9
+
10
+ export function filterByExtension(file: { path: string }, extension: string) {
11
+ const path = file?.path || '';
12
+ return path.endsWith(extension.startsWith('.') ? extension : `.${extension}`);
13
+ }
14
+
15
+ function catchFormatErrors(format: string, formatter: Formatter) {
16
+ return (res: Response) => {
17
+ try {
18
+ return formatter(res);
19
+ } catch (err) {
20
+ throw new Error(
21
+ `Response cannot be parsed into the expected format (${format}): ${err.message}`,
22
+ );
23
+ }
24
+ };
25
+ }
26
+
27
+ const responseFormatters = fromJS({
28
+ json: async (res: Response) => {
29
+ const contentType = res.headers.get('Content-Type') || '';
30
+ if (!contentType.startsWith('application/json') && !contentType.startsWith('text/json')) {
31
+ throw new Error(`${contentType} is not a valid JSON Content-Type`);
32
+ }
33
+ return res.json();
34
+ },
35
+ text: async (res: Response) => res.text(),
36
+ blob: async (res: Response) => res.blob(),
37
+ }).mapEntries(([format, formatter]: [string, Formatter]) => [
38
+ format,
39
+ catchFormatErrors(format, formatter),
40
+ ]);
41
+
42
+ export async function parseResponse(
43
+ res: Response,
44
+ { expectingOk = true, format = 'text', apiName = '' },
45
+ ) {
46
+ let body;
47
+ try {
48
+ const formatter = responseFormatters.get(format, false);
49
+ if (!formatter) {
50
+ throw new Error(`${format} is not a supported response format.`);
51
+ }
52
+ body = await formatter(res);
53
+ } catch (err) {
54
+ throw new APIError(err.message, res.status, apiName);
55
+ }
56
+ if (expectingOk && !res.ok) {
57
+ const isJSON = format === 'json';
58
+ const message = isJSON ? body.message || body.msg || body.error?.message : body;
59
+ throw new APIError(isJSON && message ? message : body, res.status, apiName);
60
+ }
61
+ return body;
62
+ }
63
+
64
+ export function responseParser(options: {
65
+ expectingOk?: boolean;
66
+ format: string;
67
+ apiName: string;
68
+ }) {
69
+ return (res: Response) => parseResponse(res, options);
70
+ }
71
+
72
+ export function parseLinkHeader(header: string | null) {
73
+ if (!header) {
74
+ return {};
75
+ }
76
+ return flow([
77
+ linksString => linksString.split(','),
78
+ map((str: string) => str.trim().split(';')),
79
+ map(([linkStr, keyStr]) => [
80
+ keyStr.match(/rel="(.*?)"/)[1],
81
+ linkStr
82
+ .trim()
83
+ .match(/<(.*?)>/)[1]
84
+ .replace(/\+/g, '%20'),
85
+ ]),
86
+ fromPairs,
87
+ ])(header);
88
+ }
89
+
90
+ export async function getAllResponses(
91
+ url: string,
92
+ options: { headers?: {} } = {},
93
+ linkHeaderRelName: string,
94
+ nextUrlProcessor: (url: string) => string,
95
+ ) {
96
+ const maxResponses = 30;
97
+ let responseCount = 1;
98
+
99
+ let req = unsentRequest.fromFetchArguments(url, options);
100
+
101
+ const pageResponses = [];
102
+
103
+ while (req && responseCount < maxResponses) {
104
+ const pageResponse = await unsentRequest.performRequest(req);
105
+ const linkHeader = pageResponse.headers.get('Link');
106
+ const nextURL = linkHeader && parseLinkHeader(linkHeader)[linkHeaderRelName];
107
+
108
+ const { headers = {} } = options;
109
+ req = nextURL && unsentRequest.fromFetchArguments(nextUrlProcessor(nextURL), { headers });
110
+ pageResponses.push(pageResponse);
111
+ responseCount++;
112
+ }
113
+
114
+ return pageResponses;
115
+ }
116
+
117
+ export function getPathDepth(path: string) {
118
+ const depth = path.split('/').length;
119
+ return depth;
120
+ }
@@ -0,0 +1,12 @@
1
+ import { sha256 } from 'js-sha256';
2
+
3
+ export default (blob: Blob): Promise<string> =>
4
+ new Promise((resolve, reject) => {
5
+ const fr = new FileReader();
6
+ fr.onload = ({ target }) => resolve(sha256(target?.result || ''));
7
+ fr.onerror = err => {
8
+ fr.abort();
9
+ reject(err);
10
+ };
11
+ fr.readAsArrayBuffer(blob);
12
+ });
package/src/git-lfs.ts ADDED
@@ -0,0 +1,133 @@
1
+ //
2
+ // Pointer file parsing
3
+
4
+ import { filter, flow, fromPairs, map } from 'lodash/fp';
5
+
6
+ import getBlobSHA from './getBlobSHA';
7
+
8
+ import type { AssetProxy } from './implementation';
9
+
10
+ export interface PointerFile {
11
+ size: number;
12
+ sha: string;
13
+ }
14
+
15
+ function splitIntoLines(str: string) {
16
+ return str.split('\n');
17
+ }
18
+
19
+ function splitIntoWords(str: string) {
20
+ return str.split(/\s+/g);
21
+ }
22
+
23
+ function isNonEmptyString(str: string) {
24
+ return str !== '';
25
+ }
26
+
27
+ const withoutEmptyLines = flow([map((str: string) => str.trim()), filter(isNonEmptyString)]);
28
+ export const parsePointerFile: (data: string) => PointerFile = flow([
29
+ splitIntoLines,
30
+ withoutEmptyLines,
31
+ map(splitIntoWords),
32
+ fromPairs,
33
+ ({ size, oid, ...rest }) => ({
34
+ size: parseInt(size),
35
+ sha: oid?.split(':')[1],
36
+ ...rest,
37
+ }),
38
+ ]);
39
+
40
+ //
41
+ // .gitattributes file parsing
42
+
43
+ function removeGitAttributesCommentsFromLine(line: string) {
44
+ return line.split('#')[0];
45
+ }
46
+
47
+ function parseGitPatternAttribute(attributeString: string) {
48
+ // There are three kinds of attribute settings:
49
+ // - a key=val pair sets an attribute to a specific value
50
+ // - a key without a value and a leading hyphen sets an attribute to false
51
+ // - a key without a value and no leading hyphen sets an attribute
52
+ // to true
53
+ if (attributeString.includes('=')) {
54
+ return attributeString.split('=');
55
+ }
56
+ if (attributeString.startsWith('-')) {
57
+ return [attributeString.slice(1), false];
58
+ }
59
+ return [attributeString, true];
60
+ }
61
+
62
+ const parseGitPatternAttributes = flow([map(parseGitPatternAttribute), fromPairs]);
63
+
64
+ const parseGitAttributesPatternLine = flow([
65
+ splitIntoWords,
66
+ ([pattern, ...attributes]) => [pattern, parseGitPatternAttributes(attributes)],
67
+ ]);
68
+
69
+ const parseGitAttributesFileToPatternAttributePairs = flow([
70
+ splitIntoLines,
71
+ map(removeGitAttributesCommentsFromLine),
72
+ withoutEmptyLines,
73
+ map(parseGitAttributesPatternLine),
74
+ ]);
75
+
76
+ export const getLargeMediaPatternsFromGitAttributesFile = flow([
77
+ parseGitAttributesFileToPatternAttributePairs,
78
+ filter(
79
+ ([, attributes]) =>
80
+ attributes.filter === 'lfs' && attributes.diff === 'lfs' && attributes.merge === 'lfs',
81
+ ),
82
+ map(([pattern]) => pattern),
83
+ ]);
84
+
85
+ export function createPointerFile({ size, sha }: PointerFile) {
86
+ return `\
87
+ version https://git-lfs.github.com/spec/v1
88
+ oid sha256:${sha}
89
+ size ${size}
90
+ `;
91
+ }
92
+
93
+ export async function getPointerFileForMediaFileObj(
94
+ client: { uploadResource: (pointer: PointerFile, resource: Blob) => Promise<string> },
95
+ fileObj: File,
96
+ path: string,
97
+ ) {
98
+ const { name, size } = fileObj;
99
+ const sha = await getBlobSHA(fileObj);
100
+ await client.uploadResource({ sha, size }, fileObj);
101
+ const pointerFileString = createPointerFile({ sha, size });
102
+ const pointerFileBlob = new Blob([pointerFileString]);
103
+ const pointerFile = new File([pointerFileBlob], name, { type: 'text/plain' });
104
+ const pointerFileSHA = await getBlobSHA(pointerFile);
105
+ return {
106
+ fileObj: pointerFile,
107
+ size: pointerFileBlob.size,
108
+ sha: pointerFileSHA,
109
+ raw: pointerFileString,
110
+ path,
111
+ };
112
+ }
113
+
114
+ export async function getLargeMediaFilteredMediaFiles(
115
+ client: {
116
+ uploadResource: (pointer: PointerFile, resource: Blob) => Promise<string>;
117
+ matchPath: (path: string) => boolean;
118
+ },
119
+ mediaFiles: AssetProxy[],
120
+ ) {
121
+ return await Promise.all(
122
+ mediaFiles.map(async mediaFile => {
123
+ const { fileObj, path } = mediaFile;
124
+ const fixedPath = path.startsWith('/') ? path.slice(1) : path;
125
+ if (!client.matchPath(fixedPath)) {
126
+ return mediaFile;
127
+ }
128
+
129
+ const pointerFileDetails = await getPointerFileForMediaFileObj(client, fileObj as File, path);
130
+ return { ...mediaFile, ...pointerFileDetails };
131
+ }),
132
+ );
133
+ }