create-portfoliable 1.0.3

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.
Files changed (62) hide show
  1. package/bin/create-portfoliable.mjs +425 -0
  2. package/cli/portfoliable.mjs +836 -0
  3. package/index.html +18 -0
  4. package/package.json +102 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/favicon.png +0 -0
  7. package/scripts/check-updates.mjs +31 -0
  8. package/scripts/ensure-valence-index-css.mjs +245 -0
  9. package/scripts/generate-password-hash.mjs +25 -0
  10. package/scripts/release-orchestrator.mjs +363 -0
  11. package/scripts/scaffold-case.mjs +460 -0
  12. package/scripts/scaffold-consumer.mjs +87 -0
  13. package/scripts/smoke-homeview.mjs +133 -0
  14. package/scripts/smoke-initializer.mjs +129 -0
  15. package/scripts/smoke-packed.mjs +184 -0
  16. package/scripts/switch-valence-source.mjs +162 -0
  17. package/scripts/sync-template-shared.mjs +136 -0
  18. package/scripts/terminal-ui.mjs +274 -0
  19. package/scripts/verify-integration.mjs +72 -0
  20. package/src/App.js +10015 -0
  21. package/src/header-visibility.contract.js +31 -0
  22. package/src/header-visibility.contract.test.js +41 -0
  23. package/src/main.js +12 -0
  24. package/src/parser/markdown.js +855 -0
  25. package/src/style.css +88 -0
  26. package/templates/README.md +242 -0
  27. package/templates/configs/i18n/i18n.config.js +230 -0
  28. package/templates/configs/i18n/i18n.labels.js +188 -0
  29. package/templates/configs/portfoliable.a11y.config.js +262 -0
  30. package/templates/configs/portfoliable.design.config.js +651 -0
  31. package/templates/gitignore +9 -0
  32. package/templates/index.html +20 -0
  33. package/templates/public/api/.htaccess +4 -0
  34. package/templates/public/api/README.md +27 -0
  35. package/templates/public/api/password.config.example.json +16 -0
  36. package/templates/public/api/password.config.json +16 -0
  37. package/templates/public/api/unlock-case.php +56 -0
  38. package/templates/public/favicon.ico +0 -0
  39. package/templates/public/favicon.png +0 -0
  40. package/templates/public/portfoliable-logo.svg +3 -0
  41. package/templates/scripts/add-language.mjs +253 -0
  42. package/templates/scripts/convert-video-to-mp4.mjs +207 -0
  43. package/templates/scripts/convert-wav-to-mp3.mjs +184 -0
  44. package/templates/scripts/delete-case.mjs +320 -0
  45. package/templates/scripts/delete-language.mjs +195 -0
  46. package/templates/scripts/generate-password-hash.mjs +98 -0
  47. package/templates/scripts/i18n-config-utils.mjs +123 -0
  48. package/templates/scripts/scaffold-case.mjs +444 -0
  49. package/templates/scripts/sync-locales.mjs +1048 -0
  50. package/templates/scripts/validate-content.mjs +257 -0
  51. package/templates/scripts/validate-protection.mjs +187 -0
  52. package/templates/src/cases/index.js +188 -0
  53. package/templates/src/content/about/ABOUTME.md +202 -0
  54. package/templates/src/content/cases/template-case/case.md +479 -0
  55. package/templates/src/content/cases/template-case/cover.png +0 -0
  56. package/templates/src/content/cases/template-case/he-cover.png +0 -0
  57. package/templates/src/content/cases/template-case/template-audio.mp3 +0 -0
  58. package/templates/src/content/cases/template-case/template-video.mp4 +0 -0
  59. package/templates/src/content/thumbnail-options.generated.json +1044 -0
  60. package/templates/src/main.js +23 -0
  61. package/templates/src/parser/markdown.js +805 -0
  62. package/vite.config.js +111 -0
