@jjlmoya/utils-games-development 1.52.0 → 1.53.0

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -2
  3. package/src/tests/locale_completeness.test.ts +1 -1
  4. package/src/tests/tool_validation.test.ts +1 -1
  5. package/src/tool/localizationSanitizer/app-client.ts +270 -0
  6. package/src/tool/localizationSanitizer/bibliography.astro +6 -0
  7. package/src/tool/localizationSanitizer/bibliography.ts +14 -0
  8. package/src/tool/localizationSanitizer/component.astro +116 -0
  9. package/src/tool/localizationSanitizer/csv-parser.ts +95 -0
  10. package/src/tool/localizationSanitizer/csv-sanitizer.ts +98 -0
  11. package/src/tool/localizationSanitizer/entry.ts +28 -0
  12. package/src/tool/localizationSanitizer/i18n/de.ts +176 -0
  13. package/src/tool/localizationSanitizer/i18n/en.ts +176 -0
  14. package/src/tool/localizationSanitizer/i18n/es.ts +176 -0
  15. package/src/tool/localizationSanitizer/i18n/fr.ts +176 -0
  16. package/src/tool/localizationSanitizer/i18n/id.ts +176 -0
  17. package/src/tool/localizationSanitizer/i18n/it.ts +176 -0
  18. package/src/tool/localizationSanitizer/i18n/ja.ts +176 -0
  19. package/src/tool/localizationSanitizer/i18n/ko.ts +176 -0
  20. package/src/tool/localizationSanitizer/i18n/nl.ts +176 -0
  21. package/src/tool/localizationSanitizer/i18n/pl.ts +176 -0
  22. package/src/tool/localizationSanitizer/i18n/pt.ts +176 -0
  23. package/src/tool/localizationSanitizer/i18n/ru.ts +176 -0
  24. package/src/tool/localizationSanitizer/i18n/sv.ts +176 -0
  25. package/src/tool/localizationSanitizer/i18n/tr.ts +176 -0
  26. package/src/tool/localizationSanitizer/i18n/zh.ts +176 -0
  27. package/src/tool/localizationSanitizer/index.ts +12 -0
  28. package/src/tool/localizationSanitizer/json-sanitizer.ts +118 -0
  29. package/src/tool/localizationSanitizer/localization-sanitizer.css +634 -0
  30. package/src/tool/localizationSanitizer/logic.test.ts +38 -0
  31. package/src/tool/localizationSanitizer/logic.ts +23 -0
  32. package/src/tool/localizationSanitizer/seo.astro +15 -0
  33. package/src/tool/localizationSanitizer/types.ts +55 -0
  34. package/src/tool/localizationSanitizer/ui.ts +39 -0
  35. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-games-development",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -8,12 +8,14 @@ export { audioLoopPointFinder } from './tool/audioLoopPointFinder/entry';
8
8
  export type { AudioLoopPointFinderUI, AudioLoopPointFinderLocaleContent } from './tool/audioLoopPointFinder/entry';
9
9
  export { saveFileEditor } from './tool/saveFileEditor/entry';
10
10
  export type { SaveFileEditorUI, SaveFileEditorLocaleContent } from './tool/saveFileEditor/entry';
11
+ export { localizationSanitizer } from './tool/localizationSanitizer/entry';
12
+ export type { LocalizationSanitizerUI, LocalizationSanitizerLocaleContent } from './tool/localizationSanitizer/entry';
11
13
  export { gamesCategory } from './category';
12
14
  import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
13
15
  import { itchioGameTester } from './tool/itchioGameTester/entry';
14
16
  import { spriteSheetPacker } from './tool/spriteSheetPacker/entry';
15
17
  import { audioLoopPointFinder } from './tool/audioLoopPointFinder/entry';
16
18
  import { saveFileEditor } from './tool/saveFileEditor/entry';
19
+ import { localizationSanitizer } from './tool/localizationSanitizer/entry';
17
20
 
18
- export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor];
19
-
21
+ export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer];
@@ -3,6 +3,6 @@ import { ALL_TOOLS } from '../tools';
3
3
 
4
4
  describe('Locale Completeness Validation', () => {
5
5
  it('five tools are registered', () => {
6
- expect(ALL_TOOLS.length).toBe(5);
6
+ expect(ALL_TOOLS.length).toBe(6);
7
7
  });
8
8
  });
@@ -5,7 +5,7 @@ import { gamesCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have 5 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(5);
8
+ expect(ALL_TOOLS.length).toBe(6);
9
9
  });
10
10
 
11
11
  it('gamesCategory should be defined', () => {
@@ -0,0 +1,270 @@
1
+ import { detectTranslationFormat, sanitizeTranslationFile, type SanitizerIssue, type SanitizerResult, type TranslationFormat } from './logic';
2
+
3
+ const SAMPLE_CSV = 'key,en,es,fr\nmenu.play,Play,"Jugar, ahora",Jouer\nmenu.pause,Pause,,Pause\nmenu.play,Play again,Reproducir,Jouer encore\nmenu.quit,Quit,Salir,\n';
4
+
5
+ function createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, className?: string): HTMLElementTagNameMap[K] {
6
+ const element = document.createElement(tagName);
7
+ if (className) element.className = className;
8
+ return element;
9
+ }
10
+
11
+ function datasetText(root: HTMLElement, key: string): string { return root.dataset[key] || ''; }
12
+
13
+ function issueLabel(root: HTMLElement, item: SanitizerIssue): string {
14
+ const labels: Record<SanitizerIssue['kind'], string> = {
15
+ empty: datasetText(root, 'emptyIssue'),
16
+ duplicate: datasetText(root, 'duplicateIssue'),
17
+ malformed: datasetText(root, 'malformedIssue'),
18
+ parse: datasetText(root, 'parseIssue'),
19
+ };
20
+ return labels[item.kind];
21
+ }
22
+
23
+ function renderIssueCard(root: HTMLElement, item: SanitizerIssue): HTMLElement {
24
+ const card = createElement('div', `ls-issue-card ls-issue-${item.kind}`);
25
+ const marker = createElement('span', 'ls-issue-marker');
26
+ const copy = createElement('div', 'ls-issue-copy');
27
+ const title = createElement('strong');
28
+ title.textContent = issueLabel(root, item);
29
+ const detail = createElement('span');
30
+ const location = item.kind === 'parse' ? '' : `${datasetText(root, 'rowLabel')} ${item.row} · ${item.column ? `${datasetText(root, 'columnLabel')} ${item.column}` : datasetText(root, 'keyLabel')} ${item.key || '—'}`;
31
+ detail.textContent = location;
32
+ copy.append(title, detail);
33
+ card.append(marker, copy);
34
+ return card;
35
+ }
36
+
37
+ function renderIssues(root: HTMLElement, result: SanitizerResult | null): void {
38
+ const list = root.querySelector<HTMLElement>('#ls-issue-list');
39
+ if (!list) return;
40
+ list.replaceChildren();
41
+ if (!result) return;
42
+ if (result.issues.length === 0) {
43
+ const success = createElement('div', 'ls-no-issues');
44
+ success.textContent = datasetText(root, 'noIssues');
45
+ list.append(success);
46
+ return;
47
+ }
48
+ result.issues.slice(0, 40).forEach((item) => list.append(renderIssueCard(root, item)));
49
+ if (result.issues.length > 40) {
50
+ const more = createElement('span', 'ls-issue-more');
51
+ more.textContent = `+${result.issues.length - 40}`;
52
+ list.append(more);
53
+ }
54
+ }
55
+
56
+ function renderEmptyState(root: HTMLElement): HTMLElement {
57
+ const empty = createElement('div', 'ls-empty-state');
58
+ const grid = createElement('span', 'ls-empty-grid');
59
+ grid.setAttribute('aria-hidden', 'true');
60
+ const title = createElement('strong');
61
+ title.textContent = datasetText(root, 'waitingTitle');
62
+ const subtitle = createElement('span');
63
+ subtitle.textContent = datasetText(root, 'waitingSubtitle');
64
+ empty.append(grid, title, subtitle);
65
+ return empty;
66
+ }
67
+
68
+ function renderPreviewTable(result: SanitizerResult): HTMLElement {
69
+ const table = createElement('table', 'ls-table');
70
+ const thead = createElement('thead');
71
+ const headingRow = createElement('tr');
72
+ result.headers.forEach((header) => {
73
+ const heading = createElement('th');
74
+ heading.textContent = header;
75
+ headingRow.append(heading);
76
+ });
77
+ thead.append(headingRow);
78
+ const body = createElement('tbody');
79
+ result.rows.slice(0, 12).forEach((row) => {
80
+ const tableRow = createElement('tr');
81
+ row.forEach((value) => {
82
+ const cell = createElement('td');
83
+ cell.textContent = value;
84
+ if (!value.trim()) cell.classList.add('is-empty');
85
+ tableRow.append(cell);
86
+ });
87
+ body.append(tableRow);
88
+ });
89
+ table.append(thead, body);
90
+ return table;
91
+ }
92
+
93
+ function renderPreview(root: HTMLElement, result: SanitizerResult | null): void {
94
+ const preview = root.querySelector<HTMLElement>('#ls-preview');
95
+ if (!preview) return;
96
+ preview.replaceChildren();
97
+ if (!result || !result.valid) {
98
+ preview.append(renderEmptyState(root));
99
+ return;
100
+ }
101
+ preview.append(renderPreviewTable(result));
102
+ if (result.rows.length > 12) {
103
+ const note = createElement('span', 'ls-preview-note');
104
+ note.textContent = `+${result.rows.length - 12}`;
105
+ preview.append(note);
106
+ }
107
+ }
108
+
109
+ function updateElementText(root: HTMLElement, selector: string, text: string): void {
110
+ const element = root.querySelector<HTMLElement>(selector);
111
+ if (element) element.textContent = text;
112
+ }
113
+
114
+ function updateCounts(root: HTMLElement, result: SanitizerResult | null): void {
115
+ updateElementText(root, '#ls-empty-count', result ? String(result.stats.emptyCells) : '0');
116
+ updateElementText(root, '#ls-duplicate-count', result ? String(result.stats.duplicateKeys) : '0');
117
+ updateElementText(root, '#ls-malformed-count', result ? String(result.stats.malformedRows) : '0');
118
+ updateElementText(root, '#ls-clean-count', result ? String(result.stats.cleanRows) : '0');
119
+ }
120
+
121
+ function updateHealth(root: HTMLElement, result: SanitizerResult | null): void {
122
+ const ring = root.querySelector<HTMLElement>('#ls-health-ring');
123
+ if (!result) {
124
+ updateElementText(root, '#ls-health-value', '--');
125
+ updateElementText(root, '#ls-health-status', datasetText(root, 'waitingTitle'));
126
+ updateElementText(root, '#ls-health-subtitle', datasetText(root, 'waitingSubtitle'));
127
+ if (ring) ring.style.setProperty('--health-angle', '0deg');
128
+ return;
129
+ }
130
+ const { stats } = result;
131
+ const score = result.valid ? Math.max(0, 100 - (stats.emptyCells * 4) - (stats.duplicateKeys * 18) - (stats.malformedRows * 12)) : 0;
132
+ updateElementText(root, '#ls-health-value', `${score}`);
133
+ updateElementText(root, '#ls-health-status', score === 100 ? datasetText(root, 'readyStatus') : datasetText(root, 'reviewStatus'));
134
+ updateElementText(root, '#ls-health-subtitle', result.valid ? `${stats.totalRows} → ${stats.cleanRows}` : datasetText(root, 'parseIssue'));
135
+ if (ring) ring.style.setProperty('--health-angle', `${score * 3.6}deg`);
136
+ }
137
+
138
+ function updateButtons(root: HTMLElement, result: SanitizerResult | null): void {
139
+ const downloadButton = root.querySelector<HTMLButtonElement>('#ls-download-button');
140
+ const copyButton = root.querySelector<HTMLButtonElement>('#ls-copy-button');
141
+ const disabled = !result || !result.valid || result.cleanText.length === 0;
142
+ if (downloadButton) downloadButton.disabled = disabled;
143
+ if (copyButton) copyButton.disabled = disabled;
144
+ }
145
+
146
+ function renderResult(root: HTMLElement, result: SanitizerResult | null): void {
147
+ updateCounts(root, result);
148
+ updateHealth(root, result);
149
+ updateButtons(root, result);
150
+ }
151
+
152
+ function setFormatButtons(root: HTMLElement, format: TranslationFormat): void {
153
+ root.querySelectorAll<HTMLButtonElement>('.ls-format-button[data-format]').forEach((button) => {
154
+ const isActive = button.dataset.format === format;
155
+ button.classList.toggle('is-active', isActive);
156
+ button.setAttribute('aria-pressed', String(isActive));
157
+ });
158
+ }
159
+
160
+ interface AppState {
161
+ source: string;
162
+ format: TranslationFormat;
163
+ fileName: string;
164
+ result: SanitizerResult | null;
165
+ }
166
+
167
+ function bindDragEvents(dropZone: HTMLElement, onFile: (file: File) => void): void {
168
+ dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('is-dragging'); });
169
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('is-dragging'));
170
+ dropZone.addEventListener('drop', (e) => {
171
+ e.preventDefault();
172
+ dropZone.classList.remove('is-dragging');
173
+ if (e.dataTransfer?.files[0]) onFile(e.dataTransfer.files[0]);
174
+ });
175
+ }
176
+
177
+ function bindDropZoneInput(dropZone: HTMLElement, fileInput: HTMLInputElement, onFile: (file: File) => void): void {
178
+ dropZone.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) fileInput.click(); });
179
+ dropZone.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } });
180
+ bindDragEvents(dropZone, onFile);
181
+ }
182
+
183
+ function bindExportActions(root: HTMLElement, getState: () => AppState, announce: (msg: string) => void): void {
184
+ root.querySelector('#ls-download-button')?.addEventListener('click', () => {
185
+ const state = getState();
186
+ if (!state.result?.valid) return;
187
+ const blob = new Blob([state.result.cleanText], { type: state.format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json;charset=utf-8' });
188
+ const url = URL.createObjectURL(blob);
189
+ const a = createElement('a');
190
+ a.href = url;
191
+ a.download = `${state.fileName}.clean.${state.format === 'csv' ? 'csv' : 'json'}`;
192
+ a.click();
193
+ URL.revokeObjectURL(url);
194
+ });
195
+ root.querySelector('#ls-copy-button')?.addEventListener('click', async () => {
196
+ const state = getState();
197
+ if (!state.result?.valid) return;
198
+ try {
199
+ await navigator.clipboard.writeText(state.result.cleanText);
200
+ announce(datasetText(root, 'copiedMessage'));
201
+ } catch { announce(datasetText(root, 'parseIssue')); }
202
+ });
203
+ }
204
+
205
+ interface AppHandlers {
206
+ processSource: (source: string, name: string, format?: TranslationFormat) => void;
207
+ clearWorkspace: () => void;
208
+ readFile: (file: File) => void;
209
+ }
210
+
211
+ function bindAppEvents(root: HTMLElement, state: AppState, handlers: AppHandlers, announce: (msg: string) => void): void {
212
+ const fileInput = root.querySelector<HTMLInputElement>('#ls-file-input');
213
+ const dropZone = root.querySelector<HTMLElement>('#ls-drop-zone');
214
+ root.querySelector('#ls-browse-button')?.addEventListener('click', () => fileInput?.click());
215
+ if (dropZone && fileInput) bindDropZoneInput(dropZone, fileInput, handlers.readFile);
216
+ fileInput?.addEventListener('change', () => { if (fileInput.files?.[0]) handlers.readFile(fileInput.files[0]); });
217
+ root.querySelectorAll<HTMLButtonElement>('.ls-format-button').forEach((b) => {
218
+ b.addEventListener('click', () => {
219
+ const fmt = b.dataset.format as TranslationFormat;
220
+ state.format = fmt;
221
+ setFormatButtons(root, fmt);
222
+ if (state.source) handlers.processSource(state.source, `${state.fileName}.${fmt}`, fmt);
223
+ });
224
+ });
225
+ root.querySelector('#ls-sample-button')?.addEventListener('click', () => handlers.processSource(SAMPLE_CSV, datasetText(root, 'sampleFileName'), 'csv'));
226
+ root.querySelector('#ls-clear-button')?.addEventListener('click', handlers.clearWorkspace);
227
+ bindExportActions(root, () => state, announce);
228
+ }
229
+
230
+ function createSourceProcessor(root: HTMLElement, state: AppState, announce: (msg: string) => void) {
231
+ const fileNameEl = root.querySelector<HTMLElement>('#ls-file-name');
232
+ return (source: string, name: string, format = detectTranslationFormat(name, source)) => {
233
+ state.source = source;
234
+ state.fileName = name.replace(/\.[^/.]+$/, '') || 'localization';
235
+ state.format = format;
236
+ setFormatButtons(root, format);
237
+ state.result = sanitizeTranslationFile(source, format);
238
+ if (fileNameEl) fileNameEl.textContent = name;
239
+ renderResult(root, state.result);
240
+ renderIssues(root, state.result);
241
+ renderPreview(root, state.result);
242
+ announce(state.result.valid ? `${state.result.stats.cleanRows}` : datasetText(root, 'parseIssue'));
243
+ };
244
+ }
245
+
246
+ export function initLocalizationSanitizerApp(): void {
247
+ const root = document.getElementById('localization-sanitizer-root');
248
+ if (!root || root.dataset.initialized === 'true') return;
249
+ root.dataset.initialized = 'true';
250
+ const fileInput = root.querySelector<HTMLInputElement>('#ls-file-input');
251
+ const fileNameEl = root.querySelector<HTMLElement>('#ls-file-name');
252
+ const liveStatus = root.querySelector<HTMLElement>('#ls-live-status');
253
+ const state: AppState = { source: '', format: 'csv', fileName: 'localization', result: null };
254
+ const announce = (msg: string) => { if (liveStatus) liveStatus.textContent = msg; };
255
+ const processSource = createSourceProcessor(root, state, announce);
256
+ const clearWorkspace = () => {
257
+ state.source = ''; state.result = null; state.fileName = 'localization';
258
+ if (fileInput) fileInput.value = '';
259
+ if (fileNameEl) fileNameEl.textContent = datasetText(root, 'waitingTitle');
260
+ renderResult(root, null); renderIssues(root, null); renderPreview(root, null); announce('');
261
+ };
262
+ const readFile = (file: File) => {
263
+ const reader = new FileReader();
264
+ reader.addEventListener('load', () => processSource(String(reader.result || ''), file.name));
265
+ reader.addEventListener('error', () => announce(datasetText(root, 'parseIssue')));
266
+ reader.readAsText(file);
267
+ };
268
+ bindAppEvents(root, state, { processSource, clearWorkspace, readFile }, announce);
269
+ renderResult(root, null);
270
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,14 @@
1
+ export const bibliographyEntries = [
2
+ {
3
+ name: 'Godot ResourceImporterCSVTranslation Documentation',
4
+ url: 'https://docs.godotengine.org/en/stable/classes/class_resourceimportercsvtranslation.html',
5
+ },
6
+ {
7
+ name: 'Unity Localization CSV Import and Export Documentation',
8
+ url: 'https://docs.unity3d.com/Packages/com.unity.localization@1.5/manual/CSV.html',
9
+ },
10
+ {
11
+ name: 'RFC 4180 Common Format and MIME Type for CSV Files',
12
+ url: 'https://datatracker.ietf.org/doc/html/rfc4180',
13
+ },
14
+ ];
@@ -0,0 +1,116 @@
1
+ ---
2
+ import type { LocalizationSanitizerUI } from './ui';
3
+ import './localization-sanitizer.css';
4
+
5
+ interface Props {
6
+ ui: LocalizationSanitizerUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div
13
+ class="ls-root"
14
+ id="localization-sanitizer-root"
15
+ data-csv-tab={ui.csvTab}
16
+ data-json-tab={ui.jsonTab}
17
+ data-ready-status={ui.readyStatus}
18
+ data-review-status={ui.reviewStatus}
19
+ data-empty-cells-label={ui.emptyCellsLabel}
20
+ data-duplicate-keys-label={ui.duplicateKeysLabel}
21
+ data-malformed-rows-label={ui.malformedRowsLabel}
22
+ data-clean-rows-label={ui.cleanRowsLabel}
23
+ data-no-issues={ui.noIssues}
24
+ data-empty-issue={ui.emptyIssue}
25
+ data-duplicate-issue={ui.duplicateIssue}
26
+ data-malformed-issue={ui.malformedIssue}
27
+ data-parse-issue={ui.parseIssue}
28
+ data-row-label={ui.rowLabel}
29
+ data-column-label={ui.columnLabel}
30
+ data-key-label={ui.keyLabel}
31
+ data-sample-file-name={ui.sampleFileName}
32
+ data-file-type-note={ui.fileTypeNote}
33
+ data-waiting-title={ui.waitingTitle}
34
+ data-waiting-subtitle={ui.waitingSubtitle}
35
+ data-copied-message={ui.copiedMessage}
36
+ >
37
+ <div class="ls-topline">
38
+ <div>
39
+ <span class="ls-kicker">LOCALIZATION QA</span>
40
+ <strong id="ls-file-name">{ui.waitingTitle}</strong>
41
+ </div>
42
+ <span class="ls-privacy-note">{ui.privacyNote}</span>
43
+ </div>
44
+
45
+ <div class="ls-format-bar" aria-label={ui.formatLabel} role="group">
46
+ <span class="ls-format-label">{ui.formatLabel}</span>
47
+ <button type="button" class="ls-format-button is-active" data-format="csv" aria-pressed="true">{ui.csvTab}</button>
48
+ <button type="button" class="ls-format-button" data-format="json" aria-pressed="false">{ui.jsonTab}</button>
49
+ <button type="button" class="ls-format-button" id="ls-clear-button">{ui.clearButton}</button>
50
+ </div>
51
+
52
+ <section class="ls-drop-panel" id="ls-drop-zone" aria-label={ui.inputLabel} tabindex="0">
53
+ <input id="ls-file-input" type="file" accept=".csv,.json,text/csv,application/json" hidden aria-label={ui.browseButton} />
54
+ <div class="ls-drop-mark" aria-hidden="true"><span>CSV</span><span>JSON</span></div>
55
+ <div class="ls-drop-copy">
56
+ <strong>{ui.dropTitle}</strong>
57
+ <span>{ui.dropSubtitle}</span>
58
+ <span class="ls-file-type-note">{ui.fileTypeNote}</span>
59
+ </div>
60
+ <div class="ls-drop-actions">
61
+ <button type="button" class="ls-button ls-button-primary" id="ls-browse-button">{ui.browseButton}</button>
62
+ <button type="button" class="ls-button ls-button-quiet" id="ls-sample-button">{ui.sampleButton}</button>
63
+ </div>
64
+ </section>
65
+
66
+ <div class="ls-workbench">
67
+ <aside class="ls-inspector">
68
+ <div class="ls-section-heading">
69
+ <span class="ls-section-index">01</span>
70
+ <div><span class="ls-section-eyebrow">INSPECTION</span><h2>{ui.healthLabel}</h2></div>
71
+ </div>
72
+ <div class="ls-health-card">
73
+ <div class="ls-health-ring" id="ls-health-ring"><span id="ls-health-value">--</span></div>
74
+ <div><strong id="ls-health-status">{ui.waitingTitle}</strong><span id="ls-health-subtitle">{ui.waitingSubtitle}</span></div>
75
+ </div>
76
+ <div class="ls-stat-list" aria-live="polite">
77
+ <div class="ls-stat-row"><span class="ls-stat-dot ls-dot-empty"></span><span>{ui.emptyCellsLabel}</span><strong id="ls-empty-count">0</strong></div>
78
+ <div class="ls-stat-row"><span class="ls-stat-dot ls-dot-duplicate"></span><span>{ui.duplicateKeysLabel}</span><strong id="ls-duplicate-count">0</strong></div>
79
+ <div class="ls-stat-row"><span class="ls-stat-dot ls-dot-malformed"></span><span>{ui.malformedRowsLabel}</span><strong id="ls-malformed-count">0</strong></div>
80
+ <div class="ls-stat-row ls-stat-row-clean"><span class="ls-stat-dot ls-dot-clean"></span><span>{ui.cleanRowsLabel}</span><strong id="ls-clean-count">0</strong></div>
81
+ </div>
82
+ <div class="ls-issue-section">
83
+ <div class="ls-section-heading ls-section-heading-small"><span class="ls-section-index">02</span><h2>{ui.issueListTitle}</h2></div>
84
+ <div id="ls-issue-list" class="ls-issue-list" aria-live="polite"></div>
85
+ </div>
86
+ </aside>
87
+
88
+ <section class="ls-preview-panel">
89
+ <div class="ls-section-heading">
90
+ <span class="ls-section-index">03</span>
91
+ <div><span class="ls-section-eyebrow">DATA VIEW</span><h2>{ui.previewTitle}</h2><p>{ui.previewSubtitle}</p></div>
92
+ </div>
93
+ <div id="ls-preview" class="ls-preview-frame">
94
+ <div class="ls-empty-state"><span class="ls-empty-grid" aria-hidden="true"></span><strong>{ui.waitingTitle}</strong><span>{ui.waitingSubtitle}</span></div>
95
+ </div>
96
+ <div class="ls-export-card">
97
+ <div class="ls-export-copy"><span class="ls-section-eyebrow">CLEAN OUTPUT</span><h2>{ui.exportTitle}</h2><p>{ui.exportSubtitle}</p></div>
98
+ <div class="ls-export-actions">
99
+ <button type="button" class="ls-button ls-button-primary" id="ls-download-button" disabled>{ui.downloadButton}</button>
100
+ <button type="button" class="ls-button ls-button-outline" id="ls-copy-button" disabled>{ui.copyButton}</button>
101
+ </div>
102
+ </div>
103
+ </section>
104
+ </div>
105
+ <p id="ls-live-status" class="ls-live-status" aria-live="polite"></p>
106
+ </div>
107
+
108
+ <script>
109
+ import { initLocalizationSanitizerApp } from './app-client';
110
+
111
+ if (document.readyState === 'loading') {
112
+ document.addEventListener('DOMContentLoaded', initLocalizationSanitizerApp);
113
+ } else {
114
+ initLocalizationSanitizerApp();
115
+ }
116
+ </script>
@@ -0,0 +1,95 @@
1
+ import type { ParsedCsvRow } from './types';
2
+
3
+ interface CsvState {
4
+ rows: ParsedCsvRow[];
5
+ row: string[];
6
+ field: string;
7
+ inQuotes: boolean;
8
+ quoteClosed: boolean;
9
+ malformed: boolean;
10
+ }
11
+
12
+ function createCsvState(): CsvState {
13
+ return {
14
+ rows: [],
15
+ row: [],
16
+ field: '',
17
+ inQuotes: false,
18
+ quoteClosed: false,
19
+ malformed: false,
20
+ };
21
+ }
22
+
23
+ function flushRow(state: CsvState): void {
24
+ const values = [...state.row, state.field];
25
+ if (values.some((value) => value.trim() !== '')) {
26
+ state.rows.push({ values, malformed: state.malformed });
27
+ }
28
+ state.row = [];
29
+ state.field = '';
30
+ state.quoteClosed = false;
31
+ state.malformed = false;
32
+ }
33
+
34
+ function handleQuotedChar(state: CsvState, character: string, nextCharacter: string): number {
35
+ if (character === '"' && nextCharacter === '"') {
36
+ state.field += '"';
37
+ return 1;
38
+ }
39
+ if (character === '"') {
40
+ state.inQuotes = false;
41
+ state.quoteClosed = true;
42
+ } else {
43
+ state.field += character;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ function handleUnquotedChar(state: CsvState, character: string, nextCharacter: string): void {
49
+ if (character === '"') {
50
+ if (state.field.length === 0) {
51
+ state.inQuotes = true;
52
+ } else {
53
+ state.malformed = true;
54
+ state.field += character;
55
+ }
56
+ } else if (character === ',') {
57
+ state.row.push(state.field);
58
+ state.field = '';
59
+ state.quoteClosed = false;
60
+ } else if (character === '\n' || (character === '\r' && nextCharacter !== '\n')) {
61
+ flushRow(state);
62
+ } else {
63
+ if (state.quoteClosed) state.malformed = true;
64
+ state.field += character;
65
+ }
66
+ }
67
+
68
+ function processCsvChar(state: CsvState, source: string, index: number): number {
69
+ const character = source[index] ?? '';
70
+ const nextCharacter = source[index + 1] ?? '';
71
+ if (state.inQuotes) {
72
+ return handleQuotedChar(state, character, nextCharacter);
73
+ }
74
+ handleUnquotedChar(state, character, nextCharacter);
75
+ return 0;
76
+ }
77
+
78
+ export function parseCsvRows(source: string): ParsedCsvRow[] {
79
+ const state = createCsvState();
80
+ for (let index = 0; index < source.length; index += 1) {
81
+ index += processCsvChar(state, source, index);
82
+ }
83
+ if (state.inQuotes) state.malformed = true;
84
+ if (state.field.length > 0 || state.row.length > 0 || state.malformed) {
85
+ flushRow(state);
86
+ }
87
+ return state.rows;
88
+ }
89
+
90
+ export function csvEscape(value: string): string {
91
+ if (/[",\n\r]/.test(value)) {
92
+ return `"${value.replaceAll('"', '""')}"`;
93
+ }
94
+ return value;
95
+ }
@@ -0,0 +1,98 @@
1
+ import { csvEscape, parseCsvRows } from './csv-parser';
2
+ import { EMPTY_STATS, type IssueInput, type ParsedCsvRow, type SanitizerIssue, type SanitizerResult, type TranslationFormat } from './types';
3
+
4
+ export function normalizedHeaders(headers: string[]): string[] {
5
+ return headers.map((header, index) => header.trim() || `column_${index + 1}`);
6
+ }
7
+
8
+ export function keyColumnIndex(headers: string[]): number {
9
+ const index = headers.findIndex((header) => /^(key|id|identifier|loc[_-]?key)$/i.test(header.trim()));
10
+ return index >= 0 ? index : 0;
11
+ }
12
+
13
+ export function issue(input: IssueInput): SanitizerIssue {
14
+ return { ...input };
15
+ }
16
+
17
+ export function emptyResult(format: TranslationFormat, detail: string): SanitizerResult {
18
+ return {
19
+ format, valid: false, headers: [], rows: [], cleanText: '',
20
+ issues: [issue({ kind: 'parse', row: 1, column: '', key: '', detail })],
21
+ stats: { ...EMPTY_STATS },
22
+ };
23
+ }
24
+
25
+ function normalizeCsvRowValues(originalValues: string[], expectedColumns: number): string[] {
26
+ if (originalValues.length > expectedColumns) {
27
+ return originalValues.slice(0, expectedColumns - 1).concat(originalValues.slice(expectedColumns - 1).join(','));
28
+ }
29
+ return originalValues.concat(Array.from({ length: Math.max(0, expectedColumns - originalValues.length) }, () => ''));
30
+ }
31
+
32
+ interface CheckRowParams {
33
+ parsedRow: ParsedCsvRow;
34
+ rowNumber: number;
35
+ expectedColumns: number;
36
+ keyIndex: number;
37
+ headers: string[];
38
+ }
39
+
40
+ function checkRowIssues(params: CheckRowParams): { values: string[]; key: string; rowIssues: SanitizerIssue[] } {
41
+ const { parsedRow, rowNumber, expectedColumns, keyIndex, headers } = params;
42
+ const values = normalizeCsvRowValues(parsedRow.values, expectedColumns);
43
+ const key = (values[keyIndex] ?? '').trim();
44
+ const rowIssues: SanitizerIssue[] = [];
45
+
46
+ if (parsedRow.malformed || parsedRow.values.length !== expectedColumns) {
47
+ rowIssues.push(issue({ kind: 'malformed', row: rowNumber, column: '', key, detail: parsedRow.malformed ? 'unescaped_quote' : `expected_${expectedColumns}_columns` }));
48
+ }
49
+ if (!key) {
50
+ rowIssues.push(issue({ kind: 'empty', row: rowNumber, column: headers[keyIndex] ?? '', key: '', detail: 'empty_key' }));
51
+ }
52
+ values.forEach((value, columnIndex) => {
53
+ if (columnIndex !== keyIndex && value.trim() === '') {
54
+ rowIssues.push(issue({ kind: 'empty', row: rowNumber, column: headers[columnIndex] ?? '', key, detail: 'empty_translation' }));
55
+ }
56
+ });
57
+
58
+ return { values, key, rowIssues };
59
+ }
60
+
61
+ function computeCsvStats(totalRows: number, cleanRowsCount: number, issues: SanitizerIssue[]) {
62
+ return {
63
+ totalRows, cleanRows: cleanRowsCount,
64
+ emptyCells: issues.filter((item) => item.kind === 'empty').length,
65
+ duplicateKeys: issues.filter((item) => item.kind === 'duplicate').length,
66
+ malformedRows: new Set(issues.filter((item) => item.kind === 'malformed').map((item) => item.row)).size,
67
+ };
68
+ }
69
+
70
+ export function sanitizeCsv(source: string): SanitizerResult {
71
+ const parsedRows = parseCsvRows(source.replace(/^\uFEFF/, ''));
72
+ if (parsedRows.length === 0) return emptyResult('csv', 'empty_file');
73
+
74
+ const headers = normalizedHeaders(parsedRows[0]?.values ?? []);
75
+ const keyIndex = keyColumnIndex(headers);
76
+ const expectedColumns = headers.length;
77
+ const issues: SanitizerIssue[] = [];
78
+ const rows: string[][] = [];
79
+ const seenKeys = new Set<string>();
80
+
81
+ parsedRows.slice(1).forEach((parsedRow, sourceIndex) => {
82
+ const rowNumber = sourceIndex + 2;
83
+ const { values, key, rowIssues } = checkRowIssues({ parsedRow, rowNumber, expectedColumns, keyIndex, headers });
84
+ issues.push(...rowIssues);
85
+ if (key && seenKeys.has(key)) {
86
+ issues.push(issue({ kind: 'duplicate', row: rowNumber, column: headers[keyIndex] ?? '', key, detail: 'duplicate_key' }));
87
+ } else if (key) {
88
+ seenKeys.add(key);
89
+ rows.push(values);
90
+ }
91
+ });
92
+
93
+ return {
94
+ format: 'csv', valid: true, headers, rows,
95
+ cleanText: [headers, ...rows].map((row) => row.map(csvEscape).join(',')).join('\n'),
96
+ issues, stats: computeCsvStats(parsedRows.length - 1, rows.length, issues),
97
+ };
98
+ }