ultimate-jekyll-manager 0.0.39 → 0.0.41

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
  };
@@ -44,3 +44,11 @@ By vising {{ brand }}, you agree to comply.
44
44
  - Account page: [/account](/account)
45
45
  - Admin page: [/admin](/admin)
46
46
  - Admin sub-page: [/admin/subpage](/admin/subpage)
47
+
48
+ ## This is an input
49
+ <div class="form-group">
50
+ <input type="email" name="slap_honey" class="form-control" placeholder="Your Email">
51
+ </div>
52
+
53
+ ## This button has a title
54
+ <a href="https://itwcreativeworks.com" class="btn btn-primary" title="Visit ITW Creative Works">Visit ITW Creative Works</a>
@@ -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
  }
@@ -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,29 @@ 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
+ const CONTROL = 'UJ-TRANSLATION-CONTROL';
43
44
 
44
45
  const TRANSLATION_DELAY_MS = 500; // wait between each translation
45
46
  const TRANSLATION_BATCH_SIZE = 25; // wait longer every N translations
46
47
  const TRANSLATION_BATCH_DELAY_MS = 10000; // longer wait after batch
47
48
 
49
+ // Prompt
50
+ const SYSTEM_PROMPT = `
51
+ You are a professional translator.
52
+ Translate the provided content, preserving all original formatting, HTML structure, metadata, and links.
53
+ Do not explain anything — just return the translated content.
54
+ 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.
55
+
56
+ DO NOT translate the following elements (but still keep them in place):
57
+ - URLs or other non-text elements.
58
+ - the brand name ({brand}).
59
+ - the control tag (${CONTROL}).
60
+
61
+ Translate to {lang}
62
+ `;
63
+
48
64
  // Variables
49
65
  let octokit;
50
66
 
@@ -61,11 +77,8 @@ async function translation(complete) {
61
77
  // Log
62
78
  logger.log('Starting...');
63
79
 
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') {
80
+ // Quit if NOT in build mode and UJ_TRANSLATION_FORCE is not true
81
+ if (!Manager.isBuildMode() && process.env.UJ_TRANSLATION_FORCE !== 'true') {
69
82
  logger.log('Skipping translation in development mode');
70
83
  return complete();
71
84
  }
@@ -103,6 +116,34 @@ async function translation(complete) {
103
116
  return complete();
104
117
  };
105
118
 
119
+ // TODO: Currently this does not work because it will run an infinite loop
120
+ function translationWatcher(complete) {
121
+ // Quit if in build mode
122
+ if (Manager.isBuildMode()) {
123
+ logger.log('[watcher] Skipping watcher in build mode');
124
+ return complete();
125
+ }
126
+
127
+ // Log
128
+ logger.log('[watcher] Watching for changes...');
129
+
130
+ // Get ignored pages
131
+ const ignoredPages = getIgnoredPages();
132
+ const ignore = [
133
+ ...ignoredPages.files.map(key => `_site/${key}.html`),
134
+ ...ignoredPages.folders.map(folder => `_site/${folder}/**/*`)
135
+ ]
136
+
137
+ // Watch for changes
138
+ watch(input, { delay: delay, ...getGlobOptions(), }, translation)
139
+ .on('change', (path) => {
140
+ logger.log(`[watcher] File changed (${path})`);
141
+ });
142
+
143
+ // Complete
144
+ return complete();
145
+ }
146
+
106
147
  // Default Task
107
148
  module.exports = series(translation);
108
149
 
@@ -110,7 +151,6 @@ module.exports = series(translation);
110
151
  async function processTranslation() {
111
152
  const enabled = config?.translation?.enabled !== false;
112
153
  const languages = config?.translation?.languages || [];
113
- const ignoredPages = getIgnoredPages();
114
154
  const updatedFiles = new Set();
115
155
 
116
156
  // Quit if translation is disabled or no languages are configured
@@ -135,13 +175,7 @@ async function processTranslation() {
135
175
  // }
136
176
 
137
177
  // 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
- });
178
+ const allFiles = glob(input, getGlobOptions());
145
179
 
146
180
  // Log
147
181
  logger.log(`Translating ${allFiles.length} files for ${languages.length} supported languages: ${languages.join(', ')}`);
@@ -153,6 +187,8 @@ async function processTranslation() {
153
187
  skipped: new Set(),
154
188
  }
155
189
  };
