@jungvonmatt/contentful-ssg 1.0.0-alpha.0 → 1.0.5

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
@@ -40,28 +40,67 @@ npx cssg init --typescript
40
40
  <!-- prettier-ignore -->
41
41
  #### Configuration values
42
42
 
43
- | Name | Type | Default | Description |
44
- | ------------------ | ------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
- | accessToken | `String` | `undefined` | Content Delivery API - access token |
46
- | previewAccessToken | `String` | `undefined` | Content Preview API - access token |
47
- | spaceId | `String` | `undefined` | Contentful Space id |
48
- | environmentId | `String` | `'master'` | Contentful Environment id |
49
- | format | `String`\|`Function`\|`Object` | `'yaml'` | File format ( `yaml`, `toml`, `md`, `json`) You can add a function returning the format or you can add a mapping object like `{yaml: [glob pattern]}` ([pattern](https://github.com/micromatch/micromatch) should match the directory) |
50
- | directory | `String` | `'./content'` | Base directory for content files. |
51
- | validate | `Function` | `undefined` | Pass `function(transformContext, runtimeContext){...}` to validate an entry. Return `false` to skip the entry completely. Without a validate function entries with a missing required field are skipped. |
52
- | transform | `Function` | `undefined` | Pass `function(transformContext, runtimeContext){...}` to modify the stored object. Return `undefined` to skip the entry completely. (no file will be written) |
53
- | mapDirectory | `Function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize the directory per content-type relative to the base directory. |
54
- | mapFilename | `Function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize the filename per entry |
55
- | mapAssetLink | `Function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize how asset links are stored |
56
- | mapEntryLink | `Function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize how entry links are stored |
57
- | mapMetaFields | `Function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize the meta fields per entry |
58
- | richTextRenderer | `Boolean`\|`Object`\|`Function` | `{}` | We use the contentful [`rich-text-html-renderer`](https://github.com/contentful/rich-text/tree/master/packages/rich-text-html-renderer) to render the html.<br/> You can pass a [configuration object](https://github.com/contentful/rich-text/tree/master/packages/rich-text-html-renderer#usage)<br/> or you can pass `function(document){...}` to use your own richtext renderer or you can turn it off by passing `false` to get a mirrored version of the JSON output |
59
- | before | `Function` | `undefined` | Runs `function(runtimeContext){...}` before processing the content right after pulling data from contentful |
60
- | after | `Function` | `undefined` | Runs `function(runtimeContext){...}` after processing the content right before the cleanup |
43
+ | Name | Type | Default | Description |
44
+ | ------------------ | --------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
+ | accessToken | `string` | `undefined` | Content Delivery API - access token |
46
+ | previewAccessToken | `string` | `undefined` | Content Preview API - access token |
47
+ | spaceId | `string` | `undefined` | Contentful Space id |
48
+ | environmentId | `string` | `'master'` | Contentful Environment id |
49
+ | format | `string`\|`function`\|`object` | `'yaml'` | File format ( `yaml`, `toml`, `md`, `json`) You can add a function returning the format or you can add a mapping object like `{yaml: [glob pattern]}` ([pattern](https://github.com/micromatch/micromatch) should match the directory) |
50
+ | plugins | `[string]`\|`[[string, options]]`\|`[{resolve:'string', options:{}}]` | `[]` | Add plugins to contentful-ssg. See [Plugins](#plugins) |
51
+ | directory | `string` | `'./content'` | Base directory for content files. |
52
+ | validate | `function` | `undefined` | Pass `function(transformContext, runtimeContext){...}` to validate an entry. Return `false` to skip the entry completely. Without a validate function entries with a missing required field are skipped. |
53
+ | transform | `function` | `undefined` | Pass `function(transformContext, runtimeContext){...}` to modify the stored object. Return `undefined` to skip the entry completely. (no file will be written) |
54
+ | mapDirectory | `function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize the directory per content-type relative to the base directory. |
55
+ | mapFilename | `function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize the filename per entry |
56
+ | mapAssetLink | `function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize how asset links are stored |
57
+ | mapEntryLink | `function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize how entry links are stored |
58
+ | mapMetaFields | `function` | `undefined` | Pass `function(transformContext, runtimeContext, defaultValue){...}` to customize the meta fields per entry |
59
+ | richTextRenderer | `boolean`\|`object`\|`function` | `{}` | We use the contentful [`rich-text-html-renderer`](https://github.com/contentful/rich-text/tree/master/packages/rich-text-html-renderer) to render the html.<br/> You can pass a [configuration object](https://github.com/contentful/rich-text/tree/master/packages/rich-text-html-renderer#usage)<br/> or you can pass `function(document){...}` to use your own richtext renderer or you can turn it off by passing `false` to get a mirrored version of the JSON output |
60
+ | before | `function` | `undefined` | Runs `function(runtimeContext){...}` before processing the content right after pulling data from contentful |
61
+ | after | `function` | `undefined` | Runs `function(runtimeContext){...}` after processing the content right before the cleanup |
62
+
63
+ ### Plugins
64
+
65
+ You can add plugins to contentful-ssg by adding the package name or a local path to the plugins array of your configuration.
61
66
 
67
+ ```js
68
+ plugins: ['my-plugin-package', './plugins/my-local-plugin]
69
+ ```
70
+
71
+ All plugins can have options specified by wrapping the name and an options object in an array inside your config or by using a more verbose object notation.
72
+
73
+ For specifying no options, these are all equivalent:
74
+ ```js
75
+ {
76
+ "plugins": ["my-plugin", ["my-plugin"], ["my-plugin", {}], {resolve: "my-plugin", options: {}}]
77
+ }
78
+ ```
79
+
80
+ To specify an option, pass an object with the keys as the option names.
81
+ ```js
82
+ {
83
+ "plugins": [
84
+ [
85
+ "my-plugin-a",
86
+ {
87
+ "option": "value"
88
+ }
89
+ ],
90
+ {
91
+ resolve: "my-plugin-b",
92
+ options: {
93
+ "option": "value"
94
+ }
95
+ }
96
+ ]
97
+ }
98
+ ```
62
99
 
63
100
  ### Runtime Hooks
101
+
64
102
  **before**
103
+
65
104
  ```js
66
105
  import
67
106
  (runtimeContext) => {
@@ -72,44 +111,50 @@ import
72
111
  ```
73
112
 
74
113
  **after**
114
+
75
115
  ```js
76
116
  (runtimeContext) => {
77
117
  // Do things after processing the localized contentful entries before cleanup
78
118
  // We have access to values added to the context in the before hook
79
- console.log(runtimeContext.key) // -> 'test'
80
- }
119
+ console.log(runtimeContext.key); // -> 'test'
120
+ };
81
121
  ```
82
122
 
83
123
  ### Transform Hooks
124
+
84
125
  **transform**
126
+
85
127
  ```js
86
128
  (transformContext, runtimeContext) => {
87
- const {content} = transformContext;
129
+ const { content } = transformContext;
88
130
  // modify content and
89
131
  // return object
90
132
  return content;
91
- }
133
+ };
92
134
  ```
93
135
 
94
136
  **mapFilename**
137
+
95
138
  ```js
96
139
  (transformContext, runtimeContext, defaultValue) => {
97
140
  // customize the filename on entry level
98
141
  // return string
99
142
  return defaultValue;
100
- }
143
+ };
101
144
  ```
102
145
 
103
146
  **mapDirectory**
147
+
104
148
  ```js
105
149
  (transformContext, runtimeContext, defaultValue) => {
106
150
  // customize the directory on entry level
107
151
  // return string
108
152
  return defaultValue;
109
- }
153
+ };
110
154
  ```
111
155
 
112
156
  **mapAssetLink**
157
+
113
158
  ```js
