@wads.dev/i18n-editor 0.0.1-alpha.0 → 0.0.1-rc.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.
@@ -1,176 +1,220 @@
1
1
  <!doctype html>
2
- <html lang="pt-BR">
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Editor de bundle i18n</title>
6
+ <title>i18n Bundle Editor</title>
7
7
  <link rel="stylesheet" href="./styles.css">
8
8
  <script type="module" src="./editor.js"></script>
9
9
  </head>
10
10
  <body>
11
11
  <main class="page">
12
- <header class="page-header">
13
- <p class="eyebrow">i18n bundle</p>
14
- <div class="page-title-row">
15
- <h1>Visualizador de traduções</h1>
16
- <button id="toggle-settings-panel" class="icon-button" type="button" aria-label="Mostrar configurações" aria-expanded="false" title="Mostrar configurações">
12
+ <header class="app-header">
13
+ <div class="app-identity">
14
+ <span class="eyebrow" data-i18n="page.eyebrow">i18n bundle</span>
15
+ <h1 data-i18n="page.title">Translation Editor</h1>
16
+ </div>
17
+ <div class="app-actions">
18
+ <label class="compact-language-field" for="editor-language" data-i18n-title="language.label" title="Language">
19
+ <select id="editor-language">
20
+ <option value="en" data-i18n="language.english">English</option>
21
+ <option value="pt" data-i18n="language.portuguese">Português</option>
22
+ </select>
23
+ </label>
24
+ <button id="toggle-translation-toolbar" class="icon-button" type="button" data-i18n-title="editor.hideToolbar" data-i18n-aria-label="editor.hideToolbar" aria-expanded="true" title="Hide toolbar" aria-label="Hide toolbar">
25
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 14 5-5 5 5" /></svg>
26
+ </button>
27
+ <button id="toggle-settings-panel" class="icon-button" type="button" aria-label="Show settings" aria-expanded="false" title="Show settings">
17
28
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z"/><path d="m19.4 13.5 1.1.9-2 3.4-1.3-.5a8 8 0 0 1-1.8 1l-.2 1.4h-4l-.2-1.4a8 8 0 0 1-1.8-1l-1.3.5-2-3.4 1.1-.9a8.5 8.5 0 0 1 0-2l-1.1-.9 2-3.4 1.3.5a8 8 0 0 1 1.8-1l.2-1.4h4l.2 1.4a8 8 0 0 1 1.8 1l1.3-.5 2 3.4-1.1.9a8.5 8.5 0 0 1 0 2Z"/></svg>
18
29
  </button>
19
30
  </div>
20
- <p class="intro">O editor detecta a configuração e a bundle do projeto atual. Quando necessário, ele gera a bundle diretamente do catálogo TypeScript.</p>
21
31
  </header>
22
32
 
23
33
  <section id="project-status" class="project-status loading" role="status" aria-live="polite">
24
34
  <span class="status-spinner" aria-hidden="true"></span>
25
- <span id="project-status-text">Conectando ao projeto…</span>
26
- <button id="retry-project-load" type="button" hidden>Tentar novamente</button>
35
+ <span id="project-status-text">Connecting to the project…</span>
36
+ <button id="retry-project-load" type="button" data-i18n="messages.retry" hidden>Try again</button>
27
37
  </section>
28
38
 
29
39
  <section id="settings-panel" class="settings-panel" aria-labelledby="settings-title" hidden>
30
40
  <div class="settings-intro">
31
- <h2 id="settings-title">Configurações do projeto</h2>
32
- <p>Defina a estrutura das chaves e os caminhos que uma futura exportação deverá gerar a partir da raiz do projeto.</p>
41
+ <h2 id="settings-title" data-i18n="settings.title">Project settings</h2>
42
+ <p data-i18n="settings.intro">Define the key structure and paths generated by exports from the project root.</p>
33
43
  </div>
34
44
  <div class="settings-fields">
35
45
  <label class="level-field" for="level-count">
36
- <span>Quantidade de níveis</span>
46
+ <span data-i18n="settings.levelCount">Level count</span>
37
47
  <input id="level-count" type="number" min="0" step="1" value="2">
38
48
  </label>
39
49
  <label class="level-field level-names-field" for="level-names">
40
- <span>Nomes dos níveis</span>
41
- <input id="level-names" type="text" value="Módulo, Feature" placeholder="Módulo, Feature, Subfeature">
50
+ <span data-i18n="settings.levelNames">Level names</span>
51
+ <input id="level-names" type="text" value="Module, Feature" placeholder="Module, Feature, Subfeature">
42
52
  </label>
43
53
  </div>
44
54
  <div class="level-paths-section">
45
- <h3>Imports por nível</h3>
46
- <p>O nível 0 representa a raiz e não consome uma parte da chave. Use <code>{value}</code> nos demais níveis. Replacers aceitam JSON plano ou aninhado.</p>
55
+ <h3 data-i18n="settings.importsByLevel">Imports by level</h3>
56
+ <p data-i18n="settings.importsHelp">Level 0 represents the root and does not consume a key segment. Use {value} in the remaining levels. Replacers accept flat or nested JSON.</p>
47
57
  <div id="level-imports" class="level-imports"></div>
48
58
  </div>
49
59
  <div class="settings-fields output-fields">
50
60
  <label class="level-field" for="translations-directory">
51
- <span>Pasta de traduções</span>
61
+ <span data-i18n="settings.translationsDirectory">Translations directory</span>
52
62
  <input id="translations-directory" type="text" value="i18n" placeholder="i18n">
53
63
  </label>
54
64
  <label class="level-field" for="language-file-template">
55
- <span>Arquivo por idioma</span>
65
+ <span data-i18n="settings.languageFileTemplate">Language file template</span>
56
66
  <input id="language-file-template" type="text" value="{language}.ts" placeholder="{language}.ts">
57
67
  </label>
58
68
  </div>
59
69
  <div class="settings-fields output-fields">
60
70
  <label class="level-field level-json-field" for="language-replacer">
61
- <span>Replacer dos idiomas (JSON)</span>
71
+ <span data-i18n="settings.languageReplacer">Language replacer (JSON)</span>
62
72
  <textarea id="language-replacer" spellcheck="false" placeholder='{ "pt": "ptBR" }'></textarea>
63
73
  </label>
64
74
  </div>
65
75
  <div class="export-settings-section">
66
- <h3>Exportação</h3>
67
- <p>Configure a resolução de caminhos e a formatação dos arquivos TypeScript gerados.</p>
76
+ <h3 data-i18n="settings.exportTitle">Export</h3>
77
+ <p data-i18n="settings.exportIntro">Configure path resolution and formatting for generated TypeScript files.</p>
68
78
  <div class="export-settings-fields">
69
79
  <label class="level-field level-json-field" for="import-aliases">
70
- <span>Aliases de import (JSON)</span>
80
+ <span data-i18n="settings.importAliases">Import aliases (JSON)</span>
71
81
  <textarea id="import-aliases" spellcheck="false" placeholder='{ "@/": "src/" }'></textarea>
72
82
  </label>
73
83
  </div>
74
84
  <div class="code-format-fields">
75
85
  <label class="check-field" for="use-double-quotes">
76
86
  <input id="use-double-quotes" type="checkbox">
77
- <span>Usar aspas duplas nas strings geradas</span>
87
+ <span data-i18n="settings.doubleQuotes">Use double quotes in generated strings</span>
78
88
  </label>
79
89
  <label class="check-field" for="use-semicolons">
80
90
  <input id="use-semicolons" type="checkbox" checked>
81
- <span>Usar ponto e vírgula</span>
91
+ <span data-i18n="settings.semicolons">Use semicolons</span>
82
92
  </label>
83
93
  <label class="check-field" for="use-shorthand-properties">
84
94
  <input id="use-shorthand-properties" type="checkbox" checked>
85
- <span>Usar propriedades shorthand</span>
95
+ <span data-i18n="settings.shorthandProperties">Use shorthand properties</span>
86
96
  </label>
87
97
  <label class="check-field" for="use-trailing-commas">
88
98
  <input id="use-trailing-commas" type="checkbox" checked>
89
- <span>Usar vírgula no último elemento</span>
99
+ <span data-i18n="settings.trailingCommas">Use trailing commas</span>
90
100
  </label>
91
101
  <label class="level-field" for="indentation-character">
92
- <span>Caractere de indentação</span>
102
+ <span data-i18n="settings.indentationCharacter">Indentation character</span>
93
103
  <select id="indentation-character">
94
- <option value="space">Espaço</option>
95
- <option value="tab">Tab</option>
104
+ <option value="space" data-i18n="settings.space">Space</option>
105
+ <option value="tab" data-i18n="settings.tab">Tab</option>
96
106
  </select>
97
107
  </label>
98
108
  <label class="level-field" for="indentation-size">
99
- <span>Quantidade por nível</span>
109
+ <span data-i18n="settings.indentationSize">Indentation size</span>
100
110
  <input id="indentation-size" type="number" min="1" max="8" step="1" value="2">
101
111
  </label>
102
112
  <label class="level-field" for="print-width">
103
- <span>Largura máxima da linha</span>
113
+ <span data-i18n="settings.printWidth">Maximum line width</span>
104
114
  <input id="print-width" type="number" min="40" max="400" step="1" value="120">
105
115
  </label>
106
116
  <label class="level-field" for="max-object-inline-items">
107
- <span>Máximo de propriedades inline</span>
117
+ <span data-i18n="settings.maxObjectInlineItems">Maximum inline properties</span>
108
118
  <input id="max-object-inline-items" type="number" min="0" max="100" step="1" value="4">
109
119
  </label>
110
120
  <label class="level-field" for="max-array-inline-items">
111
- <span>Máximo de itens inline</span>
121
+ <span data-i18n="settings.maxArrayInlineItems">Maximum inline items</span>
112
122
  <input id="max-array-inline-items" type="number" min="0" max="100" step="1" value="8">
113
123
  </label>
114
124
  <label class="level-field" for="object-layout">
115
- <span>Layout de objetos</span>
125
+ <span data-i18n="settings.objectLayout">Object layout</span>
116
126
  <select id="object-layout">
117
- <option value="fit">Inline quando couber</option>
118
- <option value="multiline">Sempre multiline</option>
127
+ <option value="fit" data-i18n="settings.inlineWhenFits">Inline when it fits</option>
128
+ <option value="multiline" data-i18n="settings.alwaysMultiline">Always multiline</option>
119
129
  </select>
120
130
  </label>
121
131
  <label class="level-field" for="array-layout">
122
- <span>Layout de arrays</span>
132
+ <span data-i18n="settings.arrayLayout">Array layout</span>
123
133
  <select id="array-layout">
124
- <option value="fit">Inline quando couber</option>
125
- <option value="multiline">Sempre multiline</option>
134
+ <option value="fit" data-i18n="settings.inlineWhenFits">Inline when it fits</option>
135
+ <option value="multiline" data-i18n="settings.alwaysMultiline">Always multiline</option>
126
136
  </select>
127
137
  </label>
128
138
  </div>
129
139
  </div>
130
140
  <div class="deletion-settings-section">
131
- <h3>Arquivos divergentes</h3>
132
- <p>Arquivos dentro das pastas de tradução que não aparecem no plano são candidatos à exclusão.</p>
141
+ <h3 data-i18n="settings.divergentFiles">Divergent files</h3>
142
+ <p data-i18n="settings.divergentFilesHelp">Files inside translation directories that are absent from the plan are deletion candidates.</p>
133
143
  <div class="deletion-settings-fields">
134
144
  <label class="check-field" for="deletion-enabled">
135
145
  <input id="deletion-enabled" type="checkbox" checked>
136
- <span>Detectar e avisar sobre candidatos à exclusão</span>
146
+ <span data-i18n="settings.detectDeletionCandidates">Detect and warn about deletion candidates</span>
137
147
  </label>
138
148
  <label class="level-field" for="ignored-deletion-extensions">
139
- <span>Extensões ignoradas</span>
149
+ <span data-i18n="settings.ignoredExtensions">Ignored extensions</span>
140
150
  <input id="ignored-deletion-extensions" type="text" placeholder=".md, .txt">
141
151
  </label>
142
152
  <label class="check-field" for="auto-delete">
143
153
  <input id="auto-delete" type="checkbox">
144
- <span>Permitir exclusão sem <code>--delete</code></span>
154
+ <span data-i18n="settings.allowDeletion">Allow deletion without --delete</span>
145
155
  </label>
146
156
  </div>
147
157
  </div>
148
158
  </section>
149
159
 
150
160
  <section class="report" aria-labelledby="report-title">
151
- <div class="report-header">
152
- <div>
153
- <h2 id="report-title">Tabela de traduções</h2>
154
- <p id="bundle-summary">Carregue um bundle para visualizar as traduções.</p>
161
+ <div id="translation-toolbar" class="report-header">
162
+ <div class="report-heading">
163
+ <h2 id="report-title" data-i18n="editor.title">Translations</h2>
164
+ <p id="bundle-summary" data-i18n="editor.emptySummary">Load a bundle to view translations.</p>
155
165
  <p id="editor-feedback" class="feedback" role="status" aria-live="polite"></p>
166
+ <p id="review-summary" class="review-summary" role="status" aria-live="polite"></p>
167
+ </div>
168
+ <div class="translation-actions">
169
+ <section class="filter-box" aria-label="Filters" data-i18n-aria-label="editor.filters">
170
+ <label class="toolbar-toggle" for="show-unreferenced" data-i18n-title="editor.unreferencedOnly" title="Show unreferenced only">
171
+ <input id="show-unreferenced" type="checkbox" disabled>
172
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01M10.3 4.4 2.8 18a2 2 0 0 0 1.75 3h14.9A2 2 0 0 0 21.2 18L13.7 4.4a2 2 0 0 0-3.4 0Z" /></svg>
173
+ <span data-i18n="editor.unreferencedOnly">Show unreferenced only</span>
174
+ </label>
175
+ <label class="toolbar-toggle toolbar-toggle--with-count" for="show-new-keys" data-i18n-title="editor.newKeysOnly" title="Show new keys only">
176
+ <input id="show-new-keys" type="checkbox" disabled>
177
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v18M3 12h18" /><path d="m5 5 2 2m10-2-2 2m2 10-2-2M5 19l2-2" /></svg>
178
+ <span data-i18n="editor.newKeysOnly">Show new keys only</span>
179
+ <output id="new-key-count" class="filter-count"></output>
180
+ </label>
181
+ <input id="translation-search" class="search-field" type="search" data-i18n-placeholder="editor.searchPlaceholder" data-i18n-aria-label="editor.search" placeholder="Search by key or translation" aria-label="Search">
182
+ </section>
183
+ <label class="toolbar-field visual-level-field" for="visual-level-count" data-i18n-title="editor.visualizationLevelsHelp" title="Choose how many hierarchy levels become collapsible translation groups.">
184
+ <span data-i18n="editor.visualizationLevels">Levels</span>
185
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
186
+ <input id="visual-level-count" type="number" min="0" max="12" step="1" value="2">
187
+ </label>
188
+ <button id="analyze-usage" class="secondary-button icon-button toolbar-icon-button" type="button" data-i18n-title="editor.analyzeUsages" data-i18n-aria-label="editor.analyzeUsages" title="Analyze usages" aria-label="Analyze usages" disabled>
189
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 1-2.34-5.66M20 4v7h-7" /></svg>
190
+ </button>
191
+ <button id="mark-current-reviewed" class="secondary-button icon-button toolbar-icon-button" type="button" data-i18n-title="editor.markCurrentReviewed" data-i18n-aria-label="editor.markCurrentReviewed" title="Mark current keys as reviewed" aria-label="Mark current keys as reviewed" disabled>
192
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6M5 6h7M5 18h7" /></svg>
193
+ </button>
194
+ <button id="toggle-all-groups" class="secondary-button icon-button toolbar-icon-button" type="button" data-i18n-title="editor.expandAll" data-i18n-aria-label="editor.expandAll" title="Expand all" aria-label="Expand all" aria-pressed="false" disabled>
195
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="m8 14 4 4 4-4M8 10l4-4 4 4M12 6v12" /></svg>
196
+ </button>
156
197
  </div>
157
- <label class="search-field" for="translation-search">
158
- <span>Buscar</span>
159
- <input id="translation-search" type="search" placeholder="Chave ou tradução">
160
- </label>
161
198
  </div>
162
- <div id="empty-state" class="empty-state">Nenhum bundle carregado.</div>
199
+ <div id="empty-state" class="empty-state" data-i18n="editor.noBundle">No bundle loaded.</div>
163
200
  <div id="table-wrap" class="table-wrap" hidden></div>
164
201
  </section>
165
202
 
166
203
  <section class="export-preview-panel" aria-labelledby="export-preview-title">
167
204
  <div class="export-preview-header">
168
205
  <div>
169
- <h2 id="export-preview-title">Prévia dos arquivos gerados</h2>
170
- <p>Esta é uma simulação em tempo real. Verifique os diffs quando quiser comparar o resultado com os arquivos atuais do projeto.</p>
206
+ <h2 id="export-preview-title" data-i18n="exportPreview.title">Generated files preview</h2>
207
+ <p data-i18n="exportPreview.intro">This is a real-time simulation. Check diffs to compare the result with the current project files.</p>
171
208
  <p id="export-preview-feedback" class="export-preview-feedback" role="status" aria-live="polite"></p>
172
209
  </div>
173
- <button id="check-export-diffs" class="secondary-button" type="button" disabled>Verificar diffs</button>
210
+ <div class="export-actions">
211
+ <label class="export-delete-option" for="export-delete-obsolete">
212
+ <input id="export-delete-obsolete" type="checkbox">
213
+ <span data-i18n="exportPreview.deleteDivergent">Delete divergent files</span>
214
+ </label>
215
+ <button id="check-export-diffs" class="secondary-button" type="button" data-i18n="exportPreview.checkDiffs" disabled>Check diffs</button>
216
+ <button id="export-project" class="primary-button" type="button" data-i18n="exportPreview.exportProject" disabled>Export to project</button>
217
+ </div>
174
218
  </div>
175
219
  <div id="export-preview" class="export-preview" aria-live="polite"></div>
176
220
  </section>
