prosebook 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +595 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-CF7HziDa.d.ts +498 -0
- package/dist/config-CF7HziDa.d.ts.map +1 -0
- package/dist/config-CxVMn19f.js +1146 -0
- package/dist/config-CxVMn19f.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-PkPvsB07.js +1033 -0
- package/dist/sarif-PkPvsB07.js.map +1 -0
- package/dist/scan-B0tZ7y-D.js +2447 -0
- package/dist/scan-B0tZ7y-D.js.map +1 -0
- package/dist/tools.d.ts +351 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +238 -0
- package/dist/vite.js.map +1 -0
- package/package.json +148 -15
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +110 -0
- package/src/tools/config.ts +195 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +259 -0
- package/src/tools/icu.ts +182 -0
- package/src/tools/index.ts +78 -0
- package/src/tools/lint.ts +828 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +173 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +252 -0
- package/index.js +0 -1
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook`: every string an app shows, in one typed catalog.
|
|
3
|
+
*
|
|
4
|
+
* The catalog is YAML, one entry per string, with the context a translator needs beside it.
|
|
5
|
+
* `prosebook generate` compiles it into typed modules, one per locale, whose messages are plain
|
|
6
|
+
* functions: this runtime looks them up, picks the locale and falls back, and parses nothing.
|
|
7
|
+
*/
|
|
8
|
+
export { createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
|
|
9
|
+
export { chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
|
|
10
|
+
export { createHelpers, flatten } from './runtime/helpers.ts';
|
|
11
|
+
export type {
|
|
12
|
+
AnyTagFn,
|
|
13
|
+
ArgsParam,
|
|
14
|
+
Chunks,
|
|
15
|
+
Compiled,
|
|
16
|
+
CompiledMessages,
|
|
17
|
+
DefaultTags,
|
|
18
|
+
Helpers,
|
|
19
|
+
DevHooks,
|
|
20
|
+
HotHook,
|
|
21
|
+
MessageShape,
|
|
22
|
+
MessageShapes,
|
|
23
|
+
ReadHook,
|
|
24
|
+
Register,
|
|
25
|
+
RegisteredLocale,
|
|
26
|
+
RegisteredMessages,
|
|
27
|
+
RichKey,
|
|
28
|
+
RichParam,
|
|
29
|
+
RichValues,
|
|
30
|
+
TagFn,
|
|
31
|
+
} from './runtime/types.ts';
|
|
@@ -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
|
+
}
|