114
159
  (transformContext, runtimeContext, defaultValue) => {
115
160
  const {asset} = transformContext;
@@ -120,6 +165,7 @@ import
120
165
  ```
121
166
 
122
167
  **mapEntryLink**
168
+
123
169
  ```js
124
170
  (transformContext, runtimeContext, defaultValue) => {
125
171
  const {entry} = transformContext;
@@ -130,6 +176,7 @@ import
130
176
  ```
131
177
 
132
178
  **mapMetaFields**
179
+
133
180
  ```js
134
181
  (transformContext, runtimeContext, defaultValue) => {
135
182
  const {entry} = transformContext;
@@ -139,7 +186,6 @@ import
139
186
  }
140
187
  ```
141
188
 
142
-
143
189
  #### Helper functions
144
190
 
145
191
  ###### collectValues
@@ -177,12 +223,10 @@ npx cssg fetch
177
223
 
178
224
  See [`cssg-plugin-grow`](../cssg-plugin-grow)
179
225
 
180
-
181
226
  ### Hugo
182
227
 
183
228
  See [`cssg-plugin-hugo`](../cssg-plugin-hugo)
184
229
 
185
-
186
230
  [npm-url]: https://www.npmjs.com/package/@jungvonmatt/contentful-ssg
187
231
  [npm-image]: https://img.shields.io/npm/v/@jungvonmatt/contentful-ssg.svg
188
232
  [ci-url]: https://github.com/jungvonmatt/contentful-ssg/actions?workflow=Tests
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { existsSync } from 'fs';
5
+ import { readFile } from 'fs/promises';
5
6
  import { outputFile } from 'fs-extra';
6
7
  import prettier from 'prettier';
7
8
  import { Command } from 'commander';
@@ -44,7 +45,17 @@ program
44
45
  if (verified.directory?.startsWith('/')) {
45
46
  verified.directory = path.relative(process.cwd(), verified.directory);
46
47
  }
47
- const environmentKeys = Object.keys(environmentConfig).filter((key) => environmentConfig[key] === verified[key]);
48
+ const environmentKeys = Object.keys(environmentConfig);
49
+ if (environmentConfig && existsSync('.env')) {
50
+ const envSource = await readFile('.env', 'utf8');
51
+ const nextEnvSource = envSource
52
+ .replace(/(CONTENTFUL_SPACE_ID\s*=\s*['"]?)[^'"]*(['"]?)/, `$1${verified.spaceId}$2`)
53
+ .replace(/(CONTENTFUL_ENVIRONMENT_ID\s*=\s*['"]?)[^'"]*(['"]?)/, `$1${verified.environmentId}$2`)
54
+ .replace(/(CONTENTFUL_MANAGEMENT_TOKEN\s*=\s*['"]?)[^'"]*(['"]?)/, `$1${verified.managementToken}$2`)
55
+ .replace(/(CONTENTFUL_PREVIEW_TOKEN\s*=\s*['"]?)[^'"]*(['"]?)/, `$1${verified.previewAccessToken}$2`)
56
+ .replace(/(CONTENTFUL_DELIVERY_TOKEN\s*=\s*['"]?)[^'"]*(['"]?)/, `$1${verified.accessToken}$2`);
57
+ await outputFile('.env', nextEnvSource);
58
+ }
48
59
  const cleanedConfig = omitKeys(verified, 'preview', 'verbose', 'rootDir', 'resolvedPlugins', 'host', 'managementToken', ...environmentKeys);
49
60
  let content = '';
50
61
  if (useTypescript) {
package/dist/index.js CHANGED
@@ -116,12 +116,7 @@ export const run = async (config) => {
116
116
  },
117
117
  {
118
118
  title: 'Cleanup',
119
- skip: (ctx) => ctx.fileManager.count === 0,
120
- task: async (ctx) => {
121
- console.log(`Cleaning ${ctx.fileManager.count} files...`);
122
- await ctx.fileManager.cleanup();
123
- console.log('done');
124
- },
119
+ task: async (ctx) => ctx.fileManager.cleanup(),
125
120
  },
126
121
  ], { renderer: CustomListrRenderer });
127
122
  const ctx = await tasks.run();
@@ -1,3 +1,3 @@
1
1
  import type { Config, ContentfulConfig } from '../types.js';
2
- export declare const getEnvironmentConfig: () => ContentfulConfig;
2
+ export declare const getEnvironmentConfig: (strict?: boolean) => ContentfulConfig;
3
3
  export declare const getConfig: (args?: Partial<Config>) => Promise<Config>;
@@ -14,8 +14,20 @@ const typescriptLoader = async (filePath) => {
14
14
  };
15
15
  const mergeOptions = mergeOptionsModule.bind({ ignoreUndefined: true });
16
16
  const resolvePlugin = async (plugin, config) => {
17
- const pluginName = typeof plugin === 'string' ? plugin : plugin.resolve;
18
- const pluginOptions = typeof plugin === 'string' ? {} : plugin.options || {};
17
+ let pluginName;
18
+ let pluginOptions;
19
+ if (typeof plugin === 'string') {
20
+ pluginName = plugin;
21
+ pluginOptions = {};
22
+ }
23
+ else if (Array.isArray(plugin)) {
24
+ pluginName = plugin[0];
25
+ pluginOptions = plugin[1] || {};
26
+ }
27
+ else {
28
+ pluginName = plugin.resolve;
29
+ pluginOptions = plugin.options || {};
30
+ }
19
31
  const { rootDir, verbose } = config;
20
32
  try {
21
33
  const requireSource = rootDir === null ? createRequire() : createRequire(`${rootDir}/:internal:`);
@@ -60,13 +72,13 @@ const loadConfig = async (moduleName) => {
60
72
  });
61
73
  return explorer.search();
62
74
  };
63
- export const getEnvironmentConfig = () => removeEmpty({
75
+ export const getEnvironmentConfig = (strict = true) => removeEmpty({
64
76
  spaceId: process.env.CONTENTFUL_SPACE_ID,
65
77
  environmentId: process.env.CONTENTFUL_ENVIRONMENT_ID,
66
78
  managementToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
67
79
  previewAccessToken: process.env.CONTENTFUL_PREVIEW_TOKEN,
68
80
  accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN,
69
- });
81
+ }, strict);
70
82
  export const getConfig = async (args) => {
71
83
  const defaultOptions = {
72
84
  environmentId: 'master',
@@ -75,18 +87,18 @@ export const getConfig = async (args) => {
75
87
  plugins: [],
76
88
  resolvedPlugins: [],
77
89
  };
78
- const environmentOptions = getEnvironmentConfig();
90
+ const environmentOptions = getEnvironmentConfig(false);
79
91
  let contentfulCliOptions = {};
80
92
  try {
81
93
  const contentfulConfig = await loadConfig('contentful');
82
- if (!contentfulConfig.isEmpty) {
94
+ if (contentfulConfig && !contentfulConfig.isEmpty) {
83
95
  const { managementToken, activeSpaceId, activeEnvironmentId, host } = contentfulConfig.config;
84
96
  contentfulCliOptions = removeEmpty({
85
97
  spaceId: activeSpaceId,
86
98
  managementToken,
87
99
  environmentId: activeEnvironmentId,
88
100
  host,
89
- });
101
+ }, false);
90
102
  }
91
103
  }
92
104
  catch (error) {
@@ -104,7 +116,7 @@ export const getConfig = async (args) => {
104
116
  args.rootDir = process.cwd();
105
117
  try {
106
118
  const configFile = await loadConfig('contentful-ssg');
107
- if (!configFile.isEmpty) {
119
+ if (configFile && !configFile.isEmpty) {
108
120
  configFileOptions = configFile.config;
109
121
  args.rootDir = dirname(configFile.filepath);
110
122
  if (configFileOptions.directory && !isAbsolute(configFileOptions.directory)) {
@@ -12,5 +12,6 @@ export declare class FileManager {
12
12
  initialize(): Promise<void>;
13
13
  writeFile(file: string, data: any, options?: WriteFileOptions | BufferEncoding | string): Promise<void>;
14
14
  deleteFile(file: string): Promise<void>;
15
- cleanup(): Promise<PromiseSettledResult<void>[]>;
15
+ removeEmptyDirectories(directory: string): Promise<void>;
16
+ cleanup(): Promise<boolean>;
16
17
  }
@@ -1,6 +1,6 @@
1
- import { dirname, resolve, relative } from 'path';
1
+ import { dirname, resolve, relative, join } from 'path';
2
2
  import ignore from 'ignore';
3
- import { readFile, readdir } from 'fs/promises';
3
+ import { readFile, readdir, lstat } from 'fs/promises';
4
4
  import { remove, outputFile } from 'fs-extra';
5
5
  export class FileManager {
6
6
  ignoreBase = process.cwd();
@@ -35,21 +35,30 @@ export class FileManager {
35
35
  }
36
36
  }
37
37
  async deleteFile(file) {
38
- await remove(file);
39
38
  if (this.files.has(resolve(file))) {
40
39
  this.files.delete(resolve(file));
41
40
  }
42
- const dir = dirname(file);
43
- try {
44
- const files = await readdir(dirname(file));
45
- if ((files || []).length === 0) {
46
- await remove(dir);
47
- }
41
+ return remove(file);
42
+ }
43
+ async removeEmptyDirectories(directory) {
44
+ const fileStats = await lstat(directory);
45
+ if (!fileStats.isDirectory()) {
46
+ return;
47
+ }
48
+ let fileNames = await readdir(directory);
49
+ if (fileNames.length > 0) {
50
+ const recursiveRemovalPromises = fileNames.map(async (fileName) => this.removeEmptyDirectories(join(directory, fileName)));
51
+ await Promise.all(recursiveRemovalPromises);
52
+ fileNames = await readdir(directory);
53
+ }
54
+ if (fileNames.length === 0 && directory !== this.config.directory) {
55
+ await remove(directory);
48
56
  }
49
- catch { }
50
57
  }
51
58
  async cleanup() {
52
59
  const promises = [...this.ignoredFiles].map(async (file) => this.deleteFile(file));
53
- return Promise.allSettled(promises);
60
+ await Promise.allSettled(promises);
61
+ await this.removeEmptyDirectories(this.config.directory);
62
+ return true;
54
63
  }
55
64
  }
@@ -5,7 +5,8 @@ export declare const isObject: (something: any) => boolean;
5
5
  export declare const getEntries: <T>(obj: T) => Entries<T>;
6
6
  export declare const fromEntries: <T = [string, unknown][]>(entries: Entries<T>) => T;
7
7
  export declare const omitKeys: <T, K extends keyof T>(obj: T, ...keys: K[]) => T;
8
- export declare const removeEmpty: <T>(iterable: T) => T;
8
+ export declare const filterKeys: <T, K extends keyof T>(obj: T, ...keys: K[]) => T;
9
+ export declare const removeEmpty: <T>(iterable: T, strict?: boolean) => T;
9
10
  export declare const snakeCaseKeys: <T>(iterable: T) => T;
10
11
  export declare const groupBy: <T extends Record<string, unknown>>(array: T[], key: keyof T) => Record<string, T[]>;
11
12
  export {};
@@ -8,14 +8,19 @@ export const omitKeys = (obj, ...keys) => {
8
8
  const filtered = entries.filter(([key]) => !keys.includes(key));
9
9
  return fromEntries(filtered);
10
10
  };
11
- export const removeEmpty = (iterable) => {
11
+ export const filterKeys = (obj, ...keys) => {
12
+ const entries = getEntries(obj);
13
+ const filtered = entries.filter(([key]) => keys.includes(key));
14
+ return fromEntries(filtered);
15
+ };
16
+ export const removeEmpty = (iterable, strict = true) => {
12
17
  if (Array.isArray(iterable)) {
13
18
  return iterable
14
- .filter((v) => v !== null && v !== undefined)
19
+ .filter((v) => v !== null && v !== undefined && (strict || Boolean(v)))
15
20
  .map((v) => (v === Object(v) ? removeEmpty(v) : v));
16
21
  }
17
22
  return fromEntries(getEntries(iterable)
18
- .filter(([, v]) => v !== null && v !== undefined)
23
+ .filter(([, v]) => v !== null && v !== undefined && (strict || Boolean(v)))
19
24
  .map(([k, v]) => [k, v === Object(v) ? removeEmpty(v) : v]));
20
25
  };
21
26
  export const snakeCaseKeys = (iterable) => {
package/dist/types.d.ts CHANGED
@@ -47,7 +47,7 @@ export declare type Config = Partial<ContentfulConfig> & Hooks & {
47
47
  rootDir?: string;
48
48
  directory: string;
49
49
  verbose?: boolean;
50
- plugins?: Array<PluginInfo | string>;
50
+ plugins?: Array<[string, KeyValueMap] | PluginInfo | string>;
51
51
  resolvedPlugins?: Hooks[];
52
52
  preset?: string;
53
53
  richTextRenderer?: RichTextConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungvonmatt/contentful-ssg",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.5",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  ".": "./dist/index.js",
10
10
  "./lib/object": "./dist/lib/object.js",
11
11
  "./lib/array": "./dist/lib/array.js",
12
+ "./lib/utils": "./dist/lib/utils.js",
12
13
  "./lib/contentful": "./dist/lib/contentful.js",
13
14
  "./lib/hook-manager": "./dist/lib/hook-manager.js",
14
15
  "./lib/file-manager": "./dist/lib/file-manager.js",
@@ -23,6 +24,9 @@
23
24
  ".": [
24
25
  "./dist/types.d.ts"
25
26
  ],
27
+ "lib/utils": [
28
+ "./dist/lib/utils.d.ts"
29
+ ],
26
30
  "lib/object": [
27
31
  "./dist/lib/object.d.ts"
28
32
  ],
@@ -59,6 +63,9 @@
59
63
  "cssg": "./dist/cli.js",
60
64
  "contentful-ssg": "./dist/cli.js"
61
65
  },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
62
69
  "scripts": {
63
70
  "clean": "rimraf ./dist",
64
71
  "lint": "eslint --color src --fix --ext .ts",
@@ -88,6 +95,7 @@
88
95
  "dotenv": "^10.0.0",
89
96
  "dotenv-expand": "^5.1.0",
90
97
  "esbuild": "^0.13.13",
98
+ "find-up": "^6.2.0",
91
99
  "fs-extra": "^10.0.0",
92
100
  "globby": "^12.0.2",
93
101
  "gray-matter": "^4.0.3",
@@ -135,5 +143,5 @@
135
143
  "module": "es2020"
136
144
  }
137
145
  },
138
- "gitHead": "bb9c2e924044cf85018576fd6ca84d3277fcbc74"
146
+ "gitHead": "03741b4add20ce95e1cd539bc46b7630cddcc2c3"
139
147
  }
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import path from 'path';
5
5
  import chalk from 'chalk';
6
6
  import { existsSync } from 'fs';
7
+ import { readFile } from 'fs/promises';
7
8
  import { outputFile } from 'fs-extra';
8
9
  import prettier from 'prettier';
9
10
  import { Command } from 'commander';
@@ -67,9 +68,34 @@ program
67
68
  verified.directory = path.relative(process.cwd(), verified.directory);
68
69
  }
69
70
 
70
- const environmentKeys: Array<keyof ContentfulConfig> = (
71
- Object.keys(environmentConfig) as Array<keyof ContentfulConfig>
72
- ).filter((key) => environmentConfig[key] === verified[key]);
71
+ const environmentKeys: Array<keyof ContentfulConfig> = Object.keys(
72
+ environmentConfig
73
+ ) as Array<keyof ContentfulConfig>;
74
+
75
+ // Update .env file
76
+ if (environmentConfig && existsSync('.env')) {
77
+ const envSource = await readFile('.env', 'utf8');
78
+ const nextEnvSource = envSource
79
+ .replace(/(CONTENTFUL_SPACE_ID\s*=\s*['"]?)[^'"]*(['"]?)/, `$1${verified.spaceId}$2`)
80
+ .replace(
81
+ /(CONTENTFUL_ENVIRONMENT_ID\s*=\s*['"]?)[^'"]*(['"]?)/,
82
+ `$1${verified.environmentId}$2`
83
+ )
84
+ .replace(
85
+ /(CONTENTFUL_MANAGEMENT_TOKEN\s*=\s*['"]?)[^'"]*(['"]?)/,
86
+ `$1${verified.managementToken}$2`
87
+ )
88
+ .replace(
89
+ /(CONTENTFUL_PREVIEW_TOKEN\s*=\s*['"]?)[^'"]*(['"]?)/,
90
+ `$1${verified.previewAccessToken}$2`
91
+ )
92
+ .replace(
93
+ /(CONTENTFUL_DELIVERY_TOKEN\s*=\s*['"]?)[^'"]*(['"]?)/,
94
+ `$1${verified.accessToken}$2`
95
+ );
96
+
97
+ await outputFile('.env', nextEnvSource);
98
+ }
73
99
 
74
100
  const cleanedConfig = omitKeys(
75
101
  verified,
package/src/index.ts CHANGED
@@ -143,13 +143,7 @@ export const run = async (config: Config): Promise<void> => {
143
143
 
144
144
  {
145
145
  title: 'Cleanup',
146
- skip: (ctx) => ctx.fileManager.count === 0,
147
- task: async (ctx) => {
148
- console.log(`Cleaning ${ctx.fileManager.count} files...`);
149
- await ctx.fileManager.cleanup();
150
-
151
- console.log('done');
152
- },
146
+ task: async (ctx) => ctx.fileManager.cleanup(),
153
147
  },
154
148
  ],
155
149
  { renderer: CustomListrRenderer }
package/src/lib/config.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  ContentfulConfig,
11
11
  ContentfulRcConfig,
12
12
  Hooks,
13
+ KeyValueMap,
13
14
  PluginInfo,
14
15
  PluginModule,
15
16
  } from '../types.js';
@@ -29,11 +30,22 @@ const typescriptLoader: Loader = async (filePath: string): Promise<any> => {
29
30
  const mergeOptions = mergeOptionsModule.bind({ ignoreUndefined: true });
30
31
 
31
32
  const resolvePlugin = async (
32
- plugin: string | PluginInfo,
33
+ plugin: string | [string, KeyValueMap] | PluginInfo,
33
34
  config: Partial<Config>
34
35
  ): Promise<Hooks> => {
35
- const pluginName = typeof plugin === 'string' ? plugin : plugin.resolve;
36
- const pluginOptions = typeof plugin === 'string' ? {} : plugin.options || {};
36
+ let pluginName: string;
37
+ let pluginOptions: KeyValueMap;
38
+ if (typeof plugin === 'string') {
39
+ pluginName = plugin;
40
+ pluginOptions = {};
41
+ } else if (Array.isArray(plugin)) {
42
+ pluginName = plugin[0];
43
+ pluginOptions = plugin[1] || {};
44
+ } else {
45
+ pluginName = plugin.resolve;
46
+ pluginOptions = plugin.options || {};
47
+ }
48
+
37
49
  const { rootDir, verbose } = config;
38
50
 
39
51
  try {
@@ -93,14 +105,17 @@ const loadConfig = async (moduleName: string): Promise<CosmiconfigResult> => {
93
105
  return explorer.search();
94
106
  };
95
107
 
96
- export const getEnvironmentConfig = (): ContentfulConfig =>
97
- removeEmpty({
98
- spaceId: process.env.CONTENTFUL_SPACE_ID!,
99
- environmentId: process.env.CONTENTFUL_ENVIRONMENT_ID!,
100
- managementToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN!,
101
- previewAccessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
102
- accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN!,
103
- });
108
+ export const getEnvironmentConfig = (strict = true): ContentfulConfig =>
109
+ removeEmpty(
110
+ {
111
+ spaceId: process.env.CONTENTFUL_SPACE_ID!,
112
+ environmentId: process.env.CONTENTFUL_ENVIRONMENT_ID!,
113
+ managementToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN!,
114
+ previewAccessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
115
+ accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN!,
116
+ },
117
+ strict
118
+ );
104
119
 
105
120
  /**
106
121
  * Get configuration
@@ -115,22 +130,25 @@ export const getConfig = async (args?: Partial<Config>): Promise<Config> => {
115
130
  resolvedPlugins: [],
116
131
  };
117
132
 
118
- const environmentOptions = getEnvironmentConfig();
133
+ const environmentOptions = getEnvironmentConfig(false);
119
134
  let contentfulCliOptions: Partial<ContentfulConfig> = {};
120
135
 
121
136
  try {
122
137
  // Get configuration from contentful rc file (created by the contentful cli command)
123
138
  const contentfulConfig = await loadConfig('contentful');
124
- if (!contentfulConfig.isEmpty) {
139
+ if (contentfulConfig && !contentfulConfig.isEmpty) {
125
140
  const { managementToken, activeSpaceId, activeEnvironmentId, host } =
126
141
  contentfulConfig.config as ContentfulRcConfig;
127
142
 
128
- contentfulCliOptions = removeEmpty({
129
- spaceId: activeSpaceId,
130
- managementToken,
131
- environmentId: activeEnvironmentId,
132
- host,
133
- });
143
+ contentfulCliOptions = removeEmpty(
144
+ {
145
+ spaceId: activeSpaceId,
146
+ managementToken,
147
+ environmentId: activeEnvironmentId,
148
+ host,
149
+ },
150
+ false
151
+ );
134
152
  }
135
153
  } catch (error: unknown) {
136
154
  if (typeof error === 'string') {
@@ -147,7 +165,7 @@ export const getConfig = async (args?: Partial<Config>): Promise<Config> => {
147
165
  try {
148
166
  // Get configuration from contentful-ssg rc file
149
167
  const configFile = await loadConfig('contentful-ssg');
150
- if (!configFile.isEmpty) {
168
+ if (configFile && !configFile.isEmpty) {
151
169
  configFileOptions = configFile.config as Partial<Config>;
152
170
  args.rootDir = dirname(configFile.filepath);
153
171
  if (configFileOptions.directory && !isAbsolute(configFileOptions.directory)) {
@@ -11,8 +11,9 @@ jest.mock('fs-extra', () => ({
11
11
  remove: jest.fn(),
12
12
  }));
13
13
  jest.mock('fs/promises', () => ({
14
+ lstat: jest.fn().mockResolvedValue({isDirectory: jest.fn().mockReturnValue(false).mockResolvedValueOnce(true).mockResolvedValueOnce(true)}),
14
15
  readFile: jest.fn().mockResolvedValue(''),
15
- readdir: jest.fn().mockResolvedValue([]).mockResolvedValueOnce(['/test/b.md']),
16
+ readdir: jest.fn().mockResolvedValue([]).mockResolvedValueOnce(['/test']),
16
17
  }));
17
18
 
18
19
  describe('FileManager', () => {
@@ -60,14 +61,13 @@ describe('FileManager', () => {
60
61
  .mockReturnValueOnce(true)
61
62
  .mockReturnValueOnce(true)
62
63
  .mockReturnValue(false);
63
- const result = await fileManager.cleanup();
64
64
 
65
- expect(Array.isArray(result)).toEqual(true);
66
- expect(result.length).toEqual(2);
65
+ const result = await fileManager.cleanup();
66
+ expect(result).toEqual(true);
67
67
 
68
- expect(remove).toHaveBeenCalledTimes(3);
69
68
  expect(remove).toHaveBeenNthCalledWith(1, '/test/a.md');
70
69
  expect(remove).toHaveBeenNthCalledWith(2, '/test/b.md');
71
- expect(remove).toHaveBeenNthCalledWith(3, '/test');
70
+ expect(remove).toHaveBeenNthCalledWith(3, '/testbase/test');
71
+ expect(remove).toHaveBeenCalledTimes(3);
72
72
  });
73
73
  });
@@ -1,8 +1,8 @@
1
1
  import type { WriteFileOptions } from 'fs-extra';
2
2
  import type { Config, Ignore } from '../types';
3
- import { dirname, resolve, relative } from 'path';
3
+ import { dirname, resolve, relative, join } from 'path';
4
4
  import ignore from 'ignore';
5
- import { readFile, readdir } from 'fs/promises';
5
+ import { readFile, readdir, lstat } from 'fs/promises';
6
6
  import { remove, outputFile } from 'fs-extra';
7
7
 
8
8
  export class FileManager {
@@ -56,24 +56,51 @@ export class FileManager {
56
56
  }
57
57
 
58
58
  async deleteFile(file: string) {
59
- await remove(file);
60
-
61
59
  if (this.files.has(resolve(file))) {
62
60
  this.files.delete(resolve(file));
63
61
  }
64
62
 
65
- const dir = dirname(file);
66
- try {
67
- const files = await readdir(dirname(file));
68
- if ((files || []).length === 0) {
69
- await remove(dir);
70
- }
71
- } catch {}
63
+ return remove(file);
64
+ }
65
+
66
+ /**
67
+ * Recursively removes empty directories from the given directory.
68
+ *
69
+ * If the directory itself is empty, it is also removed.
70
+ *
71
+ * Code taken from: https://gist.github.com/jakub-g/5903dc7e4028133704a4
72
+ *
73
+ * @param {string} directory Path to the directory to clean up
74
+ */
75
+ async removeEmptyDirectories(directory: string) {
76
+ // Lstat does not follow symlinks (in contrast to stat)
77
+ const fileStats = await lstat(directory);
78
+ if (!fileStats.isDirectory()) {
79
+ return;
80
+ }
81
+
82
+ let fileNames = await readdir(directory);
83
+ if (fileNames.length > 0) {
84
+ const recursiveRemovalPromises = fileNames.map(async (fileName) =>
85
+ this.removeEmptyDirectories(join(directory, fileName))
86
+ );
87
+ await Promise.all(recursiveRemovalPromises);
88
+
89
+ // Re-evaluate fileNames; after deleting subdirectory
90
+ // we may have parent directory empty now
91
+ fileNames = await readdir(directory);
92
+ }
93
+
94
+ if (fileNames.length === 0 && directory !== this.config.directory) {
95
+ await remove(directory);
96
+ }
72
97
  }
73
98
 
74
99
  async cleanup() {
75
100
  const promises = [...this.ignoredFiles].map(async (file) => this.deleteFile(file));
76
101
 
77
- return Promise.allSettled(promises);
102
+ await Promise.allSettled(promises);
103
+ await this.removeEmptyDirectories(this.config.directory);
104
+ return true;
78
105
  }
79
106
  }
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/naming-convention */
2
- import { isObject, omitKeys, removeEmpty, snakeCaseKeys, groupBy } from './object';
2
+ import { isObject, omitKeys, filterKeys, removeEmpty, snakeCaseKeys, groupBy } from './object';
3
3
 
4
4
  test('isObject', async () => {
5
5
  const arr = [];
@@ -17,6 +17,11 @@ test('omitKeys', () => {
17
17
  expect(value).toEqual({ b: 2 });
18
18
  });
19
19
 
20
+ test('filterKeys', () => {
21
+ const value = filterKeys({ a: 1, b: 2, c: 3 }, 'a', 'c');
22
+ expect(value).toEqual({ a: 1, c: 3 });
23
+ });
24
+
20
25
  test('removeEmpty', () => {
21
26
  const value = removeEmpty({
22
27
  a: { c: undefined },
@@ -26,6 +31,16 @@ test('removeEmpty', () => {
26
31
  expect(value).toEqual({ a: {}, c: [1, { x: 1, z: [7] }, 3, 5] });
27
32
  });
28
33
 
34
+
35
+ test('removeEmpty non-strict', () => {
36
+ const value = removeEmpty({
37
+ a: "",
38
+ b: 0,
39
+ c: 1,
40
+ }, false);
41
+ expect(value).toEqual({ c: 1 });
42
+ });
43
+
29
44
  test('groupBy', () => {
30
45
  const value = groupBy(
31
46
  [
package/src/lib/object.ts CHANGED
@@ -30,16 +30,27 @@ export const omitKeys = <T, K extends keyof T>(obj: T, ...keys: K[]): T => {
30
30
  return fromEntries(filtered);
31
31
  };
32
32
 
33
+ /**
34
+ * Filter values by key from object
35
+ * @param {*} obj
36
+ * @param {*} keys
37
+ */
38
+ export const filterKeys = <T, K extends keyof T>(obj: T, ...keys: K[]): T => {
39
+ const entries: Entries<T> = getEntries(obj);
40
+ const filtered = entries.filter(([key]) => keys.includes(key as K));
41
+ return fromEntries(filtered);
42
+ };
43
+
33
44
  /**
34
45
  * Recursive remove empty items (null,undefined) from object
35
46
  * @param iterable Source object
36
47
  * @returns Cleaned object
37
48
  */
38
- export const removeEmpty = <T>(iterable: T): T => {
49
+ export const removeEmpty = <T>(iterable: T, strict = true): T => {
39
50
  if (Array.isArray(iterable)) {
40
51
  return (
41
52
  iterable
42
- .filter((v) => v !== null && v !== undefined)
53
+ .filter((v) => v !== null && v !== undefined && (strict || Boolean(v)))
43
54
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
44
55
  .map((v) => (v === Object(v) ? removeEmpty(v) : v)) as unknown as T
45
56
  );
@@ -47,7 +58,7 @@ export const removeEmpty = <T>(iterable: T): T => {
47
58
 
48
59
  return fromEntries(
49
60
  getEntries(iterable)
50
- .filter(([, v]) => v !== null && v !== undefined)
61
+ .filter(([, v]) => v !== null && v !== undefined && (strict || Boolean(v)))
51
62
  .map(([k, v]) => [k, v === Object(v) ? removeEmpty(v) : v])
52
63
  );
53
64
  };
package/src/types.ts CHANGED
@@ -81,7 +81,7 @@ export type Config = Partial<ContentfulConfig> &
81
81
  rootDir?: string;
82
82
  directory: string;
83
83
  verbose?: boolean;
84
- plugins?: Array<PluginInfo | string>;
84
+ plugins?: Array<[string, KeyValueMap] | PluginInfo | string>;
85
85
  resolvedPlugins?: Hooks[];
86
86
  preset?: string;
87
87
  richTextRenderer?: RichTextConfig;