@@ -0,0 +1,1048 @@
1
+ #!/usr/bin/env node
2
+ // File: scripts/sync-locales.mjs
3
+ // Purpose: Keep case/about markdown locale sections aligned with configured i18n locales.
4
+ // Author: Lio Schimanko
5
+
6
+ // MARK: IMPORTS
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { readLocaleConfigFromI18nConfig } from './i18n-config-utils.mjs';
10
+
11
+ // MARK: LOCALE SYNC CATALOGS
12
+ // Lists case config fields expected to stay localized across all supported locales.
13
+ const CASE_LOCALIZED_FIELDS = [
14
+ 'title',
15
+ 'shortDesc',
16
+ 'readTime',
17
+ 'kicker',
18
+ 'slugByLocale',
19
+ 'thumbSrc',
20
+ 'socialImage',
21
+ 'summary',
22
+ 'audioLabel',
23
+ 'audioSrc',
24
+ 'summaryProps.text',
25
+ 'summaryProps.labelHeader',
26
+ 'summaryProps.ariaLabel'
27
+ ];
28
+
29
+ const CASE_CONFIG_GROUPS = [
30
+ { title: 'Identity', keys: ['id', 'caseOrder', 'slugByLocale', 'socialImage'] },
31
+ { title: 'Localized Metadata', keys: ['title', 'shortDesc', 'readTime', 'kicker'] },
32
+ { title: 'Thumbnail and Cover', keys: ['thumbSrc', 'thumbCategory', 'thumbBrand', 'thumbModel', 'thumbColor', 'showCover'] },
33
+ { title: 'Summary Experience (config-driven)', keys: ['showSummary', 'summary', 'summaryProps'] },
34
+ { title: 'Reader Experience', keys: ['showReader', 'showToc', 'showNavigator'] },
35
+ { title: 'Audio Experience', keys: ['audioLabel', 'audioSrc', 'showPlayer'] },
36
+ { title: 'Visibility and Protection', keys: ['visibility', 'isProtected', 'isUnlocked'] },
37
+ { title: 'Social', keys: ['social'] },
38
+ { title: 'Actions', keys: ['actions'] },
39
+ { title: 'Custom Buttons', keys: ['customButtons'] }
40
+ ];
41
+
42
+ const ABOUT_CONFIG_GROUPS = [
43
+ { title: 'Identity', keys: ['slugByLocale', 'socialImage'] },
44
+ { title: 'Localized Content', keys: ['title', 'subtitle'] },
45
+ { title: 'Visibility', keys: ['visibility'] },
46
+ { title: 'Actions', keys: ['actions'] },
47
+ { title: 'Social', keys: ['social'] },
48
+ { title: 'Custom Buttons', keys: ['customButtons'] }
49
+ ];
50
+
51
+ const LABEL_GROUPS = [
52
+ {
53
+ title: 'SEO Meta',
54
+ keys: [
55
+ 'meta_home_title',
56
+ 'meta_home_description'
57
+ ]
58
+ },
59
+ {
60
+ title: 'Home View',
61
+ keys: [
62
+ 'h1_title',
63
+ 'footer_text',
64
+ 'nav_home',
65
+ 'about_title',
66
+ 'view_about_aria_label'
67
+ ]
68
+ },
69
+ {
70
+ title: 'Case View and Actions',
71
+ keys: [
72
+ 'view_case_aria_label',
73
+ 'protected_case',
74
+ 'enter_passcode',
75
+ 'unlock',
76
+ 'case_unlocked',
77
+ 'incorrect_passcode'
78
+ ]
79
+ },
80
+ {
81
+ title: 'Player View',
82
+ keys: [
83
+ 'player_video_title',
84
+ 'player_label_play',
85
+ 'player_label_pause',
86
+ 'player_label_cc_on',
87
+ 'player_label_cc_off',
88
+ 'player_label_mute',
89
+ 'player_label_unmute',
90
+ 'player_label_speed',
91
+ ]
92
+ },
93
+ {
94
+ title: 'Audio Player',
95
+ keys: [
96
+ 'player_audio_reader',
97
+ 'player_audio_play',
98
+ 'player_audio_pause',
99
+ 'player_audio_mute',
100
+ 'player_audio_unmute',
101
+ 'player_audio_speed',
102
+ 'player_audio_hide',
103
+ 'player_audio_show',
104
+ 'player_audio_autoscroll_on',
105
+ 'player_audio_autoscroll_off',
106
+ 'player_audio_volume',
107
+ 'player_audio_volume_level',
108
+ 'player_audio_position'
109
+ ]
110
+ },
111
+ {
112
+ title: 'Search and Navigator',
113
+ keys: [
114
+ 'btn_return',
115
+ 'search_cases_placeholder',
116
+ 'search_cases_tooltip_open',
117
+ 'search_cases_tooltip_close',
118
+ 'search_main_view_title',
119
+ 'search_main_view_snippet',
120
+ 'search_case_studies_label',
121
+ 'case_nav_prev',
122
+ 'case_nav_next',
123
+ 'case_nav_prev_locked',
124
+ 'case_nav_next_locked'
125
+ ]
126
+ },
127
+ {
128
+ title: 'Breadcrumb Menus',
129
+ keys: [
130
+ 'breadcrumb_menu_case_header',
131
+ 'breadcrumb_menu_video_header',
132
+ ]
133
+ },
134
+ {
135
+ title: 'Accessibility Modal',
136
+ keys: [
137
+ 'popup_a11y_title',
138
+ 'a11y_cat_typography',
139
+ 'a11y_size',
140
+ 'a11y_dyslexia',
141
+ 'a11y_cat_visuals',
142
+ 'a11y_dark',
143
+ 'a11y_contrast',
144
+ 'a11y_motion',
145
+ 'a11y_tab'
146
+ ]
147
+ },
148
+ {
149
+ title: 'Language Modal',
150
+ keys: [
151
+ 'popup_lang_title',
152
+ 'lang_cat'
153
+ ]
154
+ },
155
+ {
156
+ title: 'Sharing',
157
+ keys: [
158
+ 'share_text',
159
+ ]
160
+ },
161
+ {
162
+ title: 'Toast',
163
+ keys: [
164
+ 'resume_reading',
165
+ 'toast_label_close',
166
+ 'toast_label_never_show'
167
+ ]
168
+ },
169
+ {
170
+ title: 'Diagnostics and Errors',
171
+ keys: [
172
+ 'mermaid_error'
173
+ ]
174
+ }
175
+ ];
176
+
177
+ const GROUPED_LABEL_KEYS = LABEL_GROUPS.flatMap((group) => group.keys);
178
+ const LEGACY_LABEL_KEYS = new Set([
179
+ 'about_title_fallback',
180
+ 'summary_header_default',
181
+ 'audio_label_reader',
182
+ 'case_nav_prev_case',
183
+ 'case_nav_next_case',
184
+ 'breadcrumb_menu_case_category',
185
+ 'breadcrumb_menu_video_category',
186
+ 'nav_about',
187
+ 'case_empty_state_explore',
188
+ 'player_label_back'
189
+ ]);
190
+
191
+ // MARK: FILE AND LAYOUT HELPERS
192
+ // Recursively collects markdown files under the target content directory.
193
+ function listMarkdownFiles(dirPath) {
194
+ if (!fs.existsSync(dirPath)) return [];
195
+
196
+ const files = [];
197
+ // Walks all nested directories and captures markdown file paths.
198
+ const walk = (currentDir) => {
199
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
200
+ entries.forEach((entry) => {
201
+ const nextPath = path.join(currentDir, entry.name);
202
+ if (entry.isDirectory()) {
203
+ walk(nextPath);
204
+ return;
205
+ }
206
+ if (entry.name.toLowerCase().endsWith('.md')) {
207
+ files.push(nextPath);
208
+ }
209
+ });
210
+ };
211
+
212
+ walk(dirPath);
213
+ return files;
214
+ }
215
+
216
+ // Resolves template or consumer layout paths used by locale sync operations.
217
+ function resolveProjectLayout(cwd = process.cwd()) {
218
+ const templateModeRoot = path.join(cwd, 'templates');
219
+ const hasTemplateI18n = fs.existsSync(path.join(templateModeRoot, 'configs', 'i18n', 'i18n.config.js'));
220
+
221
+ if (hasTemplateI18n) {
222
+ return {
223
+ mode: 'template',
224
+ root: cwd,
225
+ i18nConfigPath: path.join(templateModeRoot, 'configs', 'i18n', 'i18n.config.js'),
226
+ labelsPath: path.join(templateModeRoot, 'configs', 'i18n', 'i18n.labels.js'),
227
+ casesDir: path.join(templateModeRoot, 'src', 'content', 'cases'),
228
+ aboutPath: path.join(templateModeRoot, 'src', 'content', 'about', 'ABOUTME.md')
229
+ };
230
+ }
231
+
232
+ return {
233
+ mode: 'consumer',
234
+ root: cwd,
235
+ i18nConfigPath: path.join(cwd, 'configs', 'i18n', 'i18n.config.js'),
236
+ labelsPath: path.join(cwd, 'configs', 'i18n', 'i18n.labels.js'),
237
+ casesDir: path.join(cwd, 'src', 'content', 'cases'),
238
+ aboutPath: path.join(cwd, 'src', 'content', 'about', 'ABOUTME.md')
239
+ };
240
+ }
241
+
242
+ // Loads supported locale codes and default locale from i18n config.
243
+ function loadSupportedLocales(i18nConfigPath) {
244
+ const localeConfig = readLocaleConfigFromI18nConfig(i18nConfigPath);
245
+ return {
246
+ localeCodes: localeConfig.supportedLocales,
247
+ defaultLocale: localeConfig.defaultLocale
248
+ };
249
+ }
250
+
251
+ // Parses labels file in modern JS-module format with legacy wrapper fallback.
252
+ function parseI18nLabelsFile(source) {
253
+ const raw = String(source || '');
254
+
255
+ // Preferred format: JavaScript module with `export default { ... }`.
256
+ const moduleMatch = raw.match(/^\s*export\s+default\s+([\s\S]*?)\s*;?\s*$/i);
257
+ if (moduleMatch?.[1]) {
258
+ const expression = String(moduleMatch[1]).trim();
259
+ try {
260
+ const evaluateObject = new Function(`return (${expression});`);
261
+ const parsed = evaluateObject();
262
+ return {
263
+ labelsByLocale: parsed && typeof parsed === 'object' ? parsed : {},
264
+ parseError: null
265
+ };
266
+ } catch (error) {
267
+ return { labelsByLocale: {}, parseError: error };
268
+ }
269
+ }
270
+
271
+ // Backward-compatible migration support for legacy markdown wrapper.
272
+ const markdownMatch = raw.match(/<!--\s*i18n-labels\s*([\s\S]*?)-->/i);
273
+ if (markdownMatch?.[1]) {
274
+ const expression = String(markdownMatch[1] || '').trim();
275
+ try {
276
+ const evaluateObject = new Function(`return (${expression});`);
277
+ const parsed = evaluateObject();
278
+ return {
279
+ labelsByLocale: parsed && typeof parsed === 'object' ? parsed : {},
280
+ parseError: null
281
+ };
282
+ } catch (error) {
283
+ return { labelsByLocale: {}, parseError: error };
284
+ }
285
+ }
286
+
287
+ return { labelsByLocale: {}, parseError: null };
288
+ }
289
+
290
+ // Normalizes label keys and removes deprecated key aliases.
291
+ function normalizeLabelKey(rawKey) {
292
+ const key = String(rawKey || '').trim();
293
+ if (!key) return '';
294
+ if (/^lang_[a-z0-9-]+$/i.test(key) && key !== 'lang_title') return '';
295
+ if (key === 'ph_email_lio') return 'ph_email';
296
+ return key;
297
+ }
298
+
299
+ // Sanitizes locale label maps to canonical key/value string pairs.
300
+ function sanitizeLocaleLabels(input) {
301
+ const normalized = {};
302
+ Object.entries(input || {}).forEach(([rawKey, rawValue]) => {
303
+ const key = normalizeLabelKey(rawKey);
304
+ if (!key) return;
305
+ if (LEGACY_LABEL_KEYS.has(key)) return;
306
+ if (typeof rawValue === 'string') {
307
+ normalized[key] = rawValue;
308
+ return;
309
+ }
310
+ normalized[key] = String(rawValue ?? '');
311
+ });
312
+ return normalized;
313
+ }
314
+
315
+ // Escapes label values for stable JavaScript serialization.
316
+ function escapeLabelValue(value) {
317
+ return JSON.stringify(String(value ?? ''));
318
+ }
319
+
320
+ // Builds deterministic key order by grouped labels then discovered extras.
321
+ function buildCanonicalLabelKeys(defaultLabels) {
322
+ const ordered = [];
323
+ const seen = new Set();
324
+
325
+ GROUPED_LABEL_KEYS.forEach((key) => {
326
+ if (seen.has(key)) return;
327
+ seen.add(key);
328
+ ordered.push(key);
329
+ });
330
+
331
+ Object.keys(defaultLabels || {}).forEach((key) => {
332
+ if (seen.has(key)) return;
333
+ seen.add(key);
334
+ ordered.push(key);
335
+ });
336
+
337
+ return ordered;
338
+ }
339
+
340
+ // Serializes one locale labels object grouped by semantic sections.
341
+ function serializeLocaleObjectWithGroups(localeLabels, canonicalKeys) {
342
+ const groupedOutput = [];
343
+ const covered = new Set();
344
+
345
+ LABEL_GROUPS.forEach((group) => {
346
+ const keysInGroup = group.keys.filter((key) => canonicalKeys.includes(key));
347
+ if (keysInGroup.length === 0) return;
348
+
349
+ groupedOutput.push(` // ${group.title}`);
350
+ keysInGroup.forEach((key) => {
351
+ covered.add(key);
352
+ const value = key in localeLabels ? localeLabels[key] : '';
353
+ groupedOutput.push(` ${JSON.stringify(key)}: ${escapeLabelValue(value)},`);
354
+ });
355
+ groupedOutput.push('');
356
+ });
357
+
358
+ const otherKeys = canonicalKeys.filter((key) => !covered.has(key));
359
+ if (otherKeys.length > 0) {
360
+ groupedOutput.push(' // Other');
361
+ otherKeys.forEach((key) => {
362
+ const value = key in localeLabels ? localeLabels[key] : '';
363
+ groupedOutput.push(` ${JSON.stringify(key)}: ${escapeLabelValue(value)},`);
364
+ });
365
+ groupedOutput.push('');
366
+ }
367
+
368
+ while (groupedOutput[groupedOutput.length - 1] === '') {
369
+ groupedOutput.pop();
370
+ }
371
+
372
+ return groupedOutput.join('\n');
373
+ }
374
+
375
+ // Serializes all locales into export default labels module format.
376
+ function serializeI18nLabelsJs(labelsByLocale, canonicalKeys, localeOrder) {
377
+ const lines = ['{'];
378
+
379
+ localeOrder.forEach((localeCode, index) => {
380
+ lines.push(` ${JSON.stringify(localeCode)}: {`);
381
+ lines.push(serializeLocaleObjectWithGroups(labelsByLocale[localeCode] || {}, canonicalKeys));
382
+ lines.push(index === localeOrder.length - 1 ? ' }' : ' },');
383
+ });
384
+
385
+ lines.push('}');
386
+
387
+ return `export default ${lines.join('\n')};\n`;
388
+ }
389
+
390
+ // Synchronizes labels file locale keys and values with current locale configuration.
391
+ function syncI18nLabelsFile(labelsPath, localeCodes, defaultLocale, options = {}) {
392
+ if (!fs.existsSync(labelsPath)) {
393
+ return false;
394
+ }
395
+
396
+ const pruneLocales = options.pruneLocales === true;
397
+
398
+ const source = fs.readFileSync(labelsPath, 'utf8');
399
+ const parsed = parseI18nLabelsFile(source);
400
+ if (parsed.parseError) {
401
+ console.warn(`[sync-locales] Skipping labels sync due to invalid labels object at ${labelsPath}. Fix syntax and save again.`);
402
+ return false;
403
+ }
404
+ const existingLabels = parsed.labelsByLocale && typeof parsed.labelsByLocale === 'object'
405
+ ? parsed.labelsByLocale
406
+ : {};
407
+
408
+ const normalizedLocaleMap = {};
409
+ Object.entries(existingLabels).forEach(([rawLocaleCode, localeLabels]) => {
410
+ const localeCode = String(rawLocaleCode || '').trim().toLowerCase();
411
+ if (!localeCode) return;
412
+ normalizedLocaleMap[localeCode] = sanitizeLocaleLabels(localeLabels);
413
+ });
414
+
415
+ const fallbackDefaultLocale = localeCodes[0] || Object.keys(normalizedLocaleMap)[0] || '';
416
+ const resolvedDefaultLocale = defaultLocale || fallbackDefaultLocale;
417
+ const defaultLabels = normalizedLocaleMap[resolvedDefaultLocale] && typeof normalizedLocaleMap[resolvedDefaultLocale] === 'object'
418
+ ? normalizedLocaleMap[resolvedDefaultLocale]
419
+ : {};
420
+
421
+ const canonicalKeys = buildCanonicalLabelKeys(defaultLabels);
422
+ const localeOrder = [...localeCodes];
423
+ if (!pruneLocales) {
424
+ Object.keys(normalizedLocaleMap).forEach((localeCode) => {
425
+ if (!localeOrder.includes(localeCode)) {
426
+ localeOrder.push(localeCode);
427
+ }
428
+ });
429
+ }
430
+
431
+ const nextLabelsByLocale = {};
432
+
433
+ localeOrder.forEach((localeCode) => {
434
+ const existingLocaleLabels = normalizedLocaleMap[localeCode] || {};
435
+ const nextLocaleLabels = {};
436
+
437
+ canonicalKeys.forEach((labelKey) => {
438
+ if (typeof existingLocaleLabels[labelKey] === 'string') {
439
+ nextLocaleLabels[labelKey] = existingLocaleLabels[labelKey];
440
+ return;
441
+ }
442
+
443
+ if (localeCode === resolvedDefaultLocale && typeof defaultLabels[labelKey] === 'string') {
444
+ nextLocaleLabels[labelKey] = defaultLabels[labelKey];
445
+ return;
446
+ }
447
+
448
+ nextLocaleLabels[labelKey] = '';
449
+ });
450
+
451
+ nextLabelsByLocale[localeCode] = nextLocaleLabels;
452
+ });
453
+
454
+ const nextContent = serializeI18nLabelsJs(nextLabelsByLocale, canonicalKeys, localeOrder);
455
+ if (nextContent === source) return false;
456
+ fs.writeFileSync(labelsPath, nextContent, 'utf8');
457
+ return true;
458
+ }
459
+
460
+ // Ensures an object field is represented as locale->value map with fallbacks.
461
+ function ensureLocalizedObject(target, key, localeCodes, defaultValue = '', options = {}) {
462
+ const current = target[key];
463
+ const result = {};
464
+ const preserveKeys = Array.isArray(options.preserveKeys) ? options.preserveKeys : [];
465
+ const fallbackLocale = String(options.fallbackLocale || localeCodes[0] || '').trim().toLowerCase();
466
+
467
+ preserveKeys.forEach((preserveKey) => {
468
+ if (current && typeof current === 'object' && !Array.isArray(current) && preserveKey in current) {
469
+ result[preserveKey] = current[preserveKey];
470
+ }
471
+ });
472
+
473
+ if (typeof current === 'string') {
474
+ localeCodes.forEach((localeCode) => {
475
+ result[localeCode] = current;
476
+ });
477
+ } else if (current && typeof current === 'object') {
478
+ const fallbackLocalizedValue =
479
+ (fallbackLocale && typeof current[fallbackLocale] === 'string' ? current[fallbackLocale] : '')
480
+ || Object.values(current).find((value) => typeof value === 'string')
481
+ || defaultValue;
482
+
483
+ localeCodes.forEach((localeCode) => {
484
+ const localizedValue = current[localeCode];
485
+ if (typeof localizedValue === 'string') {
486
+ result[localeCode] = localizedValue;
487
+ } else {
488
+ result[localeCode] = fallbackLocalizedValue;
489
+ }
490
+ });
491
+ } else {
492
+ localeCodes.forEach((localeCode) => {
493
+ result[localeCode] = defaultValue;
494
+ });
495
+ }
496
+
497
+ target[key] = result;
498
+ }
499
+
500
+ // Parses booleans from mixed string/boolean values with configurable fallback.
501
+ function parseBooleanLike(value, fallbackValue = true) {
502
+ if (typeof value === 'boolean') return value;
503
+ if (typeof value === 'string') {
504
+ const normalized = value.trim().toLowerCase();
505
+ if (normalized === 'true') return true;
506
+ if (normalized === 'false') return false;
507
+ }
508
+ return fallbackValue;
509
+ }
510
+
511
+ // Normalizes any value into kebab-case slug text.
512
+ function toSlug(value, fallbackValue = 'item') {
513
+ const normalized = String(value || '')
514
+ .normalize('NFD')
515
+ .replace(/[\u0300-\u036f]/g, '')
516
+ .toLowerCase()
517
+ .replace(/[^a-z0-9]+/g, '-')
518
+ .replace(/^-+|-+$/g, '');
519
+
520
+ if (normalized.length > 0) {
521
+ return normalized;
522
+ }
523
+
524
+ return String(fallbackValue || 'item')
525
+ .toLowerCase()
526
+ .replace(/[^a-z0-9]+/g, '-')
527
+ .replace(/^-+|-+$/g, '')
528
+ || 'item';
529
+ }
530
+
531
+ // Normalizes visibility flags and locale visibility map shape.
532
+ function ensureVisibilityConfig(target) {
533
+ const visibility = target.visibility && typeof target.visibility === 'object' && !Array.isArray(target.visibility)
534
+ ? { ...target.visibility }
535
+ : {};
536
+
537
+ visibility.web = parseBooleanLike(visibility.web, true);
538
+ visibility.crawlers = parseBooleanLike(visibility.crawlers, true);
539
+ visibility.ai = parseBooleanLike(visibility.ai, true);
540
+
541
+ if (!visibility.locales || typeof visibility.locales !== 'object' || Array.isArray(visibility.locales)) {
542
+ visibility.locales = {};
543
+ }
544
+
545
+ target.visibility = visibility;
546
+ }
547
+
548
+ // Parses named HTML comment config blocks and returns config + stripped body.
549
+ function parseCommentConfigBlock(source, blockName) {
550
+ const regex = new RegExp(`<!--\\s*${blockName}\\s*([\\s\\S]*?)-->`, 'i');
551
+ const match = String(source || '').match(regex);
552
+ if (!match) {
553
+ return null;
554
+ }
555
+
556
+ try {
557
+ const expression = String(match[1] || '').trim();
558
+ const evaluateObject = new Function(`return (${expression});`);
559
+ const parsed = evaluateObject();
560
+ return {
561
+ regex,
562
+ config: parsed && typeof parsed === 'object' ? parsed : {},
563
+ body: String(source || '').replace(regex, '').trim()
564
+ };
565
+ } catch {
566
+ return null;
567
+ }
568
+ }
569
+
570
+ // Serializes arbitrary values into readable multiline JSON blocks.
571
+ function serializeConfigValue(value) {
572
+ return JSON.stringify(value, null, 2);
573
+ }
574
+
575
+ // Serializes config objects with grouped section comments and deterministic key order.
576
+ function serializeGroupedConfigObject(config, groups) {
577
+ const emitted = new Set();
578
+ const lines = ['{'];
579
+
580
+ groups.forEach((group) => {
581
+ const entries = group.keys.filter((key) => Object.prototype.hasOwnProperty.call(config, key));
582
+ if (entries.length === 0) return;
583
+
584
+ lines.push(` // ${group.title}`);
585
+ entries.forEach((key) => {
586
+ emitted.add(key);
587
+ const serializedValue = serializeConfigValue(config[key]).split('\n');
588
+ if (serializedValue.length === 1) {
589
+ lines.push(` ${JSON.stringify(key)}: ${serializedValue[0]},`);
590
+ return;
591
+ }
592
+
593
+ lines.push(` ${JSON.stringify(key)}: ${serializedValue[0]}`);
594
+ serializedValue.slice(1, -1).forEach((line) => {
595
+ lines.push(` ${line}`);
596
+ });
597
+ lines.push(` ${serializedValue[serializedValue.length - 1]},`);
598
+ });
599
+ lines.push('');
600
+ });
601
+
602
+ const remainingKeys = Object.keys(config).filter((key) => !emitted.has(key));
603
+ if (remainingKeys.length > 0) {
604
+ lines.push(' // Other');
605
+ remainingKeys.forEach((key) => {
606
+ const serializedValue = serializeConfigValue(config[key]).split('\n');
607
+ if (serializedValue.length === 1) {
608
+ lines.push(` ${JSON.stringify(key)}: ${serializedValue[0]},`);
609
+ return;
610
+ }
611
+
612
+ lines.push(` ${JSON.stringify(key)}: ${serializedValue[0]}`);
613
+ serializedValue.slice(1, -1).forEach((line) => {
614
+ lines.push(` ${line}`);
615
+ });
616
+ lines.push(` ${serializedValue[serializedValue.length - 1]},`);
617
+ });
618
+ lines.push('');
619
+ }
620
+
621
+ while (lines[lines.length - 1] === '') {
622
+ lines.pop();
623
+ }
624
+
625
+ lines.push('}');
626
+ return lines.join('\n');
627
+ }
628
+
629
+ // Rebuilds a named comment config block and appends normalized markdown body.
630
+ function serializeCommentConfigBlock(blockName, config, body, groups = []) {
631
+ const configText = serializeGroupedConfigObject(config, groups);
632
+ return `<!-- ${blockName}\n${configText}\n-->\n\n${String(body || '').trim()}\n`;
633
+ }
634
+
635
+ // Splits markdown body into locale-marked sections while preserving shared content fallback.
636
+ function splitLocalizedBody(bodyText) {
637
+ const sections = {};
638
+ const regex = /<!--\s*lang:([a-z0-9-]+)\s*-->/gi;
639
+ const source = String(bodyText || '').trim();
640
+ let activeLocale = null;
641
+ let lastIndex = 0;
642
+ let hasMarkers = false;
643
+ let match;
644
+
645
+ while ((match = regex.exec(source)) !== null) {
646
+ const localeCode = String(match[1] || '').trim().toLowerCase();
647
+ if (!localeCode) continue;
648
+ hasMarkers = true;
649
+
650
+ if (activeLocale) {
651
+ sections[activeLocale] = `${sections[activeLocale] || ''}${source.slice(lastIndex, match.index)}`.trim();
652
+ }
653
+
654
+ activeLocale = localeCode;
655
+ lastIndex = regex.lastIndex;
656
+ }
657
+
658
+ if (activeLocale) {
659
+ sections[activeLocale] = `${sections[activeLocale] || ''}${source.slice(lastIndex)}`.trim();
660
+ }
661
+
662
+ return {
663
+ hasMarkers,
664
+ sections,
665
+ shared: hasMarkers ? '' : source
666
+ };
667
+ }
668
+
669
+ // Reconstructs locale sections and fills missing content with placeholders.
670
+ function buildLocalizedBody(localeCodes, parsedBody, placeholderBuilder, options = {}) {
671
+ const pruneLocales = options.pruneLocales === true;
672
+ const existingLocales = Object.keys(parsedBody.sections || {});
673
+ const outputLocales = pruneLocales
674
+ ? [...localeCodes]
675
+ : [...localeCodes, ...existingLocales.filter((localeCode) => !localeCodes.includes(localeCode))];
676
+
677
+ if (!parsedBody.hasMarkers) {
678
+ const defaultLocale = outputLocales[0] || '';
679
+ if (defaultLocale) {
680
+ parsedBody.sections[defaultLocale] = parsedBody.shared;
681
+ }
682
+ }
683
+
684
+ return outputLocales
685
+ .map((localeCode) => {
686
+ const currentSection = String(parsedBody.sections[localeCode] || '').trim();
687
+ const content = currentSection || placeholderBuilder(localeCode);
688
+ return `<!-- lang:${localeCode} -->\n${content}`;
689
+ })
690
+ .join('\n\n')
691
+ .trim();
692
+ }
693
+
694
+ // Normalizes case config schema, booleans, localized fields, and action payloads.
695
+ function syncCaseConfig(caseConfig, localeCodes) {
696
+ delete caseConfig.audioSrcRecruiter;
697
+
698
+ caseConfig.showSummary = parseBooleanLike(caseConfig.showSummary, false);
699
+ caseConfig.showReader = parseBooleanLike(caseConfig.showReader, true);
700
+ caseConfig.showPlayer = parseBooleanLike(caseConfig.showPlayer, true);
701
+ caseConfig.showToc = parseBooleanLike(caseConfig.showToc, false);
702
+ caseConfig.showNavigator = parseBooleanLike(caseConfig.showNavigator, true);
703
+ caseConfig.showCover = parseBooleanLike(caseConfig.showCover, true);
704
+
705
+ caseConfig.kicker = caseConfig.kicker ?? caseConfig.year ?? {};
706
+
707
+ ensureLocalizedObject(caseConfig, 'title', localeCodes, '', { preserveKeys: ['show'] });
708
+ if (typeof caseConfig.title.show !== 'boolean') {
709
+ caseConfig.title.show = parseBooleanLike(caseConfig.showH1, true);
710
+ }
711
+
712
+ ensureLocalizedObject(caseConfig, 'shortDesc', localeCodes, '', { preserveKeys: ['show'] });
713
+ if (typeof caseConfig.shortDesc.show !== 'boolean') {
714
+ caseConfig.shortDesc.show = parseBooleanLike(caseConfig.showH2, true);
715
+ }
716
+
717
+ ['readTime', 'kicker', 'thumbSrc', 'summary', 'audioLabel', 'audioSrc'].forEach((fieldName) => {
718
+ ensureLocalizedObject(caseConfig, fieldName, localeCodes, '');
719
+ });
720
+
721
+ ensureLocalizedObject(caseConfig, 'socialImage', localeCodes, '');
722
+ ensureLocalizedObject(caseConfig, 'slugByLocale', localeCodes, '');
723
+ localeCodes.forEach((localeCode) => {
724
+ const localizedTitle = String(caseConfig.title?.[localeCode] || '').trim();
725
+ const localizedSlug = String(caseConfig.slugByLocale?.[localeCode] || '').trim();
726
+ if (localizedSlug) {
727
+ caseConfig.slugByLocale[localeCode] = toSlug(localizedSlug, caseConfig.id || 'case');
728
+ return;
729
+ }
730
+
731
+ caseConfig.slugByLocale[localeCode] = toSlug(localizedTitle, caseConfig.id || 'case');
732
+ });
733
+
734
+ ensureVisibilityConfig(caseConfig);
735
+ caseConfig.isProtected = parseBooleanLike(caseConfig.isProtected, false);
736
+ caseConfig.isUnlocked = parseBooleanLike(caseConfig.isUnlocked, false);
737
+
738
+ if (!caseConfig.summaryProps || typeof caseConfig.summaryProps !== 'object' || Array.isArray(caseConfig.summaryProps)) {
739
+ caseConfig.summaryProps = {};
740
+ }
741
+
742
+ if (typeof caseConfig.summaryProps.labelHeader === 'undefined' && typeof caseConfig.summaryProps['label-header'] !== 'undefined') {
743
+ caseConfig.summaryProps.labelHeader = caseConfig.summaryProps['label-header'];
744
+ }
745
+
746
+ if (typeof caseConfig.summaryProps.ariaLabel === 'undefined' && typeof caseConfig.summaryProps['aria-label'] !== 'undefined') {
747
+ caseConfig.summaryProps.ariaLabel = caseConfig.summaryProps['aria-label'];
748
+ }
749
+
750
+ if (typeof caseConfig.summaryProps.showMetrics === 'undefined' && typeof caseConfig.summaryProps['show-metrics'] !== 'undefined') {
751
+ caseConfig.summaryProps.showMetrics = caseConfig.summaryProps['show-metrics'];
752
+ }
753
+
754
+ delete caseConfig.summaryProps['label-header'];
755
+ delete caseConfig.summaryProps['aria-label'];
756
+ delete caseConfig.summaryProps['show-metrics'];
757
+
758
+ ensureLocalizedObject(caseConfig.summaryProps, 'text', localeCodes, '');
759
+ ensureLocalizedObject(caseConfig.summaryProps, 'labelHeader', localeCodes, '');
760
+ ensureLocalizedObject(caseConfig.summaryProps, 'ariaLabel', localeCodes, '');
761
+ caseConfig.summaryProps.active = parseBooleanLike(caseConfig.summaryProps.active, false);
762
+ caseConfig.summaryProps.showMetrics = parseBooleanLike(caseConfig.summaryProps.showMetrics, false);
763
+
764
+ delete caseConfig.year;
765
+ delete caseConfig.slug;
766
+ delete caseConfig.showH1;
767
+ delete caseConfig.showH2;
768
+
769
+ if (!caseConfig.actions || typeof caseConfig.actions !== 'object') {
770
+ caseConfig.actions = {};
771
+ }
772
+
773
+ const primaryAction = caseConfig.actions.primary && typeof caseConfig.actions.primary === 'object'
774
+ ? caseConfig.actions.primary
775
+ : (caseConfig.actions.primary = {});
776
+ const secondaryAction = caseConfig.actions.secondary && typeof caseConfig.actions.secondary === 'object'
777
+ ? caseConfig.actions.secondary
778
+ : (caseConfig.actions.secondary1 && typeof caseConfig.actions.secondary1 === 'object'
779
+ ? caseConfig.actions.secondary1
780
+ : (caseConfig.actions.secondary = {}));
781
+ const tertiaryAction = caseConfig.actions.tertiary && typeof caseConfig.actions.tertiary === 'object'
782
+ ? caseConfig.actions.tertiary
783
+ : (caseConfig.actions.secondary2 && typeof caseConfig.actions.secondary2 === 'object'
784
+ ? caseConfig.actions.secondary2
785
+ : (caseConfig.actions.tertiary = {}));
786
+
787
+ if (!primaryAction.videoSrc && caseConfig.videoSrc) {
788
+ primaryAction.videoSrc = caseConfig.videoSrc;
789
+ }
790
+ if (!primaryAction.vttSrc && caseConfig.vttSrc) {
791
+ primaryAction.vttSrc = caseConfig.vttSrc;
792
+ }
793
+ if (!secondaryAction.url && caseConfig.repoSrc) {
794
+ secondaryAction.url = caseConfig.repoSrc;
795
+ }
796
+ if (!tertiaryAction.url && caseConfig.demoSrc) {
797
+ tertiaryAction.url = caseConfig.demoSrc;
798
+ }
799
+
800
+ ensureLocalizedObject(primaryAction, 'videoSrc', localeCodes, '');
801
+ ensureLocalizedObject(primaryAction, 'vttSrc', localeCodes, '');
802
+ ensureLocalizedObject(primaryAction, 'url', localeCodes, '');
803
+ ensureLocalizedObject(secondaryAction, 'url', localeCodes, '');
804
+ ensureLocalizedObject(tertiaryAction, 'url', localeCodes, '');
805
+
806
+ delete caseConfig.videoSrc;
807
+ delete caseConfig.vttSrc;
808
+ delete caseConfig.repoSrc;
809
+ delete caseConfig.demoSrc;
810
+
811
+ if (caseConfig.actions && typeof caseConfig.actions === 'object') {
812
+ ['primary', 'secondary', 'tertiary', 'secondary1', 'secondary2'].forEach((actionKey) => {
813
+ const action = caseConfig.actions[actionKey];
814
+ if (!action || typeof action !== 'object') return;
815
+ ensureLocalizedObject(action, 'label', localeCodes, '');
816
+ ensureLocalizedObject(action, 'tooltip', localeCodes, '');
817
+ ensureLocalizedObject(action, 'imageAlt', localeCodes, '');
818
+ ensureLocalizedObject(action, 'ariaLabel', localeCodes, '');
819
+ });
820
+ }
821
+
822
+ if (Array.isArray(caseConfig.customButtons)) {
823
+ caseConfig.customButtons.forEach((button) => {
824
+ if (!button || typeof button !== 'object') return;
825
+ ensureLocalizedObject(button, 'label', localeCodes, '');
826
+ ensureLocalizedObject(button, 'tooltip', localeCodes, '');
827
+ ensureLocalizedObject(button, 'imageAlt', localeCodes, '');
828
+ ensureLocalizedObject(button, 'ariaLabel', localeCodes, '');
829
+ });
830
+ }
831
+
832
+ const caseSocial = caseConfig.social && typeof caseConfig.social === 'object' ? caseConfig.social : null;
833
+ if (caseSocial) {
834
+ Object.entries(caseSocial).forEach(([platformKey, platformConfig]) => {
835
+ if (platformKey === 'links') return;
836
+ if (!platformConfig || typeof platformConfig !== 'object') return;
837
+ ensureLocalizedObject(platformConfig, 'tooltip', localeCodes, '');
838
+ });
839
+ }
840
+ }
841
+
842
+ // Normalizes about config schema, localized fields, and social/action tooltip localization.
843
+ function syncAboutConfig(aboutConfig, localeCodes) {
844
+ ensureLocalizedObject(aboutConfig, 'title', localeCodes, 'About Me', { preserveKeys: ['show'] });
845
+ ensureLocalizedObject(aboutConfig, 'subtitle', localeCodes, '', { preserveKeys: ['show'] });
846
+ ensureLocalizedObject(aboutConfig, 'slugByLocale', localeCodes, '');
847
+
848
+ if (typeof aboutConfig.title.show !== 'boolean') {
849
+ aboutConfig.title.show = parseBooleanLike(aboutConfig.showH1, true);
850
+ }
851
+
852
+ if (typeof aboutConfig.subtitle.show !== 'boolean') {
853
+ aboutConfig.subtitle.show = parseBooleanLike(aboutConfig.showH2, true);
854
+ }
855
+
856
+ delete aboutConfig.showH1;
857
+ delete aboutConfig.showH2;
858
+
859
+ localeCodes.forEach((localeCode) => {
860
+ const localizedTitle = String(aboutConfig.title?.[localeCode] || '').trim();
861
+ const localizedSlug = String(aboutConfig.slugByLocale?.[localeCode] || '').trim();
862
+ if (localizedSlug) {
863
+ aboutConfig.slugByLocale[localeCode] = toSlug(localizedSlug, 'about');
864
+ return;
865
+ }
866
+
867
+ aboutConfig.slugByLocale[localeCode] = toSlug(localizedTitle, 'about');
868
+ });
869
+
870
+ ensureVisibilityConfig(aboutConfig);
871
+
872
+ if (aboutConfig.actions && typeof aboutConfig.actions === 'object') {
873
+ ['primary', 'secondary', 'tertiary', 'secondary1', 'secondary2'].forEach((actionKey) => {
874
+ const action = aboutConfig.actions[actionKey];
875
+ if (!action || typeof action !== 'object') return;
876
+ ensureLocalizedObject(action, 'label', localeCodes, '');
877
+ ensureLocalizedObject(action, 'tooltip', localeCodes, '');
878
+ ensureLocalizedObject(action, 'imageAlt', localeCodes, '');
879
+ ensureLocalizedObject(action, 'ariaLabel', localeCodes, '');
880
+ });
881
+ }
882
+
883
+ if (Array.isArray(aboutConfig.customButtons)) {
884
+ aboutConfig.customButtons.forEach((button) => {
885
+ if (!button || typeof button !== 'object') return;
886
+ ensureLocalizedObject(button, 'label', localeCodes, '');
887
+ ensureLocalizedObject(button, 'tooltip', localeCodes, '');
888
+ ensureLocalizedObject(button, 'imageAlt', localeCodes, '');
889
+ ensureLocalizedObject(button, 'ariaLabel', localeCodes, '');
890
+ });
891
+ }
892
+
893
+ const aboutSocial = aboutConfig.social && typeof aboutConfig.social === 'object' ? aboutConfig.social : null;
894
+ if (aboutSocial) {
895
+ Object.entries(aboutSocial).forEach(([platformKey, platformConfig]) => {
896
+ if (platformKey === 'links') return;
897
+ if (!platformConfig || typeof platformConfig !== 'object') return;
898
+ ensureLocalizedObject(platformConfig, 'tooltip', localeCodes, '');
899
+ });
900
+ }
901
+ }
902
+
903
+ // Synchronizes one case markdown file config block and localized body sections.
904
+ function syncCaseFile(filePath, localeCodes, options = {}) {
905
+ const source = fs.readFileSync(filePath, 'utf8');
906
+ const parsed = parseCommentConfigBlock(source, 'config');
907
+ if (!parsed) return false;
908
+
909
+ syncCaseConfig(parsed.config, localeCodes);
910
+
911
+ const localizedBody = buildLocalizedBody(
912
+ localeCodes,
913
+ splitLocalizedBody(parsed.body),
914
+ (localeCode) => `## Context\nAdd localized context in ${localeCode}.\n\n## Outcome\nAdd localized outcome in ${localeCode}.`,
915
+ options
916
+ );
917
+
918
+ const nextContent = serializeCommentConfigBlock('config', parsed.config, localizedBody, CASE_CONFIG_GROUPS);
919
+ if (nextContent === source) return false;
920
+
921
+ fs.writeFileSync(filePath, nextContent, 'utf8');
922
+ return true;
923
+ }
924
+
925
+ // Synchronizes about markdown config block and localized body sections.
926
+ function syncAboutFile(filePath, localeCodes, options = {}) {
927
+ if (!fs.existsSync(filePath)) return false;
928
+
929
+ const source = fs.readFileSync(filePath, 'utf8');
930
+ const parsed = parseCommentConfigBlock(source, 'about-config');
931
+ if (!parsed) return false;
932
+
933
+ syncAboutConfig(parsed.config, localeCodes);
934
+
935
+ const localizedBody = buildLocalizedBody(
936
+ localeCodes,
937
+ splitLocalizedBody(parsed.body),
938
+ (localeCode) => `# About Me\n\nAdd localized About content in ${localeCode}.`,
939
+ options
940
+ );
941
+
942
+ const nextContent = serializeCommentConfigBlock('about-config', parsed.config, localizedBody, ABOUT_CONFIG_GROUPS);
943
+ if (nextContent === source) return false;
944
+
945
+ fs.writeFileSync(filePath, nextContent, 'utf8');
946
+ return true;
947
+ }
948
+
949
+ // MARK: PUBLIC SYNC API
950
+ // Runs one locale sync pass across labels, all case files, and about content.
951
+ export async function runLocaleSync(options = {}) {
952
+ const layout = resolveProjectLayout(options.cwd || process.cwd());
953
+ const localeConfig = loadSupportedLocales(layout.i18nConfigPath);
954
+ const localeCodes = localeConfig.localeCodes;
955
+ const cases = listMarkdownFiles(layout.casesDir);
956
+ const syncOptions = {
957
+ pruneLocales: options.pruneLocales === true
958
+ };
959
+
960
+ let updatedCount = 0;
961
+
962
+ if (syncI18nLabelsFile(layout.labelsPath, localeCodes, localeConfig.defaultLocale, syncOptions)) {
963
+ updatedCount += 1;
964
+ }
965
+
966
+ cases.forEach((filePath) => {
967
+ if (syncCaseFile(filePath, localeCodes, syncOptions)) {
968
+ updatedCount += 1;
969
+ }
970
+ });
971
+
972
+ if (syncAboutFile(layout.aboutPath, localeCodes, syncOptions)) {
973
+ updatedCount += 1;
974
+ }
975
+
976
+ return {
977
+ mode: layout.mode,
978
+ localeCodes,
979
+ scannedCases: cases.length,
980
+ updatedFiles: updatedCount,
981
+ i18nConfigPath: layout.i18nConfigPath,
982
+ labelsPath: layout.labelsPath
983
+ };
984
+ }
985
+
986
+ // Watches locale config changes and re-runs locale sync with debounce.
987
+ export function watchLocaleSync(options = {}) {
988
+ const layout = resolveProjectLayout(options.cwd || process.cwd());
989
+ const logger = options.logger || console;
990
+ let disposed = false;
991
+ let timer = null;
992
+
993
+ // Executes immediate sync and logs status updates through provided logger.
994
+ const runNow = async () => {
995
+ try {
996
+ const result = await runLocaleSync({ cwd: layout.root });
997
+ logger.log(`[sync-locales] Synced ${result.updatedFiles} file(s) for locales: ${result.localeCodes.join(', ')}`);
998
+ } catch (error) {
999
+ logger.error('[sync-locales] Failed to sync locales:', error);
1000
+ }
1001
+ };
1002
+
1003
+ runNow();
1004
+
1005
+ // Watch only locale configuration in dev mode to avoid rewriting labels on each manual labels edit.
1006
+ const watchTargets = [layout.i18nConfigPath].filter((filePath) => fs.existsSync(filePath));
1007
+ const watchers = watchTargets.map((filePath) => fs.watch(filePath, () => {
1008
+ if (disposed) return;
1009
+ if (timer) clearTimeout(timer);
1010
+ timer = setTimeout(() => {
1011
+ runNow();
1012
+ }, 180);
1013
+ }));
1014
+
1015
+ return () => {
1016
+ disposed = true;
1017
+ if (timer) clearTimeout(timer);
1018
+ watchers.forEach((watcher) => watcher.close());
1019
+ };
1020
+ }
1021
+
1022
+ // MARK: SCRIPT ENTRYPOINT
1023
+ // CLI entrypoint for one-shot sync and watch mode.
1024
+ if (import.meta.url === `file://${process.argv[1]}`) {
1025
+ const args = new Set(process.argv.slice(2));
1026
+ const watchMode = args.has('--watch');
1027
+
1028
+ if (watchMode) {
1029
+ const dispose = watchLocaleSync();
1030
+ process.on('SIGINT', () => {
1031
+ dispose();
1032
+ process.exit(0);
1033
+ });
1034
+ process.on('SIGTERM', () => {
1035
+ dispose();
1036
+ process.exit(0);
1037
+ });
1038
+ } else {
1039
+ runLocaleSync()
1040
+ .then((result) => {
1041
+ console.log(`[sync-locales] Synced ${result.updatedFiles} file(s) for locales: ${result.localeCodes.join(', ')}`);
1042
+ })
1043
+ .catch((error) => {
1044
+ console.error('[sync-locales] Failed:', error);
1045
+ process.exit(1);
1046
+ });
1047
+ }
1048
+ }