prosebook 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +604 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +508 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config-D--RITk2.js +1208 -0
  7. package/dist/config-D--RITk2.js.map +1 -0
  8. package/dist/config-_4HfnJQu.d.ts +517 -0
  9. package/dist/config-_4HfnJQu.d.ts.map +1 -0
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +1 -0
  12. package/dist/copy-DXnzXsJa.d.ts +162 -0
  13. package/dist/copy-DXnzXsJa.d.ts.map +1 -0
  14. package/dist/helpers-BPuPGjQP.js +61 -0
  15. package/dist/helpers-BPuPGjQP.js.map +1 -0
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +188 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inspector.d.ts +37 -0
  21. package/dist/inspector.d.ts.map +1 -0
  22. package/dist/inspector.js +572 -0
  23. package/dist/inspector.js.map +1 -0
  24. package/dist/react/index.d.ts +37 -0
  25. package/dist/react/index.d.ts.map +1 -0
  26. package/dist/react/index.js +86 -0
  27. package/dist/react/index.js.map +1 -0
  28. package/dist/sarif-CI8HOokm.js +1033 -0
  29. package/dist/sarif-CI8HOokm.js.map +1 -0
  30. package/dist/scan-Ch2BfIUD.js +2561 -0
  31. package/dist/scan-Ch2BfIUD.js.map +1 -0
  32. package/dist/tools.d.ts +377 -0
  33. package/dist/tools.d.ts.map +1 -0
  34. package/dist/tools.js +4 -0
  35. package/dist/vite.d.ts +24 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +264 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +148 -15
  40. package/src/cli.ts +528 -0
  41. package/src/index.ts +31 -0
  42. package/src/inspector-dialog.ts +450 -0
  43. package/src/inspector.ts +228 -0
  44. package/src/react/index.tsx +112 -0
  45. package/src/runtime/copy.ts +221 -0
  46. package/src/runtime/helpers.ts +66 -0
  47. package/src/runtime/locale.ts +49 -0
  48. package/src/runtime/types.ts +106 -0
  49. package/src/tools/catalog.ts +449 -0
  50. package/src/tools/compile.ts +121 -0
  51. package/src/tools/config.ts +211 -0
  52. package/src/tools/diff.ts +84 -0
  53. package/src/tools/entry.ts +325 -0
  54. package/src/tools/extract.ts +694 -0
  55. package/src/tools/generate.ts +257 -0
  56. package/src/tools/icu.ts +211 -0
  57. package/src/tools/index.ts +91 -0
  58. package/src/tools/lint.ts +865 -0
  59. package/src/tools/lock.ts +95 -0
  60. package/src/tools/mf1.ts +246 -0
  61. package/src/tools/mf2.ts +752 -0
  62. package/src/tools/migrate.ts +138 -0
  63. package/src/tools/presets.ts +159 -0
  64. package/src/tools/pseudo.ts +40 -0
  65. package/src/tools/sarif.ts +80 -0
  66. package/src/tools/scan.ts +303 -0
  67. package/src/tools/syntax.ts +24 -0
  68. package/src/tools/types.ts +137 -0
  69. package/src/vite.ts +296 -0
  70. package/index.js +0 -1
