ai-localize-reporting 2.0.6 → 3.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # ai-localize-reporting
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - bug fixes
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - ai-localize-shared@3.0.0
13
+
14
+ ## 2.0.7
15
+
16
+ ### Minor Changes
17
+
18
+ - **HTML report UI refinements** (9 fixes):
19
+ - Accordion chevron now rotates correctly on expand/collapse (CSS `transform` fix).
20
+ - Translation coverage % in the stat card now shows the correct rounded integer.
21
+ - Filter input and pagination controls are now visible and functional on all tables.
22
+ - Language filter chips on the Missing Translations table now toggle correctly.
23
+ - Keyboard shortcut hint (`Cmd+D` / `Ctrl+D`) for dark-mode toggle is now displayed.
24
+ - Mobile sidebar collapses automatically after a nav link is clicked.
25
+ - Print/PDF CSS hides interactive controls and expands all accordions.
26
+ - Stat card hover-lift animation no longer causes layout shift on Safari.
27
+ - CSV/JSON export buttons now correctly serialise filtered (not full) table data.
28
+ - **91 new tests** — 58 `html-reporter` tests + 33 `cli-reporter` tests covering all
29
+ major rendering paths, edge cases, and the new UI fixes.
30
+ - **CLI reporter circular next-steps fix** — the "Recommended next steps" list no longer
31
+ repeats the same step in a loop when all checks pass.
32
+
33
+ ### Patch Changes
34
+
35
+ - Added `repository`, `homepage`, `bugs`, `author` fields to `package.json` for npm registry display.
36
+ - Added `sideEffects: false` to enable tree-shaking in bundlers.
37
+ - Added `prepublishOnly` script to ensure the package is built before publishing.
38
+
3
39
  ## 2.0.6
4
40
 
5
41
  ### Patch Changes
@@ -21,7 +57,7 @@
21
57
  - `coveragePct` is clamped to `[0, 100]` with `Math.max(0, Math.min(100, …))`.
22
58
  - `progressBar()`, `miniBar()`, and the `renderTable()` center-align branch all guard
23
59
  `filled`, `empty`, and padding values with `Math.max(0, …)` to prevent negative repeats.
24
- (`packages/reporting/src/cli-reporter.ts`)
60
+ (`packages/reporting/src/cli-reporter.ts`)
25
61
 
26
62
  ## 2.0.4
27
63
 
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 ai-localize-core contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js CHANGED
@@ -135,15 +135,16 @@ function computeInsights(report) {
135
135
  }
136
136
  }
137
137
  const totalKeys = new Set(details.detectedTexts.map((d) => d.suggestedKey)).size;
138
- const missing = details.missingKeys.length;
139
- const coveragePct = totalKeys > 0 ? Math.round((totalKeys - missing) / totalKeys * 100) : 100;
138
+ const distinctMissingKeys = new Set(details.missingKeys.map((mk) => mk.key)).size;
139
+ const coveredKeys = Math.max(0, totalKeys - distinctMissingKeys);
140
+ const coveragePct = totalKeys > 0 ? Math.round(coveredKeys / totalKeys * 100) : 100;
140
141
  return { duplicates, inconsistencies, namespaceHints, coveragePct, totalKeys, duplicateKeyCount };
141
142
  }
142
143
  function buildNavItem(id, icon, label, count, alert = false) {
143
144
  return `<a href="#${id}" class="nav-item${alert ? " nav-alert" : ""}" data-section="${id}">
144
145
  <span class="nav-icon">${icon}</span>
145
146
  <span class="nav-label">${esc(label)}</span>
146
- <span class="nav-count">${count}</span>
147
+ <span class="nav-count">${count}</span>
147
148
  </a>`;
148
149
  }
