ultimate-jekyll-manager 0.0.38 → 0.0.40

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
@@ -76,6 +76,7 @@ npx uj audit
76
76
  ```bash
77
77
  GH_TOKEN=XXX \
78
78
  GITHUB_REPOSITORY=XXX \
79
+ UJ_TRANSLATION_CACHE=true \
79
80
  npx uj translation
80
81
  ```
81
82
  <!-- Developing -->
@@ -11,5 +11,5 @@ module.exports = async function (options) {
11
11
  // Log
12
12
  logger.log(`Starting audit...`);
13
13
 
14
- await execute('UJ_FORCE_AUDIT=true bundle exec npm run gulp -- audit', { log: true })
14
+ await execute('UJ_AUDIT_FORCE=true bundle exec npm run gulp -- audit', { log: true })
15
15
  };
@@ -11,5 +11,5 @@ module.exports = async function (options) {
11
11
  // Log
12
12
  logger.log(`Starting translation...`);
13
13
 
14
- await execute('UJ_FORCE_TRANSLATION=true bundle exec npm run gulp -- translation', { log: true })
14
+ await execute('UJ_TRANSLATION_FORCE=true bundle exec npm run gulp -- translation', { log: true })
15
15
  };
@@ -35,8 +35,8 @@ async function audit(complete) {
35
35
  // Log
36
36
  logger.log('Starting...');
37
37
 
38
- // Quit if NOT in build mode and UJ_FORCE_AUDIT is not true
39
- if (!Manager.isBuildMode() && process.env.UJ_FORCE_AUDIT !== 'true') {
38
+ // Quit if NOT in build mode and UJ_AUDIT_FORCE is not true
39
+ if (!Manager.isBuildMode() && process.env.UJ_AUDIT_FORCE !== 'true') {
40
40
  logger.log('Skipping audit in development mode');
41
41
  return complete();
42
42
  }
@@ -72,10 +72,15 @@ async function validateFormat(file, content) {
72
72
  (async () => {
73
73
  try {
74
74
  // Format the content using Prettier
75
- const formatted = await formatDocument(content, format, true);
75
+ const formatted = await formatDocument(content, format);
76
76
 
77
77
  // Save the formatted content back to the file
78
- jetpack.write(file, formatted);
78
+ jetpack.write(file, formatted.content);
79
+
80
+ // Quit if there is an error
81
+ if (formatted.error) {
82
+ throw formatted.error;
83
+ }
79
84
 
80
85
  return { valid: true, messages: [] };
81
86
  } catch (e) {
@@ -232,7 +232,7 @@ function defaultsWatcher(complete) {
232
232
  // Watch for changes
233
233
  watch(input, { delay: delay, dot: true }, defaults)
234
234
  .on('change', (path) => {
235
- logger.log(`[watcher] File ${path} was changed`);
235
+ logger.log(`[watcher] File changed (${path})`);
236
236
  });
237
237
 
238
238
  // Complete
@@ -91,7 +91,7 @@ function developmentRebuildWatcher(complete) {
91
91
  // Watch for changes
92
92
  watch(input, { delay: delay, dot: true }, developmentRebuild)
93
93
  .on('change', (path) => {
94
- logger.log(`[watcher] File ${path} was changed`);
94
+ logger.log(`[watcher] File changed (${path})`);
95
95
  });
96
96
 
97
97
  // Complete
@@ -102,7 +102,7 @@ function distributeWatcher(complete) {
102
102
  // Watch for changes
103
103
  watch(input, { delay: delay, dot: true }, distribute)
104
104
  .on('change', (path) => {
105
- logger.log(`[watcher] File ${path} was changed`);
105
+ logger.log(`[watcher] File changed (${path})`);
106
106
  });
107
107
 
108
108
  // Complete
@@ -130,7 +130,7 @@ function imageminWatcher(complete) {
130
130
  // Watch for changes
131
131
  watch(input, { delay: delay, dot: true }, imagemin)
132
132
  .on('change', (path) => {
133
- logger.log(`[watcher] File ${path} was changed`);
133
+ logger.log(`[watcher] File changed (${path})`);
134
134
  });
135
135
 
136
136
  // Complete
@@ -79,9 +79,6 @@ async function jekyll(complete) {
79
79
  // Run buildpost hook
80
80
  await hook('build:post', index);
81
81
 
82
- // QUIT
83
- // QUIT
84
-
85
82
  // Log
86
83
  logger.log('Finished!');
87
84
 
@@ -113,7 +110,7 @@ function jekyllWatcher(complete) {
113
110
  // Watch for changes
114
111
  watch(input, { delay: delay, dot: true }, jekyll)
115
112
  .on('change', (path) => {
116
- logger.log(`[watcher] File ${path} was changed`);
113
+ logger.log(`[watcher] File changed (${path})`);
117
114
 
118
115
  // Check if changed file is a .rb file
119
116
  if (path.endsWith('.rb')) {
@@ -113,7 +113,7 @@ function sassWatcher(complete) {
113
113
  // Watch for changes
114
114
  watch(input, { delay: delay, dot: true }, sass)
115
115
  .on('change', (path) => {
116
- logger.log(`[watcher] File ${path} was changed`);
116
+ logger.log(`[watcher] File changed (${path})`);
117
117
  });
118
118
 
119
119
  // Complete
@@ -1,7 +1,7 @@
1
1
  // Libraries
2
2
  const Manager = new (require('../../build.js'));
3
3
  const logger = Manager.logger('translation');
4
- const { series } = require('gulp');
4
+ const { series, watch } = require('gulp');
5
5
  const glob = require('glob').globSync;
6
6
  const path = require('path');
7
7
  const fetch = require('wonderful-fetch');
@@ -9,7 +9,7 @@ const jetpack = require('fs-jetpack');
9
9
  const cheerio = require('cheerio');
10
10
  const crypto = require('crypto');
11
11
  const yaml = require('js-yaml');
12
- const { execute, wait } = require('node-powertools');
12
+ const { execute, wait, template } = require('node-powertools');
13
13
  const { Octokit } = require('@octokit/rest')
14
14
  const AdmZip = require('adm-zip') // npm install adm-zip
15
15
 
@@ -38,13 +38,25 @@ const RECHECK_DAYS = 0;
38
38
  // const AI_MODEL = 'gpt-4.1-nano';
39
39
  const AI_MODEL = 'gpt-4.1-mini';
40
40
  const TRANSLATION_BRANCH = 'uj-translations';
41
- const LOUD = false;
42
- // const LOUD = true;
41
+ // const LOUD = false;
42
+ const LOUD = process.env.UJ_LOUD_LOGS === 'true';
43
43
 
44
44
  const TRANSLATION_DELAY_MS = 500; // wait between each translation
45
45
  const TRANSLATION_BATCH_SIZE = 25; // wait longer every N translations
46
46
  const TRANSLATION_BATCH_DELAY_MS = 10000; // longer wait after batch
47
47
 
48
+ // Prompt
49
+ const SYSTEM_PROMPT = `
50
+ You are a professional translator.
51
+ Translate the provided content, preserving all original formatting, HTML structure, metadata, and links.
52
+ Do not explain anything — just return the translated content.
53
+ The content is TAGGED with [0]...[/0], etc. to mark the text. You MUST KEEP THESE TAGS IN PLACE IN YOUR RESPONSE and OPEN ([0]) and CLOSE ([/0]) them PROPERLY.
54
+ DO NOT translate URLs or other non-text elements.
55
+ DO NOT translate the brand.
56
+ Brand: {brand}
57
+ Translate to {lang}
58
+ `;
59
+
48
60
  // Variables
49
61
  let octokit;
50
62
 
@@ -61,11 +73,8 @@ async function translation(complete) {
61
73
  // Log
62
74
  logger.log('Starting...');
63
75
 
64
- // Get ignored pages
65
- const ignoredPages = getIgnoredPages();
66
-
67
- // Quit if NOT in build mode and UJ_FORCE_TRANSLATION is not true
68
- if (!Manager.isBuildMode() && process.env.UJ_FORCE_TRANSLATION !== 'true') {
76
+ // Quit if NOT in build mode and UJ_TRANSLATION_FORCE is not true
77
+ if (!Manager.isBuildMode() && process.env.UJ_TRANSLATION_FORCE !== 'true') {
69
78
  logger.log('Skipping translation in development mode');
70
79
  return complete();
71
80
  }
@@ -103,6 +112,34 @@ async function translation(complete) {
103
112
  return complete();
104
113
  };
105
114
 
115
+ // TODO: Currently this does not work because it will run an infinite loop
116
+ function translationWatcher(complete) {
117
+ // Quit if in build mode
118
+ if (Manager.isBuildMode()) {
119
+ logger.log('[watcher] Skipping watcher in build mode');
120
+ return complete();
121
+ }
122
+
123
+ // Log
124
+ logger.log('[watcher] Watching for changes...');
125
+
126
+ // Get ignored pages
127
+ const ignoredPages = getIgnoredPages();
128
+ const ignore = [
129
+ ...ignoredPages.files.map(key => `_site/${key}.html`),
130
+ ...ignoredPages.folders.map(folder => `_site/${folder}/**/*`)
131
+ ]
132
+
133
+ // Watch for changes
134
+ watch(input, { delay: delay, ...getGlobOptions(), }, translation)
135
+ .on('change', (path) => {
136
+ logger.log(`[watcher] File changed (${path})`);
137
+ });
138
+
139
+ // Complete
140
+ return complete();
141
+ }
142
+
106
143
  // Default Task
107
144
  module.exports = series(translation);
108
145
 
@@ -110,7 +147,6 @@ module.exports = series(translation);
110
147
  async function processTranslation() {
111
148
  const enabled = config?.translation?.enabled !== false;
112
149
  const languages = config?.translation?.languages || [];
113
- const ignoredPages = getIgnoredPages();
114
150
  const updatedFiles = new Set();
115
151
 
116
152
  // Quit if translation is disabled or no languages are configured
@@ -135,20 +171,20 @@ async function processTranslation() {
135
171
  // }
136
172
 
137
173
  // Get files
138
- const allFiles = glob(input, {
139
- nodir: true,
140
- ignore: [
141
- ...ignoredPages.files.map(key => `_site/${key}.html`),
142
- ...ignoredPages.folders.map(folder => `_site/${folder}/**/*`)
143
- ]
144
- });
174
+ const allFiles = glob(input, getGlobOptions());
145
175
 
146
176
  // Log
147
177
  logger.log(`Translating ${allFiles.length} files for ${languages.length} supported languages: ${languages.join(', ')}`);
148
178
  // logger.log(allFiles);
149
179
 
150
180
  // Prepare meta caches per language
151
- const metas = {};
181
+ const metas = {
182
+ global: {
183
+ skipped: new Set(),
184
+ }
185
+ };
186
+ const promptHash = crypto.createHash('sha256').update(SYSTEM_PROMPT).digest('hex');
187
+
152
188
  for (const lang of languages) {
153
189
  const metaPath = path.join(CACHE_DIR, lang, 'meta.json');
154
190
  let meta = {};
@@ -159,7 +195,16 @@ async function processTranslation() {
159
195
  logger.warn(`⚠️ Failed to parse meta for [${lang}], starting fresh`);
160
196
  }
161
197
  }
162
- metas[lang] = { meta, path: metaPath, skipped: [] };
198
+
199
+ // Check if the promptHash matches; if not, invalidate the cache
200
+ if (meta.prompt?.hash !== promptHash) {
201
+ logger.warn(`⚠️ Prompt hash mismatch for [${lang}]. Invalidating cache.`);
202
+ meta = {};
203
+ }
204
+
205
+ // Store the current promptHash in the meta file
206
+ meta.prompt = { hash: promptHash };
207
+ metas[lang] = { meta, path: metaPath, skipped: new Set() };
163
208
  }
164
209
 
165
210
  // Track token usage
@@ -183,9 +228,8 @@ async function processTranslation() {
183
228
 
184
229
  // Skip all except the specified HTML file
185
230
  if (ujOnly && relativePath !== ujOnly) {
186
- for (const lang of languages) {
187
- metas[lang].skipped.push(`${relativePath} (UJ_TRANSLATION_ONLY set)`);
188
- }
231
+ // Update to work with the new SET protocol
232
+ metas.global.skipped.add(`${relativePath} (UJ_TRANSLATION_ONLY set)`);
189
233
  continue;
190
234
  }
191
235
 
@@ -214,11 +258,16 @@ async function processTranslation() {
214
258
  const age = entry?.timestamp
215
259
  ? (Date.now() - new Date(entry.timestamp).getTime()) / (1000 * 60 * 60 * 24)
216
260
  : Infinity;
217
- const useCached = entry && entry.hash === hash && (RECHECK_DAYS === 0 || age < RECHECK_DAYS);
261
+ const useCached = entry
262
+ && entry.hash === hash
263
+ && (RECHECK_DAYS === 0 || age < RECHECK_DAYS);
218
264
  const startTime = Date.now();
219
265
 
220
266
  // Check if we can use cached translation
221
- if (useCached && jetpack.exists(cachePath)) {
267
+ if (
268
+ (useCached || process.env.UJ_TRANSLATION_CACHE === 'true')
269
+ && jetpack.exists(cachePath)
270
+ ) {
222
271
  translated = jetpack.read(cachePath);
223
272
  logger.log(`📦 Using cached translation for ${relativePath} [${lang}]`);
224
273
  } else {
@@ -260,6 +309,11 @@ async function processTranslation() {
260
309
  // Log result
261
310
  // console.log('---translated---', translated);
262
311
 
312
+ // Reset the DOM to avoid conflicts between languages
313
+ const $ = cheerio.load(originalHtml);
314
+ // Collect text nodes with tags
315
+ const textNodes = collectTextNodes($, { tag: true });
316
+
263
317
  // Replace original text nodes with translated versions
264
318
  textNodes.forEach((n, i) => {
265
319
  const regex = new RegExp(`\\[${i}\\](.*?)\\[/${i}\\]`, 's');
@@ -270,14 +324,22 @@ async function processTranslation() {
270
324
  return logger.warn(`⚠️ Could not find translated tag for index ${i}`);
271
325
  }
272
326
 
327
+ // Extract original leading and trailing whitespace
328
+ const originalText = n.text;
329
+ const leadingWhitespace = originalText.match(/^\s*/)?.[0] || '';
330
+ const trailingWhitespace = originalText.match(/\s*$/)?.[0] || '';
331
+
332
+ // Reapply the original whitespace to the translation
333
+ const adjustedTranslation = `${leadingWhitespace}${translation.trim()}${trailingWhitespace}`;
334
+
273
335
  if (n.type === 'data') {
274
- n.reference.data = translation;
336
+ n.reference.data = adjustedTranslation;
275
337
  } else if (n.type === 'text') {
276
- n.node.text(translation);
338
+ n.node.text(adjustedTranslation);
277
339
  } else if (n.type === 'attr') {
278
- n.node.attr(n.attr, translation);
340
+ n.node.attr(n.attr, adjustedTranslation);
279
341
  }
280
- if (LOUD) logger.log(`${i}: ${n.text} → ${translation}`);
342
+ if (LOUD) logger.log(`${i}: "${n.text.trim()}""${adjustedTranslation.trim()}"`);
281
343
  });
282
344
 
283
345
  // Rewrite links
@@ -294,7 +356,7 @@ async function processTranslation() {
294
356
  $('meta[property="og:url"]').attr('content', canonicalUrl);
295
357
 
296
358
  // Insert language tags on this translation
297
- await insertLanguageTags($, languages, relativePath);
359
+ await insertLanguageTags($, languages, relativePath, outPath);
298
360
 
299
361
  // Insert language tags in original file
300
362
  await insertLanguageTags(cheerio.load(originalHtml), languages, relativePath, filePath);
@@ -305,7 +367,15 @@ async function processTranslation() {
305
367
  await insertLanguageTags(cheerio.load(sitemapXml, { xmlMode: true }), languages, relativePath, sitemapPath);
306
368
 
307
369
  // Save output
308
- jetpack.write(outPath, await formatDocument($.html(), undefined, false));
370
+ // const formatted = await formatDocument($.html(), 'html');
371
+
372
+ // console.log('----relativePath', relativePath);
373
+ // console.log('----filePath', filePath);
374
+ // console.log('----outPath', outPath);
375
+ // console.log('----FORMATTED.ERROR', formatted.error);
376
+
377
+ // Write the translated file
378
+ // jetpack.write(outPath, formatted.content);
309
379
  // logger.log(`✅ Wrote: ${outPath}`);
310
380
 
311
381
  // Track updated files only if it's new or updated
@@ -334,13 +404,23 @@ async function processTranslation() {
334
404
  }
335
405
  }
336
406
 
407
+ // Log skipped files
408
+ logger.warn('🚫 Skipped files:');
409
+ let totalSkipped = 0;
410
+ for (const [lang, meta] of Object.entries(metas)) {
411
+ if (meta.skipped.size > 0) {
412
+ logger.warn(` [${lang}] ${meta.skipped.size} skipped files:`);
413
+ meta.skipped.forEach(f => logger.warn(` ${f}`));
414
+ totalSkipped += meta.skipped.size;
415
+ }
416
+ }
417
+ if (totalSkipped === 0) {
418
+ logger.warn(' NONE');
419
+ }
420
+
337
421
  // Save all updated meta files
338
422
  for (const lang of languages) {
339
423
  jetpack.write(metas[lang].path, metas[lang].meta);
340
- if (metas[lang].skipped.length) {
341
- logger.warn('🚫 Skipped files:');
342
- metas[lang].skipped.forEach(f => logger.warn(f));
343
- }
344
424
  }
345
425
 
346
426
  // Log total token usage
@@ -356,14 +436,11 @@ async function processTranslation() {
356
436
  }
357
437
 
358
438
  async function translateWithAPI(openAIKey, content, lang) {
359
- // Prompt
360
- const systemPrompt = `
361
- You are a professional translator.
362
- Translate the provided content, preserving all original formatting, HTML structure, metadata, and links.
363
- Do not explain anything — just return the translated content.
364
- The content is TAGGED with [0]...[/0], etc. to mark the text. You MUST KEEP THESE TAGS IN PLACE IN YOUR RESPONSE and OPEN ([0]) and CLOSE ([/0]) them PROPERLY.
365
- Translate to ${lang}.
366
- `;
439
+ const brand = config?.brand?.name || 'Unknown Brand';
440
+ const systemPrompt = template(SYSTEM_PROMPT, {
441
+ lang,
442
+ brand
443
+ });
367
444
 
368
445
  // Request
369
446
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
@@ -521,9 +598,11 @@ async function insertLanguageTags($, languages, relativePath, filePath) {
521
598
 
522
599
  // Save the modified HTML back to the file if filePath
523
600
  if (filePath) {
524
- // const format = isHtml ? 'html' : 'xml';
525
- const format = 'html';
526
- jetpack.write(filePath, await formatDocument($.html(), format));
601
+ const format = isHtml ? 'html' : 'xml';
602
+ const formatted = await formatDocument($.html(), format);
603
+
604
+ // Write the formatted content back to the file
605
+ jetpack.write(filePath, formatted.content);
527
606
  }
528
607
  }
529
608
 
@@ -597,6 +676,17 @@ function getIgnoredPages() {
597
676
  };
598
677
  }
599
678
 
679
+ function getGlobOptions() {
680
+ const ignoredPages = getIgnoredPages();
681
+ return {
682
+ nodir: true,
683
+ ignore: [
684
+ ...ignoredPages.files.map(key => `_site/${key}.html`),
685
+ ...ignoredPages.folders.map(folder => `_site/${folder}/**/*`)
686
+ ]
687
+ }
688
+ }
689
+
600
690
  // Git Sync: Pull
601
691
  async function fetchTranslationsBranch() {
602
692
  const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/')
@@ -21,14 +21,14 @@ const collectTextNodes = ($, options) => {
21
21
  const i = textNodes.length;
22
22
  const text = node.text().trim();
23
23
  if (text) {
24
+
25
+ // Push
24
26
  textNodes.push({
25
27
  node,
26
28
  type: 'text',
27
29
  attr: null,
28
30
  text,
29
31
  tagged: `[${i}]${text}[/${i}]`,
30
- line: el.startIndex || 0, // Add line information
31
- column: 0 // Column is not directly available, default to 0
32
32
  });
33
33
  }
34
34
  return;
@@ -51,14 +51,14 @@ const collectTextNodes = ($, options) => {
51
51
  const text = node.attr('content')?.trim();
52
52
  if (text) {
53
53
  const i = textNodes.length;
54
+
55
+ // Push
54
56
  textNodes.push({
55
57
  node,
56
58
  type: 'attr',
57
59
  attr: 'content',
58
60
  text,
59
61
  tagged: `[${i}]${text}[/${i}]`,
60
- line: el.startIndex || 0, // Add line information
61
- column: 0 // Column is not directly available, default to 0
62
62
  });
63
63
  }
64
64
  }
@@ -70,10 +70,15 @@ const collectTextNodes = ($, options) => {
70
70
  if (child.type === 'text' && child.data?.trim()) {
71
71
  const i = textNodes.length;
72
72
  const text = child.data
73
- .replace(/^\s+/, '')
74
- .replace(/\s+$/, '')
75
- .replace(/\s+/g, ' ');
73
+ // Preserve a single leading whitespace if it exists
74
+ .replace(/^\s*(\s)\s*/, '$1')
75
+ // Preserve a single trailing whitespace if it exists
76
+ .replace(/\s*(\s)\s*$/, '$1')
77
+ // Normalize internal whitespace
78
+ .replace(/\s+/g, ' ')
79
+ // const text = trimPreserveOneCleanInner(child.data);
76
80
 
81
+ // Push
77
82
  textNodes.push({
78
83
  node,
79
84
  type: 'data',
@@ -81,8 +86,6 @@ const collectTextNodes = ($, options) => {
81
86
  reference: child,
82
87
  text,
83
88
  tagged: `[${i}]${text}[/${i}]`,
84
- line: el.startIndex || 0, // Add line information
85
- column: 0 // Column is not directly available, default to 0
86
89
  });
87
90
  }
88
91
  });
@@ -92,3 +95,12 @@ const collectTextNodes = ($, options) => {
92
95
  };
93
96
 
94
97
  module.exports = collectTextNodes;
98
+
99
+
100
+ function trimPreserveOneCleanInner(str) {
101
+ const match = str.match(/^(\s*)(.*?)(\s*)$/);
102
+ const leading = match[1] ? match[1][0] || '' : '';
103
+ const content = match[2].replace(/\s+/g, ' ');
104
+ const trailing = match[3] ? match[3][0] || '' : '';
105
+ return leading + content + trailing;
106
+ }
@@ -6,10 +6,9 @@ const path = require('path');
6
6
  // Load package
7
7
  const rootPathPackage = Manager.getRootPath('main');
8
8
 
9
- module.exports = async function formatHTML(content, format, throwError) {
9
+ module.exports = async (content, format) => {
10
10
  // Set default format to 'html' if not provided
11
11
  format = format || 'html';
12
- throwError = typeof throwError === 'undefined' ? true : throwError;
13
12
 
14
13
  // Setup Prettier options
15
14
  const options = {
@@ -30,16 +29,19 @@ module.exports = async function formatHTML(content, format, throwError) {
30
29
  return prettier
31
30
  .format(content, options)
32
31
  .then((formatted) => {
33
- return removeMultipleNewlines(formatted);
32
+ return {
33
+ content: removeMultipleNewlines(formatted),
34
+ error: null,
35
+ };
34
36
  })
35
37
  .catch((e) => {
36
- if (throwError) {
37
- throw e;
38
- }
39
- return removeMultipleNewlines(content);
38
+ return {
39
+ content: removeMultipleNewlines(content),
40
+ error: e,
41
+ };
40
42
  });
41
43
  };
42
44
 
43
45
  function removeMultipleNewlines(content) {
44
- return content.replace(/\n\s*\n+/g, '\n');
46
+ return content.replace(/\n\s*\n+/g, '\n').trim();
45
47
  }
@@ -186,7 +186,7 @@ function webpackWatcher(complete) {
186
186
  watch(input, { delay: delay, dot: true }, webpack)
187
187
  .on('change', (path) => {
188
188
  // Log
189
- logger.log(`[watcher] File ${path} was changed`);
189
+ logger.log(`[watcher] File changed (${path})`);
190
190
  });
191
191
 
192
192
  // Complete
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {