@verbaly/studio 0.56.1 → 0.58.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,184 +0,0 @@
1
- <script lang="ts">
2
- interface Entry {
3
- level: 'ok' | 'warn' | 'error';
4
- check: string;
5
- message: string;
6
- fix?: string;
7
- }
8
-
9
- interface Props {
10
- entries: Entry[];
11
- ok: boolean;
12
- onClose: () => void;
13
- }
14
-
15
- const { entries, ok, onClose }: Props = $props();
16
-
17
- // doctor names the part of the compiler that answered; this says what it means for your project
18
- const TITLE: Record<string, string> = {
19
- config: 'Your settings',
20
- cli: 'The verbaly command',
21
- catalogs: 'Your catalogs',
22
- routing: 'Where the language lives in your urls',
23
- plugin: 'The piece that wires your build',
24
- types: 'The generated types',
25
- sources: 'Reading your code',
26
- orphans: 'Keys nothing in your code uses',
27
- bundle: 'What the browser downloads',
28
- translations: 'The translations themselves',
29
- };
30
-
31
- const bad = $derived(entries.filter((e) => e.level !== 'ok').length);
32
- </script>
33
-
34
- <section class="health" aria-label="Health">
35
- <header>
36
- <h2>{ok ? 'Nothing is broken' : `${bad} of ${entries.length} need you`}</h2>
37
- <p>
38
- This is the same check <code>npx verbaly doctor</code> runs, read out loud. It looks at your
39
- settings, your catalogs and the way your build is wired, and it never changes anything.
40
- </p>
41
- <button class="close" onclick={onClose}>Back to the messages</button>
42
- </header>
43
-
44
- <ul>
45
- <!-- doctor can report the same check twice, so the position is the only unique key here -->
46
- {#each entries as entry, i (i)}
47
- <li data-level={entry.level}>
48
- <span class="mark" aria-hidden="true">
49
- {#if entry.level === 'ok'}
50
- <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3.4 8.4 3 3 6.2-6.8" /></svg>
51
- {:else if entry.level === 'warn'}
52
- <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 4v5" /><path d="M8 12h.01" /></svg>
53
- {:else}
54
- <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
55
- {/if}
56
- </span>
57
- <span class="body">
58
- <b>{TITLE[entry.check] ?? entry.check}</b>
59
- <span class="msg">{entry.message}</span>
60
- {#if entry.fix}
61
- <span class="fix">{entry.fix}</span>
62
- {/if}
63
- </span>
64
- <span class="sr">{entry.level}</span>
65
- </li>
66
- {/each}
67
- </ul>
68
- </section>
69
-
70
- <style>
71
- .health {
72
- flex: 1;
73
- min-width: 0;
74
- overflow-y: auto;
75
- background: var(--paper);
76
- }
77
-
78
- header {
79
- padding: 28px 24px 20px;
80
- border-bottom: 1px solid var(--rule);
81
- }
82
-
83
- h2 {
84
- margin: 0;
85
- font-size: 21px;
86
- font-weight: 500;
87
- letter-spacing: -0.014em;
88
- color: var(--ink);
89
- }
90
-
91
- header p {
92
- margin: 8px 0 0;
93
- max-width: 64ch;
94
- font-size: 13px;
95
- color: var(--muted);
96
- }
97
-
98
- .close {
99
- margin-top: 16px;
100
- padding: 5px 12px;
101
- border: 1px solid var(--rule-strong);
102
- border-radius: var(--radius);
103
- background: var(--paper);
104
- color: var(--body);
105
- font: inherit;
106
- font-size: 12.5px;
107
- cursor: pointer;
108
- }
109
-
110
- .close:hover {
111
- background: var(--ground);
112
- }
113
-
114
- ul {
115
- margin: 0;
116
- padding: 0;
117
- list-style: none;
118
- }
119
-
120
- li {
121
- display: flex;
122
- align-items: flex-start;
123
- gap: 12px;
124
- padding: 14px 24px;
125
- border-bottom: 1px solid var(--rule);
126
- }
127
-
128
- .mark {
129
- width: 20px;
130
- height: 20px;
131
- flex: none;
132
- margin-top: 1px;
133
- display: inline-flex;
134
- align-items: center;
135
- justify-content: center;
136
- border-radius: 50%;
137
- background: var(--done-tint);
138
- color: var(--done);
139
- }
140
-
141
- [data-level='warn'] .mark {
142
- background: var(--missing-tint);
143
- color: var(--missing);
144
- }
145
-
146
- [data-level='error'] .mark {
147
- background: var(--broken-tint);
148
- color: var(--broken);
149
- }
150
-
151
- .body {
152
- display: flex;
153
- flex-direction: column;
154
- gap: 2px;
155
- min-width: 0;
156
- }
157
-
158
- b {
159
- font-size: 13.5px;
160
- font-weight: 500;
161
- color: var(--ink);
162
- }
163
-
164
- .msg {
165
- font-size: 13px;
166
- color: var(--body);
167
- }
168
-
169
- /* the remedy is the whole point of a diagnosis, so it never hides behind a disclosure */
170
- .fix {
171
- margin-top: 4px;
172
- font-family: var(--mono);
173
- font-size: 12px;
174
- color: var(--draft);
175
- }
176
-
177
- .sr {
178
- position: absolute;
179
- width: 1px;
180
- height: 1px;
181
- overflow: hidden;
182
- clip-path: inset(50%);
183
- }
184
- </style>
@@ -1,56 +0,0 @@
1
- <script lang="ts">
2
- import type { MessageState } from './model';
3
-
4
- // Each ink carries its own mark, so the ladder reads in greyscale and to a colourblind eye.
5
- const { state }: { state: MessageState } = $props();
6
-
7
- const WORD: Record<MessageState, string> = {
8
- done: 'translated',
9
- draft: 'written by a machine',
10
- missing: 'not translated, shows in your source language',
11
- broken: 'breaks your site',
12
- };
13
- </script>
14
-
15
- {#if state === 'done'}
16
- <svg viewBox="0 0 22 22" width="15" height="15" aria-hidden="true">
17
- <path d="M5 11h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
18
- </svg>
19
- {:else if state === 'missing'}
20
- <svg viewBox="0 0 22 22" width="15" height="15" aria-hidden="true" fill="none">
21
- <path
22
- d="M6 12.5 11 7l5 5.5"
23
- stroke="currentColor"
24
- stroke-width="1.7"
25
- stroke-linecap="round"
26
- stroke-linejoin="round"
27
- />
28
- <path d="M11 8v7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
29
- </svg>
30
- {:else if state === 'draft'}
31
- <svg viewBox="0 0 22 22" width="15" height="15" aria-hidden="true" fill="none">
32
- <path
33
- d="M4.5 12.5c1.6-2.2 3.2-2.2 4.8 0s3.2 2.2 4.8 0 3.2-2.2 4.8 0"
34
- stroke="currentColor"
35
- stroke-width="1.7"
36
- stroke-linecap="round"
37
- />
38
- </svg>
39
- {:else}
40
- <svg viewBox="0 0 22 22" width="15" height="15" aria-hidden="true">
41
- <path d="M11 6v6.4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
42
- <circle cx="11" cy="16" r="1.2" fill="currentColor" />
43
- </svg>
44
- {/if}
45
- <span class="sr">{WORD[state]}</span>
46
-
47
- <style>
48
- .sr {
49
- position: absolute;
50
- width: 1px;
51
- height: 1px;
52
- overflow: hidden;
53
- clip-path: inset(50%);
54
- white-space: nowrap;
55
- }
56
- </style>
@@ -1,337 +0,0 @@
1
- <script lang="ts">
2
- import HealthView from './Health.svelte';
3
- import Rail from './Rail.svelte';
4
- import RowView from './Row.svelte';
5
- import { applyState, emptyReason, health, visible, type Panel } from './model';
6
- import Actions from './Actions.svelte';
7
- import type { Check, StudioApi } from './api';
8
- import { EMPTY } from './words';
9
-
10
- interface Props {
11
- panel: Panel;
12
- api: StudioApi;
13
- }
14
-
15
- const { panel, api }: Props = $props();
16
-
17
- let editing: string | null = $state(null);
18
- let note: { text: string; file: string } | null = $state(null);
19
- let pending: { locale: string; keys: string[] } | null = $state(null);
20
- let view: 'messages' | 'health' = $state('messages');
21
- let checks: { ok: boolean; entries: Check[] } | null = $state(null);
22
-
23
- // doctor reads the disk, so it is asked once at boot and again whenever you open the view
24
- async function openHealth() {
25
- view = 'health';
26
- const result = await api.health();
27
- if (result.error) {
28
- note = { text: result.error, file: '' };
29
- return;
30
- }
31
- if (result.value) checks = result.value;
32
- }
33
-
34
- $effect(() => {
35
- void api.health().then((result) => {
36
- if (result.value) checks = result.value;
37
- });
38
- });
39
-
40
- // a command rewrote the catalogs, so the panel re-reads them instead of patching key by key
41
- async function refresh(said: string) {
42
- const answer = await api.state();
43
- if (answer.error || !answer.value) {
44
- note = { text: answer.error ?? '[verbaly] the server sent no state', file: '' };
45
- return;
46
- }
47
- // the open editor and the undo both point at keys this run may have moved under them
48
- editing = null;
49
- pending = null;
50
- applyState(panel, answer.value);
51
- note = { text: said, file: '' };
52
- const next = await api.health();
53
- if (next.value) checks = next.value;
54
- }
55
-
56
- // the flag says who wrote the text, so both directions are a write and both report what moved
57
- async function setRead(locale: string, keys: string[] | undefined, undo: boolean) {
58
- const result = await api.setRead(locale, keys, undo);
59
- if (result.error) {
60
- note = { text: result.error, file: '' };
61
- return;
62
- }
63
- const moved = new Set(result.value?.keys ?? []);
64
- for (const row of panel.rows) {
65
- if (!moved.has(row.key)) continue;
66
- const cell = row.cells.find((c) => c.locale === locale);
67
- if (cell?.text) cell.state = undo ? 'draft' : 'done';
68
- }
69
- pending = undo ? null : { locale, keys: result.value?.keys ?? [] };
70
- }
71
-
72
- // the row shows what the server refused; the panel only celebrates what it accepted
73
- async function save(locale: string, key: string, text: string): Promise<string | null> {
74
- const result = await api.save(locale, key, text);
75
- if (result.error) return result.error;
76
- const row = panel.rows.find((r) => r.key === key);
77
- const cell = row?.cells.find((c) => c.locale === locale);
78
- if (cell) {
79
- cell.text = text;
80
- cell.state = text ? 'done' : 'missing';
81
- cell.issue = undefined;
82
- }
83
- note = { text, file: `${locale}.json` };
84
- return null;
85
- }
86
-
87
- // `state` would shadow the rune, so the filter is named for what it reads like at the call site
88
- let only: 'all' | 'look' | 'missing' | 'draft' | 'broken' = $state('all');
89
- let text: string = $state('');
90
- // null means nobody has picked yet, so the default follows the project instead of freezing at mount
91
- let picked: string[] | null = $state(null);
92
-
93
- const targets = $derived(panel.locales.filter((locale) => locale !== panel.sourceLocale));
94
- const shown = $derived(picked ?? targets.slice(0, 1));
95
-
96
- const rail = $derived(health(panel));
97
- const rows = $derived(visible(panel.rows, { text, state: only, locales: shown }));
98
- const empty = $derived(emptyReason(panel, shown, rows));
99
-
100
- // every count is over the languages actually shown, so the rail never promises rows a filter hides
101
- const counts = $derived.by(() => {
102
- const out: Record<string, number> = { all: 0, look: 0, missing: 0, draft: 0, broken: 0 };
103
- for (const id of ['all', 'look', 'missing', 'draft', 'broken'] as const) {
104
- out[id] = visible(panel.rows, { text, state: id, locales: shown }).length;
105
- }
106
- return out;
107
- });
108
-
109
- // a virtual window is a slice here: it measured 0.000 ms on the real catalog, so no library
110
- const STEP = 60;
111
- let limit: number = $state(STEP);
112
- $effect(() => {
113
- void text;
114
- void only;
115
- void shown;
116
- limit = STEP;
117
- });
118
-
119
- function onScroll(event: Event) {
120
- const el = event.currentTarget as HTMLElement;
121
- if (el.scrollTop + el.clientHeight > el.scrollHeight - 600 && limit < rows.length) {
122
- limit += STEP;
123
- }
124
- }
125
-
126
- function toggle(locale: string) {
127
- picked = shown.includes(locale)
128
- ? shown.filter((other: string) => other !== locale)
129
- : [...shown, locale];
130
- }
131
- </script>
132
-
133
- <div class="shell">
134
- <header class="bar">
135
- <span class="brand">verbaly <b>studio</b></span>
136
- <input
137
- class="search"
138
- type="search"
139
- bind:value={text}
140
- placeholder="Search the text, not the key"
141
- aria-label="Search the text, not the key"
142
- />
143
- <span class="root" title={panel.root}>{panel.root}</span>
144
- </header>
145
-
146
- {#if panel.problems.length}
147
- <ul class="problems">
148
- {#each panel.problems as problem (problem.scope + problem.message)}
149
- <li><code>{problem.scope}</code> {problem.message}</li>
150
- {/each}
151
- </ul>
152
- {/if}
153
-
154
- <div class="body">
155
- <Rail
156
- health={rail}
157
- {shown}
158
- state={only}
159
- {counts}
160
- {pending}
161
- checks={checks ? { bad: checks.entries.filter((e) => e.level !== 'ok').length } : null}
162
- onToggle={toggle}
163
- onState={(next) => (only = next)}
164
- onRead={(locale) => setRead(locale, undefined, false)}
165
- onUndo={() => pending && setRead(pending.locale, pending.keys, true)}
166
- onHealth={openHealth}
167
- >
168
- {#snippet actions()}
169
- <Actions {api} scanning={panel.scanning} targets={targets.length} {shown} onDone={refresh} />
170
- {/snippet}
171
- </Rail>
172
-
173
- {#if view === 'health'}
174
- <HealthView
175
- entries={checks?.entries ?? []}
176
- ok={checks?.ok ?? true}
177
- onClose={() => (view = 'messages')}
178
- />
179
- {:else}
180
- <main class="table" onscroll={onScroll}>
181
- {#if empty}
182
- <p class="note">{EMPTY[empty]}{empty === 'oneLocale' ? ` ${EMPTY.oneLocaleFix}` : ''}</p>
183
- {:else}
184
- {#each rows.slice(0, limit) as row (row.key)}
185
- <RowView {row} {shown} {editing} onEdit={(id) => (editing = id)} onSave={save} />
186
- {/each}
187
- {#if limit < rows.length}
188
- <p class="note">{rows.length - limit} more below</p>
189
- {/if}
190
- {/if}
191
- </main>
192
- {/if}
193
- </div>
194
-
195
- {#if note}
196
- <p class="snack" role="status">
197
- <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
198
- <path d="m3.4 8.4 3 3 6.2-6.8" />
199
- </svg>
200
- {#if note.file}Saved in <code>{note.file}</code>{:else}{note.text}{/if}
201
- <button onclick={() => (note = null)}>Dismiss</button>
202
- </p>
203
- {/if}
204
- </div>
205
-
206
- <style>
207
- .shell {
208
- position: relative;
209
- display: flex;
210
- flex-direction: column;
211
- height: 100%;
212
- }
213
-
214
- /* the bar stays ink in both themes: the one brand colour cannot vanish on a theme switch */
215
- .bar {
216
- display: flex;
217
- align-items: center;
218
- gap: 16px;
219
- flex: none;
220
- padding: 0 16px;
221
- height: 48px;
222
- background: var(--bar);
223
- color: var(--on-bar);
224
- /* the bar keeps its value in both themes, so in dark it needs an edge: it measured 1.01:1 */
225
- box-shadow: inset 0 -1px 0 rgb(255 255 255 / 0.12);
226
- }
227
-
228
- .brand {
229
- font-size: 13px;
230
- letter-spacing: 0.02em;
231
- opacity: 0.75;
232
- white-space: nowrap;
233
- }
234
-
235
- .brand b {
236
- opacity: 1;
237
- font-weight: 600;
238
- }
239
-
240
- .search {
241
- flex: 1;
242
- max-width: 520px;
243
- padding: 6px 12px;
244
- border: 1px solid rgb(255 255 255 / 0.16);
245
- border-radius: var(--radius);
246
- background: rgb(255 255 255 / 0.06);
247
- color: var(--on-bar);
248
- font: inherit;
249
- font-size: 13px;
250
- }
251
-
252
- .search::placeholder {
253
- color: rgb(255 255 255 / 0.45);
254
- }
255
-
256
- .search:focus-visible {
257
- outline: 2px solid var(--done);
258
- outline-offset: 1px;
259
- }
260
-
261
- .root {
262
- margin-left: auto;
263
- font-family: var(--mono);
264
- font-size: 11.5px;
265
- opacity: 0.55;
266
- max-width: 30ch;
267
- overflow: hidden;
268
- text-overflow: ellipsis;
269
- white-space: nowrap;
270
- }
271
-
272
- .problems {
273
- margin: 0;
274
- padding: 8px 16px;
275
- list-style: none;
276
- background: var(--broken-tint);
277
- color: var(--broken);
278
- font-size: 12.5px;
279
- border-bottom: 1px solid var(--rule);
280
- }
281
-
282
- .body {
283
- display: flex;
284
- flex: 1;
285
- min-height: 0;
286
- }
287
-
288
- .table {
289
- flex: 1;
290
- min-width: 0;
291
- overflow-y: auto;
292
- background: var(--paper);
293
- }
294
-
295
- .note {
296
- margin: 0;
297
- padding: 24px 20px;
298
- color: var(--muted);
299
- font-size: 13px;
300
- }
301
-
302
- /* the one thing that moves: it reports a write that really happened, and it waits to be dismissed */
303
- .snack {
304
- position: absolute;
305
- left: 50%;
306
- bottom: 24px;
307
- transform: translateX(-50%);
308
- display: flex;
309
- align-items: center;
310
- gap: 12px;
311
- margin: 0;
312
- height: 44px;
313
- padding: 0 12px 0 20px;
314
- background: var(--bar);
315
- color: var(--on-bar);
316
- border-radius: var(--radius);
317
- font-size: 13px;
318
- white-space: nowrap;
319
- box-shadow: 0 2px 6px rgb(6 16 5 / 0.18), 0 34px 64px -28px rgb(6 16 5 / 0.4);
320
- }
321
-
322
- .snack svg {
323
- color: #8ed67f;
324
- }
325
-
326
- .snack button {
327
- height: 30px;
328
- padding: 0 12px;
329
- border: 0;
330
- border-radius: 4px;
331
- background: rgb(255 255 255 / 0.14);
332
- color: inherit;
333
- font: inherit;
334
- font-size: 12.5px;
335
- cursor: pointer;
336
- }
337
- </style>
@@ -1,12 +0,0 @@
1
- import type { Component } from 'svelte';
2
- import type { Panel } from './model';
3
- import type { StudioApi } from './api';
4
-
5
- // Written by hand, like @verbaly/svelte's Trans.svelte.d.ts: the build copies the component as
6
- // source and there is no svelte2tsx step in this repo.
7
- declare const PanelView: Component<{
8
- panel: Panel;
9
- api: StudioApi;
10
- }>;
11
-
12
- export default PanelView;