149
150
  function buildStatCard(value, label, hint, status, icon) {
@@ -156,15 +157,15 @@ function buildStatCard(value, label, hint, status, icon) {
156
157
  }
157
158
  function buildAccordion(id, title, subtitle, content, open = false, severity = "info") {
158
159
  return `<div class="accordion${open ? " accordion-open" : ""}" id="acc-${id}">
159
- <button class="accordion-trigger" aria-expanded="${open}" aria-controls="panel-${id}" onclick="toggleAccordion('${id}')">
160
- <span class="accordion-icon severity-${severity}">&#9679;</span>
160
+ <button class="accordion-trigger" aria-expanded="${open ? "true" : "false"}" aria-controls="panel-${id}" onclick="toggleAccordion('${escJs(id)}')">
161
+ <span class="accordion-icon severity-${severity}" aria-hidden="true">&#9679;</span>
161
162
  <span class="accordion-title">${title}</span>
162
- <span class="accordion-subtitle">${subtitle}</span>
163
- <span class="accordion-chevron">&#8964;</span>
163
+ <span class="accordion-subtitle">${subtitle}</span>
164
+ <span class="accordion-chevron" aria-hidden="true">&#9660;</span>
164
165
  </button>
165
- <div class="accordion-panel" id="panel-${id}" role="region" ${open ? "" : "hidden"}>
166
+ <div class="accordion-panel" id="panel-${id}" role="region" aria-labelledby="acc-${id}"${open ? "" : " hidden"}>
166
167
  <div class="accordion-body">${content}</div>
167
- </div>
168
+ </div>
168
169
  </div>`;
169
170
  }
170
171
  function buildCoverageDonut(pct) {
@@ -304,13 +305,17 @@ function buildHtml(report) {
304
305
  e.filePath ? `<code class="path-code">${esc(e.filePath)}</code>` : "\u2014",
305
306
  `<span class="detail-text">${esc(e.message)}</span>`
306
307
  ]);
307
- const langChips = [...missingByLang.entries()].map(([lang, keys]) => `<span class="chip chip-red" onclick="filterTable('tbl-missing', '${escJs(lang)}')">${esc(lang)} <strong>${keys.length}</strong></span>`).join(" ");
308
+ const langChips = [...missingByLang.entries()].map(([lang, keys]) => `<button class="chip chip-red" onclick="applyLangFilter('${escJs(lang)}')" aria-label="Filter by ${esc(lang)}">${esc(lang)} <strong>${keys.length}</strong></button>`).join(" ");
308
309
  missingContent = `
309
310
  <div class="insight-legend">
310
311
  These locale keys exist in the <strong>default language</strong> but are <strong>absent</strong> in one or more target language files.
311
312
  Ask your translators to fill in these entries. Running <code>ai-localize extract</code> seeds all target files with the source value.
312
313
  </div>
313
- <div class="chip-row">Filter by language: ${langChips}</div>
314
+ <div class="chip-row">
315
+ <span class="chip-label">Filter by language:</span>
316
+ ${langChips}
317
+ <button class="chip chip-clear" onclick="applyLangFilter('')" aria-label="Clear language filter">&#10005; Clear</button>
318
+ </div>
314
319
  ${buildSearchableTable("tbl-missing", [
315
320
  { key: "key", label: "Key" },
316
321
  { key: "language", label: "Language", width: "100px" },
@@ -477,7 +482,7 @@ ${nav}
477
482
  <div class="page-header-left">
478
483
  <h1 class="page-title">&#127760; Localization Analytics Dashboard</h1>
479
484
  <div class="page-meta">
480
- <span class="meta-chip">${badge(framework, "blue")}</span>
485
+ <span class="meta-chip">${badge(framework, "blue")}</span>
481
486
  <span class="meta-item">&#128197; ${scanDate}</span>
482
487
  <span class="meta-item">&#9201; ${duration}ms</span>
483
488
  <span class="meta-item">&#128196; ${filesScanned} files</span>
@@ -492,11 +497,11 @@ ${nav}
492
497
  <!-- Summary -->
493
498
  <section id="overview" class="section">
494
499
  <div class="section-title-row">
495
- <h2 class="section-title">&#128202; Summary</h2>
500
+ <h2 class="section-title">&#128202; Summary</h2>
496
501
  </div>
497
502
  ${summaryCards}
498
503
  ${diffExplainer}
499
- </section>
504
+ </section>
500
505
 
501
506
  <!-- Charts -->
502
507
  <section id="charts" class="section">
@@ -553,7 +558,7 @@ ${nav}
553
558
 
554
559
  <!-- Assets -->
555
560
  <section id="assets" class="section">
556
- <div class="section-title-row">
561
+ <div class="section-title-row">
557
562
  <h2 class="section-title">&#128230; CDN Assets <span class="section-count count-neutral">${assets.totalAssets}</span></h2>
558
563
  </div>
559
564
  ${buildAccordion(
@@ -564,7 +569,7 @@ ${nav}
564
569
  false,
565
570
  assets.legacyCdnUrls > 0 ? "warn" : "ok"
566
571
  )}
567
- </section>
572
+ </section>
568
573
 
569
574
  <!-- AI Insights -->
570
575
  <section id="insights" class="section">
@@ -579,6 +584,9 @@ ${nav}
579
584
 
580
585
  <footer class="page-footer">
581
586
  Generated by <strong>ai-localize-core</strong> &mdash; deterministic, offline-capable i18n tooling &mdash; ${scanDate}
587
+ <span class="footer-shortcuts" aria-label="Keyboard shortcuts">
588
+ <kbd>Esc</kbd> clear search &nbsp;&bull;&nbsp; <kbd>Cmd+D</kbd> toggle theme &nbsp;&bull;&nbsp; <kbd>Tab</kbd> navigate
589
+ </span>
582
590
  </footer>
583
591
  </main>
584
592
 
@@ -653,10 +661,14 @@ function JS(report, insights) {
653
661
  // \u2500\u2500 Active nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
654
662
  var sections = document.querySelectorAll('section[id]');
655
663
  var navItems = document.querySelectorAll('.nav-item[data-section]');
664
+ // Use 120px offset to account for sticky table headers and give visual lead-time
665
+ var NAV_OFFSET = 120;
656
666
  function onScroll() {
657
- var scrollY = window.scrollY + 80;
667
+ var scrollY = window.scrollY + NAV_OFFSET;
658
668
  var current = '';
659
- sections.forEach(function(s) { if (scrollY >= s.offsetTop) current = s.id; });
669
+ sections.forEach(function(s) {
670
+ if (scrollY >= s.offsetTop) current = s.id;
671
+ });
660
672
  navItems.forEach(function(a) {
661
673
  a.classList.toggle('nav-active', a.getAttribute('data-section') === current);
662
674
  });
@@ -668,10 +680,18 @@ var scrollY = window.scrollY + 80;
668
680
  window.toggleAccordion = function(id) {
669
681
  var acc = document.getElementById('acc-' + id);
670
682
  var panel = document.getElementById('panel-' + id);
683
+ if (!acc || !panel) return;
671
684
  var btn = acc.querySelector('.accordion-trigger');
672
- var open = acc.classList.toggle('accordion-open');
673
- btn.setAttribute('aria-expanded', open);
674
- if (open) panel.removeAttribute('hidden'); else panel.setAttribute('hidden', '');
685
+ var isOpen = acc.classList.contains('accordion-open');
686
+ if (isOpen) {
687
+ acc.classList.remove('accordion-open');
688
+ btn.setAttribute('aria-expanded', 'false');
689
+ panel.setAttribute('hidden', '');
690
+ } else {
691
+ acc.classList.add('accordion-open');
692
+ btn.setAttribute('aria-expanded', 'true');
693
+ panel.removeAttribute('hidden');
694
+ }
675
695
  };
676
696
  window.expandAll = function() {
677
697
  document.querySelectorAll('.accordion').forEach(function(acc) {
@@ -679,7 +699,7 @@ var scrollY = window.scrollY + 80;
679
699
  var panel = document.getElementById('panel-' + id);
680
700
  var btn = acc.querySelector('.accordion-trigger');
681
701
  acc.classList.add('accordion-open');
682
- btn.setAttribute('aria-expanded', 'true');
702
+ if (btn) btn.setAttribute('aria-expanded', 'true');
683
703
  if (panel) panel.removeAttribute('hidden');
684
704
  });
685
705
  };
@@ -687,30 +707,40 @@ var scrollY = window.scrollY + 80;
687
707
  document.querySelectorAll('.accordion').forEach(function(acc) {
688
708
  var id = acc.id.replace('acc-', '');
689
709
  var panel = document.getElementById('panel-' + id);
690
- var btn = acc.querySelector('.accordion-trigger');
691
- acc.classList.remove('accordion-open');
692
- btn.setAttribute('aria-expanded', 'false');
710
+ var btn = acc.querySelector('.accordion-trigger');
711
+ acc.classList.remove('accordion-open');
712
+ if (btn) btn.setAttribute('aria-expanded', 'false');
693
713
  if (panel) panel.setAttribute('hidden', '');
694
714
  });
695
715
  };
696
716
 
697
- // \u2500\u2500 Table: filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
717
+ // \u2500\u2500 Table: filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
718
+ // Mark rows with data-filtered attribute instead of display:none so that
719
+ // renderPagination can independently control visibility for pagination.
698
720
  window.filterTable = function(tableId, query) {
699
721
  var tbl = document.getElementById(tableId);
700
722
  if (!tbl) return;
701
- var q = query.toLowerCase();
723
+ var q = (query || '').toLowerCase().trim();
702
724
  var rows = tbl.querySelectorAll('tbody tr');
703
- var shown = 0;
704
725
  rows.forEach(function(row) {
705
- var match = row.textContent.toLowerCase().indexOf(q) !== -1;
706
- row.style.display = match ? '' : 'none';
707
- if (match) shown++;
726
+ if (q === '' || row.textContent.toLowerCase().indexOf(q) !== -1) {
727
+ row.removeAttribute('data-filtered');
728
+ } else {
729
+ row.setAttribute('data-filtered', '1');
730
+ }
708
731
  });
709
- var meta = document.getElementById(tableId + '-meta');
710
- if (meta) meta.textContent = shown + ' / ' + rows.length + ' rows';
732
+ // Reset to page 1 whenever filter changes
733
+ tbl.setAttribute('data-page', '1');
711
734
  renderPagination(tableId);
712
735
  };
713
736
 
737
+ // \u2500\u2500 Language chip filter (syncs search-box input + resets page) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
738
+ window.applyLangFilter = function(lang) {
739
+ var inp = document.querySelector('#tbl-missing-wrapper .table-search');
740
+ if (inp) inp.value = lang;
741
+ window.filterTable('tbl-missing', lang);
742
+ };
743
+
714
744
  // \u2500\u2500 Table: sort \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
715
745
  var sortState = {};
716
746
  window.sortTable = function(tableId, col) {
@@ -721,39 +751,52 @@ var scrollY = window.scrollY + 80;
721
751
  var colIndex = -1;
722
752
  tbl.querySelectorAll('thead th').forEach(function(th, i) {
723
753
  if (th.getAttribute('data-col') === col) colIndex = i;
724
- th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = '\\u21C5');
754
+ var icon = th.querySelector('.sort-icon');
755
+ if (icon) icon.textContent = '\\u21C5';
725
756
  });
726
- if (colIndex === -1) return;
757
+ if (colIndex === -1) return;
727
758
  var th = tbl.querySelector('thead th[data-col="' + col + '"]');
728
- if (th) th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = dir === 'asc' ? '\\u2191' : '\\u2193');
759
+ if (th) {
760
+ var icon = th.querySelector('.sort-icon');
761
+ if (icon) icon.textContent = dir === 'asc' ? '\\u2191' : '\\u2193';
762
+ }
729
763
  var tbody = tbl.querySelector('tbody');
730
764
  var rows = Array.from(tbody.querySelectorAll('tr'));
731
765
  rows.sort(function(a, b) {
732
766
  var av = (a.cells[colIndex] || {}).textContent || '';
733
- var bv = (b.cells[colIndex] || {}).textContent || '';
767
+ var bv = (b.cells[colIndex] || {}).textContent || '';
734
768
  var an = parseFloat(av), bn = parseFloat(bv);
735
769
  if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
736
770
  return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
737
771
  });
738
772
  rows.forEach(function(r) { tbody.appendChild(r); });
739
- renderPagination(tableId);
773
+ renderPagination(tableId);
740
774
  };
741
775
 
742
776
  // \u2500\u2500 Table: pagination \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
777
+ // Reads data-filtered attribute set by filterTable; does not conflict with
778
+ // filter visibility since rows are never hidden by this function until
779
+ // it has determined which rows belong to the current page.
743
780
  function renderPagination(tableId) {
744
781
  var tbl = document.getElementById(tableId);
745
782
  var pag = document.getElementById(tableId + '-pagination');
746
783
  var meta = document.getElementById(tableId + '-meta');
747
784
  if (!tbl || !pag) return;
748
785
  var pageSize = parseInt(tbl.getAttribute('data-page-size') || '50', 10);
749
- var rows = Array.from(tbl.querySelectorAll('tbody tr')).filter(function(r) { return r.style.display !== 'none'; });
750
- var total = rows.length;
751
- var pages = Math.ceil(total / pageSize);
786
+ var allRows = Array.from(tbl.querySelectorAll('tbody tr'));
787
+ // Rows that passed the current filter
788
+ var visibleRows = allRows.filter(function(r) { return !r.hasAttribute('data-filtered'); });
789
+ var total = visibleRows.length;
790
+ var pages = Math.max(1, Math.ceil(total / pageSize));
752
791
  var page = parseInt(tbl.getAttribute('data-page') || '1', 10);
753
792
  if (page > pages) page = 1;
754
- tbl.setAttribute('data-page', page);
755
- rows.forEach(function(r, i) {
756
- r.style.display = (i >= (page - 1) * pageSize && i < page * pageSize) ? '' : 'none';
793
+ if (page < 1) page = 1;
794
+ tbl.setAttribute('data-page', String(page));
795
+ // First hide all rows, then show only the current page slice of visible rows
796
+ allRows.forEach(function(r) { r.style.display = 'none'; });
797
+ var startIdx = (page - 1) * pageSize;
798
+ visibleRows.forEach(function(r, i) {
799
+ if (i >= startIdx && i < startIdx + pageSize) r.style.display = '';
757
800
  });
758
801
  if (meta) meta.textContent = total + ' row' + (total !== 1 ? 's' : '');
759
802
  pag.innerHTML = '';
@@ -763,13 +806,13 @@ renderPagination(tableId);
763
806
  b.textContent = label;
764
807
  b.className = 'pag-btn' + (active ? ' pag-active' : '') + (disabled ? ' pag-disabled' : '');
765
808
  b.disabled = disabled;
766
- b.onclick = function() { tbl.setAttribute('data-page', p); renderPagination(tableId); };
809
+ b.onclick = function() { tbl.setAttribute('data-page', String(p)); renderPagination(tableId); };
767
810
  return b;
768
811
  }
769
- pag.appendChild(btn('\\u00AB', 1, false, page === 1));
812
+ pag.appendChild(btn('\\u00AB', 1, false, page === 1));
770
813
  pag.appendChild(btn('\\u2039', page - 1, false, page === 1));
771
- var start = Math.max(1, page - 2), end = Math.min(pages, page + 2);
772
- for (var i = start; i <= end; i++) pag.appendChild(btn(i, i, i === page, false));
814
+ var winStart = Math.max(1, page - 2), winEnd = Math.min(pages, page + 2);
815
+ for (var i = winStart; i <= winEnd; i++) pag.appendChild(btn(i, i, i === page, false));
773
816
  pag.appendChild(btn('\\u203A', page + 1, false, page === pages));
774
817
  pag.appendChild(btn('\\u00BB', pages, false, page === pages));
775
818
  var info = document.createElement('span');
@@ -877,21 +920,33 @@ renderPagination(tableId);
877
920
 
878
921
  function downloadFile(filename, content, mimeType) {
879
922
  var a = document.createElement('a');
880
- a.href = URL.createObjectURL(new Blob([content], { type: mimeType }));
923
+ a.href = URL.createObjectURL(new Blob([content], { type: mimeType }));
881
924
  a.download = filename;
882
925
  a.click();
883
926
  setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
884
927
  }
885
928
 
886
929
  // \u2500\u2500 Keyboard navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
930
+ // Escape: clear all table search inputs and reset filters
931
+ // Cmd/Ctrl+D: toggle dark/light theme
887
932
  document.addEventListener('keydown', function(e) {
888
933
  if (e.key === 'Escape') {
889
- document.querySelectorAll('.table-search').forEach(function(inp) { inp.value = ''; filterTable(inp.closest('.table-wrapper').querySelector('table').id, ''); });
934
+ document.querySelectorAll('.table-search').forEach(function(inp) {
935
+ var wrapper = inp.closest('.table-wrapper');
936
+ if (!wrapper) return;
937
+ var tbl = wrapper.querySelector('table');
938
+ if (!tbl) return;
939
+ inp.value = '';
940
+ window.filterTable(tbl.id, '');
941
+ });
942
+ }
943
+ if ((e.metaKey || e.ctrlKey) && e.key === 'd') {
944
+ e.preventDefault();
945
+ window.toggleTheme();
890
946
  }
891
- if ((e.metaKey || e.ctrlKey) && e.key === 'd') { e.preventDefault(); window.toggleTheme(); }
892
947
  });
893
948
 
894
- // \u2500\u2500 Smooth scroll for nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
949
+ // \u2500\u2500 Smooth scroll for nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
895
950
  document.querySelectorAll('a.nav-item[href^="#"]').forEach(function(a) {
896
951
  a.addEventListener('click', function(e) {
897
952
  e.preventDefault();
@@ -1238,10 +1293,12 @@ padding: 6px 16px 3px;
1238
1293
  }
1239
1294
  .accordion-subtitle { font-size: 12px; color: var(--text-muted); margin-right: 12px; }
1240
1295
  .accordion-chevron {
1241
- font-size: 18px;
1296
+ font-size: 14px;
1242
1297
  color: var(--text-muted);
1243
1298
  transition: transform var(--transition);
1299
+ display: inline-block;
1244
1300
  }
1301
+ /* \u25BE (&#9660;) points down by default; rotates to \u25B4 when open */
1245
1302
  .accordion-open .accordion-chevron { transform: rotate(180deg); }
1246
1303
  .accordion-panel { border-top: 1px solid var(--border); }
1247
1304
  .accordion-body { padding: 18px; }
@@ -1311,15 +1368,20 @@ border-radius: var(--radius);
1311
1368
 
1312
1369
  /* \u2500\u2500 Chip filter row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1313
1370
  .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; font-size: 12px; color: var(--text-muted); }
1371
+ .chip-label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
1314
1372
  .chip {
1315
1373
  display: inline-flex; align-items: center; gap: 4px;
1316
1374
  padding: 3px 10px; border-radius: 14px;
1317
- font-size: 12px; font-weight: 600;
1375
+ font-size: 12px; font-weight: 600;
1318
1376
  cursor: pointer; border: 1px solid transparent;
1319
- transition: box-shadow var(--transition);
1377
+ transition: box-shadow var(--transition), background var(--transition);
1378
+ font-family: var(--font);
1320
1379
  }
1321
1380
  .chip:hover { box-shadow: 0 0 0 2px var(--accent); }
1381
+ .chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1322
1382
  .chip-red { background: #fee2e2; color: #991b1b; }
1383
+ .chip-clear { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
1384
+ .chip-clear:hover { background: var(--border); box-shadow: none; }
1323
1385
  [data-theme="dark"] .chip-red { background: #450a0a; color: #fca5a5; }
1324
1386
 
1325
1387
  /* \u2500\u2500 Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
@@ -1586,25 +1648,57 @@ font-size: 12px;
1586
1648
  font-size: 12px;
1587
1649
  color: var(--text-subtle);
1588
1650
  text-align: center;
1651
+ display: flex;
1652
+ flex-direction: column;
1653
+ align-items: center;
1654
+ gap: 8px;
1655
+ }
1656
+ .footer-shortcuts { font-size: 11px; color: var(--text-subtle); }
1657
+ kbd {
1658
+ display: inline-block;
1659
+ padding: 1px 6px;
1660
+ font-size: 10px;
1661
+ font-family: var(--font-mono);
1662
+ background: var(--bg-elevated);
1663
+ border: 1px solid var(--border);
1664
+ border-bottom-width: 2px;
1665
+ border-radius: 4px;
1666
+ color: var(--text-muted);
1667
+ white-space: nowrap;
1589
1668
  }
1590
1669
 
1591
1670
  /* \u2500\u2500 Print \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1592
1671
  @media print {
1593
1672
  .sidebar, .theme-toggle, .table-controls, .table-pagination, .page-header-right { display: none !important; }
1594
1673
  .main { margin-left: 0 !important; padding: 16px; }
1595
- .accordion-panel { display: block !important; }
1596
- .accordion-panel[hidden] { display: block !important; }
1674
+ /* Force accordion panels visible \u2014 the hidden attribute needs !important override */
1675
+ .accordion-panel { display: block !important; visibility: visible !important; }
1676
+ .accordion-panel[hidden] { display: block !important; visibility: visible !important; }
1597
1677
  .stat-card, .chart-card, .accordion, .table-wrapper { break-inside: avoid; }
1598
1678
  body { background: white; color: black; }
1679
+ a { color: inherit; text-decoration: none; }
1599
1680
  }
1600
1681
 
1601
- /* \u2500\u2500 Responsive \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1682
+ /* \u2500\u2500 Responsive / Mobile \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1602
1683
  @media (max-width: 768px) {
1603
1684
  body { flex-direction: column; }
1604
- .sidebar { position: relative; width: 100%; height: auto; flex-direction: row; flex-wrap: wrap; overflow: visible; border-right: none; border-bottom: 1px solid var(--border); }
1685
+ /* On mobile the sidebar becomes an in-flow top-bar, not fixed/absolute */
1686
+ .sidebar {
1687
+ position: relative;
1688
+ width: 100%;
1689
+ height: auto;
1690
+ overflow: visible;
1691
+ border-right: none;
1692
+ border-bottom: 1px solid var(--border);
1693
+ flex-direction: column;
1694
+ }
1695
+ .nav-group { display: flex; flex-wrap: wrap; padding: 4px 8px; border-bottom: none; }
1696
+ .nav-group-label { width: 100%; padding: 4px 8px 2px; }
1697
+ .nav-item { padding: 6px 10px; border-radius: var(--radius-sm); }
1605
1698
  .main { margin-left: 0; padding: 16px; }
1606
1699
  .stats-grid { grid-template-columns: repeat(2, 1fr); }
1607
1700
  .insights-grid { grid-template-columns: 1fr; }
1701
+ .charts-grid { grid-template-columns: 1fr; }
1608
1702
  .page-header { flex-direction: column; }
1609
1703
  .theme-toggle { top: 8px; right: 8px; }
1610
1704
  }
@@ -2050,21 +2144,33 @@ function printCliSummary(report) {
2050
2144
  ln(centre(dim("Generated by ") + bold("ai-localize-core") + dim(" \xB7 deterministic, offline-capable i18n tooling")));
2051
2145
  ln(hr("\u2550", "cyan"));
2052
2146
  ln();
2053
- if (!allOk) {
2147
+ const nextSteps = [];
2148
+ if (hardcodedTexts > 0) {
2149
+ nextSteps.push(
2150
+ "Run " + c("brightCyan", "ai-localize scan") + dim(" then ") + c("brightCyan", "ai-localize extract") + dim(" to wrap hardcoded strings and generate locale keys.")
2151
+ );
2152
+ }
2153
+ if (missingTranslations > 0) {
2154
+ nextSteps.push(
2155
+ "Run " + c("brightCyan", "ai-localize extract") + dim(" to seed missing translations in target language files.")
2156
+ );
2157
+ }
2158
+ if (unusedKeys > 0) {
2159
+ nextSteps.push(
2160
+ "Run " + c("brightCyan", "ai-localize cleanup") + dim(" to remove unused keys from locale files.")
2161
+ );
2162
+ }
2163
+ if (assets.legacyCdnUrls > 0) {
2164
+ nextSteps.push(
2165
+ "Run " + c("brightCyan", "ai-localize replace-cdn") + dim(" to migrate legacy CDN URLs to CloudFront.")
2166
+ );
2167
+ }
2168
+ if (nextSteps.length > 0) {
2054
2169
  ln(bold(" \u26A1 Recommended next steps:"));
2055
2170
  ln();
2056
- if (hardcodedTexts > 0) {
2057
- ln("" + c("cyan", "1.") + " Run " + c("brightCyan", "ai-localize full-migrate") + dim(" to wrap hardcoded strings with translation calls."));
2058
- }
2059
- if (missingTranslations > 0) {
2060
- ln(" " + c("cyan", "2.") + " Run " + c("brightCyan", "ai-localize extract") + dim(" to seed target language files."));
2061
- }
2062
- if (unusedKeys > 0) {
2063
- ln(" " + c("cyan", "3.") + " Run " + c("brightCyan", "ai-localize cleanup") + dim(" to remove unused keys."));
2064
- }
2065
- if (assets.legacyCdnUrls > 0) {
2066
- ln(" " + c("cyan", "4.") + " Run " + c("brightCyan", "ai-localize replace-cdn") + dim(" to migrate legacy CDN URLs to CloudFront."));
2067
- }
2171
+ nextSteps.forEach((step, i) => {
2172
+ ln(" " + c("cyan", String(i + 1) + ".") + " " + step);
2173
+ });
2068
2174
  ln();
2069
2175
  }
2070
2176
  const output = out.join("\n");
package/dist/index.mjs CHANGED
@@ -97,15 +97,16 @@ function computeInsights(report) {
97
97
  }
98
98
  }
99
99
  const totalKeys = new Set(details.detectedTexts.map((d) => d.suggestedKey)).size;
100
- const missing = details.missingKeys.length;
101
- const coveragePct = totalKeys > 0 ? Math.round((totalKeys - missing) / totalKeys * 100) : 100;
100
+ const distinctMissingKeys = new Set(details.missingKeys.map((mk) => mk.key)).size;
101
+ const coveredKeys = Math.max(0, totalKeys - distinctMissingKeys);
102
+ const coveragePct = totalKeys > 0 ? Math.round(coveredKeys / totalKeys * 100) : 100;
102
103
  return { duplicates, inconsistencies, namespaceHints, coveragePct, totalKeys, duplicateKeyCount };
103
104
  }
104
105
  function buildNavItem(id, icon, label, count, alert = false) {
105
106
  return `<a href="#${id}" class="nav-item${alert ? " nav-alert" : ""}" data-section="${id}">
106
107
  <span class="nav-icon">${icon}</span>
107
108
  <span class="nav-label">${esc(label)}</span>
108
- <span class="nav-count">${count}</span>
109
+ <span class="nav-count">${count}</span>
109
110
  </a>`;
110
111
  }
111
112
  function buildStatCard(value, label, hint, status, icon) {
@@ -118,15 +119,15 @@ function buildStatCard(value, label, hint, status, icon) {
118
119
  }
119
120
  function buildAccordion(id, title, subtitle, content, open = false, severity = "info") {
120
121
  return `<div class="accordion${open ? " accordion-open" : ""}" id="acc-${id}">
121
- <button class="accordion-trigger" aria-expanded="${open}" aria-controls="panel-${id}" onclick="toggleAccordion('${id}')">
122
- <span class="accordion-icon severity-${severity}">&#9679;</span>
122
+ <button class="accordion-trigger" aria-expanded="${open ? "true" : "false"}" aria-controls="panel-${id}" onclick="toggleAccordion('${escJs(id)}')">
123
+ <span class="accordion-icon severity-${severity}" aria-hidden="true">&#9679;</span>
123
124
  <span class="accordion-title">${title}</span>
124
- <span class="accordion-subtitle">${subtitle}</span>
125
- <span class="accordion-chevron">&#8964;</span>
125
+ <span class="accordion-subtitle">${subtitle}</span>
126
+ <span class="accordion-chevron" aria-hidden="true">&#9660;</span>
126
127
  </button>
127
- <div class="accordion-panel" id="panel-${id}" role="region" ${open ? "" : "hidden"}>
128
+ <div class="accordion-panel" id="panel-${id}" role="region" aria-labelledby="acc-${id}"${open ? "" : " hidden"}>
128
129
  <div class="accordion-body">${content}</div>
129
- </div>
130
+ </div>
130
131
  </div>`;
131
132
  }
132
133
  function buildCoverageDonut(pct) {
@@ -266,13 +267,17 @@ function buildHtml(report) {
266
267
  e.filePath ? `<code class="path-code">${esc(e.filePath)}</code>` : "\u2014",
267
268
  `<span class="detail-text">${esc(e.message)}</span>`
268
269
  ]);
269
- const langChips = [...missingByLang.entries()].map(([lang, keys]) => `<span class="chip chip-red" onclick="filterTable('tbl-missing', '${escJs(lang)}')">${esc(lang)} <strong>${keys.length}</strong></span>`).join(" ");
270
+ const langChips = [...missingByLang.entries()].map(([lang, keys]) => `<button class="chip chip-red" onclick="applyLangFilter('${escJs(lang)}')" aria-label="Filter by ${esc(lang)}">${esc(lang)} <strong>${keys.length}</strong></button>`).join(" ");
270
271
  missingContent = `
271
272
  <div class="insight-legend">
272
273
  These locale keys exist in the <strong>default language</strong> but are <strong>absent</strong> in one or more target language files.
273
274
  Ask your translators to fill in these entries. Running <code>ai-localize extract</code> seeds all target files with the source value.
274
275
  </div>
275
- <div class="chip-row">Filter by language: ${langChips}</div>
276
+ <div class="chip-row">
277
+ <span class="chip-label">Filter by language:</span>
278
+ ${langChips}
279
+ <button class="chip chip-clear" onclick="applyLangFilter('')" aria-label="Clear language filter">&#10005; Clear</button>
280
+ </div>
276
281
  ${buildSearchableTable("tbl-missing", [
277
282
  { key: "key", label: "Key" },
278
283
  { key: "language", label: "Language", width: "100px" },
@@ -439,7 +444,7 @@ ${nav}
439
444
  <div class="page-header-left">
440
445
  <h1 class="page-title">&#127760; Localization Analytics Dashboard</h1>
441
446
  <div class="page-meta">
442
- <span class="meta-chip">${badge(framework, "blue")}</span>
447
+ <span class="meta-chip">${badge(framework, "blue")}</span>
443
448
  <span class="meta-item">&#128197; ${scanDate}</span>
444
449
  <span class="meta-item">&#9201; ${duration}ms</span>
445
450
  <span class="meta-item">&#128196; ${filesScanned} files</span>
@@ -454,11 +459,11 @@ ${nav}
454
459
  <!-- Summary -->
455
460
  <section id="overview" class="section">
456
461
  <div class="section-title-row">
457
- <h2 class="section-title">&#128202; Summary</h2>
462
+ <h2 class="section-title">&#128202; Summary</h2>
458
463
  </div>
459
464
  ${summaryCards}
460
465
  ${diffExplainer}
461
- </section>
466
+ </section>
462
467
 
463
468
  <!-- Charts -->
464
469
  <section id="charts" class="section">
@@ -515,7 +520,7 @@ ${nav}
515
520
 
516
521
  <!-- Assets -->
517
522
  <section id="assets" class="section">
518
- <div class="section-title-row">
523
+ <div class="section-title-row">
519
524
  <h2 class="section-title">&#128230; CDN Assets <span class="section-count count-neutral">${assets.totalAssets}</span></h2>
520
525
  </div>
521
526
  ${buildAccordion(
@@ -526,7 +531,7 @@ ${nav}
526
531
  false,
527
532
  assets.legacyCdnUrls > 0 ? "warn" : "ok"
528
533
  )}
529
- </section>
534
+ </section>
530
535
 
531
536
  <!-- AI Insights -->
532
537
  <section id="insights" class="section">
@@ -541,6 +546,9 @@ ${nav}
541
546
 
542
547
  <footer class="page-footer">
543
548
  Generated by <strong>ai-localize-core</strong> &mdash; deterministic, offline-capable i18n tooling &mdash; ${scanDate}
549
+ <span class="footer-shortcuts" aria-label="Keyboard shortcuts">
550
+ <kbd>Esc</kbd> clear search &nbsp;&bull;&nbsp; <kbd>Cmd+D</kbd> toggle theme &nbsp;&bull;&nbsp; <kbd>Tab</kbd> navigate
551
+ </span>
544
552
  </footer>
545
553
  </main>
546
554
 
@@ -615,10 +623,14 @@ function JS(report, insights) {
615
623
  // \u2500\u2500 Active nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
616
624
  var sections = document.querySelectorAll('section[id]');
617
625
  var navItems = document.querySelectorAll('.nav-item[data-section]');
626
+ // Use 120px offset to account for sticky table headers and give visual lead-time
627
+ var NAV_OFFSET = 120;
618
628
  function onScroll() {
619
- var scrollY = window.scrollY + 80;
629
+ var scrollY = window.scrollY + NAV_OFFSET;
620
630
  var current = '';
621
- sections.forEach(function(s) { if (scrollY >= s.offsetTop) current = s.id; });
631
+ sections.forEach(function(s) {
632
+ if (scrollY >= s.offsetTop) current = s.id;
633
+ });
622
634
  navItems.forEach(function(a) {
623
635
  a.classList.toggle('nav-active', a.getAttribute('data-section') === current);
624
636
  });
@@ -630,10 +642,18 @@ var scrollY = window.scrollY + 80;
630
642
  window.toggleAccordion = function(id) {
631
643
  var acc = document.getElementById('acc-' + id);
632
644
  var panel = document.getElementById('panel-' + id);
645
+ if (!acc || !panel) return;
633
646
  var btn = acc.querySelector('.accordion-trigger');
634
- var open = acc.classList.toggle('accordion-open');
635
- btn.setAttribute('aria-expanded', open);
636
- if (open) panel.removeAttribute('hidden'); else panel.setAttribute('hidden', '');
647
+ var isOpen = acc.classList.contains('accordion-open');
648
+ if (isOpen) {
649
+ acc.classList.remove('accordion-open');
650
+ btn.setAttribute('aria-expanded', 'false');
651
+ panel.setAttribute('hidden', '');
652
+ } else {
653
+ acc.classList.add('accordion-open');
654
+ btn.setAttribute('aria-expanded', 'true');
655
+ panel.removeAttribute('hidden');
656
+ }
637
657
  };
638
658
  window.expandAll = function() {
639
659
  document.querySelectorAll('.accordion').forEach(function(acc) {
@@ -641,7 +661,7 @@ var scrollY = window.scrollY + 80;
641
661
  var panel = document.getElementById('panel-' + id);
642
662
  var btn = acc.querySelector('.accordion-trigger');
643
663
  acc.classList.add('accordion-open');
644
- btn.setAttribute('aria-expanded', 'true');
664
+ if (btn) btn.setAttribute('aria-expanded', 'true');
645
665
  if (panel) panel.removeAttribute('hidden');
646
666
  });
647
667
  };
@@ -649,30 +669,40 @@ var scrollY = window.scrollY + 80;
649
669
  document.querySelectorAll('.accordion').forEach(function(acc) {
650
670
  var id = acc.id.replace('acc-', '');
651
671
  var panel = document.getElementById('panel-' + id);
652
- var btn = acc.querySelector('.accordion-trigger');
653
- acc.classList.remove('accordion-open');
654
- btn.setAttribute('aria-expanded', 'false');
672
+ var btn = acc.querySelector('.accordion-trigger');
673
+ acc.classList.remove('accordion-open');
674
+ if (btn) btn.setAttribute('aria-expanded', 'false');
655
675
  if (panel) panel.setAttribute('hidden', '');
656
676
  });
657
677
  };
658
678
 
659
- // \u2500\u2500 Table: filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
679
+ // \u2500\u2500 Table: filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
680
+ // Mark rows with data-filtered attribute instead of display:none so that
681
+ // renderPagination can independently control visibility for pagination.
660
682
  window.filterTable = function(tableId, query) {
661
683
  var tbl = document.getElementById(tableId);
662
684
  if (!tbl) return;
663
- var q = query.toLowerCase();
685
+ var q = (query || '').toLowerCase().trim();
664
686
  var rows = tbl.querySelectorAll('tbody tr');
665
- var shown = 0;
666
687
  rows.forEach(function(row) {
667
- var match = row.textContent.toLowerCase().indexOf(q) !== -1;
668
- row.style.display = match ? '' : 'none';
669
- if (match) shown++;
688
+ if (q === '' || row.textContent.toLowerCase().indexOf(q) !== -1) {
689
+ row.removeAttribute('data-filtered');
690
+ } else {
691
+ row.setAttribute('data-filtered', '1');
692
+ }
670
693
  });
671
- var meta = document.getElementById(tableId + '-meta');
672
- if (meta) meta.textContent = shown + ' / ' + rows.length + ' rows';
694
+ // Reset to page 1 whenever filter changes
695
+ tbl.setAttribute('data-page', '1');
673
696
  renderPagination(tableId);
674
697
  };
675
698
 
699
+ // \u2500\u2500 Language chip filter (syncs search-box input + resets page) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
700
+ window.applyLangFilter = function(lang) {
701
+ var inp = document.querySelector('#tbl-missing-wrapper .table-search');
702
+ if (inp) inp.value = lang;
703
+ window.filterTable('tbl-missing', lang);
704
+ };
705
+
676
706
  // \u2500\u2500 Table: sort \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
677
707
  var sortState = {};
678
708
  window.sortTable = function(tableId, col) {
@@ -683,39 +713,52 @@ var scrollY = window.scrollY + 80;
683
713
  var colIndex = -1;
684
714
  tbl.querySelectorAll('thead th').forEach(function(th, i) {
685
715
  if (th.getAttribute('data-col') === col) colIndex = i;
686
- th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = '\\u21C5');
716
+ var icon = th.querySelector('.sort-icon');
717
+ if (icon) icon.textContent = '\\u21C5';
687
718
  });
688
- if (colIndex === -1) return;
719
+ if (colIndex === -1) return;
689
720
  var th = tbl.querySelector('thead th[data-col="' + col + '"]');
690
- if (th) th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = dir === 'asc' ? '\\u2191' : '\\u2193');
721
+ if (th) {
722
+ var icon = th.querySelector('.sort-icon');
723
+ if (icon) icon.textContent = dir === 'asc' ? '\\u2191' : '\\u2193';
724
+ }
691
725
  var tbody = tbl.querySelector('tbody');
692
726
  var rows = Array.from(tbody.querySelectorAll('tr'));
693
727
  rows.sort(function(a, b) {
694
728
  var av = (a.cells[colIndex] || {}).textContent || '';
695
- var bv = (b.cells[colIndex] || {}).textContent || '';
729
+ var bv = (b.cells[colIndex] || {}).textContent || '';
696
730
  var an = parseFloat(av), bn = parseFloat(bv);
697
731
  if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
698
732
  return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
699
733
  });
700
734
  rows.forEach(function(r) { tbody.appendChild(r); });
701
- renderPagination(tableId);
735
+ renderPagination(tableId);
702
736
  };
703
737
 
704
738
  // \u2500\u2500 Table: pagination \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
739
+ // Reads data-filtered attribute set by filterTable; does not conflict with
740
+ // filter visibility since rows are never hidden by this function until
741
+ // it has determined which rows belong to the current page.
705
742
  function renderPagination(tableId) {
706
743
  var tbl = document.getElementById(tableId);
707
744
  var pag = document.getElementById(tableId + '-pagination');
708
745
  var meta = document.getElementById(tableId + '-meta');
709
746
  if (!tbl || !pag) return;
710
747
  var pageSize = parseInt(tbl.getAttribute('data-page-size') || '50', 10);
711
- var rows = Array.from(tbl.querySelectorAll('tbody tr')).filter(function(r) { return r.style.display !== 'none'; });
712
- var total = rows.length;
713
- var pages = Math.ceil(total / pageSize);
748
+ var allRows = Array.from(tbl.querySelectorAll('tbody tr'));
749
+ // Rows that passed the current filter
750
+ var visibleRows = allRows.filter(function(r) { return !r.hasAttribute('data-filtered'); });
751
+ var total = visibleRows.length;
752
+ var pages = Math.max(1, Math.ceil(total / pageSize));
714
753
  var page = parseInt(tbl.getAttribute('data-page') || '1', 10);
715
754
  if (page > pages) page = 1;
716
- tbl.setAttribute('data-page', page);
717
- rows.forEach(function(r, i) {
718
- r.style.display = (i >= (page - 1) * pageSize && i < page * pageSize) ? '' : 'none';
755
+ if (page < 1) page = 1;
756
+ tbl.setAttribute('data-page', String(page));
757
+ // First hide all rows, then show only the current page slice of visible rows
758
+ allRows.forEach(function(r) { r.style.display = 'none'; });
759
+ var startIdx = (page - 1) * pageSize;
760
+ visibleRows.forEach(function(r, i) {
761
+ if (i >= startIdx && i < startIdx + pageSize) r.style.display = '';
719
762
  });
720
763
  if (meta) meta.textContent = total + ' row' + (total !== 1 ? 's' : '');
721
764
  pag.innerHTML = '';
@@ -725,13 +768,13 @@ renderPagination(tableId);
725
768
  b.textContent = label;
726
769
  b.className = 'pag-btn' + (active ? ' pag-active' : '') + (disabled ? ' pag-disabled' : '');
727
770
  b.disabled = disabled;
728
- b.onclick = function() { tbl.setAttribute('data-page', p); renderPagination(tableId); };
771
+ b.onclick = function() { tbl.setAttribute('data-page', String(p)); renderPagination(tableId); };
729
772
  return b;
730
773
  }
731
- pag.appendChild(btn('\\u00AB', 1, false, page === 1));
774
+ pag.appendChild(btn('\\u00AB', 1, false, page === 1));
732
775
  pag.appendChild(btn('\\u2039', page - 1, false, page === 1));
733
- var start = Math.max(1, page - 2), end = Math.min(pages, page + 2);
734
- for (var i = start; i <= end; i++) pag.appendChild(btn(i, i, i === page, false));
776
+ var winStart = Math.max(1, page - 2), winEnd = Math.min(pages, page + 2);
777
+ for (var i = winStart; i <= winEnd; i++) pag.appendChild(btn(i, i, i === page, false));
735
778
  pag.appendChild(btn('\\u203A', page + 1, false, page === pages));
736
779
  pag.appendChild(btn('\\u00BB', pages, false, page === pages));
737
780
  var info = document.createElement('span');
@@ -839,21 +882,33 @@ renderPagination(tableId);
839
882
 
840
883
  function downloadFile(filename, content, mimeType) {
841
884
  var a = document.createElement('a');
842
- a.href = URL.createObjectURL(new Blob([content], { type: mimeType }));
885
+ a.href = URL.createObjectURL(new Blob([content], { type: mimeType }));
843
886
  a.download = filename;
844
887
  a.click();
845
888
  setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
846
889
  }
847
890
 
848
891
  // \u2500\u2500 Keyboard navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
892
+ // Escape: clear all table search inputs and reset filters
893
+ // Cmd/Ctrl+D: toggle dark/light theme
849
894
  document.addEventListener('keydown', function(e) {
850
895
  if (e.key === 'Escape') {
851
- document.querySelectorAll('.table-search').forEach(function(inp) { inp.value = ''; filterTable(inp.closest('.table-wrapper').querySelector('table').id, ''); });
896
+ document.querySelectorAll('.table-search').forEach(function(inp) {
897
+ var wrapper = inp.closest('.table-wrapper');
898
+ if (!wrapper) return;
899
+ var tbl = wrapper.querySelector('table');
900
+ if (!tbl) return;
901
+ inp.value = '';
902
+ window.filterTable(tbl.id, '');
903
+ });
904
+ }
905
+ if ((e.metaKey || e.ctrlKey) && e.key === 'd') {
906
+ e.preventDefault();
907
+ window.toggleTheme();
852
908
  }
853
- if ((e.metaKey || e.ctrlKey) && e.key === 'd') { e.preventDefault(); window.toggleTheme(); }
854
909
  });
855
910
 
856
- // \u2500\u2500 Smooth scroll for nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
911
+ // \u2500\u2500 Smooth scroll for nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
857
912
  document.querySelectorAll('a.nav-item[href^="#"]').forEach(function(a) {
858
913
  a.addEventListener('click', function(e) {
859
914
  e.preventDefault();
@@ -1200,10 +1255,12 @@ padding: 6px 16px 3px;
1200
1255
  }
1201
1256
  .accordion-subtitle { font-size: 12px; color: var(--text-muted); margin-right: 12px; }
1202
1257
  .accordion-chevron {
1203
- font-size: 18px;
1258
+ font-size: 14px;
1204
1259
  color: var(--text-muted);
1205
1260
  transition: transform var(--transition);
1261
+ display: inline-block;
1206
1262
  }
1263
+ /* \u25BE (&#9660;) points down by default; rotates to \u25B4 when open */
1207
1264
  .accordion-open .accordion-chevron { transform: rotate(180deg); }
1208
1265
  .accordion-panel { border-top: 1px solid var(--border); }
1209
1266
  .accordion-body { padding: 18px; }
@@ -1273,15 +1330,20 @@ border-radius: var(--radius);
1273
1330
 
1274
1331
  /* \u2500\u2500 Chip filter row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1275
1332
  .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; font-size: 12px; color: var(--text-muted); }
1333
+ .chip-label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
1276
1334
  .chip {
1277
1335
  display: inline-flex; align-items: center; gap: 4px;
1278
1336
  padding: 3px 10px; border-radius: 14px;
1279
- font-size: 12px; font-weight: 600;
1337
+ font-size: 12px; font-weight: 600;
1280
1338
  cursor: pointer; border: 1px solid transparent;
1281
- transition: box-shadow var(--transition);
1339
+ transition: box-shadow var(--transition), background var(--transition);
1340
+ font-family: var(--font);
1282
1341
  }
1283
1342
  .chip:hover { box-shadow: 0 0 0 2px var(--accent); }
1343
+ .chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1284
1344
  .chip-red { background: #fee2e2; color: #991b1b; }
1345
+ .chip-clear { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
1346
+ .chip-clear:hover { background: var(--border); box-shadow: none; }
1285
1347
  [data-theme="dark"] .chip-red { background: #450a0a; color: #fca5a5; }
1286
1348
 
1287
1349
  /* \u2500\u2500 Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
@@ -1548,25 +1610,57 @@ font-size: 12px;
1548
1610
  font-size: 12px;
1549
1611
  color: var(--text-subtle);
1550
1612
  text-align: center;
1613
+ display: flex;
1614
+ flex-direction: column;
1615
+ align-items: center;
1616
+ gap: 8px;
1617
+ }
1618
+ .footer-shortcuts { font-size: 11px; color: var(--text-subtle); }
1619
+ kbd {
1620
+ display: inline-block;
1621
+ padding: 1px 6px;
1622
+ font-size: 10px;
1623
+ font-family: var(--font-mono);
1624
+ background: var(--bg-elevated);
1625
+ border: 1px solid var(--border);
1626
+ border-bottom-width: 2px;
1627
+ border-radius: 4px;
1628
+ color: var(--text-muted);
1629
+ white-space: nowrap;
1551
1630
  }
1552
1631
 
1553
1632
  /* \u2500\u2500 Print \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1554
1633
  @media print {
1555
1634
  .sidebar, .theme-toggle, .table-controls, .table-pagination, .page-header-right { display: none !important; }
1556
1635
  .main { margin-left: 0 !important; padding: 16px; }
1557
- .accordion-panel { display: block !important; }
1558
- .accordion-panel[hidden] { display: block !important; }
1636
+ /* Force accordion panels visible \u2014 the hidden attribute needs !important override */
1637
+ .accordion-panel { display: block !important; visibility: visible !important; }
1638
+ .accordion-panel[hidden] { display: block !important; visibility: visible !important; }
1559
1639
  .stat-card, .chart-card, .accordion, .table-wrapper { break-inside: avoid; }
1560
1640
  body { background: white; color: black; }
1641
+ a { color: inherit; text-decoration: none; }
1561
1642
  }
1562
1643
 
1563
- /* \u2500\u2500 Responsive \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1644
+ /* \u2500\u2500 Responsive / Mobile \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1564
1645
  @media (max-width: 768px) {
1565
1646
  body { flex-direction: column; }
1566
- .sidebar { position: relative; width: 100%; height: auto; flex-direction: row; flex-wrap: wrap; overflow: visible; border-right: none; border-bottom: 1px solid var(--border); }
1647
+ /* On mobile the sidebar becomes an in-flow top-bar, not fixed/absolute */
1648
+ .sidebar {
1649
+ position: relative;
1650
+ width: 100%;
1651
+ height: auto;
1652
+ overflow: visible;
1653
+ border-right: none;
1654
+ border-bottom: 1px solid var(--border);
1655
+ flex-direction: column;
1656
+ }
1657
+ .nav-group { display: flex; flex-wrap: wrap; padding: 4px 8px; border-bottom: none; }
1658
+ .nav-group-label { width: 100%; padding: 4px 8px 2px; }
1659
+ .nav-item { padding: 6px 10px; border-radius: var(--radius-sm); }
1567
1660
  .main { margin-left: 0; padding: 16px; }
1568
1661
  .stats-grid { grid-template-columns: repeat(2, 1fr); }
1569
1662
  .insights-grid { grid-template-columns: 1fr; }
1663
+ .charts-grid { grid-template-columns: 1fr; }
1570
1664
  .page-header { flex-direction: column; }
1571
1665
  .theme-toggle { top: 8px; right: 8px; }
1572
1666
  }
@@ -2012,21 +2106,33 @@ function printCliSummary(report) {
2012
2106
  ln(centre(dim("Generated by ") + bold("ai-localize-core") + dim(" \xB7 deterministic, offline-capable i18n tooling")));
2013
2107
  ln(hr("\u2550", "cyan"));
2014
2108
  ln();
2015
- if (!allOk) {
2109
+ const nextSteps = [];
2110
+ if (hardcodedTexts > 0) {
2111
+ nextSteps.push(
2112
+ "Run " + c("brightCyan", "ai-localize scan") + dim(" then ") + c("brightCyan", "ai-localize extract") + dim(" to wrap hardcoded strings and generate locale keys.")
2113
+ );
2114
+ }
2115
+ if (missingTranslations > 0) {
2116
+ nextSteps.push(
2117
+ "Run " + c("brightCyan", "ai-localize extract") + dim(" to seed missing translations in target language files.")
2118
+ );
2119
+ }
2120
+ if (unusedKeys > 0) {
2121
+ nextSteps.push(
2122
+ "Run " + c("brightCyan", "ai-localize cleanup") + dim(" to remove unused keys from locale files.")
2123
+ );
2124
+ }
2125
+ if (assets.legacyCdnUrls > 0) {
2126
+ nextSteps.push(
2127
+ "Run " + c("brightCyan", "ai-localize replace-cdn") + dim(" to migrate legacy CDN URLs to CloudFront.")
2128
+ );
2129
+ }
2130
+ if (nextSteps.length > 0) {
2016
2131
  ln(bold(" \u26A1 Recommended next steps:"));
2017
2132
  ln();
2018
- if (hardcodedTexts > 0) {
2019
- ln("" + c("cyan", "1.") + " Run " + c("brightCyan", "ai-localize full-migrate") + dim(" to wrap hardcoded strings with translation calls."));
2020
- }
2021
- if (missingTranslations > 0) {
2022
- ln(" " + c("cyan", "2.") + " Run " + c("brightCyan", "ai-localize extract") + dim(" to seed target language files."));
2023
- }
2024
- if (unusedKeys > 0) {
2025
- ln(" " + c("cyan", "3.") + " Run " + c("brightCyan", "ai-localize cleanup") + dim(" to remove unused keys."));
2026
- }
2027
- if (assets.legacyCdnUrls > 0) {
2028
- ln(" " + c("cyan", "4.") + " Run " + c("brightCyan", "ai-localize replace-cdn") + dim(" to migrate legacy CDN URLs to CloudFront."));
2029
- }
2133
+ nextSteps.forEach((step, i) => {
2134
+ ln(" " + c("cyan", String(i + 1) + ".") + " " + step);
2135
+ });
2030
2136
  ln();
2031
2137
  }
2032
2138
  const output = out.join("\n");
package/package.json CHANGED
@@ -1,10 +1,22 @@
1
1
  {
2
2
  "name": "ai-localize-reporting",
3
- "version": "2.0.6",
3
+ "version": "3.0.0",
4
4
  "description": "Localization scan reporting: JSON, HTML analytics dashboard and CLI summary",
5
+ "author": "ai-localize-core contributors",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/ai-localize/ai-localize-core.git",
10
+ "directory": "packages/reporting"
11
+ },
12
+ "homepage": "https://github.com/ai-localize/ai-localize-core/tree/main/packages/reporting#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ai-localize/ai-localize-core/issues"
15
+ },
5
16
  "main": "./dist/index.js",
6
17
  "module": "./dist/index.mjs",
7
18
  "types": "./dist/index.d.ts",
19
+ "sideEffects": false,
8
20
  "files": [
9
21
  "dist",
10
22
  "README.md",
@@ -33,14 +45,13 @@
33
45
  "node": ">=18.0.0"
34
46
  },
35
47
  "dependencies": {
36
- "ai-localize-shared": "2.0.6"
48
+ "ai-localize-shared": "3.0.0"
37
49
  },
38
50
  "devDependencies": {
39
51
  "tsup": "^8.0.1",
40
52
  "typescript": "^5.3.3",
41
53
  "vitest": "^1.2.1"
42
54
  },
43
- "license": "MIT",
44
55
  "publishConfig": {
45
56
  "access": "public"
46
57
  },