@@ -0,0 +1,450 @@
1
+ /**
2
+ * The inspector's dialog: Alt-right-click a string to edit its entry on the page, in every locale, as
3
+ * fields or as the YAML written in its files. The server checks each edit as the whole catalog before
4
+ * writing it (`tools/entry.ts`); once written, the page updates in place like after any catalog edit.
5
+ * While typing, it asks the server to check the edit unsaved, and shows what lint says of each locale
6
+ * under its text: a finding selects its words, and one that has a fix makes it.
7
+ *
8
+ * A reviewer who would rather ask than edit leaves a comment, or marks the entry for removal: both are
9
+ * written in the catalog, where whoever edits next, often an agent, finds them among the warnings.
10
+ *
11
+ * It renders in a shadow root, so the app's styles don't reach it and its own don't leak out.
12
+ */
13
+ import type { Problem } from '#src/tools/catalog.ts';
14
+ import type { EntryEdit, EntryFields, EntryFinding, EntryView } from '#src/tools/entry.ts';
15
+
16
+ export interface DialogOptions {
17
+ base: string;
18
+ key: string;
19
+ /** The locale the string was found in, whose translation comes first, or nothing to start at the source. */
20
+ locale?: string;
21
+ /** Where the pointer was, so the dialog opens beside it. */
22
+ x: number;
23
+ y: number;
24
+ /** Opens the entry in the editor instead. */
25
+ openFile: () => void;
26
+ }
27
+
28
+ type Reply = { view?: EntryView; problems?: Problem[] };
29
+
30
+ const STYLE = `
31
+ :host { all: initial; }
32
+ .panel {
33
+ --bg: #fff; --fg: #1d1b17; --muted: #6b665c; --line: #e2ded4; --field: #faf9f6; --err: #b91c1c; --warn: #b45309; --ok: #15803d;
34
+ position: fixed; z-index: 2147483647; display: flex; flex-direction: column;
35
+ width: min(460px, calc(100vw - 24px)); max-height: min(80vh, 680px);
36
+ background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 10px;
37
+ box-shadow: 0 16px 48px rgb(0 0 0 / 0.22); font: 13px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif;
38
+ color-scheme: light;
39
+ }
40
+ @media (prefers-color-scheme: dark) {
41
+ .panel { --bg: #1d1c17; --fg: #ece8dd; --muted: #a29c8d; --line: #34322a; --field: #15140f; --err: #f87171; --warn: #fbbf24; --ok: #4ade80; color-scheme: dark; }
42
+ }
43
+ header, footer { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
44
+ header { border-bottom: 1px solid var(--line); }
45
+ footer { border-top: 1px solid var(--line); justify-content: space-between; flex-wrap: wrap; }
46
+ .key { font: 600 12px/1.5 ui-monospace, monospace; color: #fff; background: #7c3aed; padding: 1px 6px; border-radius: 4px; }
47
+ .locale { font: 12px/1 ui-monospace, monospace; color: var(--muted); }
48
+ .grow { flex: 1; }
49
+ .tabs { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
50
+ .tabs button { border: 0; border-radius: 0; padding: 4px 10px; background: transparent; }
51
+ .tabs button[aria-selected='true'] { background: #7c3aed; color: #fff; }
52
+ .body { padding: 12px; overflow: auto; display: grid; gap: 12px; }
53
+ label { display: grid; gap: 4px; font-size: 12px; font-weight: 500; color: var(--muted); }
54
+ label small { font-weight: 400; font-family: ui-monospace, monospace; }
55
+ textarea, input, select {
56
+ font: 13px/1.45 ui-monospace, 'SF Mono', Consolas, monospace; color: var(--fg); background: var(--field);
57
+ border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; width: 100%; box-sizing: border-box;
58
+ }
59
+ textarea { resize: vertical; }
60
+ .prose { font-family: inherit; }
61
+ textarea:focus, input:focus, select:focus { outline: 2px solid #7c3aed; outline-offset: -1px; }
62
+ .row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; }
63
+ .check { display: flex; align-items: center; gap: 6px; padding-bottom: 7px; color: var(--fg); font-weight: 400; }
64
+ .check input { width: auto; }
65
+ fieldset { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; }
66
+ legend { font-size: 12px; font-weight: 500; color: var(--muted); padding: 0 0 4px; }
67
+ .problems { margin: 0; padding: 0 12px 0 28px; color: var(--err); }
68
+ .problems:empty { display: none; }
69
+ .problems li { margin: 2px 0; }
70
+ .findings { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; font-weight: 400; color: var(--fg); }
71
+ .findings:empty { display: none; }
72
+ .findings.all { padding: 0 12px 8px; }
73
+ .findings li { display: flex; align-items: baseline; gap: 6px; }
74
+ .findings li::before { content: ''; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--warn); }
75
+ .findings li.error::before { background: var(--err); }
76
+ .findings .say { flex: 1; border: 0; padding: 0; background: transparent; font: inherit; text-align: left; color: inherit; }
77
+ .findings button.say:hover { text-decoration: underline; }
78
+ .findings .rule { font: 11px/1.4 ui-monospace, monospace; color: var(--muted); }
79
+ .findings button.fix { padding: 2px 8px; font-size: 12px; }
80
+ .status { color: var(--muted); }
81
+ .status.ok { color: var(--ok); }
82
+ button {
83
+ font: 500 13px/1 system-ui, -apple-system, 'Segoe UI', sans-serif; color: var(--fg); background: var(--bg);
84
+ border: 1px solid var(--line); border-radius: 6px; padding: 7px 12px; cursor: pointer;
85
+ }
86
+ button.primary { background: #7c3aed; border-color: #7c3aed; color: #fff; }
87
+ button.link { border: 0; padding: 7px 0; color: var(--muted); text-decoration: underline; background: transparent; }
88
+ button.close { border: 0; padding: 2px 6px; font-size: 18px; color: var(--muted); background: transparent; }
89
+ button.remove[aria-pressed='true'] { color: var(--err); border-color: currentColor; }
90
+ .removing textarea[name='message'], .removing textarea[name^='translation.'] { text-decoration: line-through; color: var(--muted); }
91
+ `;
92
+
93
+ let closeOpen: (() => void) | undefined;
94
+
95
+ /** An element with properties set (`className`, `value`, `checked`, ...) and children appended. */
96
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: object = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
97
+ const node = document.createElement(tag);
98
+ Object.assign(node, props);
99
+ node.append(...children);
100
+ return node;
101
+ }
102
+
103
+ const rowsFor = (text: string, least: number) => Math.min(12, Math.max(least, text.split('\n').length + (text.length > 60 ? 1 : 0)));
104
+
105
+ /** Opens the dialog for an entry, closing any already open. */
106
+ export async function openDialog(options: DialogOptions): Promise<void> {
107
+ closeOpen?.();
108
+ const query = new URLSearchParams({ key: options.key });
109
+ const host = el('div');
110
+ const root = host.attachShadow({ mode: 'open' });
111
+ const panel = el('div', { className: 'panel' });
112
+ // Hidden until it has its place beside the pointer, which needs its size, which needs the entry.
113
+ panel.style.visibility = 'hidden';
114
+ panel.setAttribute('role', 'dialog');
115
+ panel.setAttribute('aria-label', `Edit ${options.key}`);
116
+ root.append(el('style', { textContent: STYLE }), panel);
117
+
118
+ const close = () => {
119
+ clearTimeout(timer);
120
+ host.remove();
121
+ if (closeOpen === close) closeOpen = undefined;
122
+ };
123
+ closeOpen = close;
124
+
125
+ const problems = el('ul', { className: 'problems' });
126
+ const status = el('span', { className: 'status' });
127
+ const body = el('div', { className: 'body' });
128
+ const tabs = {
129
+ fields: el('button', { type: 'button', textContent: 'Fields' }),
130
+ yaml: el('button', { type: 'button', textContent: 'YAML' }),
131
+ };
132
+ let tab: 'fields' | 'yaml' = 'fields';
133
+ let view: EntryView | undefined;
134
+ // Marked for removal: written with the fields, so it is the YAML tab's to change while that is open.
135
+ let remove = false;
136
+ const removeButton = el('button', {
137
+ type: 'button',
138
+ className: 'remove',
139
+ title: 'Leave the entry for whoever edits next to delete, with the code that reads it',
140
+ });
141
+ const showRemove = () => {
142
+ removeButton.hidden = tab === 'yaml' || view === undefined;
143
+ removeButton.setAttribute('aria-pressed', String(remove));
144
+ removeButton.textContent = remove ? 'Marked for removal' : 'Mark for removal';
145
+ panel.classList.toggle('removing', remove && tab === 'fields');
146
+ };
147
+ removeButton.addEventListener('click', () => {
148
+ remove = !remove;
149
+ showRemove();
150
+ });
151
+ // What lint said, and each locale's text it said it of: a finding's place and fix are in that text.
152
+ let findings: EntryFinding[] = [];
153
+ let checked = new Map<string, string>();
154
+ const heard = (next: EntryView) => {
155
+ findings = next.findings;
156
+ checked = new Map([[next.source, next.fields.message], ...Object.entries(next.fields.translations)]);
157
+ };
158
+ const all = el('ul', { className: 'findings all' });
159
+
160
+ const showProblems = (list: readonly Problem[]) =>
161
+ problems.replaceChildren(
162
+ ...list.map((p) =>
163
+ el('li', { textContent: p.file === undefined ? p.message : `${[p.file, p.key].filter(Boolean).join(' ')}: ${p.message}` }),
164
+ ),
165
+ );
166
+
167
+ const area = (name: string, label: string, value: string, prose = false, least = 2) =>
168
+ el(
169
+ 'label',
170
+ {},
171
+ label,
172
+ // YAML doesn't wrap: a wrapped line would hide its indentation.
173
+ el('textarea', {
174
+ name,
175
+ value,
176
+ rows: rowsFor(value, least),
177
+ spellcheck: prose,
178
+ wrap: prose ? 'soft' : 'off',
179
+ className: prose ? 'prose' : '',
180
+ }),
181
+ );
182
+
183
+ // The locale the string was read in comes first; the others follow, in the catalog's order.
184
+ const focus = () => (options.locale !== undefined && view?.locales.includes(options.locale) === true ? options.locale : undefined);
185
+ const ordered = () => (view === undefined ? [] : [...new Set([focus(), ...view.locales].filter((l) => l !== undefined))]);
186
+ const placeholder = (label: HTMLLabelElement, text: string) => {
187
+ label.querySelector('textarea')!.placeholder = text;
188
+ return label;
189
+ };
190
+
191
+ const fieldOf = (locale: string) =>
192
+ root.querySelector<HTMLTextAreaElement>(`[name="${locale === view?.source ? 'message' : `translation.${locale}`}"]`);
193
+
194
+ const item = (f: EntryFinding, prefix: string) => {
195
+ const field = fieldOf(f.locale);
196
+ // Typed over since the check: the next one is on its way, so the old place and fix are left alone.
197
+ const current = () => (tab === 'fields' && field !== null && field.value === checked.get(f.locale) ? field : undefined);
198
+ const text = `${prefix}${f.message}`;
199
+ const at = tab === 'fields' ? f.at : undefined;
200
+ const say =
201
+ at === undefined
202
+ ? el('span', { className: 'say', textContent: text })
203
+ : el('button', { type: 'button', className: 'say', textContent: text });
204
+ say.addEventListener('click', () => {
205
+ const target = current();
206
+ if (at === undefined || target === undefined) return;
207
+ target.focus();
208
+ target.setSelectionRange(at.start, at.end);
209
+ });
210
+ const li = el('li', { className: f.severity }, say, el('span', { className: 'rule', textContent: f.rule }));
211
+ const fixed = f.fixed;
212
+ if (fixed !== undefined && tab === 'fields') {
213
+ const fix = el('button', { type: 'button', className: 'fix', textContent: 'Fix' });
214
+ fix.addEventListener('click', () => {
215
+ const target = current();
216
+ if (target === undefined) return;
217
+ target.value = fixed;
218
+ target.dispatchEvent(new Event('input', { bubbles: true }));
219
+ });
220
+ li.append(fix);
221
+ }
222
+ return li;
223
+ };
224
+
225
+ const showFindings = () => {
226
+ if (tab === 'yaml') {
227
+ all.replaceChildren(...findings.map((f) => item(f, `${f.locale}: `)));
228
+ return;
229
+ }
230
+ all.replaceChildren();
231
+ for (const list of root.querySelectorAll<HTMLUListElement>('.body .findings'))
232
+ list.replaceChildren(...findings.filter((f) => f.locale === list.dataset['locale']).map((f) => item(f, '')));
233
+ };
234
+
235
+ /** A text field with what lint says of its locale under it. */
236
+ const withFindings = (label: HTMLLabelElement, locale: string) => {
237
+ const list = el('ul', { className: 'findings' });
238
+ list.dataset['locale'] = locale;
239
+ label.append(list);
240
+ return label;
241
+ };
242
+
243
+ const render = () => {
244
+ if (view === undefined) return;
245
+ tabs.fields.setAttribute('aria-selected', String(tab === 'fields'));
246
+ tabs.yaml.setAttribute('aria-selected', String(tab === 'yaml'));
247
+ remove = view.fields.remove;
248
+ showRemove();
249
+ if (tab === 'yaml') {
250
+ const v = view;
251
+ body.replaceChildren(
252
+ area('yaml.catalog', v.files.catalog, v.yaml.catalog, false, 3),
253
+ ...ordered().map((l) =>
254
+ placeholder(area(`yaml.${l}`, v.files.translations[l]!, v.yaml.translations[l] ?? '', false, 1), `${v.key}: …`),
255
+ ),
256
+ );
257
+ showFindings();
258
+ return;
259
+ }
260
+ const f = view.fields;
261
+ const fallsBack = `No translation: the ${view.source} text shows`;
262
+ const translation = (l: string) =>
263
+ withFindings(
264
+ placeholder(
265
+ area(`translation.${l}`, `Translation · ${l}${f.translations[l] === '' ? ' · none yet' : ''}`, f.translations[l] ?? '', true),
266
+ fallsBack,
267
+ ),
268
+ l,
269
+ );
270
+ const others = ordered().filter((l) => l !== focus());
271
+ const select = (name: string, value: string, choices: string[]) =>
272
+ el(
273
+ 'select',
274
+ { name },
275
+ el('option', { value: '', textContent: '—' }),
276
+ ...choices.map((c) => el('option', { value: c, textContent: c, selected: c === value })),
277
+ );
278
+ const argNames = [...new Set([...view.argNames, ...Object.keys(f.args)])];
279
+ body.replaceChildren(
280
+ ...(focus() === undefined ? [] : [translation(focus()!)]),
281
+ withFindings(area('message', `${focus() === undefined ? 'Message' : 'Source'} · ${view.source}`, f.message, true), view.source),
282
+ ...(others.length === 0
283
+ ? []
284
+ : [
285
+ el(
286
+ 'fieldset',
287
+ {},
288
+ el('legend', { textContent: focus() === undefined ? 'Translations' : 'Other translations' }),
289
+ ...others.map(translation),
290
+ ),
291
+ ]),
292
+ placeholder(area('comment', 'Comment · for whoever edits next', f.comment, true, 1), 'Ask for a change instead of making it'),
293
+ area('context', 'Context', f.context, true, 1),
294
+ el(
295
+ 'div',
296
+ { className: 'row' },
297
+ el('label', {}, 'Kind', select('kind', f.kind, view.kinds)),
298
+ el('label', {}, 'Surface', select('surface', f.surface, view.surfaces)),
299
+ el('label', { className: 'check' }, el('input', { type: 'checkbox', name: 'fragment', checked: f.fragment }), 'Fragment'),
300
+ ),
301
+ ...(argNames.length === 0
302
+ ? []
303
+ : [
304
+ el(
305
+ 'fieldset',
306
+ {},
307
+ el('legend', { textContent: 'What each placeholder holds' }),
308
+ ...argNames.map((n) =>
309
+ el('label', {}, el('small', { textContent: `{${n}}` }), el('input', { name: `arg.${n}`, value: f.args[n] ?? '' })),
310
+ ),
311
+ ),
312
+ ]),
313
+ );
314
+ showFindings();
315
+ };
316
+
317
+ const value = (name: string) => root.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(`[name="${name}"]`)?.value;
318
+
319
+ const current = (): EntryEdit => {
320
+ const byLocale = (prefix: string) => Object.fromEntries(ordered().map((l) => [l, value(`${prefix}${l}`) ?? '']));
321
+ if (tab === 'yaml') return { yaml: { catalog: value('yaml.catalog') ?? '', translations: byLocale('yaml.') } };
322
+ const args: Record<string, string> = {};
323
+ for (const input of root.querySelectorAll<HTMLInputElement>('input[name^="arg."]')) args[input.name.slice(4)] = input.value;
324
+ const fields: EntryFields = {
325
+ message: value('message') ?? '',
326
+ translations: byLocale('translation.'),
327
+ context: value('context') ?? '',
328
+ kind: value('kind') ?? '',
329
+ surface: value('surface') ?? '',
330
+ fragment: root.querySelector<HTMLInputElement>('input[name="fragment"]')?.checked === true,
331
+ args,
332
+ comment: value('comment') ?? '',
333
+ remove,
334
+ };
335
+ return { fields };
336
+ };
337
+
338
+ const send = async (write: boolean): Promise<EntryView | undefined> => {
339
+ const res = await fetch(`${options.base}__prosebook/entry`, {
340
+ method: 'POST',
341
+ headers: { 'Content-Type': 'application/json' },
342
+ body: JSON.stringify({ key: options.key, edit: current(), write }),
343
+ });
344
+ const reply = (await res.json()) as Reply;
345
+ showProblems(reply.problems ?? []);
346
+ return reply.view;
347
+ };
348
+
349
+ // Checked a moment after typing stops; a reply to an older check than the last is dropped.
350
+ let timer: ReturnType<typeof setTimeout> | undefined;
351
+ let asked = 0;
352
+ const check = () => {
353
+ clearTimeout(timer);
354
+ timer = setTimeout(() => {
355
+ const mine = ++asked;
356
+ void send(false).then((checkedView) => {
357
+ if (mine !== asked) return;
358
+ if (checkedView === undefined) findings = [];
359
+ else heard(checkedView);
360
+ showFindings();
361
+ });
362
+ }, 350);
363
+ };
364
+ body.addEventListener('input', check);
365
+ body.addEventListener('change', check);
366
+
367
+ const save = async () => {
368
+ status.className = 'status';
369
+ status.textContent = 'Checking…';
370
+ const saved = await send(true);
371
+ status.textContent = '';
372
+ if (saved === undefined) return;
373
+ status.className = 'status ok';
374
+ status.textContent = 'Saved';
375
+ setTimeout(close, 600);
376
+ };
377
+
378
+ const switchTo = async (next: 'fields' | 'yaml') => {
379
+ if (next === tab) return;
380
+ // The other tab shows this one's edits, so they are turned into it on the server, checked.
381
+ const converted = await send(false);
382
+ if (converted === undefined) return;
383
+ view = converted;
384
+ heard(converted);
385
+ tab = next;
386
+ render();
387
+ };
388
+
389
+ tabs.fields.addEventListener('click', () => void switchTo('fields'));
390
+ tabs.yaml.addEventListener('click', () => void switchTo('yaml'));
391
+
392
+ const saveButton = el('button', { type: 'button', className: 'primary', textContent: 'Save' });
393
+ saveButton.addEventListener('click', () => void save());
394
+ const cancel = el('button', { type: 'button', textContent: 'Cancel' });
395
+ cancel.addEventListener('click', close);
396
+ const closeButton = el('button', { type: 'button', className: 'close', textContent: '×', title: 'Close' });
397
+ closeButton.addEventListener('click', close);
398
+ const openFile = el('button', { type: 'button', className: 'link', textContent: 'Open in editor' });
399
+ openFile.addEventListener('click', () => {
400
+ options.openFile();
401
+ close();
402
+ });
403
+
404
+ panel.append(
405
+ el(
406
+ 'header',
407
+ {},
408
+ el('span', { className: 'key', textContent: options.key }),
409
+ el('span', { className: 'locale', textContent: options.locale ?? '' }),
410
+ el('span', { className: 'grow' }),
411
+ el('span', { className: 'tabs' }, tabs.fields, tabs.yaml),
412
+ closeButton,
413
+ ),
414
+ body,
415
+ all,
416
+ problems,
417
+ el('footer', {}, openFile, el('span', { className: 'grow' }), status, removeButton, cancel, saveButton),
418
+ );
419
+ // Keys typed here are the dialog's: the app's shortcuts don't see them.
420
+ for (const type of ['keydown', 'keyup', 'keypress'] as const) host.addEventListener(type, (e) => e.stopPropagation());
421
+ panel.addEventListener('keydown', (e) => {
422
+ if (e.key === 'Escape') close();
423
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void save();
424
+ });
425
+
426
+ document.body.append(host);
427
+ const res = await fetch(`${options.base}__prosebook/entry?${query}`);
428
+ const reply = (await res.json()) as EntryView | { problems: Problem[] };
429
+ if ('problems' in reply) {
430
+ showProblems(reply.problems);
431
+ } else {
432
+ view = reply;
433
+ heard(reply);
434
+ }
435
+ render();
436
+
437
+ // Beside the pointer, inside the window.
438
+ const box = panel.getBoundingClientRect();
439
+ const left = Math.min(Math.max(12, options.x - 24), innerWidth - box.width - 12);
440
+ const below = options.y + 16;
441
+ const top = below + box.height <= innerHeight - 12 ? below : Math.max(12, options.y - box.height - 16);
442
+ Object.assign(panel.style, { left: `${left}px`, top: `${top}px`, visibility: '' });
443
+ // Growing later, with problems or the other tab, moves it up rather than past the bottom.
444
+ new ResizeObserver(() => {
445
+ const now = panel.getBoundingClientRect();
446
+ const fitted = Math.max(12, Math.min(now.top, innerHeight - now.height - 12));
447
+ if (fitted !== now.top) panel.style.top = `${fitted}px`;
448
+ }).observe(panel);
449
+ (root.querySelector<HTMLElement>(`[name="translation.${focus()}"]`) ?? root.querySelector<HTMLElement>('textarea'))?.focus();
450
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The dev inspector the Vite plugin loads into the page: hold Alt and point at a string to see its
3
+ * key, Alt-click to open the entry it came from, Alt-Shift-click to open the catalog entry instead of
4
+ * the translation. Alt-right-click edits the entry on the page instead (`inspector-dialog.ts`).
5
+ *
6
+ * Text on screen carries no key. `<Text>` renders a fragment, so marking it would take a wrapper
7
+ * element that exists only in dev and changes what a stylesheet sees; `t()` returns a bare string. So
8
+ * the runtime reports every read (`ReadHook`) and the text under the pointer is matched back to them.
9
+ */
10
+ import type { DevHooks } from '#src/runtime/types.ts';
11
+
12
+ import { openDialog } from './inspector-dialog.ts';
13
+
14
+ export interface Read {
15
+ key: string;
16
+ /** The locale the message was found in. */
17
+ locale: string;
18
+ }
19
+
20
+ /** Recent reads by the text they came to, least recent text first; each text's keys most recent first. */
21
+ export type Reads = Map<string, Read[]>;
22
+
23
+ /** The character under the pointer: a text node and its index there. */
24
+ export interface Caret {
25
+ node: Node;
26
+ offset: number;
27
+ }
28
+
29
+ export interface Hit {
30
+ text: string;
31
+ read: Read;
32
+ /** How many other keys came to the same text. */
33
+ others: number;
34
+ /** The innermost element holding all of the text. */
35
+ element: Element;
36
+ }
37
+
38
+ // Enough for every string on a busy screen and the ones it showed a moment ago.
39
+ const LIMIT = 2000;
40
+ const ATTRIBUTES = ['placeholder', 'title', 'aria-label', 'alt', 'value'];
41
+
42
+ export function record(reads: Reads, key: string, text: string, locale: string): void {
43
+ if (text.trim() === '') return;
44
+ const seen = reads.get(text) ?? [];
45
+ reads.delete(text);
46
+ reads.set(text, seen[0]?.key === key && seen[0].locale === locale ? seen : [{ key, locale }, ...seen.filter((r) => r.key !== key)]);
47
+ if (reads.size > LIMIT) reads.delete(reads.keys().next().value!);
48
+ }
49
+
50
+ /**
51
+ * The read under the caret: the longest text that covers it, in the innermost element holding all of
52
+ * it. A rich message runs across its elements, so it wins over a shorter text inside one of them.
53
+ * Without a caret (over an input, or padding) an element's whole text, or the target's readable
54
+ * attribute, must equal a read.
55
+ */
56
+ export function keyAt(target: Element, caret: Caret | undefined, reads: Reads): Hit | undefined {
57
+ let best: Hit | undefined;
58
+ for (let el: Element | null = target; el !== null; el = el.parentElement) {
59
+ const text = el.textContent ?? '';
60
+ const at = caret === undefined ? undefined : offsetIn(el, caret);
61
+ for (const [said, seen] of reads) {
62
+ if (said.length <= (best?.text.length ?? 0)) continue;
63
+ if (at === undefined ? text.trim() === said.trim() : covers(text, said, at))
64
+ best = { text: said, read: seen[0]!, others: seen.length - 1, element: el };
65
+ }
66
+ if (best !== undefined || el !== target) continue;
67
+ for (const name of ATTRIBUTES) {
68
+ const value = el.getAttribute(name);
69
+ const seen = value === null ? undefined : reads.get(value);
70
+ if (value !== null && seen !== undefined) return { text: value, read: seen[0]!, others: seen.length - 1, element: el };
71
+ }
72
+ }
73
+ return best;
74
+ }
75
+
76
+ /** Whether an occurrence of `said` in `text` holds the character at `at`. */
77
+ function covers(text: string, said: string, at: number): boolean {
78
+ const start = text.indexOf(said, Math.max(0, at - said.length + 1));
79
+ return start !== -1 && start <= at;
80
+ }
81
+
82
+ /** The caret's index in the element's text, or nothing when the caret is outside it. */
83
+ function offsetIn(el: Element, caret: Caret): number | undefined {
84
+ if (caret.node.nodeType !== Node.TEXT_NODE || !el.contains(caret.node)) return undefined;
85
+ const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
86
+ let offset = 0;
87
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
88
+ if (node === caret.node) return offset + caret.offset;
89
+ offset += node.textContent?.length ?? 0;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /** The character under a point on screen, where the browser can say. */
95
+ export function caretAt(x: number, y: number): Caret | undefined {
96
+ if (typeof document.caretPositionFromPoint !== 'function') return undefined;
97
+ const position = document.caretPositionFromPoint(x, y);
98
+ if (position === null) return undefined;
99
+ const { offsetNode: node, offset } = position;
100
+ // A caret position sits between two characters, and where one string ends the next may begin: take
101
+ // the character the pointer is over, or the one before (past the end of a line, in padding).
102
+ if (node.nodeType !== Node.TEXT_NODE || offset === 0) return { node, offset };
103
+ const range = document.createRange();
104
+ range.setStart(node, offset);
105
+ range.setEnd(node, Math.min(offset + 1, node.textContent?.length ?? 0));
106
+ const r = range.getBoundingClientRect();
107
+ const over = !range.collapsed && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
108
+ return { node, offset: over ? offset : offset - 1 };
109
+ }
110
+
111
+ export interface InspectorOptions {
112
+ /** The dev server's base URL, ending in `/`. */
113
+ base: string;
114
+ }
115
+
116
+ /** Starts listening for reads and for Alt on the page. Called by the module the Vite plugin serves. */
117
+ export function install({ base }: InspectorOptions): void {
118
+ const reads: Reads = new Map();
119
+ (globalThis as DevHooks).__PROSEBOOK_READ__ = (key, text, locale) => record(reads, key, text, locale);
120
+
121
+ const box = document.createElement('div');
122
+ const label = document.createElement('div');
123
+ box.append(label);
124
+ Object.assign(box.style, {
125
+ position: 'fixed',
126
+ display: 'none',
127
+ zIndex: '2147483647',
128
+ pointerEvents: 'none',
129
+ boxSizing: 'border-box',
130
+ outline: '2px solid #7c3aed',
131
+ background: 'rgb(124 58 237 / 0.08)',
132
+ });
133
+ Object.assign(label.style, {
134
+ position: 'absolute',
135
+ left: '-2px',
136
+ padding: '1px 6px',
137
+ font: '12px/1.5 ui-monospace, monospace',
138
+ whiteSpace: 'nowrap',
139
+ color: '#fff',
140
+ background: '#7c3aed',
141
+ });
142
+
143
+ let pointer: { x: number; y: number } | undefined;
144
+ let frame = 0;
145
+
146
+ const hitAt = (x: number, y: number) => {
147
+ const target = document.elementFromPoint(x, y);
148
+ return target === null ? undefined : keyAt(target, caretAt(x, y), reads);
149
+ };
150
+ const hide = () => {
151
+ box.style.display = 'none';
152
+ };
153
+ const show = () => {
154
+ frame = 0;
155
+ const hit = pointer === undefined ? undefined : hitAt(pointer.x, pointer.y);
156
+ if (hit === undefined) return hide();
157
+ const r = hit.element.getBoundingClientRect();
158
+ Object.assign(box.style, { display: 'block', left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
159
+ // Below the box when there is no room above it.
160
+ Object.assign(label.style, r.top < 24 ? { top: '100%', bottom: '' } : { top: '', bottom: '100%' });
161
+ label.textContent = `${hit.read.key} · ${hit.read.locale}${hit.others > 0 ? ` (+${hit.others} with this text)` : ''}`;
162
+ if (!box.isConnected) document.body.append(box);
163
+ };
164
+ const update = (e: MouseEvent | KeyboardEvent) => {
165
+ if (!e.altKey) return hide();
166
+ if (frame === 0) frame = requestAnimationFrame(show);
167
+ };
168
+
169
+ window.addEventListener(
170
+ 'mousemove',
171
+ (e) => {
172
+ pointer = { x: e.clientX, y: e.clientY };
173
+ update(e);
174
+ },
175
+ { capture: true, passive: true },
176
+ );
177
+ window.addEventListener('keydown', update, true);
178
+ window.addEventListener('keyup', update, true);
179
+ window.addEventListener('blur', hide);
180
+ // Alt-click on a link would otherwise download it, and on a field focus it.
181
+ window.addEventListener(
182
+ 'mousedown',
183
+ (e) => {
184
+ if (e.altKey && hitAt(e.clientX, e.clientY) !== undefined) e.preventDefault();
185
+ },
186
+ true,
187
+ );
188
+ window.addEventListener(
189
+ 'click',
190
+ (e) => {
191
+ if (!e.altKey) return;
192
+ const hit = hitAt(e.clientX, e.clientY);
193
+ if (hit === undefined) return;
194
+ e.preventDefault();
195
+ e.stopPropagation();
196
+ void open(base, hit.read, e.shiftKey);
197
+ },
198
+ true,
199
+ );
200
+ window.addEventListener(
201
+ 'contextmenu',
202
+ (e) => {
203
+ if (!e.altKey) return;
204
+ const hit = hitAt(e.clientX, e.clientY);
205
+ if (hit === undefined) return;
206
+ e.preventDefault();
207
+ e.stopPropagation();
208
+ hide();
209
+ const catalog = e.shiftKey;
210
+ void openDialog({
211
+ base,
212
+ key: hit.read.key,
213
+ locale: catalog ? undefined : hit.read.locale,
214
+ x: e.clientX,
215
+ y: e.clientY,
216
+ openFile: () => void open(base, hit.read, catalog),
217
+ });
218
+ },
219
+ true,
220
+ );
221
+ }
222
+
223
+ async function open(base: string, read: Read, catalog: boolean): Promise<void> {
224
+ const query = new URLSearchParams({ key: read.key, locale: read.locale });
225
+ if (catalog) query.set('catalog', '');
226
+ const res = await fetch(`${base}__prosebook/open?${query}`);
227
+ if (!res.ok) console.warn(`prosebook: ${await res.text()}`);
228
+ }