@jungvonmatt/contentful-ssg 1.4.8 → 1.7.1

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 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
@@ -217,6 +244,11 @@ Fetch all content entries and store them as yaml in the configured directory
217
244
  npx cssg fetch
218
245
  ```
219
246
 
247
+ To see all available command line options call
248
+ ```bash
249
+ npx cssg help fetch
250
+ ```
251
+
220
252
  ## Example configuration
221
253
 
222
254
  ### Grow
package/dist/cli.js CHANGED
@@ -14,10 +14,10 @@ import { getConfig, getEnvironmentConfig } from './lib/config.js';
14
14
  import { run } from './index.js';
15
15
  const env = dotenv.config();
16
16
  dotenvExpand(env);
17
- const parseArgs = (cmd) => ({
18
- environment: cmd.env,
17
+ const parseFetchArgs = (cmd) => ({
19
18
  preview: cmd.preview,
20
19
  verbose: cmd.verbose,
20
+ ignoreErrors: cmd.ignoreErrors,
21
21
  });
22
22
  const errorHandler = (error, silence) => {
23
23
  if (!silence) {
@@ -37,7 +37,7 @@ program
37
37
  .option('--typescript', 'Initialize typescript config')
38
38
  .action(actionRunner(async (cmd) => {
39
39
  const useTypescript = Boolean(cmd?.typescript ?? false);
40
- const config = await getConfig(parseArgs(cmd || {}));
40
+ const config = await getConfig();
41
41
  const verified = await askAll(config);
42
42
  const environmentConfig = getEnvironmentConfig();
43
43
  const filePath = path.join(process.cwd(), `contentful-ssg.config.${useTypescript ? 'ts' : 'js'}`);
@@ -88,8 +88,9 @@ program
88
88
  .description('Fetch content objects')
89
89
  .option('-p, --preview', 'Fetch with preview mode')
90
90
  .option('-v, --verbose', 'Verbose output')
91
+ .option('--ignore-errors', 'No error return code when transform has errors')
91
92
  .action(actionRunner(async (cmd) => {
92
- const config = await getConfig(parseArgs(cmd || {}));
93
+ const config = await getConfig(parseFetchArgs(cmd || {}));
93
94
  const verified = await askMissing(config);
94
95
  return run(verified);
95
96
  }));
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import Listr from 'listr';
2
+ import { BehaviorSubject } 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;
@@ -54,10 +55,7 @@ export const run = async (config) => {
54
55
  {
55
56
  title: 'Before Hook',
56
57
  skip: (ctx) => !ctx.hooks.has('before'),
57
- task: async (ctx) => {
58
- const result = await ctx.hooks.before();
59
- ctx = { ...ctx, ...(result || {}) };
60
- },
58
+ task: async (ctx) => ctx.hooks.before(),
61
59
  },
62
60
  {
63
61
  title: 'Writing files',
@@ -66,6 +64,8 @@ export const run = async (config) => {
66
64
  const tasks = locales.map((locale) => ({
67
65
  title: `${locale.code}`,
68
66
  task: async () => {
67
+ const subject = new BehaviorSubject(null);
68
+ const observable = subject.asObservable();
69
69
  const data = ctx.localized.get(locale.code);
70
70
  const { entries = [] } = data || {};
71
71
  const promises = entries.map(async (entry) => {
@@ -74,6 +74,7 @@ export const run = async (config) => {
74
74
  const utils = {
75
75
  collectValues: collectValues({ ...data, entry }),
76
76
  collectParentValues: collectParentValues({ ...data, entry }),
77
+ waitFor: waitFor({ ...data, entry, observable }),
77
78
  };
78
79
  const transformContext = {
79
80
  ...data,
@@ -82,9 +83,11 @@ export const run = async (config) => {
82
83
  entry,
83
84
  locale,
84
85
  utils,
86
+ observable,
85
87
  };
86
88
  try {
87
89
  const content = await transform(transformContext, ctx, config);
90
+ subject.next({ ...transformContext, content });
88
91
  if (typeof content === 'undefined') {
89
92
  return;
90
93
  }
@@ -92,10 +95,16 @@ export const run = async (config) => {
92
95
  ctx.stats.addSuccess(transformContext);
93
96
  }
94
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
+ }
95
104
  if (error instanceof ValidationError) {
96
105
  ctx.stats.addSkipped(transformContext, error);
97
106
  }
98
- else if (typeof error === 'string' || error instanceof Error) {
107
+ else {
99
108
  ctx.stats.addError(transformContext, error);
100
109
  }
101
110
  }
@@ -109,10 +118,7 @@ export const run = async (config) => {
109
118
  {
110
119
  title: 'After Hook',
111
120
  skip: (ctx) => !ctx.hooks.has('after'),
112
- task: async (ctx) => {
113
- const result = await ctx.hooks.after();
114
- ctx = { ...ctx, ...(result || {}) };
115
- },
121
+ task: async (ctx) => ctx.hooks.after(),
116
122
  },
117
123
  {
118
124
  title: 'Cleanup',
@@ -122,4 +128,7 @@ export const run = async (config) => {
122
128
  const ctx = await tasks.run();
123
129
  await ctx.stats.print();
124
130
  console.log('\n---------------------------------------------');
131
+ if (ctx.stats.errors?.length && !config.ignoreErrors) {
132
+ process.exit(1);
133
+ }
125
134
  };
@@ -80,7 +80,7 @@ export const getEnvironmentConfig = (strict = true) => removeEmpty({
80
80
  previewAccessToken: process.env.CONTENTFUL_PREVIEW_TOKEN,
81
81
  accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN,
82
82
  }, strict);
83
- export const getConfig = async (args) => {
83
+ export const getConfig = async (args = {}) => {
84
84
  const defaultOptions = {
85
85
  environmentId: 'master',
86
86
  host: 'api.contentful.com',
@@ -7,3 +7,7 @@ export declare class ValidationError extends Error {
7
7
  locale: string;
8
8
  constructor(entry: ErrorEntry);
9
9
  }
10
+ export declare class WrappedError extends Error {
11
+ originalError: unknown;
12
+ constructor(message: string, error: unknown);
13
+ }
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
+ }
@@ -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: string | Error): void;
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 (typeof error === 'string') {
25
- this.errors.push({ error: new Error(error), ...this.toEntry(context) });
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) {
@@ -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';
@@ -50,6 +51,7 @@ export declare type Config = Partial<ContentfulConfig> & Hooks & {
50
51
  directory: string;
51
52
  managedDirectories?: string[];
52
53
  verbose?: boolean;
54
+ ignoreErrors?: boolean;
53
55
  plugins?: Array<[string, KeyValueMap] | PluginInfo | string>;
54
56
  resolvedPlugins?: Hooks[];
55
57
  preset?: string;
@@ -130,6 +132,7 @@ export declare type Task = ListrTaskObject<RuntimeContext>;
130
132
  export interface TransformHelper {
131
133
  collectValues: <T>(key: any, options?: CollectOptions) => T[];
132
134
  collectParentValues: <T>(key: any, options?: CollectOptions) => T[];
135
+ waitFor: (id: string, waitTimeout?: number) => Promise<ObservableContext>;
133
136
  }
134
137
  export declare type TransformContext = LocalizedContent & {
135
138
  [x: string]: any;
@@ -144,7 +147,11 @@ export declare type TransformContext = LocalizedContent & {
144
147
  fieldSettings?: Field;
145
148
  requiredFields?: string[];
146
149
  utils: TransformHelper;
150
+ observable: Observable<ObservableContext>;
147
151
  };
152
+ export declare type ObservableContext = Readonly<Pick<TransformContext, 'id' | 'contentTypeId' | 'entry' | 'content' | 'locale'> & {
153
+ error?: Error;
154
+ }>;
148
155
  export interface Ignore {
149
156
  add(pattern: string | Ignore | string[] | Ignore[]): Ignore;
150
157
  filter(paths: string[]): string[];
package/package.json CHANGED
@@ -1,7 +1,14 @@
1
1
  {
2
2
  "name": "@jungvonmatt/contentful-ssg",
3
- "version": "1.4.8",
3
+ "version": "1.7.1",
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": "28da6d98d1ba37a9bbaf6f1b22157c226be6d477"
166
+ "gitHead": "4649238738f3f4cce24555f2a60c8ef07793b2e6"
159
167
  }
package/src/cli.ts CHANGED
@@ -15,15 +15,15 @@ import { omitKeys } from './lib/object.js';
15
15
 
16
16
  import { getConfig, getEnvironmentConfig } from './lib/config.js';
17
17
  import { run } from './index.js';
18
- import { ContentfulConfig } from './types.js';
18
+ import { Config, ContentfulConfig } from './types.js';
19
19
 
20
20
  const env = dotenv.config();
21
21
  dotenvExpand(env);
22
22
 
23
- const parseArgs = (cmd) => ({
24
- environment: cmd.env as string,
23
+ const parseFetchArgs = (cmd): Partial<Config> => ({
25
24
  preview: cmd.preview as boolean,
26
25
  verbose: cmd.verbose as boolean,
26
+ ignoreErrors: cmd.ignoreErrors as boolean,
27
27
  });
28
28
 
29
29
  type CommandError = Error & {
@@ -54,7 +54,7 @@ program
54
54
  .action(
55
55
  actionRunner(async (cmd) => {
56
56
  const useTypescript = Boolean(cmd?.typescript ?? false);
57
- const config = await getConfig(parseArgs(cmd || {}));
57
+ const config = await getConfig();
58
58
  const verified = await askAll(config);
59
59
 
60
60
  const environmentConfig = getEnvironmentConfig();
@@ -148,9 +148,10 @@ program
148
148
  .description('Fetch content objects')
149
149
  .option('-p, --preview', 'Fetch with preview mode')
150
150
  .option('-v, --verbose', 'Verbose output')
151
+ .option('--ignore-errors', 'No error return code when transform has errors')
151
152
  .action(
152
153
  actionRunner(async (cmd) => {
153
- const config = await getConfig(parseArgs(cmd || {}));
154
+ const config = await getConfig(parseFetchArgs(cmd || {}));
154
155
  const verified = await askMissing(config);
155
156
 
156
157
  return run(verified);
package/src/index.test.ts CHANGED
@@ -86,4 +86,62 @@ describe('Run', () => {
86
86
  expect(output).toMatch(`${chalk.cyan(0)} entries skipped due to validation issues`);
87
87
  expect(output).toMatch(`${chalk.red(0)} errors`);
88
88
  });
89
+
90
+ test('fails on exception before/after', async () => {
91
+ console.log = jest.fn();
92
+ const mockError = jest.fn().mockImplementation(() => {
93
+ throw new Error();
94
+ });
95
+
96
+ await expect(async () => {
97
+ await run({
98
+ directory: 'test',
99
+ before: mockError,
100
+ });
101
+ }).rejects.toThrowError();
102
+
103
+ await expect(async () => {
104
+ await run({
105
+ directory: 'test',
106
+ after: mockError,
107
+ });
108
+ }).rejects.toThrowError();
109
+ });
110
+
111
+ test('fails on transform exception', async () => {
112
+ console.log = jest.fn();
113
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation((number) => {
114
+ throw new Error('process.exit: ' + number);
115
+ });
116
+
117
+ await expect(async () => {
118
+ await run({
119
+ directory: 'test',
120
+ transform: async () => {
121
+ throw new Error();
122
+ },
123
+ });
124
+ }).rejects.toThrowError();
125
+
126
+ expect(mockExit).toHaveBeenCalledWith(1);
127
+ mockExit.mockRestore();
128
+ });
129
+
130
+ test('does not fail on transform exception with ignoreErrors option', async () => {
131
+ console.log = jest.fn();
132
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation((number) => {
133
+ throw new Error('process.exit: ' + number);
134
+ });
135
+
136
+ await run({
137
+ directory: 'test',
138
+ ignoreErrors: true,
139
+ transform: async () => {
140
+ throw new Error();
141
+ },
142
+ });
143
+
144
+ expect(mockExit).toBeCalledTimes(0);
145
+ mockExit.mockRestore();
146
+ });
89
147
  });
package/src/index.ts CHANGED
@@ -1,5 +1,13 @@
1
- import type { Config, RuntimeContext, Task, TransformContext, TransformHelper } from './types.js';
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 { BehaviorSubject } 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
  /**
@@ -73,10 +81,7 @@ export const run = async (config: Config): Promise<void> => {
73
81
  {
74
82
  title: 'Before Hook',
75
83
  skip: (ctx) => !ctx.hooks.has('before'),
76
- task: async (ctx) => {
77
- const result = await ctx.hooks.before();
78
- ctx = { ...ctx, ...(result || {}) };
79
- },
84
+ task: async (ctx) => ctx.hooks.before(),
80
85
  },
81
86
  {
82
87
  title: 'Writing files',
@@ -86,6 +91,8 @@ export const run = async (config: Config): Promise<void> => {
86
91
  const tasks = locales.map((locale) => ({
87
92
  title: `${locale.code}`,
88
93
  task: async () => {
94
+ const subject = new BehaviorSubject<ObservableContext>(null);
95
+ const observable = subject.asObservable();
89
96
  const data = ctx.localized.get(locale.code);
90
97
  const { entries = [] } = data || {};
91
98
 
@@ -97,6 +104,7 @@ export const run = async (config: Config): Promise<void> => {
97
104
  const utils = {
98
105
  collectValues: collectValues({ ...data, entry }),
99
106
  collectParentValues: collectParentValues({ ...data, entry }),
107
+ waitFor: waitFor({ ...data, entry, observable }),
100
108
  } as TransformHelper;
101
109
 
102
110
  const transformContext: TransformContext = {
@@ -106,10 +114,12 @@ export const run = async (config: Config): Promise<void> => {
106
114
  entry,
107
115
  locale,
108
116
  utils,
117
+ observable,
109
118
  };
110
119
 
111
120
  try {
112
121
  const content = await transform(transformContext, ctx, config);
122
+ subject.next({ ...transformContext, content });
113
123
 
114
124
  if (typeof content === 'undefined') {
115
125
  return;
@@ -118,9 +128,16 @@ export const run = async (config: Config): Promise<void> => {
118
128
  await write({ ...transformContext, content }, ctx, config);
119
129
  ctx.stats.addSuccess(transformContext);
120
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
+
121
138
  if (error instanceof ValidationError) {
122
139
  ctx.stats.addSkipped(transformContext, error);
123
- } else if (typeof error === 'string' || error instanceof Error) {
140
+ } else {
124
141
  ctx.stats.addError(transformContext, error);
125
142
  }
126
143
  }
@@ -135,12 +152,8 @@ export const run = async (config: Config): Promise<void> => {
135
152
  {
136
153
  title: 'After Hook',
137
154
  skip: (ctx) => !ctx.hooks.has('after'),
138
- task: async (ctx) => {
139
- const result = await ctx.hooks.after();
140
- ctx = { ...ctx, ...(result || {}) };
141
- },
155
+ task: async (ctx) => ctx.hooks.after(),
142
156
  },
143
-
144
157
  {
145
158
  title: 'Cleanup',
146
159
  task: async (ctx) => ctx.fileManager.cleanup(),
@@ -152,4 +165,8 @@ export const run = async (config: Config): Promise<void> => {
152
165
  const ctx = await tasks.run();
153
166
  await ctx.stats.print();
154
167
  console.log('\n---------------------------------------------');
168
+
169
+ if (ctx.stats.errors?.length && !config.ignoreErrors) {
170
+ process.exit(1);
171
+ }
155
172
  };
package/src/lib/config.ts CHANGED
@@ -122,7 +122,7 @@ export const getEnvironmentConfig = (strict = true): ContentfulConfig =>
122
122
  * Get configuration
123
123
  * @param {Object} args
124
124
  */
125
- export const getConfig = async (args?: Partial<Config>): Promise<Config> => {
125
+ export const getConfig = async (args: Partial<Config> = {}): Promise<Config> => {
126
126
  const defaultOptions: Config = {
127
127
  environmentId: 'master',
128
128
  host: 'api.contentful.com',
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: string | Error): void {
32
- if (typeof error === 'string') {
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
 
@@ -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 { BehaviorSubject } 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
- ['2', { sys: { id: '2' }, fields: { slug: 'b', parent: { sys: { id: '1' } }, link: { sys: { id: '1' } } } }],
15
- ['3', { sys: { id: '3' }, fields: { slug: 'c', parent: { sys: { id: '2' } }, link: { sys: { id: '1' } } } }],
16
- ['4', { sys: { id: '4' }, fields: { slug: 'd', parent: { sys: { id: '3' } }, link: { sys: { id: '3' } } } }],
17
- ['5', { sys: { id: '5' }, fields: { slug: 'e', parent: { sys: { id: '4' } }, link: { sys: { id: '3' } } } }],
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,110 @@ 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 BehaviorSubject<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 BehaviorSubject<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 BehaviorSubject<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
+ });
85
247
  });
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 {
@@ -85,6 +86,7 @@ export type Config = Partial<ContentfulConfig> &
85
86
  directory: string;
86
87
  managedDirectories?: string[];
87
88
  verbose?: boolean;
89
+ ignoreErrors?: boolean;
88
90
  plugins?: Array<[string, KeyValueMap] | PluginInfo | string>;
89
91
  resolvedPlugins?: Hooks[];
90
92
  preset?: string;
@@ -201,6 +203,7 @@ export type Task = ListrTaskObject<RuntimeContext>;
201
203
  export interface TransformHelper {
202
204
  collectValues: <T>(key, options?: CollectOptions) => T[];
203
205
  collectParentValues: <T>(key, options?: CollectOptions) => T[];
206
+ waitFor: (id: string, waitTimeout?: number) => Promise<ObservableContext>;
204
207
  }
205
208
 
206
209
  export type TransformContext = LocalizedContent & {
@@ -216,8 +219,14 @@ export type TransformContext = LocalizedContent & {
216
219
  fieldSettings?: Field;
217
220
  requiredFields?: string[];
218
221
  utils: TransformHelper;
222
+ observable: Observable<ObservableContext>;
219
223
  };
220
224
 
225
+ export type ObservableContext = Readonly<
226
+ Pick<TransformContext, 'id' | 'contentTypeId' | 'entry' | 'content' | 'locale'> & {
227
+ error?: Error;
228
+ }
229
+ >;
221
230
  export interface Ignore {
222
231
  add(pattern: string | Ignore | string[] | Ignore[]): Ignore;
223
232
  filter(paths: string[]): string[];