@@ -20,12 +20,14 @@ body {
20
20
 
21
21
  button, input, textarea { font: inherit; }
22
22
 
23
- .page { width: calc(100% - 40px); margin: 44px auto; }
24
- .page-header { max-width: 720px; margin-bottom: 28px; }
25
- .page-title-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
26
- .eyebrow { margin: 0 0 5px; color: var(--accent); font-size: 12px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
23
+ .page { width: calc(100% - 24px); margin: 12px auto 24px; }
24
+ .app-header { display: flex; align-items: center; justify-content: space-between; min-height: 52px; margin-bottom: 10px; padding: 0 4px; }
25
+ .app-identity, .app-actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
26
+ .app-identity { gap: 9px; }
27
+ .compact-language-field select { height: 32px; padding: 5px 25px 5px 9px; border: 1px solid #c8cfdd; border-radius: 8px; color: var(--ink); background: #fff; font: inherit; font-size: 12px; font-weight: 700; }
28
+ .eyebrow { margin: 0; color: var(--accent); font-size: 10px; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; white-space: nowrap; }
27
29
  h1, h2, p { margin-top: 0; }
28
- h1 { margin-bottom: 8px; font-size: clamp(30px, 5vw, 46px); letter-spacing: -.045em; line-height: 1.05; }
30
+ h1 { margin: 0; font-size: clamp(22px, 2.25vw, 30px); letter-spacing: -.04em; line-height: 1.05; white-space: nowrap; }
29
31
  h2 { margin-bottom: 4px; font-size: 18px; }
30
32
  .intro, .report-header p { color: var(--muted); }
31
33
 
@@ -58,6 +60,47 @@ h2 { margin-bottom: 4px; font-size: 18px; }
58
60
  .export-preview-panel { margin-top: 18px; overflow: hidden; }
59
61
  .export-preview-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 20px 22px; border-bottom: 1px solid var(--line); }
60
62
  .export-preview-header p { margin-bottom: 0; color: var(--muted); }
63
+ .export-actions { display: flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
64
+ .export-delete-option { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 700; }
65
+ .export-delete-option input { width: 15px; height: 15px; margin: 0; accent-color: var(--danger); }
66
+ .translation-actions { display: flex; width: 100%; flex-wrap: wrap; align-items: flex-end; justify-content: flex-end; gap: 8px; }
67
+ .toolbar-toggle { min-height: 38px; }
68
+ .toolbar-icon-button { width: 38px; height: 38px; border-radius: 9px; }
69
+ .filter-box { display: flex; flex: 1 1 430px; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin-right: auto; padding: 0; }
70
+ .filter-box-title { align-self: center; padding: 0 3px; color: #52617c; font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
71
+ .toolbar-toggle { display: inline-flex; align-items: center; gap: 7px; padding: 8px 10px; border: 1px solid #c8cfdd; border-radius: 9px; color: #34405b; background: #fff; font-size: 12px; font-weight: 750; line-height: 1.25; white-space: nowrap; }
72
+ .toolbar-toggle:has(input:checked) { border-color: #a5b4fc; color: #3730a3; background: #eef2ff; }
73
+ .toolbar-toggle:has(input:disabled) { color: #94a3b8; background: #f8fafc; }
74
+ .toolbar-toggle input { width: 15px; height: 15px; margin: 0; accent-color: var(--accent); }
75
+ .toolbar-toggle svg, .visual-level-field svg { display: none; width: 16px; height: 16px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
76
+ .filter-count { min-width: 16px; padding-left: 2px; color: var(--accent); font-variant-numeric: tabular-nums; }
77
+ .translation-group[hidden], .table-group[hidden] { display: none; }
78
+ .toolbar-field { display: grid; gap: 4px; color: #52617c; font-size: 11px; font-weight: 750; white-space: nowrap; }
79
+ .toolbar-field > span { color: #52617c; font-size: 11px; font-weight: 750; }
80
+ .toolbar-field input { height: 38px; border: 1px solid #c8cfdd; border-radius: 8px; color: var(--ink); background: #fff; }
81
+ .visual-level-field input { width: 86px; padding: 8px 9px; }
82
+ .usage-cell { min-width: 170px; font-size: 12px; }
83
+ .usage-trigger { display: inline-flex; align-items: center; gap: 8px; padding: 4px 7px; border: 0; border-radius: 6px; color: inherit; background: transparent; font: inherit; font-weight: 800; text-align: left; cursor: pointer; }
84
+ .usage-trigger:hover, .usage-trigger[aria-expanded="true"] { background: #f1f4fb; }
85
+ .usage-trigger-action { color: #52617c; font-size: 10px; font-weight: 750; text-decoration: underline; text-underline-offset: 2px; }
86
+ .usage-trigger.usage-used { color: #15803d; }
87
+ .usage-trigger.usage-unreferenced { color: var(--danger); }
88
+ .usage-trigger.usage-uncertain { color: #b45309; }
89
+ .usage-popover { position: fixed; z-index: 1000; display: grid; gap: 7px; width: max-content; min-width: 340px; max-width: min(620px, calc(100vw - 24px)); max-height: min(360px, calc(100vh - 24px)); overflow: auto; padding: 12px 14px; border: 1px solid #c8cfdd; border-radius: 10px; color: var(--ink); background: #fff; box-shadow: 0 18px 50px rgba(24, 39, 75, .22); font-size: 12px; }
90
+ .usage-popover strong { margin-bottom: 2px; }
91
+ .usage-popover code { display: block; overflow-wrap: anywhere; color: #3047b3; white-space: normal; }
92
+ .usage-popover-uncertain { border-color: #f0c078; }
93
+ .usage-popover-unreferenced { border-color: #f2b8b5; }
94
+ .usage-empty { color: var(--muted); }
95
+ .review-removed-row { color: #9f1239; background: #fff1f2; }
96
+ .review-removed-row td { border-color: #fecdd3; }
97
+ .review-removed-row .key { color: #881337; font-weight: 800; text-decoration: line-through; text-decoration-color: #fb7185; }
98
+ .review-removed-value, .base-removed { color: #be123c; font-weight: 800; }
99
+ .key-content { display: flex; align-items: center; justify-content: flex-start; gap: 8px; }
100
+ .key-content > span { min-width: 0; overflow-wrap: anywhere; }
101
+ .remove-key-button { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto; padding: 0; border: 1px solid transparent; border-radius: 6px; color: #9f3a38; background: transparent; font: 700 18px/1 sans-serif; cursor: pointer; opacity: .22; }
102
+ .remove-key-button:hover, .remove-key-button:focus-visible { border-color: #f2b8b5; color: var(--danger); background: #fff1f2; opacity: 1; outline: none; }
103
+ .remove-key-button-unreferenced { border-color: #f2b8b5; color: var(--danger); background: #fff1f2; opacity: 1; }
61
104
  .export-preview-feedback { min-height: 21px; margin-top: 5px; font-size: 13px; }
62
105
  .export-preview-feedback.error { color: var(--danger); }
63
106
  .export-preview-feedback.warning { color: #b45309; font-weight: 700; }
@@ -82,9 +125,9 @@ h2 { margin-bottom: 4px; font-size: 18px; }
82
125
  .secondary-button { border: 1px solid #c8cfdd; color: #4c5770; background: #fff; white-space: nowrap; }
83
126
  .secondary-button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
84
127
  .secondary-button:disabled { cursor: default; opacity: .55; }
85
- button, .report-header { display: flex; align-items: center; gap: 14px; }
128
+ button { display: flex; align-items: center; gap: 8px; }
86
129
  button { cursor: pointer; border-radius: 9px; font-weight: 700; }
87
- .search-field > span { color: #34405b; font-size: 13px; font-weight: 700; }
130
+ .search-field > span { color: #52617c; font-size: 11px; font-weight: 750; }
88
131
  textarea, .search-field input { width: 100%; border: 1px solid #c8cfdd; border-radius: 9px; color: var(--ink); background: #fff; outline: none; }
89
132
  textarea:focus, .search-field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(79, 70, 229, .14); }
90
133
  textarea { min-height: 120px; padding: 12px; resize: vertical; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
@@ -93,12 +136,17 @@ button { border: 0; padding: 10px 14px; color: #fff; background: var(--accent);
93
136
  .feedback.error { color: var(--danger); }
94
137
 
95
138
  .report { overflow: hidden; }
96
- .report-header { justify-content: space-between; padding: 20px 22px; border-bottom: 1px solid var(--line); }
139
+ .report-header { display: grid; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
140
+ .report-heading { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 0; }
141
+ .report-heading h2 { margin: 0; font-size: 16px; white-space: nowrap; }
142
+ .report-heading p { display: inline; margin: 0; font-size: 12px; white-space: nowrap; }
143
+ .report-heading p:not(:empty)::before { content: '·'; margin: 0 7px; color: #a4afc1; }
144
+ .review-summary { color: #596780; }
97
145
  .icon-button { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid #c8cfdd; color: #4c5770; background: #fff; }
98
146
  .icon-button:hover { border-color: var(--accent); color: var(--accent); }
99
147
  .icon-button svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
100
- .search-field { width: min(340px, 100%); }
101
- .search-field input { margin-top: 5px; padding: 9px 11px; }
148
+ .search-field { width: min(220px, 100%); }
149
+ .search-field input { margin-top: 0; padding: 8px 10px; }
102
150
  .empty-state { padding: 56px 22px; color: var(--muted); text-align: center; }
103
151
  .table-wrap { width: 100%; max-width: 100%; overflow-x: auto; }
104
152
  .table-group { padding: 18px 22px; }
@@ -131,11 +179,123 @@ code { color: #4338ca; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Conso
131
179
  tr[hidden] { display: none; }
132
180
 
133
181
  @media (max-width: 720px) {
134
- .page { width: min(100% - 24px, 1500px); margin: 24px auto; }
135
- .report-header { align-items: flex-start; flex-direction: column; }
182
+ .page { width: calc(100% - 20px); margin: 8px auto 16px; }
183
+ .app-header { min-height: 44px; }
184
+ .eyebrow { display: none; }
185
+ h1 { font-size: 21px; }
186
+ .report-header { display: grid; }
187
+ .translation-actions { width: 100%; align-items: stretch; flex-direction: column; }
188
+ .filter-box { width: 100%; }
189
+ .filter-box-title { width: 100%; }
190
+ .toolbar-toggle, .toolbar-field { width: 100%; }
191
+ .toolbar-icon-button { width: calc(50% - 5px); }
192
+ .visual-level-field input, .search-field { width: 100%; }
136
193
  .export-preview-header { flex-direction: column; }
137
194
  .export-diff-details { margin-left: 0; }
138
195
  .settings-fields, .level-replacers { grid-template-columns: 1fr; }
139
196
  .export-settings-fields, .code-format-fields, .deletion-settings-fields { grid-template-columns: 1fr; }
140
197
  .search-field { width: 100%; }
141
198
  }
199
+
200
+ @media (max-width: 1050px) {
201
+ .filter-box { flex: 0 1 auto; margin-right: auto; }
202
+ .filter-box-title, .toolbar-toggle > span, .visual-level-field > span { display: none; }
203
+ .toolbar-toggle { position: relative; width: 38px; height: 38px; justify-content: center; padding: 0; }
204
+ .toolbar-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
205
+ .toolbar-toggle svg, .visual-level-field svg { display: block; }
206
+ .visual-level-field { position: relative; display: flex; align-items: center; justify-content: center; width: 66px; height: 38px; padding: 0 7px; border: 1px solid #c8cfdd; border-radius: 9px; background: #fff; }
207
+ .visual-level-field input { width: 30px; height: 30px; padding: 0; border: 0; background: transparent; text-align: center; }
208
+ .search-field { width: min(210px, 32vw); }
209
+ .search-field > span { display: none; }
210
+ .search-field input { height: 38px; }
211
+ }
212
+
213
+ @media (max-width: 720px) {
214
+ .translation-actions { align-items: flex-end; flex-direction: row; }
215
+ .filter-box { width: auto; }
216
+ .toolbar-toggle { width: 38px; }
217
+ .toolbar-field { width: auto; }
218
+ .toolbar-icon-button { width: 38px; }
219
+ .search-field { width: min(180px, calc(100vw - 210px)); }
220
+ }
221
+
222
+ .report-header[hidden] { display: none; }
223
+
224
+ .toolbar-icon-button {
225
+ display: inline-grid;
226
+ width: 38px;
227
+ height: 38px;
228
+ flex: 0 0 38px;
229
+ place-items: center;
230
+ padding: 0;
231
+ }
232
+
233
+ .toolbar-icon-button.is-expanded svg { transform: rotate(180deg); }
234
+
235
+ .toolbar-icon-button svg {
236
+ width: 18px;
237
+ height: 18px;
238
+ fill: none;
239
+ stroke: currentColor;
240
+ stroke-linecap: round;
241
+ stroke-linejoin: round;
242
+ stroke-width: 1.8;
243
+ }
244
+
245
+ .search-field {
246
+ width: min(260px, 34vw);
247
+ height: 38px;
248
+ padding: 8px 10px;
249
+ border: 1px solid #c8cfdd;
250
+ border-radius: 9px;
251
+ color: var(--ink);
252
+ background: #fff;
253
+ outline: none;
254
+ }
255
+
256
+ .search-field:focus {
257
+ border-color: var(--accent);
258
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, .14);
259
+ }
260
+
261
+ .visual-level-field {
262
+ display: inline-flex;
263
+ width: auto;
264
+ min-width: 98px;
265
+ height: 38px;
266
+ align-items: center;
267
+ gap: 6px;
268
+ padding: 0 8px;
269
+ border: 1px solid #c8cfdd;
270
+ border-radius: 9px;
271
+ background: #fff;
272
+ }
273
+
274
+ .visual-level-field input {
275
+ width: 32px;
276
+ height: 30px;
277
+ padding: 0;
278
+ border: 0;
279
+ background: transparent;
280
+ text-align: center;
281
+ }
282
+
283
+ @media (max-width: 1050px), (max-aspect-ratio: 1 / 1) {
284
+ .filter-box { margin-right: auto; }
285
+ .toolbar-toggle svg, .visual-level-field svg { display: block; }
286
+ .toolbar-toggle > span, .visual-level-field > span { display: none; }
287
+ .toolbar-toggle { width: 38px; height: 38px; padding: 0; justify-content: center; }
288
+ .toolbar-toggle.toolbar-toggle--with-count { width: 62px; min-width: 62px; gap: 6px; }
289
+ .toolbar-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
290
+ .visual-level-field { width: 68px; min-width: 68px; justify-content: center; padding: 0 7px; }
291
+ .visual-level-field input { display: block; }
292
+ .search-field { width: min(230px, 32vw); }
293
+ }
294
+
295
+ @media (max-width: 720px) {
296
+ .translation-actions { align-items: center; gap: 7px; }
297
+ .toolbar-icon-button, .toolbar-toggle { width: 38px; height: 38px; flex: 0 0 38px; }
298
+ .visual-level-field { width: 68px; min-width: 68px; height: 38px; flex: 0 0 68px; }
299
+ .toolbar-toggle.toolbar-toggle--with-count { width: 62px; min-width: 62px; flex-basis: 62px; }
300
+ .search-field { width: min(180px, calc(100vw - 230px)); }
301
+ }
@@ -1,14 +1,21 @@
1
1
  import fastifyStatic from '@fastify/static';
2
2
  import { assertBundle } from '@wads.dev/i18n-ts/bundle';
3
3
  import Fastify from 'fastify';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import { normalizeEditorProjectConfig } from '../core/projectConfig.js';
6
- import { planProjectExport } from './exportProject.js';
8
+ import { applyProjectExport, planProjectExport } from './exportProject.js';
7
9
  import { createProjectContext } from './projectContext.js';
10
+ import { inspectTranslationUsageCache, refreshTranslationUsageCache } from './usageAnalysis.js';
8
11
  export async function createServer(options = {}) {
9
12
  const server = Fastify({ logger: false });
10
- const publicDirectory = fileURLToPath(new URL('../public/', import.meta.url));
13
+ const compiledPublicDirectory = fileURLToPath(new URL('../public/', import.meta.url));
14
+ const publicDirectory = fs.existsSync(compiledPublicDirectory)
15
+ ? compiledPublicDirectory
16
+ : path.resolve(fileURLToPath(new URL('../../dist/public/', import.meta.url)));
11
17
  const project = createProjectContext(options);
18
+ let exportQueue = Promise.resolve();
12
19
  server.get('/api/project', async (_request, reply) => {
13
20
  try {
14
21
  return await project.getInfo();
@@ -39,6 +46,41 @@ export async function createServer(options = {}) {
39
46
  return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
40
47
  }
41
48
  });
49
+ server.post('/api/usage-analysis', { bodyLimit: 20 * 1024 * 1024 }, async (request, reply) => {
50
+ try {
51
+ const usageOptions = {
52
+ projectDirectory: project.projectDirectory,
53
+ bundle: assertBundle(request.body?.bundle),
54
+ config: normalizeEditorProjectConfig(request.body?.config),
55
+ };
56
+ return request.body?.wait === true
57
+ ? await refreshTranslationUsageCache(usageOptions)
58
+ : await inspectTranslationUsageCache(usageOptions);
59
+ }
60
+ catch (error) {
61
+ return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
62
+ }
63
+ });
64
+ server.post('/api/export', { bodyLimit: 20 * 1024 * 1024 }, async (request, reply) => {
65
+ try {
66
+ const bundle = assertBundle(request.body?.bundle);
67
+ const config = normalizeEditorProjectConfig(request.body?.config);
68
+ const deleteObsolete = request.body?.deleteObsolete === true;
69
+ const exportTask = exportQueue.catch(() => undefined).then(async () => {
70
+ const plan = await planProjectExport(options, { bundle, config });
71
+ const result = await applyProjectExport(plan, { deleteObsolete });
72
+ return {
73
+ changes: plan.changes.map(({ kind, path, status, diff }) => ({ kind, path, status, diff })),
74
+ ...result,
75
+ };
76
+ });
77
+ exportQueue = exportTask;
78
+ return await exportTask;
79
+ }
80
+ catch (error) {
81
+ return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
82
+ }
83
+ });
42
84
  await server.register(fastifyStatic, {
43
85
  root: publicDirectory,
44
86
  prefix: '/',
@@ -24,4 +24,8 @@ export type ProjectExportState = {
24
24
  export declare function planProjectExport(options?: ProjectContextOptions, state?: ProjectExportState): Promise<ProjectExportPlan>;
25
25
  export declare function applyProjectExport(plan: ProjectExportPlan, options?: {
26
26
  deleteObsolete?: boolean;
27
- }): Promise<void>;
27
+ }): Promise<{
28
+ written: number;
29
+ deleted: number;
30
+ preserved: number;
31
+ }>;
@@ -252,6 +252,7 @@ export async function planProjectExport(options = {}, state) {
252
252
  return { projectDirectory, bundlePath: info.bundlePath, changes, managedDirectories, deletion: config.deletion };
253
253
  }
254
254
  export async function applyProjectExport(plan, options = {}) {
255
+ let written = 0;
255
256
  for (const change of plan.changes.filter((item) => item.status !== 'delete')) {
256
257
  if (change.status === 'unchanged' || change.content === undefined)
257
258
  continue;
@@ -260,6 +261,7 @@ export async function applyProjectExport(plan, options = {}) {
260
261
  try {
261
262
  await fs.writeFile(temporaryPath, change.content, 'utf8');
262
263
  await fs.rename(temporaryPath, change.absolutePath);
264
+ written += 1;
263
265
  }
264
266
  catch (error) {
265
267
  await fs.rm(temporaryPath, { force: true });
@@ -268,10 +270,14 @@ export async function applyProjectExport(plan, options = {}) {
268
270
  }
269
271
  const shouldDelete = plan.deletion !== false
270
272
  && (plan.deletion.autoDelete || options.deleteObsolete === true);
273
+ let deleted = 0;
271
274
  if (shouldDelete) {
272
275
  for (const change of plan.changes.filter((item) => item.status === 'delete')) {
273
276
  await fs.rm(change.absolutePath, { force: true, recursive: true });
277
+ deleted += 1;
274
278
  }
275
279
  await Promise.all(plan.managedDirectories.map(removeEmptyDescendants));
276
280
  }
281
+ const deletionCandidates = plan.changes.filter((item) => item.status === 'delete').length;
282
+ return { written, deleted, preserved: deletionCandidates - deleted };
277
283
  }
@@ -6,6 +6,7 @@ export type ProjectContextOptions = {
6
6
  bundleFile?: string;
7
7
  };
8
8
  export declare function createProjectContext(options?: ProjectContextOptions): {
9
+ projectDirectory: string;
9
10
  getInfo: () => Promise<ProjectInfo>;
10
11
  generateBundle: () => Promise<GenerateBundleResult>;
11
12
  };