@mxml3gend/gloss 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ :root{--text-primary: #0f2343;--text-secondary: #3f5478;--text-muted: #5b7196;--surface-card: #ffffff;--surface-border: #d6e0f0;font-family:Manrope,Avenir Next,Segoe UI,sans-serif;line-height:1.45;font-weight:500;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,*:before,*:after{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;background:radial-gradient(circle at 8% 0%,rgba(255,186,132,.26),transparent 42%),radial-gradient(circle at 90% 18%,rgba(120,184,255,.28),transparent 40%),linear-gradient(180deg,#f3f7ff,#f8f1e8)}#root{width:100%}.gloss-app{--space-1: .5rem;--space-2: .75rem;--space-3: 1rem;--space-4: 1.5rem;--space-5: 2rem;--state-danger-text: #842a34;--state-danger-border: #edc7cc;--state-danger-bg: #fff5f6;--state-warning-text: #75511f;--state-warning-border: #ead5ab;--state-warning-bg: #fff9ef;--state-info-text: #2a4f85;--state-info-border: #c7d8f6;--state-info-bg: #eef4ff;--state-success-text: #1f6943;--state-success-border: #b9e0c8;--state-success-bg: #eef8f2;--state-missing-row-bg: #fffaf8;--state-partial-row-bg: #fffdf7;--state-highlight-row-bg: #f2f7ff;--state-missing-cell-bg: #fffdf9;--state-dirty-cell-bg: #f2f7ff;--state-dirty-missing-cell-bg: #fff9f0;--state-unused-bg: #f8f9fc;max-width:1360px;margin:0 auto;padding:var(--space-4) var(--space-3) calc(var(--space-5) + var(--space-2));color:var(--text-primary)}.workspace-shell{margin-top:var(--space-3);display:grid;grid-template-columns:auto minmax(0,1fr);gap:var(--space-2);align-items:start}.action-sidebar{position:sticky;top:calc(var(--space-4) + .25rem);border:1px solid #d7e2f5;border-radius:14px;background:linear-gradient(180deg,#f9fbff,#f3f7ff);padding:.5rem;display:grid;gap:.55rem;box-shadow:0 8px 18px #1f3a6914}.action-sidebar__group{display:grid;gap:.4rem}.action-sidebar__btn{width:2.35rem;height:2.35rem;border:1px solid #c7d6ee;border-radius:10px;background:#fff;color:#274d88;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background .12s ease,border-color .12s ease,transform .12s ease}.action-sidebar__btn svg{width:1.1rem;height:1.1rem}.action-sidebar__btn:hover{background:#edf4ff;border-color:#88a8db;transform:translateY(-.5px)}.action-sidebar__btn:disabled{opacity:.5;cursor:not-allowed;transform:none}.action-sidebar__btn.is-active{background:#e7efff;border-color:#5b7fc5;color:#1e3f75}.action-sidebar__btn--saving{animation:sidebar-pulse 1.1s ease-in-out infinite}@keyframes sidebar-pulse{0%{box-shadow:0 0 #2957b13d}70%{box-shadow:0 0 0 8px #2957b100}to{box-shadow:0 0 #2957b100}}.app-header{position:sticky;top:0;z-index:20;padding:var(--space-3);border-radius:16px;border:1px solid #d9e3f6;background:linear-gradient(140deg,#fdfefff7,#f8fafffa 55%,#f4f8fffa);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:0 10px 24px #16264612}.app-header__main{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:var(--space-3);align-items:center}.app-header__brand{min-width:0}.app-header__logo{display:block;width:min(380px,100%);height:auto}.app-header__controls{display:grid;justify-items:end;gap:var(--space-2)}.app-header__stats{display:flex;flex-wrap:wrap;gap:var(--space-1);justify-self:center;align-items:center;margin-top:0}.stat-chip{display:flex;align-items:baseline;gap:.45rem;padding:.4rem .68rem;border-radius:999px;background:#fffffff5;border:1px solid rgba(21,37,66,.09)}.stat-chip span{font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em}.stat-chip strong{font-size:1rem}.language-switch{display:inline-flex;align-items:center;gap:.45rem;padding:.32rem .4rem;border-radius:999px;border:1px solid var(--surface-border);background:#fffffff0}.support-cards{margin-top:var(--space-2);display:grid;gap:var(--space-1);grid-template-columns:repeat(auto-fit,minmax(240px,1fr));opacity:.88}.support-card{border-radius:12px;border:1px solid #e2e9f7;background:#fbfcff;padding:var(--space-2) var(--space-3)}.support-card h2{margin:0;font-size:.95rem}.support-card p{margin:var(--space-1) 0 0;color:var(--text-secondary);font-size:.88rem;line-height:1.45}.editor-shell{margin-top:0;border-radius:18px;border:1px solid #dbe4f5;background:#fdfefe;padding:var(--space-3);box-shadow:0 16px 30px #0f234c12;display:grid;gap:var(--space-2)}.status-bar{margin:0;border-radius:12px;border:1px solid #dbe5f7;background:#f8fbff;padding:.625rem .75rem;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);flex-wrap:wrap}.status-bar__main{margin:0;font-weight:600;font-size:.9rem}.status-bar__main--error{color:var(--state-danger-text)}.status-bar__main--warning{color:var(--state-warning-text);display:flex;align-items:center;gap:var(--space-1);flex-wrap:wrap}.status-bar__main--success{color:var(--state-success-text)}.status-bar__main--info{color:var(--text-secondary)}.status-bar__meta{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.status-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:999px;border:1px solid #cad8f2;background:#f0f5ff;color:#29476f;font-size:.76rem;font-weight:600;padding:.18rem .5rem}.status-chip[type=button]{cursor:pointer}.status-chip--muted{background:#f6f8fc;border-color:#dde4f2;color:#4d6285}.status-chip--warning{background:var(--state-warning-bg);border-color:var(--state-warning-border);color:var(--state-warning-text)}.status-chip--info{background:var(--state-info-bg);border-color:var(--state-info-border);color:var(--state-info-text)}.status-chip__action{opacity:.85;font-size:.72rem;border-left:1px solid currentColor;padding-left:.4rem}.status-bar__details{width:100%;border:1px solid #e0e8f7;border-radius:10px;background:#fff;padding:.6rem .75rem}.status-bar__details strong{display:block;margin-bottom:.45rem;color:#2f4b75;font-size:.82rem}.status-bar__details p{margin:0;color:var(--text-secondary);font-size:.84rem}.status-bar__details ul{margin:0;padding-left:1rem;display:grid;gap:.3rem}.status-bar__details li{color:#35527c;font-size:.82rem;line-height:1.35}.loading-state{margin:20vh auto 0;max-width:280px;border-radius:12px;border:1px solid #d5e2fb;background:#f7faff;padding:var(--space-3);text-align:center;color:var(--text-secondary)}.notice{margin:0 0 .7rem;padding:.6rem .75rem;border-radius:10px;border:1px solid transparent}.notice--error{color:var(--state-danger-text);background:var(--state-danger-bg);border-color:var(--state-danger-border)}.notice--success{color:var(--state-success-text);background:var(--state-success-bg);border-color:var(--state-success-border)}.notice--warning{color:var(--state-warning-text);background:var(--state-warning-bg);border-color:var(--state-warning-border)}.notice--stale{color:var(--state-warning-text);background:#fff8f0;border-color:#ecd8b6;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}.editor-tabs{display:flex;gap:var(--space-1);margin:0;padding:.25rem;border:1px solid #e2e9f6;border-radius:11px;background:#f7faff}.translations-workspace{display:grid;gap:var(--space-2)}.workspace-content-shell{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-2);align-items:start}.workspace-content-shell--with-drawer{grid-template-columns:minmax(0,1fr) minmax(320px,420px)}.editor-controls{display:grid;gap:var(--space-1);padding:var(--space-1);border-radius:14px;border:1px solid #e1e8f6;background:linear-gradient(180deg,#f9fbff,#f6f9ff)}.toolbar{display:grid;gap:.55rem;margin:0;padding:var(--space-2);border:1px solid #e4ebf8;border-radius:12px;background:#fff}.toolbar__top{display:grid;grid-template-columns:auto minmax(280px,1fr) auto;align-items:center;gap:.55rem}.toolbar__mode-switch{display:inline-flex;align-items:center;gap:.4rem}.toolbar__mode-group{display:grid;gap:.28rem}.toolbar__mode-hint{margin:0;color:#5e7395;font-size:.76rem;line-height:1.3}.toolbar__dsl-hint{margin-top:.05rem;font-size:.74rem;color:#61789e}.toolbar__field{display:grid;gap:.32rem;min-width:160px}.toolbar__field--search{min-width:0}.toolbar__field--search input{width:100%;min-height:2.3rem}.toolbar__field span{font-size:.75rem;font-weight:600;color:#5f7394;text-transform:uppercase;letter-spacing:.06em}.toolbar__actions{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:.45rem}.toolbar__translate-progress{margin:0;font-size:.82rem;color:#506687}.toolbar__chips{display:flex;align-items:center;flex-wrap:wrap;gap:.45rem}.toolbar__token-list{display:flex;flex-wrap:wrap;gap:.4rem}.toolbar__token{display:inline-flex;align-items:center;gap:.35rem;border:1px solid #c8d8f2;border-radius:999px;background:#fff;color:#2b476f;padding:.22rem .52rem;font-size:.74rem;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;cursor:pointer}.toolbar__token:hover{background:#f4f8ff;border-color:#9eb8e6}.toolbar__dsl-toggle{border:1px solid #d1ddf3;border-radius:999px;background:#f7faff;color:#36537e;font-size:.72rem;font-weight:700;letter-spacing:.03em;padding:.28rem .52rem;cursor:help}.filter-chip{border:1px solid #d5e1f5;border-radius:999px;background:#f7faff;color:#274368;font-size:.78rem;font-weight:600;padding:.3rem .58rem;cursor:pointer}.filter-chip.is-active{border-color:#87a9e3;background:#e8f0ff;color:#1d3f79}.filter-chip:disabled{opacity:.58;cursor:not-allowed}.filter-chip--clear{background:#fff;color:#516b93;border-color:#c8d8f2}.toolbar__advanced{width:100%;border:1px solid #dbe5f7;border-radius:12px;background:#f8fbff;padding:.7rem;display:grid;gap:.6rem}.toolbar__advanced-top{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.5rem}.toolbar__advanced-head{display:flex;align-items:center;justify-content:space-between;gap:.6rem;flex-wrap:wrap}.toolbar__advanced-head strong{font-size:.84rem;color:#2f4b75}.toolbar__advanced-actions{display:flex;gap:.4rem;flex-wrap:wrap}.toolbar__advanced-empty{margin:0;color:var(--text-secondary);font-size:.84rem}.toolbar__git-warning{margin:0;font-size:.82rem;color:#8f3c45}.toolbar__rules{display:grid;gap:.45rem}.toolbar__rule{display:grid;grid-template-columns:minmax(140px,1fr) minmax(140px,1fr) minmax(140px,1fr) auto;gap:.45rem;align-items:end;padding:.55rem;border:1px solid #d9e4f8;border-radius:10px;background:#fff}.toolbar__sort{display:flex;align-items:end;gap:.45rem;flex-wrap:wrap;padding-top:.25rem;border-top:1px dashed #d4dff4}.add-key-form{display:flex;align-items:flex-end;gap:var(--space-1);flex-wrap:wrap;margin:0;padding:var(--space-2);border:1px solid #e4ebf8;border-radius:12px;background:#fff}.add-key-form input{min-width:260px;flex:1 1 280px}.add-key-form__error{margin:0;color:#8f3c45;font-size:.82rem;padding:0 .125rem}.editor-main{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-2);align-items:start}.editor-main--translate{grid-template-columns:minmax(0,1fr)}.editor-explorers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--space-2)}.editor-content{min-width:0;border:1px solid #dfe8f6;border-radius:15px;background:#fcfdff;padding:var(--space-2);box-shadow:inset 0 1px #fff}.analysis-drawer{min-width:0;max-height:74vh;border:1px solid #d9e5f7;border-radius:14px;background:#f8fbff;padding:.6rem;display:grid;grid-template-rows:auto minmax(0,1fr);gap:.55rem;overflow:hidden}.analysis-drawer__header{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.analysis-drawer__title{margin:0;font-size:.82rem;color:var(--text-muted);font-weight:700;letter-spacing:.05em;text-transform:uppercase}.analysis-drawer__body{min-height:0;overflow:auto}.analysis-drawer .issues-panel,.analysis-drawer .duplicates-panel,.analysis-drawer .usage-details-panel{border:0;border-radius:0;background:transparent;padding:0}.usage-details-panel{display:grid;gap:.75rem}.usage-details-panel__meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.5rem;margin:0}.usage-details-panel__meta div{border:1px solid #dbe5f7;border-radius:10px;background:#fff;padding:.5rem .6rem}.usage-details-panel__meta dt{margin:0;font-size:.72rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted)}.usage-details-panel__meta dd{margin:.18rem 0 0;color:var(--text-primary);overflow-wrap:anywhere;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.78rem}.usage-details-panel__section{border:1px solid #dce7f9;border-radius:10px;background:#fff;padding:.55rem .65rem}.usage-details-panel__title{margin:0;color:#365179;font-size:.78rem;font-weight:700}.usage-details-panel__empty{margin:.5rem 0 0;color:var(--text-secondary);font-size:.84rem}.file-tree,.namespace-tree{border:1px solid #d8e3f4;border-radius:14px;background:linear-gradient(180deg,#f8faff,#f2f7ff);padding:var(--space-2);max-height:32vh;overflow:auto;box-shadow:inset 0 1px #fff}.namespace-tree__title{margin:0 0 var(--space-1);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);position:sticky;top:-.5rem;z-index:2;background:linear-gradient(180deg,#f8faff,#f8fafff5);padding:.45rem 0 .35rem}.namespace-tree__all-btn,.namespace-tree__node{width:100%;text-align:left;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--text-primary);font:inherit;cursor:pointer}.namespace-tree__all-btn{padding:.42rem .54rem}.namespace-tree__row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.3rem;align-items:center}.namespace-tree__toggle{border:1px solid transparent;background:transparent;color:#50688f;cursor:pointer;border-radius:7px;width:1.4rem;height:1.4rem;padding:0;display:inline-flex;align-items:center;justify-content:center}.namespace-tree__node{display:flex;justify-content:space-between;align-items:center;gap:.45rem;padding:.34rem .46rem}.namespace-tree__name{overflow:hidden;text-overflow:ellipsis}.namespace-tree__meta{color:var(--text-muted);font-size:.7rem;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace}.namespace-tree__all-btn:hover,.namespace-tree__node:hover,.namespace-tree__toggle:hover{background:#e9f1ff}.namespace-tree__all-btn.is-selected,.namespace-tree__node.is-selected{border-color:#8caadd;background:#e4edff;box-shadow:inset 3px 0 #466fcf}.namespace-tree__list{list-style:none;margin:var(--space-1) 0 0;padding:0;display:grid;gap:.1rem}.namespace-tree__item{margin:0}.namespace-tree__empty{margin:.6rem 0 0;padding:.55rem .62rem;border:1px dashed #d8e4f6;border-radius:10px;background:#f8fbff;color:var(--text-secondary);font-size:.84rem;line-height:1.42}.file-tree__title{margin:0 0 var(--space-1);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);position:sticky;top:-.5rem;z-index:2;background:linear-gradient(180deg,#f8faff,#f8fafff5);padding:.45rem 0 .35rem}.file-tree__all-btn,.file-tree__folder-btn,.file-tree__file-btn{width:100%;text-align:left;border:1px solid transparent;border-radius:9px;background:transparent;padding:.42rem .54rem;color:var(--text-primary);font:inherit;cursor:pointer}.file-tree__all-btn,.file-tree__file-btn{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.file-tree__folder-btn{display:inline-flex;align-items:center;gap:.38rem;color:#26456f;font-weight:600}.file-tree__caret{width:.8rem;color:#50688f}.file-tree__folder-name{overflow:hidden;text-overflow:ellipsis}.file-tree__all-btn:hover,.file-tree__folder-btn:hover,.file-tree__file-btn:hover{background:#e9f1ff}.file-tree__all-btn.is-selected,.file-tree__file-btn.is-selected{border-color:#8caadd;background:#e4edff;box-shadow:inset 3px 0 #466fcf}.file-tree__file-name{overflow:hidden;text-overflow:ellipsis}.file-tree__file-count{color:var(--text-muted);font-size:.72rem;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;background:#edf2fd;border:1px solid #d4e0f5;border-radius:999px;padding:.1rem .38rem}.file-tree__list{list-style:none;margin:var(--space-1) 0 0;padding:0;display:grid;gap:.1rem}.file-tree__item{margin:0}.file-tree__empty{margin:.6rem 0 0;padding:.55rem .62rem;border:1px dashed #d8e4f6;border-radius:10px;background:#f8fbff;color:var(--text-secondary);font-size:.84rem;line-height:1.42}.issues-panel{border:1px solid var(--surface-border);border-radius:12px;background:#f8fafe;padding:.75rem;display:grid;gap:.75rem}.issues-panel__header{display:flex;align-items:center;justify-content:space-between;gap:.65rem;flex-wrap:wrap}.issues-panel__actions{display:flex;flex-wrap:wrap;gap:.4rem}.issues-panel__title{margin:0;font-size:.95rem;font-weight:700}.issues-panel__count{color:var(--text-muted);font-size:.8rem;font-weight:600}.issues-panel__list{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.issue-row{display:grid;grid-template-columns:auto minmax(220px,max-content) minmax(0,1fr) auto;align-items:center;gap:.55rem;border:1px solid #e1e8f7;border-radius:10px;background:#fff;padding:.5rem .6rem}.issue-row__badge{display:inline-flex;align-items:center;border-radius:999px;border:1px solid #d0dcf3;background:#f5f8ff;color:#32527e;font-size:.7rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:.15rem .45rem}.issue-row__key{border:0;background:transparent;color:#1c4b98;font:inherit;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;text-align:left;padding:0;cursor:pointer;text-decoration:underline;text-underline-offset:2px;overflow-wrap:anywhere}.issue-row__key--passive{color:#3f5578;text-decoration:none;cursor:default}.issue-row__meta{color:var(--text-secondary);font-size:.82rem;overflow-wrap:anywhere}.issue-row__actions{display:inline-flex;justify-content:flex-end;align-items:center;gap:.35rem;flex-wrap:wrap}.issue-row--missing{border-color:#eadbb8;background:#fffbf4}.issue-row--placeholder_mismatch{border-color:#ecd8b4;background:#fffaf3}.issue-row--invalid_key{border-color:#efcfd4;background:#fff8f9}.issue-row--unused{border-color:#dde4f2;background:var(--state-unused-bg)}.issue-row--hardcoded_text{border-color:#ead7c2;background:#fffaf6}.duplicates-panel{border:1px solid var(--surface-border);border-radius:12px;background:#f8fafe;padding:.7rem}.duplicates-panel__title{margin:0 0 .65rem;font-size:.95rem;font-weight:700}.duplicates-panel__list{display:grid;gap:.75rem}.duplicate-locale{border:1px solid #d7e2f7;border-radius:10px;background:#fff;padding:.55rem}.duplicate-locale__title{margin:0 0 .45rem;font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted)}.duplicate-group{border:1px solid #e3ebfb;border-radius:9px;padding:.5rem;margin-top:.45rem}.duplicate-group__top{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:.5rem;align-items:center}.duplicate-group__value{overflow-wrap:anywhere}.duplicate-group__count{font-size:.78rem;color:var(--text-muted)}.duplicate-group__keys{margin:.45rem 0 0;padding-left:1.2rem;display:grid;gap:.2rem;color:var(--text-secondary);font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.78rem}.table-wrap{border-radius:12px;border:1px solid #d8e3f4;background:#fff;overflow:auto;max-height:76vh}.grid-table{width:100%;min-width:1080px;border-collapse:collapse}.grid-table th{text-align:left;padding:.62rem .66rem;border-bottom:1px solid #d9e3f5;background:#f7faff;position:sticky;top:0;z-index:2;color:#3a557f;font-size:.72rem;text-transform:uppercase;letter-spacing:.06em}.grid-table td{padding:.56rem .6rem;border-bottom:1px solid #edf1f8;vertical-align:top}.grid-table tr.row-state--none>td{background:var(--state-missing-row-bg)}.grid-table tr.row-state--partial>td{background:var(--state-partial-row-bg)}.grid-table tr.row-state--highlighted>td{background:var(--state-highlight-row-bg)}.grid-table tr.row-state--none td.value-cell--dirty,.grid-table tr.row-state--partial td.value-cell--dirty{background:var(--state-dirty-cell-bg)}.grid-table tr.row-state--none td.value-cell--dirty-missing,.grid-table tr.row-state--partial td.value-cell--dirty-missing{background:var(--state-dirty-missing-cell-bg)}.grid-table tbody tr:not(.usage-files-row):not(.virtual-spacer):not(.namespace-group-row):hover>td{background-color:#f8fbff}.namespace-group-row td{padding:0;border-bottom:1px solid #dfe8f6;background:#f7faff}.namespace-group-toggle{width:100%;border:0;background:transparent;padding:.56rem .66rem;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.45rem;color:#2e4f84;font:inherit;font-weight:600;cursor:pointer;text-align:left}.namespace-group-toggle:hover{background:#edf4ff}.namespace-group-toggle__caret{color:#4d6b9d;width:.9rem}.namespace-group-toggle__label{font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.82rem}.namespace-group-toggle__count{color:var(--text-muted);font-size:.74rem;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace}.key-col{width:300px;min-width:300px;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.8rem;color:#1f3659;border-right:1px solid #d7e2f4;background-clip:padding-box}.key-col__label{display:inline;overflow-wrap:anywhere}.key-col__dirty-dot{margin-left:.25rem;color:#2a63c7;font-weight:700}.key-col--translate{border-right:1px solid #dce6f7}.key-col--unused{color:#677186}.key-col--file-selected{box-shadow:inset 2px 0 #4a74d9}.key-col--placeholder-warning{box-shadow:inset 0 -2px #d39a3f}.usage-col{width:106px;min-width:106px}.usage-cell{color:var(--text-secondary);white-space:nowrap;text-align:center}.usage-cell--unused{background:var(--state-unused-bg)}.usage-toggle{border:0;background:none;padding:0;font:inherit;color:#1848a3;cursor:pointer;text-decoration:underline;text-underline-offset:2px;font-weight:600}.usage-tag{font-size:.7rem;font-weight:600;color:#667289;text-transform:uppercase;letter-spacing:.03em}.usage-files-row td{background:#f8fbff;padding-top:.7rem;padding-bottom:.8rem}.virtual-spacer td{border-bottom:0;padding:0}.usage-files{font-size:.82rem;color:var(--text-secondary);border:1px solid #dde6f6;border-radius:10px;background:#fff;padding:.6rem .7rem}.usage-files strong{margin-right:.45rem;color:var(--text-primary)}.usage-files-list{margin:.45rem 0 0;padding-left:1.2rem;display:grid;gap:.26rem;color:var(--text-primary);font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.78rem}.key-diff-block{margin-top:.7rem;padding-top:.55rem;border-top:1px dashed #d7e3f8}.key-diff-line{overflow-wrap:anywhere}.status-col{width:168px;min-width:168px;color:#4a5f80;font-size:.79rem;line-height:1.35}.status-col__summary{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}.status-col__changed{color:#2f5d9b;font-weight:600}.status-col__tags{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.2rem}.status-inline-tag{display:inline-flex;align-items:center;border-radius:999px;padding:.1rem .42rem;font-size:.7rem;font-weight:700;letter-spacing:.03em}.status-inline-tag--warning{color:var(--state-warning-text);background:var(--state-warning-bg);border:1px solid var(--state-warning-border)}.status-inline-tag--info{color:var(--state-info-text);background:var(--state-info-bg);border:1px solid var(--state-info-border)}.locale-col,.locale-cell{min-width:260px}.value-cell{background:transparent}.value-cell--missing{background:var(--state-missing-cell-bg)}.value-cell--dirty{background:var(--state-dirty-cell-bg)}.value-cell--dirty-missing{background:var(--state-dirty-missing-cell-bg)}.value-cell--active{box-shadow:inset 0 0 0 2px #2c5ecf42}.value-input{box-sizing:border-box;width:100%;resize:none;overflow:hidden;min-height:2.05rem;line-height:1.32;border:1px solid #d6e2f4;border-radius:9px;background:#fff;padding:.4rem .5rem;font:inherit;color:var(--text-primary);transition:border-color .12s ease,box-shadow .12s ease,background .12s ease}.value-input:focus{outline:none;border-color:#7ca2e0;box-shadow:0 0 0 2px #5586d824}.value-input--dirty{border-color:#0e5fd8;box-shadow:0 0 0 2px #0e5fd814}.actions-col{width:1%;min-width:0;white-space:nowrap;text-align:right}.row-actions{display:flex;gap:.4rem;flex-wrap:nowrap}.row-action-icon-btn{width:2rem;height:2rem;padding:0}.row-action-icon-btn svg{width:1.05rem;height:1.05rem}.rename-form{display:flex;gap:.4rem;flex-wrap:wrap;align-items:center}.inline-error{color:#9a241f;width:100%;font-size:.82rem}.empty-state{margin:1rem auto;padding:2.1rem 1.35rem;border:1px dashed #dbe4f5;border-radius:12px;background:linear-gradient(180deg,#fbfdff,#f6f9ff);text-align:center;max-width:700px;color:var(--text-secondary);line-height:1.48;font-size:.92rem}.empty-state--workspace{margin:1.2rem auto;padding:2.4rem 1.5rem}.empty-state--panel{margin:0;max-width:none;text-align:left;padding:1rem .92rem;border-style:solid;border-color:#dce7f8;background:#fff;font-size:.86rem;line-height:1.42}.footer-actions{margin-top:var(--space-2);padding:var(--space-2);border-top:1px solid #e2e9f6;border-radius:12px;background:linear-gradient(180deg,#f9fbff,#f6f9ff);display:grid;grid-template-columns:minmax(0,1fr);align-items:start;gap:var(--space-2)}.footer-actions__meta{min-width:0}.footer-actions__summary{margin:0;color:var(--text-secondary);font-size:.86rem;line-height:1.42;max-width:68ch}.footer-actions__xliff{margin-top:.55rem;display:flex;align-items:end;flex-wrap:wrap;gap:.45rem}.footer-actions__xliff-label{font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted)}.footer-actions__xliff-field{display:grid;gap:.18rem}.footer-actions__xliff-field span{font-size:.7rem;color:var(--text-muted)}.footer-actions__file-input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}.footer-actions__links{margin-top:.45rem;display:flex;flex-wrap:wrap;gap:.45rem}.btn{border-radius:10px;border:1px solid transparent;padding:.5rem .8rem;font-size:.9rem;font-weight:600;text-decoration:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:transform .12s ease,box-shadow .12s ease,background .12s ease}.btn:hover{transform:translateY(-.5px)}.btn:disabled{opacity:.55;cursor:not-allowed;transform:none}.btn--primary{color:#fff;background:linear-gradient(135deg,#245fdf,#1b49be);box-shadow:0 10px 18px #1f59d942}.btn--ghost{color:#1d355c;border-color:#c2d0ea;background:#f6f9ff}.btn--support{color:#fff;background:linear-gradient(135deg,#ff7849,#f14c4c);box-shadow:0 7px 16px #f14c4c3b}.btn--danger{color:#9c212a;border-color:#eec1c7;background:#fff6f7}.btn--small{padding:.4rem .6rem;font-size:.8rem}.is-active{border-color:#4d73d7;background:#eaf1ff}input,textarea,select{border-radius:9px;border:1px solid #c4d2ea;background:#fff;color:var(--text-primary);padding:.48rem .6rem;font:inherit}input:focus,textarea:focus,select:focus{outline:none;border-color:#1f5eff;box-shadow:0 0 0 3px #1f5eff24}.modal-overlay{position:fixed;inset:0;background:#0e162480;display:flex;align-items:center;justify-content:center;padding:1rem;z-index:1000}.modal-dialog{width:min(480px,100%);border-radius:14px;border:1px solid var(--surface-border);background:var(--surface-card);box-shadow:0 24px 44px #0e1f4847;padding:1rem;display:grid;gap:.75rem}.modal-dialog__message{margin:0;color:var(--text-primary);white-space:pre-line}.modal-dialog__actions{display:flex;justify-content:flex-end;gap:.5rem}.toast-stack{position:fixed;right:1rem;bottom:1rem;z-index:1100}.toast{margin:0;border-radius:10px;border:1px solid #bde0cb;background:#edf9f1;color:#1e6f45;padding:.55rem .7rem;font-size:.86rem;font-weight:600;box-shadow:0 10px 24px #10361f24}.toast--success{border-color:#bde0cb;background:#edf9f1;color:#1e6f45}@media(max-width:860px){.gloss-app{padding:var(--space-3) var(--space-2) calc(var(--space-5) + var(--space-2))}.workspace-shell{margin-top:var(--space-2);grid-template-columns:1fr}.action-sidebar{position:static;grid-template-columns:repeat(2,auto);justify-content:start;align-items:start}.action-sidebar__group{grid-auto-flow:column;grid-auto-columns:min-content}.app-header{position:static}.app-header__main{grid-template-columns:1fr}.app-header__controls{justify-items:start}.editor-shell{padding:var(--space-2)}.status-bar{align-items:flex-start}.editor-controls{padding:.5rem}.toolbar__top,.toolbar__actions,.toolbar__chips,.toolbar__field,.toolbar__field--search,.toolbar__rule{width:100%}.toolbar__top{grid-template-columns:1fr}.toolbar__rule{grid-template-columns:1fr;align-items:stretch}.toolbar__field input,.toolbar__field select,.toolbar__actions .btn,.add-key-form input{width:100%}.editor-main,.workspace-content-shell--with-drawer{grid-template-columns:1fr}.analysis-drawer{max-height:none}.editor-explorers{grid-template-columns:1fr}.namespace-tree,.file-tree{max-height:none}.footer-actions,.issue-row{grid-template-columns:1fr;align-items:stretch}.issue-row__actions{justify-content:flex-start}.toast-stack{left:.75rem;right:.75rem;bottom:.75rem}}
@@ -11,8 +11,8 @@
11
11
  <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
12
12
  <meta name="apple-mobile-web-app-title" content="Gloss" />
13
13
  <link rel="manifest" href="/site.webmanifest" />
14
- <script type="module" crossorigin src="/assets/index-Dhb2pVPI.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-CREq9Gop.css">
14
+ <script type="module" crossorigin src="/assets/index-BCr07xD_.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CjmLcA1x.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
Binary file
package/dist/usage.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { updateCacheMetrics } from "./cacheMetrics.js";
3
4
  import { createScanMatcher } from "./scanFilters.js";
4
- import { isLikelyTranslationKey } from "./translationKeys.js";
5
+ import { extractTranslationKeys } from "./usageExtractor.js";
5
6
  const SUPPORTED_EXTENSIONS = [
6
7
  ".ts",
7
8
  ".tsx",
@@ -33,23 +34,65 @@ const hasSkippedPathSegment = (relativePath) => normalizePath(relativePath)
33
34
  .split("/")
34
35
  .some((segment) => SKIP_DIRECTORIES.has(segment));
35
36
  const isSupportedFile = (name) => SUPPORTED_EXTENSIONS.some((extension) => name.endsWith(extension));
36
- const extractTranslationKeys = (content) => {
37
- const keys = new Set();
38
- const regexes = [
39
- /\b(?:t|i18n\.t|translate)\(\s*["'`]([^"'`]+)["'`]\s*[\),]/g,
40
- /\bi18nKey\s*=\s*["'`]([^"'`]+)["'`]/g,
41
- ];
42
- for (const regex of regexes) {
43
- let match = regex.exec(content);
44
- while (match) {
45
- const key = match[1]?.trim();
46
- if (key && isLikelyTranslationKey(key)) {
47
- keys.add(key);
48
- }
49
- match = regex.exec(content);
37
+ const fileSignature = (mtimeMs, size) => `${mtimeMs}:${size}`;
38
+ const keyUsageCache = new Map();
39
+ const mtimeFromEntry = (entry) => {
40
+ if (typeof entry.mtimeMs === "number" && Number.isFinite(entry.mtimeMs)) {
41
+ return entry.mtimeMs;
42
+ }
43
+ const parsed = Number.parseFloat(entry.signature.split(":")[0] ?? "");
44
+ return Number.isFinite(parsed) ? parsed : null;
45
+ };
46
+ const sizeFromEntry = (entry) => {
47
+ if (typeof entry.sizeBytes === "number" && Number.isFinite(entry.sizeBytes)) {
48
+ return entry.sizeBytes;
49
+ }
50
+ const parsed = Number.parseInt(entry.signature.split(":")[1] ?? "", 10);
51
+ return Number.isFinite(parsed) ? parsed : 0;
52
+ };
53
+ const summarizeCacheEntries = (entries) => {
54
+ let fileCount = 0;
55
+ let totalSizeBytes = 0;
56
+ let oldestMtimeMs = null;
57
+ for (const entry of entries) {
58
+ fileCount += 1;
59
+ totalSizeBytes += sizeFromEntry(entry);
60
+ const mtime = mtimeFromEntry(entry);
61
+ if (mtime !== null) {
62
+ oldestMtimeMs = oldestMtimeMs === null ? mtime : Math.min(oldestMtimeMs, mtime);
50
63
  }
51
64
  }
52
- return keys;
65
+ return { fileCount, totalSizeBytes, oldestMtimeMs };
66
+ };
67
+ export const keyUsageCacheKey = (cfg) => {
68
+ const root = projectRoot();
69
+ const i18nDirectory = translationsDir(cfg);
70
+ return `${path.resolve(root)}::${path.resolve(i18nDirectory)}::${JSON.stringify(cfg.scan ?? {})}`;
71
+ };
72
+ export const getKeyUsageCacheStatus = (cfg) => {
73
+ const cacheKey = keyUsageCacheKey(cfg);
74
+ const bucket = keyUsageCache.get(cacheKey);
75
+ if (!bucket) {
76
+ return {
77
+ cacheKey,
78
+ fileCount: 0,
79
+ totalSizeBytes: 0,
80
+ oldestMtimeMs: null,
81
+ };
82
+ }
83
+ return {
84
+ cacheKey,
85
+ ...summarizeCacheEntries(bucket.files.values()),
86
+ };
87
+ };
88
+ export const clearKeyUsageCache = () => {
89
+ const bucketCount = keyUsageCache.size;
90
+ let fileCount = 0;
91
+ for (const bucket of keyUsageCache.values()) {
92
+ fileCount += bucket.files.size;
93
+ }
94
+ keyUsageCache.clear();
95
+ return { bucketCount, fileCount };
53
96
  };
54
97
  const extractRelativeImports = (content) => {
55
98
  const imports = new Set();
@@ -120,7 +163,7 @@ const isPageFile = (relativePath) => {
120
163
  isNextAppPage ||
121
164
  isSvelteKitPage);
122
165
  };
123
- const collectFiles = async (directory, projectDir, shouldScanFile, files) => {
166
+ const collectFiles = async (directory, projectDir, shouldScanFile, cfg, previousFiles, nextFiles, files) => {
124
167
  const entries = await fs.readdir(directory, { withFileTypes: true });
125
168
  for (const entry of entries) {
126
169
  if (entry.name.startsWith(".")) {
@@ -131,7 +174,7 @@ const collectFiles = async (directory, projectDir, shouldScanFile, files) => {
131
174
  if (SKIP_DIRECTORIES.has(entry.name)) {
132
175
  continue;
133
176
  }
134
- await collectFiles(fullPath, projectDir, shouldScanFile, files);
177
+ await collectFiles(fullPath, projectDir, shouldScanFile, cfg, previousFiles, nextFiles, files);
135
178
  continue;
136
179
  }
137
180
  if (!entry.isFile() || !isSupportedFile(entry.name)) {
@@ -144,16 +187,38 @@ const collectFiles = async (directory, projectDir, shouldScanFile, files) => {
144
187
  if (!shouldScanFile(relativePath)) {
145
188
  continue;
146
189
  }
190
+ const stat = await fs.stat(fullPath);
191
+ const signature = fileSignature(stat.mtimeMs, stat.size);
192
+ const cached = previousFiles?.get(relativePath);
193
+ if (cached && cached.signature === signature) {
194
+ nextFiles.set(relativePath, cached);
195
+ files.push({
196
+ filePath: fullPath,
197
+ relativePath,
198
+ keys: new Set(cached.keys),
199
+ imports: [...cached.imports],
200
+ });
201
+ continue;
202
+ }
147
203
  const content = await fs.readFile(fullPath, "utf8");
204
+ const keys = extractTranslationKeys(content, fullPath, cfg.scan?.mode);
205
+ const imports = extractRelativeImports(content);
206
+ nextFiles.set(relativePath, {
207
+ signature,
208
+ keys,
209
+ imports,
210
+ mtimeMs: stat.mtimeMs,
211
+ sizeBytes: stat.size,
212
+ });
148
213
  files.push({
149
214
  filePath: fullPath,
150
215
  relativePath,
151
- keys: extractTranslationKeys(content),
152
- imports: extractRelativeImports(content),
216
+ keys: new Set(keys),
217
+ imports,
153
218
  });
154
219
  }
155
220
  };
156
- export async function buildKeyUsageMap(cfg) {
221
+ export async function buildKeyUsageMap(cfg, options) {
157
222
  const root = projectRoot();
158
223
  const i18nDirectory = translationsDir(cfg);
159
224
  const candidateRoots = [
@@ -164,6 +229,10 @@ export async function buildKeyUsageMap(cfg) {
164
229
  path.join(root, "routes"),
165
230
  ];
166
231
  const sourceRoots = Array.from(new Set(candidateRoots.filter((candidate) => path.resolve(candidate) !== root)));
232
+ const useCache = options?.useCache !== false;
233
+ const cacheKey = keyUsageCacheKey(cfg);
234
+ const previousCache = useCache ? keyUsageCache.get(cacheKey) : undefined;
235
+ const nextFileCache = new Map();
167
236
  const files = [];
168
237
  const shouldScanFile = createScanMatcher(cfg.scan);
169
238
  for (const sourceRoot of sourceRoots) {
@@ -171,7 +240,21 @@ export async function buildKeyUsageMap(cfg) {
171
240
  if (!stat?.isDirectory()) {
172
241
  continue;
173
242
  }
174
- await collectFiles(sourceRoot, root, shouldScanFile, files);
243
+ await collectFiles(sourceRoot, root, shouldScanFile, cfg, previousCache?.files, nextFileCache, files);
244
+ }
245
+ if (useCache) {
246
+ keyUsageCache.set(cacheKey, { files: nextFileCache });
247
+ const summary = summarizeCacheEntries(nextFileCache.values());
248
+ try {
249
+ await updateCacheMetrics(projectRoot(), "keyUsage", {
250
+ cacheKey,
251
+ ...summary,
252
+ updatedAt: new Date().toISOString(),
253
+ });
254
+ }
255
+ catch {
256
+ // Non-fatal: cache metrics are observability only.
257
+ }
175
258
  }
176
259
  const fileByPath = new Map(files.map((file) => [path.resolve(file.filePath), file]));
177
260
  const adjacency = new Map();
@@ -0,0 +1,151 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import { isLikelyTranslationKey } from "./translationKeys.js";
4
+ const REGEX_PATTERNS = [
5
+ /\b(?:t|i18n\.t|translate)\(\s*["'`]([^"'`]+)["'`]\s*[\),]/g,
6
+ /\bi18nKey\s*=\s*["'`]([^"'`]+)["'`]/g,
7
+ ];
8
+ const normalizeMode = (mode) => mode === "ast" ? "ast" : "regex";
9
+ const scriptKindForFile = (filePath) => {
10
+ const extension = path.extname(filePath).toLowerCase();
11
+ if (extension === ".tsx") {
12
+ return ts.ScriptKind.TSX;
13
+ }
14
+ if (extension === ".jsx") {
15
+ return ts.ScriptKind.JSX;
16
+ }
17
+ if (extension === ".ts" || extension === ".mts" || extension === ".cts") {
18
+ return ts.ScriptKind.TS;
19
+ }
20
+ return ts.ScriptKind.JS;
21
+ };
22
+ const getLiteralText = (node) => {
23
+ if (!node) {
24
+ return null;
25
+ }
26
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
27
+ return node.text;
28
+ }
29
+ return null;
30
+ };
31
+ const isTranslationCallee = (expression) => {
32
+ if (ts.isIdentifier(expression)) {
33
+ return expression.text === "t" || expression.text === "translate";
34
+ }
35
+ if (ts.isPropertyAccessExpression(expression) &&
36
+ ts.isIdentifier(expression.expression)) {
37
+ return expression.expression.text === "i18n" && expression.name.text === "t";
38
+ }
39
+ return false;
40
+ };
41
+ const isI18nKeyAttribute = (name) => ts.isIdentifier(name) && name.text === "i18nKey";
42
+ const extractWithAst = (source, filePath) => {
43
+ const keys = [];
44
+ const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKindForFile(filePath));
45
+ const pushKey = (value) => {
46
+ const key = value?.trim();
47
+ if (key && isLikelyTranslationKey(key)) {
48
+ keys.push(key);
49
+ }
50
+ };
51
+ const visit = (node) => {
52
+ if (ts.isCallExpression(node) && isTranslationCallee(node.expression)) {
53
+ pushKey(getLiteralText(node.arguments[0]));
54
+ }
55
+ else if (ts.isJsxAttribute(node) && isI18nKeyAttribute(node.name)) {
56
+ if (node.initializer && ts.isStringLiteral(node.initializer)) {
57
+ pushKey(node.initializer.text);
58
+ }
59
+ else if (node.initializer &&
60
+ ts.isJsxExpression(node.initializer) &&
61
+ node.initializer.expression) {
62
+ pushKey(getLiteralText(node.initializer.expression));
63
+ }
64
+ }
65
+ ts.forEachChild(node, visit);
66
+ };
67
+ visit(sourceFile);
68
+ return keys;
69
+ };
70
+ const extractWithRegex = (source) => {
71
+ const keys = [];
72
+ for (const regex of REGEX_PATTERNS) {
73
+ let match = regex.exec(source);
74
+ while (match) {
75
+ const key = match[1]?.trim();
76
+ if (key && isLikelyTranslationKey(key)) {
77
+ keys.push(key);
78
+ }
79
+ match = regex.exec(source);
80
+ }
81
+ regex.lastIndex = 0;
82
+ }
83
+ return keys;
84
+ };
85
+ export const extractTranslationKeys = (source, filePath, mode) => {
86
+ const normalizedMode = normalizeMode(mode);
87
+ if (normalizedMode === "ast") {
88
+ return extractWithAst(source, filePath);
89
+ }
90
+ return extractWithRegex(source);
91
+ };
92
+ export const replaceTranslationKeyLiterals = (source, filePath, oldKey, newKey, mode) => {
93
+ const normalizedMode = normalizeMode(mode);
94
+ if (normalizedMode === "regex") {
95
+ let replacements = 0;
96
+ const regex = /(\b(?:t|translate)\s*\(\s*)(['"])([^'"]+)\2/g;
97
+ const updated = source.replace(regex, (match, prefix, quote, key) => {
98
+ if (key !== oldKey) {
99
+ return match;
100
+ }
101
+ replacements += 1;
102
+ return `${prefix}${quote}${newKey}${quote}`;
103
+ });
104
+ regex.lastIndex = 0;
105
+ return { updated, replacements };
106
+ }
107
+ const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKindForFile(filePath));
108
+ const edits = [];
109
+ const queueEditForLiteral = (node) => {
110
+ const start = node.getStart(sourceFile);
111
+ const end = node.getEnd();
112
+ const quote = source[start] === "`" ? "`" : source[start] === "'" ? "'" : '"';
113
+ edits.push({ start, end, text: `${quote}${newKey}${quote}` });
114
+ };
115
+ const visit = (node) => {
116
+ if (ts.isCallExpression(node) && isTranslationCallee(node.expression)) {
117
+ const firstArg = node.arguments[0];
118
+ if (firstArg &&
119
+ (ts.isStringLiteral(firstArg) || ts.isNoSubstitutionTemplateLiteral(firstArg)) &&
120
+ firstArg.text === oldKey) {
121
+ queueEditForLiteral(firstArg);
122
+ }
123
+ }
124
+ else if (ts.isJsxAttribute(node) && isI18nKeyAttribute(node.name)) {
125
+ if (node.initializer && ts.isStringLiteral(node.initializer)) {
126
+ if (node.initializer.text === oldKey) {
127
+ queueEditForLiteral(node.initializer);
128
+ }
129
+ }
130
+ else if (node.initializer &&
131
+ ts.isJsxExpression(node.initializer) &&
132
+ node.initializer.expression &&
133
+ (ts.isStringLiteral(node.initializer.expression) ||
134
+ ts.isNoSubstitutionTemplateLiteral(node.initializer.expression)) &&
135
+ node.initializer.expression.text === oldKey) {
136
+ queueEditForLiteral(node.initializer.expression);
137
+ }
138
+ }
139
+ ts.forEachChild(node, visit);
140
+ };
141
+ visit(sourceFile);
142
+ edits.sort((left, right) => right.start - left.start);
143
+ let updated = source;
144
+ for (const edit of edits) {
145
+ updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
146
+ }
147
+ return {
148
+ updated,
149
+ replacements: edits.length,
150
+ };
151
+ };
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { updateCacheMetrics } from "./cacheMetrics.js";
3
4
  import { createScanMatcher } from "./scanFilters.js";
4
- import { isLikelyTranslationKey } from "./translationKeys.js";
5
+ import { extractTranslationKeys } from "./usageExtractor.js";
5
6
  const IGNORED_DIRECTORIES = new Set([
6
7
  "node_modules",
7
8
  "dist",
@@ -15,12 +16,9 @@ const IGNORED_DIRECTORIES = new Set([
15
16
  "storybook-static",
16
17
  ]);
17
18
  const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
18
- const USAGE_REGEXES = [
19
- /\b(?:t|i18n\.t|translate)\(\s*["'`]([^"'`]+)["'`]\s*[\),]/g,
20
- /\bi18nKey\s*=\s*["'`]([^"'`]+)["'`]/g,
21
- ];
22
19
  const projectRoot = () => process.env.INIT_CWD || process.cwd();
23
20
  const normalizePath = (filePath) => filePath.split(path.sep).join("/");
21
+ const usageScannerCache = new Map();
24
22
  const isScannableFile = (fileName) => SCANNED_EXTENSIONS.has(path.extname(fileName));
25
23
  const hasIgnoredPathSegment = (relativePath) => normalizePath(relativePath)
26
24
  .split("/")
@@ -46,9 +44,69 @@ export const inferUsageRoot = (cfg) => {
46
44
  }
47
45
  return parentDirectory;
48
46
  };
49
- export async function scanUsage(rootDir = projectRoot(), scan) {
50
- const usage = {};
51
- const seenFilesByKey = new Map();
47
+ export const usageScannerCacheKey = (rootDir, scan) => {
48
+ const normalizedRoot = path.resolve(rootDir);
49
+ return `${normalizedRoot}::${JSON.stringify(scan ?? {})}`;
50
+ };
51
+ const fileSignature = (mtimeMs, size) => `${mtimeMs}:${size}`;
52
+ const mtimeFromEntry = (entry) => {
53
+ if (typeof entry.mtimeMs === "number" && Number.isFinite(entry.mtimeMs)) {
54
+ return entry.mtimeMs;
55
+ }
56
+ const parsed = Number.parseFloat(entry.signature.split(":")[0] ?? "");
57
+ return Number.isFinite(parsed) ? parsed : null;
58
+ };
59
+ const sizeFromEntry = (entry) => {
60
+ if (typeof entry.sizeBytes === "number" && Number.isFinite(entry.sizeBytes)) {
61
+ return entry.sizeBytes;
62
+ }
63
+ const parsed = Number.parseInt(entry.signature.split(":")[1] ?? "", 10);
64
+ return Number.isFinite(parsed) ? parsed : 0;
65
+ };
66
+ const summarizeCacheEntries = (entries) => {
67
+ let fileCount = 0;
68
+ let totalSizeBytes = 0;
69
+ let oldestMtimeMs = null;
70
+ for (const entry of entries) {
71
+ fileCount += 1;
72
+ totalSizeBytes += sizeFromEntry(entry);
73
+ const mtime = mtimeFromEntry(entry);
74
+ if (mtime !== null) {
75
+ oldestMtimeMs = oldestMtimeMs === null ? mtime : Math.min(oldestMtimeMs, mtime);
76
+ }
77
+ }
78
+ return { fileCount, totalSizeBytes, oldestMtimeMs };
79
+ };
80
+ export const getUsageScannerCacheStatus = (rootDir, scan) => {
81
+ const cacheKey = usageScannerCacheKey(rootDir, scan);
82
+ const bucket = usageScannerCache.get(cacheKey);
83
+ if (!bucket) {
84
+ return {
85
+ cacheKey,
86
+ fileCount: 0,
87
+ totalSizeBytes: 0,
88
+ oldestMtimeMs: null,
89
+ };
90
+ }
91
+ return {
92
+ cacheKey,
93
+ ...summarizeCacheEntries(bucket.files.values()),
94
+ };
95
+ };
96
+ export const clearUsageScannerCache = () => {
97
+ const bucketCount = usageScannerCache.size;
98
+ let fileCount = 0;
99
+ for (const bucket of usageScannerCache.values()) {
100
+ fileCount += bucket.files.size;
101
+ }
102
+ usageScannerCache.clear();
103
+ return { bucketCount, fileCount };
104
+ };
105
+ export async function scanUsage(rootDir = projectRoot(), scan, options) {
106
+ const useCache = options?.useCache !== false;
107
+ const cacheKey = usageScannerCacheKey(rootDir, scan);
108
+ const previousCache = useCache ? usageScannerCache.get(cacheKey) : undefined;
109
+ const nextFiles = new Map();
52
110
  const shouldScanFile = createScanMatcher(scan);
53
111
  const scanDirectory = async (directory) => {
54
112
  const entries = await fs.readdir(directory, { withFileTypes: true });
@@ -71,30 +129,54 @@ export async function scanUsage(rootDir = projectRoot(), scan) {
71
129
  if (!shouldScanFile(relativePath)) {
72
130
  continue;
73
131
  }
74
- const source = await fs.readFile(fullPath, "utf8");
75
- for (const usageRegex of USAGE_REGEXES) {
76
- let match = usageRegex.exec(source);
77
- while (match) {
78
- const key = match[1]?.trim();
79
- if (key && isLikelyTranslationKey(key)) {
80
- if (!usage[key]) {
81
- usage[key] = { count: 0, files: [] };
82
- seenFilesByKey.set(key, new Set());
83
- }
84
- usage[key].count += 1;
85
- const fileSet = seenFilesByKey.get(key);
86
- if (fileSet && !fileSet.has(relativePath)) {
87
- fileSet.add(relativePath);
88
- usage[key].files.push(relativePath);
89
- }
90
- }
91
- match = usageRegex.exec(source);
92
- }
93
- usageRegex.lastIndex = 0;
132
+ const stat = await fs.stat(fullPath);
133
+ const signature = fileSignature(stat.mtimeMs, stat.size);
134
+ const cached = previousCache?.files.get(relativePath);
135
+ if (cached && cached.signature === signature) {
136
+ nextFiles.set(relativePath, cached);
137
+ continue;
94
138
  }
139
+ const source = await fs.readFile(fullPath, "utf8");
140
+ const keys = extractTranslationKeys(source, fullPath, scan?.mode);
141
+ nextFiles.set(relativePath, {
142
+ signature,
143
+ keys,
144
+ mtimeMs: stat.mtimeMs,
145
+ sizeBytes: stat.size,
146
+ });
95
147
  }
96
148
  };
97
149
  await scanDirectory(rootDir);
150
+ if (useCache) {
151
+ usageScannerCache.set(cacheKey, { files: nextFiles });
152
+ const summary = summarizeCacheEntries(nextFiles.values());
153
+ try {
154
+ await updateCacheMetrics(projectRoot(), "usageScanner", {
155
+ cacheKey,
156
+ ...summary,
157
+ updatedAt: new Date().toISOString(),
158
+ });
159
+ }
160
+ catch {
161
+ // Non-fatal: cache metrics are observability only.
162
+ }
163
+ }
164
+ const usage = {};
165
+ const seenFilesByKey = new Map();
166
+ for (const [relativePath, fileData] of nextFiles.entries()) {
167
+ for (const key of fileData.keys) {
168
+ if (!usage[key]) {
169
+ usage[key] = { count: 0, files: [] };
170
+ seenFilesByKey.set(key, new Set());
171
+ }
172
+ usage[key].count += 1;
173
+ const fileSet = seenFilesByKey.get(key);
174
+ if (fileSet && !fileSet.has(relativePath)) {
175
+ fileSet.add(relativePath);
176
+ usage[key].files.push(relativePath);
177
+ }
178
+ }
179
+ }
98
180
  for (const value of Object.values(usage)) {
99
181
  value.files.sort((left, right) => left.localeCompare(right));
100
182
  }