@jungvonmatt/contentful-ssg 1.4.9 → 1.7.3
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/README.md +27 -0
- package/dist/index.js +14 -2
- package/dist/lib/error.d.ts +4 -0
- package/dist/lib/error.js +8 -0
- package/dist/lib/stats.d.ts +1 -1
- package/dist/lib/stats.js +3 -3
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +74 -0
- package/dist/types.d.ts +6 -0
- package/package.json +10 -2
- package/src/index.ts +23 -3
- package/src/lib/error.ts +10 -0
- package/src/lib/stats.ts +5 -4
- package/src/lib/utils.test.ts +219 -6
- package/src/lib/utils.ts +99 -0
- package/src/types.ts +8 -0
package/README.md
CHANGED
|
@@ -209,6 +209,33 @@ Get values from linked pages to e.g. build an url out of parent slugs.
|
|
|
209
209
|
|
|
210
210
|
The same as collectValues just without the value from the current entry
|
|
211
211
|
|
|
212
|
+
###### waitFor
|
|
213
|
+
|
|
214
|
+
Wait for specific entry to be transformed.
|
|
215
|
+
Be aware that this can lead to deadlocks when you're awaiting something which
|
|
216
|
+
itself is waiting for the current entry to be transformed.
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
{
|
|
220
|
+
transform: (context) => {
|
|
221
|
+
const { utils } = context;
|
|
222
|
+
try {
|
|
223
|
+
// You can overwrite the default wait timeout of 20000ms using the second parameter
|
|
224
|
+
const linkedContext = await utils.waitFor('<contentful-id>', 5000);
|
|
225
|
+
|
|
226
|
+
// Do something usefull with the transformed data
|
|
227
|
+
// which you can't do with context.entryMap.get('<contentful-id>')
|
|
228
|
+
|
|
229
|
+
} catch (error) {
|
|
230
|
+
// Entry isn't available, the transform method for the entry throws an error
|
|
231
|
+
// or we encountered a cyclic dependency
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { ...content };
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
212
239
|
### fetch
|
|
213
240
|
|
|
214
241
|
Fetch all content entries and store them as yaml in the configured directory
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Listr from 'listr';
|
|
2
|
+
import { ReplaySubject } from 'rxjs';
|
|
2
3
|
import chalk from 'chalk';
|
|
3
4
|
import { getContentTypeId, getContentId } from './lib/contentful.js';
|
|
4
5
|
import { setup } from './tasks/setup.js';
|
|
@@ -6,7 +7,7 @@ import { fetch } from './tasks/fetch.js';
|
|
|
6
7
|
import { localize } from './tasks/localize.js';
|
|
7
8
|
import { transform } from './tasks/transform.js';
|
|
8
9
|
import { write } from './tasks/write.js';
|
|
9
|
-
import { collectParentValues, collectValues } from './lib/utils.js';
|
|
10
|
+
import { collectParentValues, collectValues, waitFor } from './lib/utils.js';
|
|
10
11
|
import { ValidationError } from './lib/error.js';
|
|
11
12
|
class CustomListrRenderer {
|
|
12
13
|
_tasks;
|
|
@@ -63,6 +64,8 @@ export const run = async (config) => {
|
|
|
63
64
|
const tasks = locales.map((locale) => ({
|
|
64
65
|
title: `${locale.code}`,
|
|
65
66
|
task: async () => {
|
|
67
|
+
const subject = new ReplaySubject();
|
|
68
|
+
const observable = subject.asObservable();
|
|
66
69
|
const data = ctx.localized.get(locale.code);
|
|
67
70
|
const { entries = [] } = data || {};
|
|
68
71
|
const promises = entries.map(async (entry) => {
|
|
@@ -71,6 +74,7 @@ export const run = async (config) => {
|
|
|
71
74
|
const utils = {
|
|
72
75
|
collectValues: collectValues({ ...data, entry }),
|
|
73
76
|
collectParentValues: collectParentValues({ ...data, entry }),
|
|
77
|
+
waitFor: waitFor({ ...data, entry, observable }),
|
|
74
78
|
};
|
|
75
79
|
const transformContext = {
|
|
76
80
|
...data,
|
|
@@ -79,9 +83,11 @@ export const run = async (config) => {
|
|
|
79
83
|
entry,
|
|
80
84
|
locale,
|
|
81
85
|
utils,
|
|
86
|
+
observable,
|
|
82
87
|
};
|
|
83
88
|
try {
|
|
84
89
|
const content = await transform(transformContext, ctx, config);
|
|
90
|
+
subject.next({ ...transformContext, content });
|
|
85
91
|
if (typeof content === 'undefined') {
|
|
86
92
|
return;
|
|
87
93
|
}
|
|
@@ -89,10 +95,16 @@ export const run = async (config) => {
|
|
|
89
95
|
ctx.stats.addSuccess(transformContext);
|
|
90
96
|
}
|
|
91
97
|
catch (error) {
|
|
98
|
+
if (error instanceof Error) {
|
|
99
|
+
subject.next({ ...transformContext, error });
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
subject.next({ ...transformContext, error: new Error(`${error}`) });
|
|
103
|
+
}
|
|
92
104
|
if (error instanceof ValidationError) {
|
|
93
105
|
ctx.stats.addSkipped(transformContext, error);
|
|
94
106
|
}
|
|
95
|
-
else
|
|
107
|
+
else {
|
|
96
108
|
ctx.stats.addError(transformContext, error);
|
|
97
109
|
}
|
|
98
110
|
}
|
package/dist/lib/error.d.ts
CHANGED
package/dist/lib/error.js
CHANGED
|
@@ -15,3 +15,11 @@ export class ValidationError extends Error {
|
|
|
15
15
|
this.name = 'ValidationError';
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
export class WrappedError extends Error {
|
|
19
|
+
originalError;
|
|
20
|
+
constructor(message, error) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.originalError = error;
|
|
23
|
+
this.name = 'WrappedError';
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/lib/stats.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare class Stats {
|
|
|
12
12
|
contentTypeId: string;
|
|
13
13
|
};
|
|
14
14
|
addSuccess(context: TransformContext, message?: string): void;
|
|
15
|
-
addError(context: TransformContext, error:
|
|
15
|
+
addError(context: TransformContext, error: unknown): void;
|
|
16
16
|
addSkipped(context: TransformContext, error: ValidationError): void;
|
|
17
17
|
print(): Promise<void>;
|
|
18
18
|
}
|
package/dist/lib/stats.js
CHANGED
|
@@ -21,11 +21,11 @@ export class Stats {
|
|
|
21
21
|
this.success.push({ message, ...this.toEntry(context) });
|
|
22
22
|
}
|
|
23
23
|
addError(context, error) {
|
|
24
|
-
if (
|
|
25
|
-
this.errors.push({ error
|
|
24
|
+
if (error instanceof Error) {
|
|
25
|
+
this.errors.push({ error, ...this.toEntry(context) });
|
|
26
26
|
}
|
|
27
27
|
else {
|
|
28
|
-
this.errors.push({ error, ...this.toEntry(context) });
|
|
28
|
+
this.errors.push({ error: new Error(`${error}`), ...this.toEntry(context) });
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
addSkipped(context, error) {
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ import type { CollectOptions, Entry, TransformContext } from '../types.js';
|
|
|
2
2
|
export declare const collectValues: (transformContext: Pick<TransformContext, 'entry' | 'entryMap'>) => (key: any, options?: CollectOptions) => any[];
|
|
3
3
|
export declare const collectParentValues: (transformContext: Pick<TransformContext, 'entry' | 'entryMap'>) => (key: any, options?: CollectOptions) => any[];
|
|
4
4
|
export declare const collect: <T = unknown>(entry: Entry, entryMap: Map<string, Entry>, options: CollectOptions) => T[];
|
|
5
|
+
export declare const waitFor: (transformContext: Pick<TransformContext, 'entry' | 'observable' | 'entryMap'>) => (id: string, waitTimeout?: number) => Promise<unknown>;
|
package/dist/lib/utils.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { getContentId, getContentTypeId } from './contentful.js';
|
|
2
|
+
import { ReplaySubject, map, distinct, filter } from 'rxjs';
|
|
1
3
|
import dlv from 'dlv';
|
|
4
|
+
import { WrappedError } from './error.js';
|
|
5
|
+
const DEFAULT_WAIT_TIMEOUT = 20000;
|
|
2
6
|
export const collectValues = (transformContext) => (key, options) => {
|
|
3
7
|
const { entry: defaultEntry, entryMap: defaultEntryMap } = transformContext;
|
|
4
8
|
const { getNextId, linkField, entry = defaultEntry, entryMap = defaultEntryMap, } = options || {};
|
|
@@ -29,3 +33,73 @@ export const collect = (entry, entryMap, options) => {
|
|
|
29
33
|
}
|
|
30
34
|
return [value];
|
|
31
35
|
};
|
|
36
|
+
const dependencies = {};
|
|
37
|
+
const buildDependencies = (v) => {
|
|
38
|
+
if ((dependencies?.[v.source] ?? []).includes(v.dest)) {
|
|
39
|
+
return dependencies;
|
|
40
|
+
}
|
|
41
|
+
if (dependencies[v.source]) {
|
|
42
|
+
dependencies[v.source] = [...dependencies[v.source], v.dest];
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
dependencies[v.source] = [v.dest];
|
|
46
|
+
}
|
|
47
|
+
Object.entries({ ...dependencies })
|
|
48
|
+
.filter(([, value]) => value.includes(v.source))
|
|
49
|
+
.forEach(([key, value]) => {
|
|
50
|
+
dependencies[key] = [...new Set([...value, v.dest, ...(dependencies[v.dest] || [])])];
|
|
51
|
+
});
|
|
52
|
+
return dependencies;
|
|
53
|
+
};
|
|
54
|
+
const waitForSubject = new ReplaySubject();
|
|
55
|
+
const cyclicErrorObservable = waitForSubject
|
|
56
|
+
.asObservable()
|
|
57
|
+
.pipe(map((value) => buildDependencies(value)));
|
|
58
|
+
export const waitFor = (transformContext) => {
|
|
59
|
+
const sourceEntry = transformContext.entry;
|
|
60
|
+
const sourceId = getContentId(sourceEntry);
|
|
61
|
+
const sourceContentTypeId = getContentTypeId(sourceEntry);
|
|
62
|
+
const source = `${sourceId} (${sourceContentTypeId})`;
|
|
63
|
+
return async (id, waitTimeout = DEFAULT_WAIT_TIMEOUT) => {
|
|
64
|
+
if (sourceId === id) {
|
|
65
|
+
throw new Error(`Can't wait for yourself.
|
|
66
|
+
Entry ${source} waiting for ${source}.`);
|
|
67
|
+
}
|
|
68
|
+
waitForSubject.next({ source: getContentId(sourceEntry), dest: id });
|
|
69
|
+
const destEntry = transformContext.entryMap.get(id);
|
|
70
|
+
const destId = getContentId(destEntry);
|
|
71
|
+
const destContentTypeId = getContentTypeId(destEntry);
|
|
72
|
+
const dest = `${destId} (${destContentTypeId})`;
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const timeout = setTimeout(() => {
|
|
75
|
+
reject(new Error(`Exceeded timeout of ${waitTimeout} ms while waiting for entry ${id} to complete.
|
|
76
|
+
Entry ${source} waiting for ${dest}.`));
|
|
77
|
+
}, waitTimeout);
|
|
78
|
+
if (transformContext.entryMap.has(id)) {
|
|
79
|
+
transformContext.observable
|
|
80
|
+
.pipe(filter((ctx) => ctx?.entry?.sys?.id === id))
|
|
81
|
+
.subscribe((value) => {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
if (value.error) {
|
|
84
|
+
reject(new WrappedError(`Awaited entry ${dest} errored`, value.error));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
resolve(value);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
cyclicErrorObservable
|
|
91
|
+
.pipe(filter((v) => v?.[sourceId]?.includes(sourceId)))
|
|
92
|
+
.pipe(distinct())
|
|
93
|
+
.subscribe((v) => {
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
const deps = v?.[sourceId] ?? [];
|
|
96
|
+
reject(new Error(`Found cyclic dependency in ${source}: ${[sourceId, ...deps].join(' -> ')}`));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
reject(new Error(`No entry with id "${id}" available`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Options } from '@contentful/rich-text-html-renderer';
|
|
2
2
|
import type { Document } from '@contentful/rich-text-types';
|
|
3
|
+
import type { Observable } from 'rxjs';
|
|
3
4
|
import type { QueryOptions, CollectionProp } from 'contentful-management/types';
|
|
4
5
|
import type { EntryFields, Locale as ContentfulLocale, Asset as ContentfulAsset, Field, Entry as ContentfulEntry, ContentType as ContentfulContentType } from 'contentful';
|
|
5
6
|
import type { ListrTaskObject } from 'listr';
|
|
@@ -131,6 +132,7 @@ export declare type Task = ListrTaskObject<RuntimeContext>;
|
|
|
131
132
|
export interface TransformHelper {
|
|
132
133
|
collectValues: <T>(key: any, options?: CollectOptions) => T[];
|
|
133
134
|
collectParentValues: <T>(key: any, options?: CollectOptions) => T[];
|
|
135
|
+
waitFor: (id: string, waitTimeout?: number) => Promise<ObservableContext>;
|
|
134
136
|
}
|
|
135
137
|
export declare type TransformContext = LocalizedContent & {
|
|
136
138
|
[x: string]: any;
|
|
@@ -145,7 +147,11 @@ export declare type TransformContext = LocalizedContent & {
|
|
|
145
147
|
fieldSettings?: Field;
|
|
146
148
|
requiredFields?: string[];
|
|
147
149
|
utils: TransformHelper;
|
|
150
|
+
observable: Observable<ObservableContext>;
|
|
148
151
|
};
|
|
152
|
+
export declare type ObservableContext = Readonly<Pick<TransformContext, 'id' | 'contentTypeId' | 'entry' | 'content' | 'locale'> & {
|
|
153
|
+
error?: Error;
|
|
154
|
+
}>;
|
|
149
155
|
export interface Ignore {
|
|
150
156
|
add(pattern: string | Ignore | string[] | Ignore[]): Ignore;
|
|
151
157
|
filter(paths: string[]): string[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jungvonmatt/contentful-ssg",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/jungvonmatt/contentful-ssg.git"
|
|
8
|
+
},
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/jungvonmatt/contentful-ssg/issues"
|
|
11
|
+
},
|
|
5
12
|
"main": "./dist/index.js",
|
|
6
13
|
"type": "module",
|
|
7
14
|
"typings": "./dist/types.d.ts",
|
|
@@ -118,6 +125,7 @@
|
|
|
118
125
|
"micromatch": "^4.0.4",
|
|
119
126
|
"prettier": "^2.4.1",
|
|
120
127
|
"read-pkg-up": "^9.0.0",
|
|
128
|
+
"rxjs": "^7.5.5",
|
|
121
129
|
"slash": "^4.0.0",
|
|
122
130
|
"snake-case": "^3.0.4",
|
|
123
131
|
"tempy": "^2.0.0",
|
|
@@ -155,5 +163,5 @@
|
|
|
155
163
|
"module": "es2020"
|
|
156
164
|
}
|
|
157
165
|
},
|
|
158
|
-
"gitHead": "
|
|
166
|
+
"gitHead": "07c6810f528dc8cf51fad3f8e8cc6081facd9232"
|
|
159
167
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Config,
|
|
3
|
+
ObservableContext,
|
|
4
|
+
RuntimeContext,
|
|
5
|
+
Task,
|
|
6
|
+
TransformContext,
|
|
7
|
+
TransformHelper,
|
|
8
|
+
} from './types.js';
|
|
2
9
|
import Listr from 'listr';
|
|
10
|
+
import { ReplaySubject } from 'rxjs';
|
|
3
11
|
import chalk from 'chalk';
|
|
4
12
|
import { getContentTypeId, getContentId } from './lib/contentful.js';
|
|
5
13
|
import { setup } from './tasks/setup.js';
|
|
@@ -7,7 +15,7 @@ import { fetch } from './tasks/fetch.js';
|
|
|
7
15
|
import { localize } from './tasks/localize.js';
|
|
8
16
|
import { transform } from './tasks/transform.js';
|
|
9
17
|
import { write } from './tasks/write.js';
|
|
10
|
-
import { collectParentValues, collectValues } from './lib/utils.js';
|
|
18
|
+
import { collectParentValues, collectValues, waitFor } from './lib/utils.js';
|
|
11
19
|
import { ValidationError } from './lib/error.js';
|
|
12
20
|
|
|
13
21
|
/**
|
|
@@ -83,6 +91,8 @@ export const run = async (config: Config): Promise<void> => {
|
|
|
83
91
|
const tasks = locales.map((locale) => ({
|
|
84
92
|
title: `${locale.code}`,
|
|
85
93
|
task: async () => {
|
|
94
|
+
const subject = new ReplaySubject<ObservableContext>();
|
|
95
|
+
const observable = subject.asObservable();
|
|
86
96
|
const data = ctx.localized.get(locale.code);
|
|
87
97
|
const { entries = [] } = data || {};
|
|
88
98
|
|
|
@@ -94,6 +104,7 @@ export const run = async (config: Config): Promise<void> => {
|
|
|
94
104
|
const utils = {
|
|
95
105
|
collectValues: collectValues({ ...data, entry }),
|
|
96
106
|
collectParentValues: collectParentValues({ ...data, entry }),
|
|
107
|
+
waitFor: waitFor({ ...data, entry, observable }),
|
|
97
108
|
} as TransformHelper;
|
|
98
109
|
|
|
99
110
|
const transformContext: TransformContext = {
|
|
@@ -103,10 +114,12 @@ export const run = async (config: Config): Promise<void> => {
|
|
|
103
114
|
entry,
|
|
104
115
|
locale,
|
|
105
116
|
utils,
|
|
117
|
+
observable,
|
|
106
118
|
};
|
|
107
119
|
|
|
108
120
|
try {
|
|
109
121
|
const content = await transform(transformContext, ctx, config);
|
|
122
|
+
subject.next({ ...transformContext, content });
|
|
110
123
|
|
|
111
124
|
if (typeof content === 'undefined') {
|
|
112
125
|
return;
|
|
@@ -115,9 +128,16 @@ export const run = async (config: Config): Promise<void> => {
|
|
|
115
128
|
await write({ ...transformContext, content }, ctx, config);
|
|
116
129
|
ctx.stats.addSuccess(transformContext);
|
|
117
130
|
} catch (error: unknown) {
|
|
131
|
+
if (error instanceof Error) {
|
|
132
|
+
subject.next({ ...transformContext, error });
|
|
133
|
+
} else {
|
|
134
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
|
|
135
|
+
subject.next({ ...transformContext, error: new Error(`${error}`) });
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
if (error instanceof ValidationError) {
|
|
119
139
|
ctx.stats.addSkipped(transformContext, error);
|
|
120
|
-
} else
|
|
140
|
+
} else {
|
|
121
141
|
ctx.stats.addError(transformContext, error);
|
|
122
142
|
}
|
|
123
143
|
}
|
package/src/lib/error.ts
CHANGED
|
@@ -17,3 +17,13 @@ export class ValidationError extends Error {
|
|
|
17
17
|
this.name = 'ValidationError';
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
export class WrappedError extends Error {
|
|
22
|
+
originalError: unknown;
|
|
23
|
+
|
|
24
|
+
constructor(message: string, error: unknown) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.originalError = error;
|
|
27
|
+
this.name = 'WrappedError';
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/lib/stats.ts
CHANGED
|
@@ -28,11 +28,12 @@ export class Stats {
|
|
|
28
28
|
this.success.push({ message, ...this.toEntry(context) });
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
addError(context: TransformContext, error:
|
|
32
|
-
if (
|
|
33
|
-
this.errors.push({ error: new Error(error), ...this.toEntry(context) });
|
|
34
|
-
} else {
|
|
31
|
+
addError(context: TransformContext, error: unknown): void {
|
|
32
|
+
if (error instanceof Error) {
|
|
35
33
|
this.errors.push({ error, ...this.toEntry(context) });
|
|
34
|
+
} else {
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
|
|
36
|
+
this.errors.push({ error: new Error(`${error}`), ...this.toEntry(context) });
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
package/src/lib/utils.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Entry, TransformContext } from '../types.js';
|
|
2
|
-
import { collect, collectParentValues, collectValues } from './utils.js';
|
|
2
|
+
import { collect, collectParentValues, collectValues, waitFor } from './utils.js';
|
|
3
|
+
import { ReplaySubject } from 'rxjs';
|
|
4
|
+
import { WrappedError } from './error.js';
|
|
3
5
|
|
|
4
6
|
const data = new Map([
|
|
5
7
|
['1', { sys: { id: '1' }, fields: { slug: 'a' } }],
|
|
@@ -9,12 +11,66 @@ const data = new Map([
|
|
|
9
11
|
['5', { sys: { id: '5' }, fields: { slug: 'e', parent: '4' } }],
|
|
10
12
|
]) as unknown as Map<string, Entry>;
|
|
11
13
|
|
|
14
|
+
const contentType = { sys: { id: 'test-type' } };
|
|
15
|
+
|
|
12
16
|
const entryMap = new Map([
|
|
13
|
-
['1', { sys: { id: '1' }, fields: { slug: 'a' } }],
|
|
14
|
-
[
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
['1', { sys: { id: '1', contentType }, fields: { slug: 'a' } }],
|
|
18
|
+
[
|
|
19
|
+
'2',
|
|
20
|
+
{
|
|
21
|
+
sys: { id: '2', contentType },
|
|
22
|
+
fields: { slug: 'b', parent: { sys: { id: '1' } }, link: { sys: { id: '1' } } },
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
'3',
|
|
27
|
+
{
|
|
28
|
+
sys: { id: '3', contentType },
|
|
29
|
+
fields: { slug: 'c', parent: { sys: { id: '2' } }, link: { sys: { id: '1' } } },
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
'4',
|
|
34
|
+
{
|
|
35
|
+
sys: { id: '4', contentType },
|
|
36
|
+
fields: { slug: 'd', parent: { sys: { id: '3' } }, link: { sys: { id: '3' } } },
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
'5',
|
|
41
|
+
{
|
|
42
|
+
sys: { id: '5', contentType },
|
|
43
|
+
fields: { slug: 'e', parent: { sys: { id: '4' } }, link: { sys: { id: '3' } } },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
'6',
|
|
48
|
+
{
|
|
49
|
+
sys: { id: '6', contentType },
|
|
50
|
+
fields: { slug: 'f', parent: { sys: { id: '1' } }, link: { sys: { id: '1' } } },
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
'7',
|
|
55
|
+
{
|
|
56
|
+
sys: { id: '7', contentType },
|
|
57
|
+
fields: { slug: 'g', parent: { sys: { id: '2' } }, link: { sys: { id: '1' } } },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
'8',
|
|
62
|
+
{
|
|
63
|
+
sys: { id: '8', contentType },
|
|
64
|
+
fields: { slug: 'h', parent: { sys: { id: '3' } }, link: { sys: { id: '3' } } },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
'9',
|
|
69
|
+
{
|
|
70
|
+
sys: { id: '9', contentType },
|
|
71
|
+
fields: { slug: 'i', parent: { sys: { id: '4' } }, link: { sys: { id: '3' } } },
|
|
72
|
+
},
|
|
73
|
+
],
|
|
18
74
|
]) as unknown as Map<string, Entry>;
|
|
19
75
|
|
|
20
76
|
const transformContext = { entryMap } as unknown as TransformContext;
|
|
@@ -82,4 +138,161 @@ describe('Utils', () => {
|
|
|
82
138
|
expect(a).toEqual(['a', 'b', 'c', 'd', 'e']);
|
|
83
139
|
expect(b).toEqual(['e', 'd', 'c', 'b', 'a']);
|
|
84
140
|
});
|
|
141
|
+
|
|
142
|
+
test('waitFor', async () => {
|
|
143
|
+
const subject = new ReplaySubject<TransformContext>(null);
|
|
144
|
+
const observable = subject.asObservable();
|
|
145
|
+
|
|
146
|
+
// Throw error when waiting for the current entry
|
|
147
|
+
expect(async () => {
|
|
148
|
+
await waitFor({ ...transformContext, entry: entryMap.get('2'), observable })('2');
|
|
149
|
+
}).rejects.toThrowError(/2.*waiting.*2/);
|
|
150
|
+
|
|
151
|
+
// Throw error when waiting for non existing entry
|
|
152
|
+
expect(async () => {
|
|
153
|
+
await waitFor({ ...transformContext, entry: entryMap.get('3'), observable })('10');
|
|
154
|
+
}).rejects.toThrowError('No entry with id "10" available');
|
|
155
|
+
|
|
156
|
+
// // Throw error when waiting timeout is reached
|
|
157
|
+
await expect(async () => {
|
|
158
|
+
await waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('4', 50);
|
|
159
|
+
}).rejects.toThrowError(/Exceeded timeout of 50 ms/);
|
|
160
|
+
|
|
161
|
+
// Mimic 500ms wait time for entry
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
subject.next({ ...transformContext, entry: entryMap.get('4'), observable });
|
|
164
|
+
}, 500);
|
|
165
|
+
|
|
166
|
+
const value = await waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('4');
|
|
167
|
+
expect(value).toEqual({ ...transformContext, entry: entryMap.get('4'), observable });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('waitFor error', async () => {
|
|
171
|
+
const subject = new ReplaySubject<TransformContext>(null);
|
|
172
|
+
const observable = subject.asObservable();
|
|
173
|
+
const entry = entryMap.get('3');
|
|
174
|
+
|
|
175
|
+
// Mimic 500ms wait time for entry
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
subject.next({
|
|
178
|
+
...transformContext,
|
|
179
|
+
entry: entryMap.get('4'),
|
|
180
|
+
observable,
|
|
181
|
+
error: new Error('test error'),
|
|
182
|
+
});
|
|
183
|
+
}, 500);
|
|
184
|
+
|
|
185
|
+
await expect(async () => {
|
|
186
|
+
await waitFor({ ...transformContext, entry, observable })('4');
|
|
187
|
+
}).rejects.toThrowError(WrappedError);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await waitFor({ ...transformContext, entry, observable })('4');
|
|
191
|
+
} catch (error) {
|
|
192
|
+
expect(error).toBeInstanceOf(WrappedError);
|
|
193
|
+
expect(error.message).toMatch('Awaited entry 4 (test-type) errored');
|
|
194
|
+
expect(error.originalError.message).toEqual('test error');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('detect cyclic dependency', async () => {
|
|
199
|
+
const subject = new ReplaySubject<TransformContext>(null);
|
|
200
|
+
const observable = subject.asObservable();
|
|
201
|
+
|
|
202
|
+
// let 9 finish regularly
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
subject.next({
|
|
205
|
+
...transformContext,
|
|
206
|
+
entry: entryMap.get('9'),
|
|
207
|
+
observable,
|
|
208
|
+
});
|
|
209
|
+
}, 100);
|
|
210
|
+
|
|
211
|
+
// mimic behaviour in index.ts
|
|
212
|
+
const waitMock = async (source: string, dest: string, delay: number) => {
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
214
|
+
try {
|
|
215
|
+
await waitFor({ ...transformContext, entry: entryMap.get(source), observable })(dest, 1000);
|
|
216
|
+
subject.next({
|
|
217
|
+
...transformContext,
|
|
218
|
+
entry: entryMap.get(source),
|
|
219
|
+
observable,
|
|
220
|
+
});
|
|
221
|
+
return `SUCCESS ${source}`;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
subject.next({
|
|
224
|
+
...transformContext,
|
|
225
|
+
entry: entryMap.get(source),
|
|
226
|
+
error,
|
|
227
|
+
observable,
|
|
228
|
+
});
|
|
229
|
+
return `${error.message}`;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const result = await Promise.all([
|
|
234
|
+
waitMock('6', '8', 0),
|
|
235
|
+
waitMock('8', '5', 10),
|
|
236
|
+
waitMock('5', '7', 20),
|
|
237
|
+
waitMock('7', '6', 30),
|
|
238
|
+
waitMock('3', '9', 40),
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
expect(result[0]).toMatch('Found cyclic dependency in 6 (test-type): 6 -> 8 -> 5 -> 7 -> 6');
|
|
242
|
+
expect(result[1]).toMatch('Found cyclic dependency in 8 (test-type): 8 -> 5 -> 7 -> 6 -> 8');
|
|
243
|
+
expect(result[2]).toMatch('Found cyclic dependency in 5 (test-type): 5 -> 7 -> 6 -> 8 -> 5');
|
|
244
|
+
expect(result[3]).toMatch(/Awaited entry 6 \(test-type\) errored/);
|
|
245
|
+
expect(result[4]).toMatch('SUCCESS 3');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('handle subscriptions on postponed subscribe', async () => {
|
|
249
|
+
const subject = new ReplaySubject<TransformContext>();
|
|
250
|
+
const observable = subject.asObservable();
|
|
251
|
+
|
|
252
|
+
const finishSuccess = (id, time = 0) =>
|
|
253
|
+
setTimeout(() => {
|
|
254
|
+
subject.next({
|
|
255
|
+
...transformContext,
|
|
256
|
+
entry: entryMap.get(id),
|
|
257
|
+
observable,
|
|
258
|
+
});
|
|
259
|
+
}, time);
|
|
260
|
+
|
|
261
|
+
const finishError = (id, time = 0) =>
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
subject.next({
|
|
264
|
+
...transformContext,
|
|
265
|
+
entry: entryMap.get(id),
|
|
266
|
+
error: new Error(`Error on ${id}`),
|
|
267
|
+
observable,
|
|
268
|
+
});
|
|
269
|
+
}, time);
|
|
270
|
+
|
|
271
|
+
finishSuccess('2', 20);
|
|
272
|
+
finishSuccess('3', 40);
|
|
273
|
+
finishError('4', 30);
|
|
274
|
+
finishSuccess('5', 50);
|
|
275
|
+
|
|
276
|
+
const transFormMock = () =>
|
|
277
|
+
Promise.all([
|
|
278
|
+
waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('2'),
|
|
279
|
+
waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('3'),
|
|
280
|
+
waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('4'),
|
|
281
|
+
waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('5'),
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
const result = await new Promise((resolve) => {
|
|
285
|
+
setTimeout(async () => {
|
|
286
|
+
try {
|
|
287
|
+
const result = await transFormMock();
|
|
288
|
+
resolve(result);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
resolve(error);
|
|
291
|
+
}
|
|
292
|
+
}, 500);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(result).toBeInstanceOf(WrappedError);
|
|
296
|
+
expect((result as WrappedError).message).toMatch(/Awaited entry 4 \(test-type\) errored/);
|
|
297
|
+
});
|
|
85
298
|
});
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { CollectOptions, Entry, TransformContext } from '../types.js';
|
|
2
|
+
import { getContentId, getContentTypeId } from './contentful.js';
|
|
3
|
+
import { ReplaySubject, map, distinct, filter } from 'rxjs';
|
|
2
4
|
import dlv from 'dlv';
|
|
5
|
+
import { WrappedError } from './error.js';
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
8
|
+
const DEFAULT_WAIT_TIMEOUT = 20000;
|
|
3
9
|
|
|
4
10
|
export const collectValues =
|
|
5
11
|
(transformContext: Pick<TransformContext, 'entry' | 'entryMap'>) =>
|
|
@@ -55,3 +61,96 @@ export const collect = <T = unknown>(
|
|
|
55
61
|
|
|
56
62
|
return [value];
|
|
57
63
|
};
|
|
64
|
+
|
|
65
|
+
const dependencies: Record<string, string[]> = {};
|
|
66
|
+
|
|
67
|
+
const buildDependencies = (v: { source: string; dest: string }) => {
|
|
68
|
+
if ((dependencies?.[v.source] ?? []).includes(v.dest)) {
|
|
69
|
+
return dependencies;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (dependencies[v.source]) {
|
|
73
|
+
dependencies[v.source] = [...dependencies[v.source], v.dest];
|
|
74
|
+
} else {
|
|
75
|
+
dependencies[v.source] = [v.dest];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
Object.entries({ ...dependencies })
|
|
79
|
+
.filter(([, value]) => value.includes(v.source))
|
|
80
|
+
.forEach(([key, value]) => {
|
|
81
|
+
dependencies[key] = [...new Set([...value, v.dest, ...(dependencies[v.dest] || [])])];
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return dependencies;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const waitForSubject = new ReplaySubject<{ source: string; dest: string }>();
|
|
88
|
+
|
|
89
|
+
const cyclicErrorObservable = waitForSubject
|
|
90
|
+
.asObservable()
|
|
91
|
+
.pipe(map((value) => buildDependencies(value)));
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Wait for entry to be transformed
|
|
95
|
+
*/
|
|
96
|
+
export const waitFor = (
|
|
97
|
+
transformContext: Pick<TransformContext, 'entry' | 'observable' | 'entryMap'>
|
|
98
|
+
) => {
|
|
99
|
+
const sourceEntry = transformContext.entry;
|
|
100
|
+
const sourceId = getContentId(sourceEntry);
|
|
101
|
+
const sourceContentTypeId = getContentTypeId(sourceEntry);
|
|
102
|
+
const source = `${sourceId} (${sourceContentTypeId})`;
|
|
103
|
+
|
|
104
|
+
return async (id: string, waitTimeout = DEFAULT_WAIT_TIMEOUT) => {
|
|
105
|
+
// Make sure we don't try to wait for the current entry
|
|
106
|
+
if (sourceId === id) {
|
|
107
|
+
throw new Error(`Can't wait for yourself.
|
|
108
|
+
Entry ${source} waiting for ${source}.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
waitForSubject.next({ source: getContentId(sourceEntry), dest: id });
|
|
112
|
+
const destEntry = transformContext.entryMap.get(id);
|
|
113
|
+
const destId = getContentId(destEntry);
|
|
114
|
+
const destContentTypeId = getContentTypeId(destEntry);
|
|
115
|
+
const dest = `${destId} (${destContentTypeId})`;
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
// If anything goes wrong, reject promise after waitTimeout
|
|
119
|
+
const timeout = setTimeout(() => {
|
|
120
|
+
reject(
|
|
121
|
+
new Error(
|
|
122
|
+
`Exceeded timeout of ${waitTimeout} ms while waiting for entry ${id} to complete.
|
|
123
|
+
Entry ${source} waiting for ${dest}.`
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}, waitTimeout);
|
|
127
|
+
|
|
128
|
+
if (transformContext.entryMap.has(id)) {
|
|
129
|
+
transformContext.observable
|
|
130
|
+
.pipe(filter((ctx) => ctx?.entry?.sys?.id === id))
|
|
131
|
+
.subscribe((value) => {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
if (value.error) {
|
|
134
|
+
reject(new WrappedError(`Awaited entry ${dest} errored`, value.error));
|
|
135
|
+
} else {
|
|
136
|
+
resolve(value);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
cyclicErrorObservable
|
|
141
|
+
.pipe(filter((v) => v?.[sourceId]?.includes(sourceId)))
|
|
142
|
+
.pipe(distinct())
|
|
143
|
+
.subscribe((v) => {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
const deps = v?.[sourceId] ?? [];
|
|
146
|
+
reject(
|
|
147
|
+
new Error(`Found cyclic dependency in ${source}: ${[sourceId, ...deps].join(' -> ')}`)
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
clearTimeout(timeout);
|
|
152
|
+
reject(new Error(`No entry with id "${id}" available`));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Options } from '@contentful/rich-text-html-renderer';
|
|
2
2
|
import type { Document } from '@contentful/rich-text-types';
|
|
3
|
+
import type { Observable } from 'rxjs';
|
|
3
4
|
import type { QueryOptions, CollectionProp } from 'contentful-management/types';
|
|
4
5
|
|
|
5
6
|
import type {
|
|
@@ -202,6 +203,7 @@ export type Task = ListrTaskObject<RuntimeContext>;
|
|
|
202
203
|
export interface TransformHelper {
|
|
203
204
|
collectValues: <T>(key, options?: CollectOptions) => T[];
|
|
204
205
|
collectParentValues: <T>(key, options?: CollectOptions) => T[];
|
|
206
|
+
waitFor: (id: string, waitTimeout?: number) => Promise<ObservableContext>;
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
export type TransformContext = LocalizedContent & {
|
|
@@ -217,8 +219,14 @@ export type TransformContext = LocalizedContent & {
|
|
|
217
219
|
fieldSettings?: Field;
|
|
218
220
|
requiredFields?: string[];
|
|
219
221
|
utils: TransformHelper;
|
|
222
|
+
observable: Observable<ObservableContext>;
|
|
220
223
|
};
|
|
221
224
|
|
|
225
|
+
export type ObservableContext = Readonly<
|
|
226
|
+
Pick<TransformContext, 'id' | 'contentTypeId' | 'entry' | 'content' | 'locale'> & {
|
|
227
|
+
error?: Error;
|
|
228
|
+
}
|
|
229
|
+
>;
|
|
222
230
|
export interface Ignore {
|
|
223
231
|
add(pattern: string | Ignore | string[] | Ignore[]): Ignore;
|
|
224
232
|
filter(paths: string[]): string[];
|