@caweb/cli 1.3.3 → 1.3.4

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
@@ -18,7 +18,7 @@
18
18
  - phpMyAdmin test site started at http://localhost:9090
19
19
  - Uses CAWebPublishing [External Project Template Configuration](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/packages-create-block-external-template/) when creating Gutenberg Blocks, see configurations [here](https://github.com/CAWebPublishing/cli/lib/template)
20
20
  - Allows for syncing of WordPress instance via Rest API, to maintain ID's please ensure [CAWebPublishing Development Toolbox](https://github.com/CAWebPublishing/caweb-dev/) plugin is installed.
21
- - Adds config.yml to both cli containers, alias are added for each ssh connection.
21
+ - Adds config.yml to both cli containers.
22
22
  <b>Example config.yml file</b>
23
23
  <pre>
24
24
  path: /var/www/html
package/commands/sync.js CHANGED
@@ -14,26 +14,65 @@ import {
14
14
  getTaxonomies,
15
15
  createTaxonomies
16
16
  } from '../lib/index.js';
17
+ import { get } from 'http';
17
18
 
18
19
  const errors = [];
19
20
 
21
+ /**
22
+ * Return all parent items for a given set of ids.
23
+ *
24
+ * @async
25
+ * @param {Array} ids
26
+ * @param {*} request
27
+ * @param {string} [tax='pages']
28
+ * @returns {unknown}
29
+ */
30
+ async function getParentItems( objects, request, tax = 'pages' ){
31
+ let parentItemsObjects = [];
32
+ let objectParentIds = objects.map((obj) => { return obj.parent; });
33
+
34
+ while( objectParentIds.length > 0 ){
35
+
36
+ // if we have parent ids, we have to collect any parent items.
37
+ let parentItems = await getTaxonomies({
38
+ ...request,
39
+ orderby: 'parent',
40
+ embed: true,
41
+ include: objectParentIds
42
+ }, tax);
43
+
44
+ // if we have parent items, we have to add to the items array.
45
+ if( parentItems ){
46
+ parentItemsObjects = parentItemsObjects.concat( parentItems );
47
+ }
48
+
49
+ // if the parent items have parent ids, we have to save those for the next run.
50
+ objectParentIds = parentItems.map((obj) => { return obj.parent; }).filter((id) => { return id !== 0; })
51
+ }
52
+
53
+ return objects.concat( parentItemsObjects ).sort( (a,b) => a.parent - b.parent );
54
+ }
20
55
 
21
56
  /**
22
57
  * Sync Environments.
23
58
  *
59
+ * @see https://developer.wordpress.org/rest-api/reference/
60
+ *
24
61
  * @param {Object} options
25
62
  * @param {Object} options.spinner A CLI spinner which indicates progress.
26
63
  * @param {boolean} options.from Remote Site URL with current changes.
27
64
  * @param {boolean} options.to Destination Site URL that should be synced.
28
65
  * @param {boolean} options.debug True if debug mode is enabled.
29
66
  * @param {Array} options.tax Taxonomy that should be synced.
67
+ * @param {Array} options.include Include specific IDs only.
30
68
  */
31
69
  export default async function sync({
32
70
  spinner,
33
71
  debug,
34
72
  from,
35
73
  to,
36
- tax
74
+ tax,
75
+ include
37
76
  } ) {
38
77
 
39
78
  const localFile = path.join(appPath, 'caweb.json');
@@ -91,104 +130,165 @@ export default async function sync({
91
130
  }
92
131
  }
93
132
 
133
+ /**
134
+ * Sync Process
135
+ * If taxonomy is undefined then we don't sync that section.
136
+ *
137
+ * 1) Site Settings are always synced.
138
+ * 2) We always collect all the media.
139
+ * 3) We only collect pages if the taxonomy is undefined or it's set to pages.
140
+ * 4) We only collect posts if the taxonomy is undefined or it's set to posts.
141
+ * 5) We only collect menus if the taxonomy is undefined or it's set to menus.
142
+ * - We also collect menu items if menus are collected.
143
+ */
144
+ let settings = [];
145
+ let media = [];
94
146
  let pages = [];
95
147
  let posts = [];
96
- let attachedMedia = [];
97
- let featuredMedia = [];
98
148
  let menus = [];
99
149
  let menuNavItems = [];
100
150
 
101
- spinner.text = `Getting Site Settings from ${from.url}`;
102
- /**
103
- * Site settings are always synced
104
- *
105
- * @see https://developer.wordpress.org/rest-api/reference/settings/
106
- */
107
- let settings = await getTaxonomies({
108
- ...fromOptions,
109
- fields: [
110
- 'show_on_front',
111
- 'page_on_front',
112
- 'posts_per_page'
113
- ],
114
- }, 'settings')
115
-
116
- /**
117
- * If taxonomy is undefined then we sync everything, otherwise we only sync what's being requested.
118
- * First we collect everything we need from the base, then we send the information to the target.
119
- * If media is requested then we need to download posts/pages to get any attached/featured media.
120
- */
121
- // collect pages
122
- if( undefined === tax || tax.includes('pages') || tax.includes('media')){
123
- // get all pages/posts
124
- spinner.text = `Gathering all pages from ${from.url}`;
125
- pages = await getTaxonomies(fromOptions);
126
- }
151
+ // Media Library.
152
+ spinner.text = `Collecting Media Library ${from.url}`;
153
+ let mediaLibrary = await getTaxonomies({
154
+ ...fromOptions,
155
+ fields: [
156
+ 'id',
157
+ 'source_url',
158
+ 'title',
159
+ 'caption',
160
+ 'alt_text',
161
+ 'date',
162
+ 'mime_type',
163
+ 'post',
164
+ 'media_details'
165
+ ],
166
+ include: tax.includes('media') && include ? include.join(',') : null
167
+ },
168
+ 'media'
169
+ );
127
170
 
128
- // collect posts
129
- if( undefined === tax || tax.includes('posts')|| tax.includes('media')){
130
- // get all pages/posts
131
- spinner.text = `Gathering all posts from ${from.url}`;
132
- posts = await getTaxonomies(fromOptions, 'posts');
171
+ // Site Settings.
172
+ if( undefined === tax || tax.includes('settings') ){
173
+ spinner.text = `Collecting Site Settings from ${from.url}`;
174
+ settings = await getTaxonomies({
175
+ ...fromOptions,
176
+ fields: [
177
+ 'show_on_front',
178
+ 'page_on_front',
179
+ 'posts_per_page'
180
+ ],
181
+ }, 'settings')
182
+
133
183
  }
134
184
 
135
- // collect media
136
- if( undefined === tax || tax.includes('media')){
137
-
138
- spinner.text = `Collecting all attached/featured images from ${from.url}`;
139
- /**
140
- * Media Library Handling
141
- * 1) attached media items by default are only possible on pages.
142
- * 2) featured media by default are only possible on posts.
143
- */
144
- const mediaFields = [
145
- 'id',
146
- 'source_url',
147
- 'title',
148
- 'caption',
149
- 'alt_text',
150
- 'date',
151
- 'mime_type'
152
- ];
153
- attachedMedia = await getTaxonomies({
185
+ // Pages.
186
+ if( undefined === tax || tax.includes('pages') ){
187
+ // get all pages/posts
188
+ spinner.text = `Collecting pages from ${from.url}`;
189
+ pages = await getTaxonomies({
154
190
  ...fromOptions,
155
- fields: mediaFields,
156
- parent: pages.map((p) => { return p.id })
191
+ orderby: 'parent',
192
+ embed: true,
193
+ include: tax && include ? include.join(',') : null
157
194
  },
158
- 'media'
195
+ 'pages'
159
196
  );
160
197
 
161
- featuredMedia = await getTaxonomies({
198
+ // we only do this if specific ids were requested.
199
+ if( include ){
200
+ // if we have parent ids, we have to collect any parent items.
201
+ pages = await getParentItems(
202
+ pages,
203
+ fromOptions,
204
+ 'pages'
205
+ )
206
+ }
207
+ }
208
+
209
+ // Posts.
210
+ if( undefined === tax || tax.includes('posts') ){
211
+ // get all pages/posts
212
+ spinner.text = `Collecting all posts from ${from.url}`;
213
+ posts = await getTaxonomies({
162
214
  ...fromOptions,
163
- fields: mediaFields,
164
- include: posts.map((p) => { if(p.featured_media){return p.featured_media } })
215
+ orderby: 'parent',
216
+ include: tax && include ? include.join(',') : null
165
217
  },
166
- 'media'
218
+ 'posts'
167
219
  );
168
-
169
- // before we can upload media files.
170
- for( let mediaObj of [].concat( attachedMedia, featuredMedia ) ){
171
- // generate a blob from the media object source_url.
172
- const mediaBlob = await axios.request(
173
- {
174
- ...fromOptions,
175
- url: mediaObj.source_url,
176
- responseType: 'arraybuffer'
177
- }
178
- )
179
- .then( (img) => { return new Blob([img.data]) })
180
- .catch( (error) => {
181
- errors.push(`${mediaObj.source_url} could not be downloaded.`)
182
- return false;
183
- });
184
-
185
- if( mediaBlob ){
186
- mediaObj.data = mediaBlob;
220
+
221
+ // we only do this if specific ids were requested.
222
+ if( include ){
223
+ // if we have parent ids, we have to collect any parent items.
224
+ posts = await getParentItems(
225
+ posts,
226
+ fromOptions,
227
+ 'posts'
228
+ )
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Media Library Handling
234
+ *
235
+ * We iterate thru the media library to identify which media should be synced.
236
+ * 1) attached media items by default are only possible on pages.
237
+ * 2) featured media by default are only possible on posts.
238
+ * 3) save any linked media in the content of pages and posts.
239
+ */
240
+ for( let m of mediaLibrary ){
241
+ // check if the media is attached to a page.
242
+ // if tax is undefined, we collect all media attached to posts and pages.
243
+ // if tax is defined, we only collect media attached to posts and pages if the id match.
244
+ if( ( ! tax && m.post ) ||
245
+ ( tax && include.includes(m.id.toString()) ) ){
246
+ media.push( m );
247
+
248
+ // we don't have to check any further.
249
+ continue;
250
+ }
251
+
252
+ for( let p of [].concat( pages, posts ) ){
253
+ // if the media is featured on a post or linked in the content.
254
+ if( p.featured_media === m.id ||
255
+ p.content.rendered.match( new RegExp(`src=&#8221;(${m.source_url}.*)&#8221;`, 'g') )){
256
+ media.push( m );
187
257
  }
188
258
  }
189
259
  }
260
+
261
+ // filter any duplicate media.
262
+ media = media.filter((m, index, self) => { return index === self.findIndex((t) => { return t.id === m.id; })} );
263
+
264
+ // before we can upload media files we have to generate the media blob data.
265
+ for( let m of media ){
266
+ const mediaBlob = await axios.request(
267
+ {
268
+ ...fromOptions,
269
+ url: m.source_url,
270
+ responseType: 'arraybuffer'
271
+ }
272
+ )
273
+ .then( (img) => { return new Blob([img.data]) })
274
+ .catch( (error) => {
275
+ errors.push(`${m.source_url} could not be downloaded.`)
276
+ return false;
277
+ });
278
+
279
+ if( mediaBlob ){
280
+ m.data = mediaBlob;
281
+ }
282
+ }
283
+
284
+ // this has to be done after we have the media data.
285
+ // Lets replace the url references in the content of the pages and posts.
286
+ for( let p of [].concat( pages, posts ) ){
287
+ p.content.rendered = p.content.rendered.replace( new RegExp(from.url, 'g'), to.url );
288
+ }
190
289
 
191
- // collect menu and nav items.
290
+
291
+ // Menu and Nav Items.
192
292
  if( undefined === tax || tax.includes('menus')){
193
293
  spinner.text = `Collecting assigned navigation menus from ${from.url}`;
194
294
  // get all menus and navigation links
@@ -201,7 +301,8 @@ export default async function sync({
201
301
  'slug',
202
302
  'meta',
203
303
  'locations'
204
- ]
304
+ ],
305
+ include: tax && include ? include.join(',') : null
205
306
  }, 'menus');
206
307
 
207
308
  menuNavItems = await getTaxonomies(
@@ -226,6 +327,7 @@ export default async function sync({
226
327
  'meta',
227
328
  'menus'
228
329
  ],
330
+ include: tax && include ? include.join(',') : null,
229
331
  menus: menus.map((menu) => { return menu.id; })
230
332
  },
231
333
  'menu-items'
@@ -236,43 +338,50 @@ export default async function sync({
236
338
  /**
237
339
  * Now we have all the data we can begin to create the taxonomies on the target.
238
340
  *
239
- * Creation order is important otherwise missing dependencies will cause errors to trigger.
240
- * We create media first, then pages, then posts, then menus and navigation links.
341
+ * Import Order is important otherwise missing dependencies will cause errors to trigger
342
+ * 1) Media
343
+ * 2) Pages
344
+ * 3) Posts
345
+ * 4) Menus & Navigation Links
346
+ * 5) Settings
241
347
  */
242
348
 
243
- // create media attachments
244
- if( undefined === tax || tax.includes('media')){
349
+ // Media.
350
+ if( media ){
245
351
  spinner.text = `Uploading media files to ${to.url}`;
246
352
  await createTaxonomies(
247
- [].concat( attachedMedia, featuredMedia ).filter((img) => { return img.data }),
353
+ media.filter((img) => { return img.data }),
248
354
  toOptions,
249
355
  'media',
250
356
  spinner
251
357
  )
252
358
  }
253
359
 
254
- // create pages
255
- if( undefined === tax || tax.includes('pages')){
360
+ // Pages.
361
+ if( pages ){
256
362
  spinner.text = `Creating all pages to ${to.url}`;
257
363
  await createTaxonomies( pages, toOptions, 'pages', spinner );
258
364
  }
259
365
 
260
- // create posts
261
- if( undefined === tax || tax.includes('posts')){
366
+ // Posts.
367
+ if( posts ){
262
368
  spinner.text = `Creating all posts to ${to.url}`;
263
369
  await createTaxonomies( posts, toOptions, 'posts', spinner );
264
370
  }
265
371
 
266
- // create menus and navigation links
267
- if( undefined === tax || tax.includes('menus')){
372
+ // Menus and Navigation Links.
373
+ if( menus ){
268
374
  spinner.text = `Reconstructing navigation menus to ${to.url}`;
269
375
  await createTaxonomies(menus, toOptions, 'menus', spinner);
270
376
  await createTaxonomies(menuNavItems, toOptions, 'menu-items', spinner);
271
377
  }
272
378
 
273
379
 
274
- // update settings
275
- await createTaxonomies(settings, toOptions, 'settings', spinner);
380
+ // Settings.
381
+ if( settings ){
382
+ spinner.text = `Updating site settings to ${to.url}`;
383
+ await createTaxonomies(settings, toOptions, 'settings', spinner);
384
+ }
276
385
 
277
386
  spinner.text = `Sync from ${from.url} to ${to.url} completed successfully.`
278
387
 
@@ -24,21 +24,6 @@ async function generateCLIConfig(workDirectoryPath){
24
24
  apache_modules: ['mod_rewrite']
25
25
  };
26
26
 
27
- let { homedir } = os.userInfo();
28
-
29
- // add any ssh hosts to the config
30
- if( fs.existsSync( path.join( homedir, '.ssh', 'config' )) ){
31
-
32
- let ssh_hosts = fs.readFileSync(path.join( homedir, '.ssh', 'config' )).toString().match(/Host\s+(.*)[^\n\r]/g);
33
-
34
- ssh_hosts.forEach((host) => {
35
- let s = host.replace(/Host\s+/, '');
36
-
37
- yml[`@${s}`] = { ssh: s};
38
- })
39
- }
40
-
41
-
42
27
  fs.writeFileSync(
43
28
  path.join(workDirectoryPath, 'config.yml'),
44
29
  yaml.dump(yml));
package/lib/cli.js CHANGED
@@ -262,7 +262,11 @@ export default function cli() {
262
262
  .addOption(new Option(
263
263
  '-t,--tax [tax...]',
264
264
  'Taxonomy that should be synced. Omitting this option will sync the full site.'
265
- ).choices(['pages', 'posts', 'media', 'menus']))
265
+ ).choices(['pages', 'posts', 'media', 'menus', 'settings']))
266
+ .addOption(new Option(
267
+ '-i,--include [include...]',
268
+ 'IDs to of taxonomies to include. Omitting this option will sync all items for given taxonomy.'
269
+ ))
266
270
  .allowUnknownOption(true)
267
271
  .action( withSpinner(env.sync) )
268
272
 
@@ -81,19 +81,19 @@ async function getTaxonomies( request, tax = 'pages' ){
81
81
  'categories',
82
82
  'tags',
83
83
  'meta',
84
+ 'parent',
85
+ '_links'
84
86
  ];
85
87
 
86
88
  let urlParams = [
87
- 'order=asc'
89
+ 'order=' + ( request.order ? processUrlParam(request.order) : 'asc' )
88
90
  ]
89
91
 
90
-
91
92
  // if fields are passed
92
93
  if( fields.length && 'all' !== fields ){
93
94
  urlParams.push( `_fields=${ fields.join(',')}` );
94
95
  }
95
96
 
96
-
97
97
  // if no request is added default to GET
98
98
  if( ! request.method ){
99
99
  request.method = 'GET'
@@ -114,6 +114,16 @@ async function getTaxonomies( request, tax = 'pages' ){
114
114
  urlParams.push( 'menus=' + processUrlParam( request.menus ) );
115
115
  }
116
116
 
117
+ // if taxonomies is not menus, we add the orderby parameter.
118
+ if( 'menus' !== tax ){
119
+ urlParams.push( `orderby=` + (request.orderby ? processUrlParam(request.orderby) : 'id') );
120
+ }
121
+
122
+ // if embed is set to true, we add the _embed parameter.
123
+ if( request.embed ){
124
+ urlParams.push( '_embed' );
125
+ }
126
+
117
127
  /**
118
128
  * The per_page parameter is capped at 100.
119
129
  *
@@ -122,7 +132,6 @@ async function getTaxonomies( request, tax = 'pages' ){
122
132
  request.per_page = request.per_page && request.per_page <= 100 ? request.per_page : 100;
123
133
  urlParams.push( 'per_page=' + request.per_page );
124
134
 
125
-
126
135
  let collection = [];
127
136
  let results = false;
128
137
 
@@ -181,8 +190,26 @@ async function createTaxonomies( taxData, request, tax = 'pages', spinner ){
181
190
  for( let obj of taxData ){
182
191
  // endpoint url.
183
192
  let url = `${request.url}${endpoint}/${tax}`;
184
- let existingID = false;
193
+ let existingID = obj.id ? obj.id : false;
194
+
195
+ // process object properties.
196
+ for( let prop in obj ){
197
+ // we process the rendered property and delete the rendered property.
198
+ if( 'object' === typeof obj[prop] && null !== obj[prop] && obj[prop].hasOwnProperty('rendered') ){
199
+ obj[prop] = processData(obj[prop].rendered);
200
+ }
185
201
 
202
+ // if obj has a parent and it's 0, we delete it.
203
+ if( 'parent' === prop && 0 === obj[prop] ){
204
+ delete obj[prop];
205
+ }
206
+
207
+ // disallowed props.
208
+ if( ['_links', '_embedded'].includes(prop) ){
209
+ delete obj[prop];
210
+ }
211
+ }
212
+
186
213
  // The REST API doesn't allow passing the id, in order to maintain ID's we make an additional request to our plugin endpoint.
187
214
  if( obj.id ){
188
215
  // first we check if the ID exist
@@ -196,9 +223,11 @@ async function createTaxonomies( taxData, request, tax = 'pages', spinner ){
196
223
  .then((res) => { return res.data.id })
197
224
  .catch(error => {return false;})
198
225
 
199
- // for menus if ID doesn't exist we also check for matching slug
200
- // if a menu with a matching slug exists we return that id instead.
201
- if( 'menus' === tax && ! idExists ){
226
+ /**
227
+ * Menus Only
228
+ * Since we can't have duplicated slugs, if the ID doesn't exist we also check for matching slugs.
229
+ */
230
+ if( 'menus' === tax && false === idExists ){
202
231
  idExists = await axios.request(
203
232
  {
204
233
  ...request,
@@ -210,33 +239,19 @@ async function createTaxonomies( taxData, request, tax = 'pages', spinner ){
210
239
  .catch(error => {return false;})
211
240
 
212
241
  }
213
- /**
214
- * if the ID doesn't exist we save it so we can make a request to our plugin endpoint.
215
- * if it does exist update endpoint url so we update the existing item.
216
- */
217
- if( ! idExists ){
218
- existingID = obj.id;
219
- }else{
220
- url = `${url}/${ obj.id }`;
242
+
243
+ // if the ID exists we append it to the url.
244
+ if( idExists ){
245
+ url = `${url}/${ idExists }`;
246
+
247
+ // we store the existing ID for later use.
248
+ existingID = idExists;
221
249
  }
222
250
 
223
251
  // id has to be deleted.
224
252
  delete obj.id;
225
253
  }
226
254
 
227
- // process properties
228
- if( obj.content && obj.content.rendered ){
229
- obj.content = processData(obj.content.rendered);
230
- }
231
-
232
- if( obj.title && obj.title.rendered ){
233
- obj.title = processData(obj.title.rendered);
234
- }
235
-
236
- if( obj.caption && obj.caption.rendered ){
237
- obj.caption = processData(obj.caption.rendered);
238
- }
239
-
240
255
  // make WordPress REST API request.
241
256
  let results = await axios.request({
242
257
  ...request,
@@ -245,25 +260,45 @@ async function createTaxonomies( taxData, request, tax = 'pages', spinner ){
245
260
  })
246
261
  .then( async (res) => { return res.data; } )
247
262
 
263
+
248
264
  /**
249
- * if the obj had an existing ID that didn't exist we make a request to our plugin endpoint to update the IDs.
265
+ * if the obj had an existing ID we make a request to our plugin endpoint to update the IDs.
250
266
  */
251
267
  if( existingID ){
268
+ let extraArgs = {};
269
+
270
+ // if media
271
+ if( 'media' === tax ){
272
+ // get original source domain
273
+ let sourceDomain = obj.source_url.substring(0, obj.source_url.indexOf('/wp-content/uploads'));
274
+ // get expected guid, replace source domain with the new domain.
275
+ let expectedGuid = obj.source_url.replace(sourceDomain, request.url);
276
+
277
+ extraArgs = {
278
+ guid: results.guid.rendered,
279
+ newGuid: expectedGuid,
280
+ // if media and source_url is different from the guid, we update the guid.
281
+ media_details: results.media_details,
282
+ }
283
+ }
252
284
 
253
285
  await axios.request({
254
286
  ...request,
255
287
  url: `${request.url}${syncEndpoint}`,
256
288
  data: {
289
+ ...extraArgs,
257
290
  id: results.id,
258
291
  newId: existingID,
259
292
  tax,
260
- locations: 'menus' === tax ? obj.locations : []
293
+ locations: 'menus' === tax ? obj.locations : [],
294
+
261
295
  }
262
296
  })
263
297
  .then( async (res) => { return res.data; } )
264
298
 
265
299
  // update the API results ID, back to the existing ID from earlier.
266
- results.id = existingID;
300
+ results.id = existingID;
301
+
267
302
  }
268
303
 
269
304
  // add results to collection
@@ -280,8 +315,13 @@ async function createTaxonomies( taxData, request, tax = 'pages', spinner ){
280
315
 
281
316
  }
282
317
 
318
+ /**
319
+ * Create a FormData from the Media Item Object
320
+ *
321
+ * @param {Object} media Media Object.
322
+ * @returns {FormData}
323
+ */
283
324
  function createMediaItem( media ){
284
-
285
325
  let fd = new FormData();
286
326
  let featureMediaFile = new File(
287
327
  [
@@ -293,10 +333,12 @@ function createMediaItem( media ){
293
333
  }
294
334
  );
295
335
  fd.append('file', featureMediaFile );
336
+
296
337
  fd.append('title', media.title);
297
- fd.append('caption', media.caption);
338
+ fd.append('caption', media.caption );
298
339
  fd.append('alt_text', media.alt_text);
299
340
  fd.append('date', media.date);
341
+ fd.append('media_details', media.media_details);
300
342
 
301
343
  return fd;
302
344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caweb/cli",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "CAWebPublishing Command Line Interface.",
5
5
  "exports": "./lib/env.js",
6
6
  "type": "module",