190
+ const promptHash = crypto.createHash('sha256').update(SYSTEM_PROMPT).digest('hex');
191
+
156
192
  for (const lang of languages) {
157
193
  const metaPath = path.join(CACHE_DIR, lang, 'meta.json');
158
194
  let meta = {};
@@ -163,6 +199,15 @@ async function processTranslation() {
163
199
  logger.warn(`⚠️ Failed to parse meta for [${lang}], starting fresh`);
164
200
  }
165
201
  }
202
+
203
+ // Check if the promptHash matches; if not, invalidate the cache
204
+ if (meta.prompt?.hash !== promptHash) {
205
+ logger.warn(`⚠️ Prompt hash mismatch for [${lang}]. Invalidating cache.`);
206
+ meta = {};
207
+ }
208
+
209
+ // Store the current promptHash in the meta file
210
+ meta.prompt = { hash: promptHash };
166
211
  metas[lang] = { meta, path: metaPath, skipped: new Set() };
167
212
  }
168
213
 
@@ -173,9 +218,16 @@ async function processTranslation() {
173
218
  for (const filePath of allFiles) {
174
219
  // Get relative path and original HTML
175
220
  const relativePath = filePath.replace(/^_site[\\/]/, '');
176
- const originalHtml = jetpack.read(filePath);
221
+ let originalHtml = jetpack.read(filePath);
177
222
  const $ = cheerio.load(originalHtml);
178
223
 
224
+ // Inject hidden control tag as last child of <body>
225
+ const controlTag = `<span id="${CONTROL}" style="display:none;">${CONTROL}</span>`;
226
+ $('body').append(controlTag);
227
+
228
+ // Reset originalHtml
229
+ originalHtml = $.html();
230
+
179
231
  // Collect text nodes with tags
180
232
  const textNodes = collectTextNodes($, { tag: true });
181
233
 
@@ -217,11 +269,30 @@ async function processTranslation() {
217
269
  const age = entry?.timestamp
218
270
  ? (Date.now() - new Date(entry.timestamp).getTime()) / (1000 * 60 * 60 * 24)
219
271
  : Infinity;
220
- const useCached = entry && entry.hash === hash && (RECHECK_DAYS === 0 || age < RECHECK_DAYS);
272
+ const useCached = entry
273
+ && entry.hash === hash
274
+ && (RECHECK_DAYS === 0 || age < RECHECK_DAYS);
221
275
  const startTime = Date.now();
222
276
 
277
+ function setResult(success) {
278
+ if (success) {
279
+ meta[relativePath] = {
280
+ timestamp: new Date().toISOString(),
281
+ hash,
282
+ };
283
+ } else {
284
+ meta[relativePath] = {
285
+ timestamp: 0,
286
+ hash: '__fail__',
287
+ };
288
+ }
289
+ }
290
+
223
291
  // Check if we can use cached translation
224
- if (useCached && jetpack.exists(cachePath)) {
292
+ if (
293
+ (useCached || process.env.UJ_TRANSLATION_CACHE === 'true')
294
+ && jetpack.exists(cachePath)
295
+ ) {
225
296
  translated = jetpack.read(cachePath);
226
297
  logger.log(`📦 Using cached translation for ${relativePath} [${lang}]`);
227
298
  } else {
@@ -241,10 +312,9 @@ async function processTranslation() {
241
312
 
242
313
  // Save to cache
243
314
  jetpack.write(cachePath, translated);
244
- meta[relativePath] = {
245
- timestamp: new Date().toISOString(),
246
- hash,
247
- };
315
+
316
+ // Set result
317
+ setResult(true);
248
318
  } catch (e) {
249
319
  const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(2);
250
320
  logger.error(`⚠️ Translation failed: ${relativePath} [${lang}] — ${e.message} (Elapsed time: ${elapsedTime}s)`);
@@ -253,10 +323,7 @@ async function processTranslation() {
253
323
  translated = bodyText;
254
324
 
255
325
  // Save failure to cache
256
- meta[relativePath] = {
257
- timestamp: 0,
258
- hash: '__fail__',
259
- };
326
+ setResult(false);
260
327
  }
261
328
  }
262
329
 
@@ -278,19 +345,41 @@ async function processTranslation() {
278
345
  return logger.warn(`⚠️ Could not find translated tag for index ${i}`);
279
346
  }
280
347
 
348
+ // Extract original leading and trailing whitespace
349
+ const originalText = n.text;
350
+ const leadingWhitespace = originalText.match(/^\s*/)?.[0] || '';
351
+ const trailingWhitespace = originalText.match(/\s*$/)?.[0] || '';
352
+
353
+ // Reapply the original whitespace to the translation
354
+ const adjustedTranslation = `${leadingWhitespace}${translation.trim()}${trailingWhitespace}`;
355
+
281
356
  if (n.type === 'data') {
282
- n.reference.data = translation;
357
+ n.reference.data = adjustedTranslation;
283
358
  } else if (n.type === 'text') {
284
- n.node.text(translation);
359
+ n.node.text(adjustedTranslation);
285
360
  } else if (n.type === 'attr') {
286
- n.node.attr(n.attr, translation);
361
+ n.node.attr(n.attr, adjustedTranslation);
287
362
  }
288
- if (LOUD) logger.log(`${i}: ${n.text} → ${translation}`);
363
+ if (LOUD) logger.log(`${i}: "${n.text.trim()}""${adjustedTranslation.trim()}"`);
289
364
  });
290
365
 
291
366
  // Rewrite links
292
367
  rewriteLinks($, lang);
293
368
 
369
+ // Check that the control tag matches the expected value
370
+ const controlTag = $(`#${CONTROL}`);
371
+ if (
372
+ controlTag.length === 0
373
+ || controlTag.text() !== CONTROL
374
+ ) {
375
+ logger.error(`⚠️ Control tag mismatch in ${relativePath} [${lang}]`);
376
+
377
+ return setResult(false);
378
+ } else {
379
+ // Delete the control tag
380
+ controlTag.remove();
381
+ }
382
+
294
383
  // Set the lang attribute on the <html> tag
295
384
  $('html').attr('lang', lang);
296
385
 
@@ -377,19 +466,16 @@ async function processTranslation() {
377
466
 
378
467
  // Push updated translation cache back to uj-translations
379
468
  if (Manager.isBuildMode()) {
380
- await pushTranslationBranch(updatedFiles);
469
+ await pushTranslationBranch( );
381
470
  }
382
471
  }
383
472
 
384
473
  async function translateWithAPI(openAIKey, content, lang) {
385
- // Prompt
386
- const systemPrompt = `
387
- You are a professional translator.
388
- Translate the provided content, preserving all original formatting, HTML structure, metadata, and links.
389
- Do not explain anything — just return the translated content.
390
- 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.
391
- Translate to ${lang}.
392
- `;
474
+ const brand = config?.brand?.name || 'Unknown Brand';
475
+ const systemPrompt = template(SYSTEM_PROMPT, {
476
+ lang,
477
+ brand
478
+ });
393
479
 
394
480
  // Request
395
481
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
@@ -550,9 +636,6 @@ async function insertLanguageTags($, languages, relativePath, filePath) {
550
636
  const format = isHtml ? 'html' : 'xml';
551
637
  const formatted = await formatDocument($.html(), format);
552
638
 
553
- console.log('---SAVING filePath', filePath);
554
- console.log('---SAVING formatted.error', formatted.error);
555
-
556
639
  // Write the formatted content back to the file
557
640
  jetpack.write(filePath, formatted.content);
558
641
  }
@@ -628,6 +711,17 @@ function getIgnoredPages() {
628
711
  };
629
712
  }
630
713
 
714
+ function getGlobOptions() {
715
+ const ignoredPages = getIgnoredPages();
716
+ return {
717
+ nodir: true,
718
+ ignore: [
719
+ ...ignoredPages.files.map(key => `_site/${key}.html`),
720
+ ...ignoredPages.folders.map(folder => `_site/${folder}/**/*`)
721
+ ]
722
+ }
723
+ }
724
+
631
725
  // Git Sync: Pull
632
726
  async function fetchTranslationsBranch() {
633
727
  const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/')
@@ -724,7 +818,7 @@ async function fetchTranslationsBranch() {
724
818
  }
725
819
 
726
820
  // Git Sync: Push
727
- async function pushTranslationBranch(updatedFiles) {
821
+ async function pushTranslationBranch( ) {
728
822
  const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
729
823
  const localRoot = path.join('.temp', 'translations');
730
824
 
@@ -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,29 +51,51 @@ 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
  }
65
65
  return;
66
66
  }
67
67
 
68
+ // Handle attributes like title and placeholder
69
+ const translatableAttributes = ['title', 'placeholder', 'alt', 'aria-label', 'aria-placeholder', 'aria-describedby', 'aria-labelledby', 'value', 'label'];
70
+ translatableAttributes.forEach(attr => {
71
+ const text = node.attr(attr)?.trim();
72
+ if (text) {
73
+ const i = textNodes.length;
74
+
75
+ // Push
76
+ textNodes.push({
77
+ node,
78
+ type: 'attr',
79
+ attr,
80
+ text,
81
+ tagged: `[${i}]${text}[/${i}]`,
82
+ });
83
+ }
84
+ });
85
+
68
86
  // Handle regular DOM text nodes
69
87
  node.contents().each((_, child) => {
70
88
  if (child.type === 'text' && child.data?.trim()) {
71
89
  const i = textNodes.length;
72
90
  const text = child.data
73
- .replace(/^\s+/, '')
74
- .replace(/\s+$/, '')
91
+ // Preserve a single leading whitespace if it exists
92
+ .replace(/^\s*(\s)\s*/, '$1')
93
+ // Preserve a single trailing whitespace if it exists
94
+ .replace(/\s*(\s)\s*$/, '$1')
95
+ // Normalize internal whitespace
75
96
  .replace(/\s+/g, ' ');
76
97
 
98
+ // Push
77
99
  textNodes.push({
78
100
  node,
79
101
  type: 'data',
@@ -81,8 +103,6 @@ const collectTextNodes = ($, options) => {
81
103
  reference: child,
82
104
  text,
83
105
  tagged: `[${i}]${text}[/${i}]`,
84
- line: el.startIndex || 0, // Add line information
85
- column: 0 // Column is not directly available, default to 0
86
106
  });
87
107
  }
88
108
  });
@@ -92,3 +112,12 @@ const collectTextNodes = ($, options) => {
92
112
  };
93
113
 
94
114
  module.exports = collectTextNodes;
115
+
116
+
117
+ function trimPreserveOneCleanInner(str) {
118
+ const match = str.match(/^(\s*)(.*?)(\s*)$/);
119
+ const leading = match[1] ? match[1][0] || '' : '';
120
+ const content = match[2].replace(/\s+/g, ' ');
121
+ const trailing = match[3] ? match[3][0] || '' : '';
122
+ return leading + content + trailing;
123
+ }
@@ -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.39",
3
+ "version": "0.0.41",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {