@worthy-ventures/metaglotta-observer 1.0.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.
@@ -0,0 +1,405 @@
1
+ import { isMarked, mark, readMarks } from './marks.js';
2
+ const MODIFIER_FLAG = {
3
+ Alt: 'altKey',
4
+ Control: 'ctrlKey',
5
+ Shift: 'shiftKey',
6
+ Meta: 'metaKey',
7
+ };
8
+ /** Where a translation plausibly lands other than in text. */
9
+ const DEFAULT_ATTRIBUTES = ['title', 'placeholder', 'aria-label', 'alt', 'value', 'label'];
10
+ /** Never worth walking into: their text is not for reading. */
11
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TEXTAREA']);
12
+ const HIGHLIGHT_CLASS = 'mg-observer-highlight';
13
+ /**
14
+ * How many observers are watching, so a second one can say so.
15
+ *
16
+ * Two on the same document silently break each other: marks are taken OUT of a node as it is
17
+ * scanned, so whichever observer's mutation callback runs first absorbs them and the other
18
+ * registers nothing at all. ALT+click then does nothing, with no error to explain it.
19
+ *
20
+ * One page only ever has one. Where this bites is a hot reload, or a test that forgot to stop
21
+ * the previous instance - both of which look like the feature is broken rather than
22
+ * double-installed.
23
+ */
24
+ let watching = 0;
25
+ export function createObserver(options) {
26
+ const armWith = options.keys ?? ['Alt'];
27
+ const attributes = options.attributes ?? DEFAULT_ATTRIBUTES;
28
+ const root = options.root ?? document;
29
+ /**
30
+ * Tech Blue, the brand accent.
31
+ *
32
+ * An outline is one of the few places the light blue is right: it sits on top of the page
33
+ * rather than under text, so its 2.14:1 against white does not matter - what matters is
34
+ * that it reads as ours. Pass highlightColor to override it on a page it disappears into.
35
+ */
36
+ const highlightColor = options.highlightColor ?? '#38bdf8';
37
+ const highlightWidth = options.highlightWidth ?? 2;
38
+ const registry = new Map();
39
+ const held = new Set();
40
+ let mutations;
41
+ let listeners = [];
42
+ let cursor;
43
+ let outlined;
44
+ const ignored = (element) => Boolean(options.ignore?.(element)) || SKIP_TAGS.has(element.tagName);
45
+ const withinIgnored = (node) => {
46
+ let at = node;
47
+ while (at) {
48
+ if (at.nodeType === Node.ELEMENT_NODE && ignored(at))
49
+ return true;
50
+ at = at.parentNode;
51
+ }
52
+ return false;
53
+ };
54
+ // ---- 2. scan ----------------------------------------------------------------------
55
+ function register(element, node, keys) {
56
+ let entry = registry.get(element);
57
+ if (!entry) {
58
+ entry = { element, nodes: new Map() };
59
+ registry.set(element, entry);
60
+ }
61
+ entry.nodes.set(node, keys);
62
+ }
63
+ /**
64
+ * Reads the keys out and puts the visible text back.
65
+ *
66
+ * Stripping is not tidiness. Marks left in the DOM are copied by Ctrl+C, submitted in
67
+ * form values, and compared against by application code that has no idea they are there -
68
+ * and because they are invisible, the resulting bug looks like magic. Writing the node
69
+ * back triggers another mutation, which is harmless: the text is no longer marked, so the
70
+ * next pass skips it.
71
+ */
72
+ function absorb(node) {
73
+ const raw = node.nodeValue;
74
+ if (!raw || !isMarked(raw))
75
+ return;
76
+ const owner = node.nodeType === Node.ATTRIBUTE_NODE ? node.ownerElement : node.parentElement;
77
+ if (!owner || withinIgnored(owner))
78
+ return;
79
+ const { text, keys } = readMarks(raw);
80
+ node.nodeValue = text;
81
+ if (keys.length)
82
+ register(owner, node, keys);
83
+ }
84
+ function scan(node) {
85
+ if (node.nodeType === Node.TEXT_NODE) {
86
+ absorb(node);
87
+ return;
88
+ }
89
+ if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_NODE)
90
+ return;
91
+ if (node.nodeType === Node.ELEMENT_NODE && ignored(node))
92
+ return;
93
+ const elements = [node, ...Array.from(node.querySelectorAll?.('*') ?? [])];
94
+ for (const element of elements) {
95
+ if (element.nodeType !== Node.ELEMENT_NODE || ignored(element))
96
+ continue;
97
+ for (const name of attributes) {
98
+ const attribute = element.getAttributeNode?.(name);
99
+ if (attribute)
100
+ absorb(attribute);
101
+ }
102
+ }
103
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
104
+ acceptNode: text => isMarked(text.nodeValue ?? '') && !SKIP_TAGS.has(text.parentElement?.tagName ?? '')
105
+ ? NodeFilter.FILTER_ACCEPT
106
+ : NodeFilter.FILTER_REJECT,
107
+ });
108
+ const found = [];
109
+ while (walker.nextNode())
110
+ found.push(walker.currentNode);
111
+ for (const text of found)
112
+ absorb(text);
113
+ }
114
+ /**
115
+ * Forgets what has left the page.
116
+ *
117
+ * Without this the registry holds every element the page has ever rendered - a leak that
118
+ * also makes findPositions() report boxes for text nobody can see.
119
+ */
120
+ function forget() {
121
+ for (const [element, entry] of registry) {
122
+ if (!element.isConnected) {
123
+ unoutline(element);
124
+ registry.delete(element);
125
+ continue;
126
+ }
127
+ for (const node of entry.nodes.keys()) {
128
+ const stillThere = node.nodeType === Node.ATTRIBUTE_NODE ? node.ownerElement?.isConnected : node.isConnected;
129
+ if (!stillThere)
130
+ entry.nodes.delete(node);
131
+ }
132
+ if (entry.nodes.size === 0) {
133
+ unoutline(element);
134
+ registry.delete(element);
135
+ }
136
+ }
137
+ }
138
+ // ---- 3. point ---------------------------------------------------------------------
139
+ function armed() {
140
+ return armWith.every(key => held.has(key));
141
+ }
142
+ function trackModifiers(event) {
143
+ for (const name of Object.keys(MODIFIER_FLAG)) {
144
+ if (event[MODIFIER_FLAG[name]])
145
+ held.add(name);
146
+ else
147
+ held.delete(name);
148
+ }
149
+ }
150
+ function registeredAt(x, y) {
151
+ for (const candidate of document.elementsFromPoint(x, y)) {
152
+ let at = candidate;
153
+ while (at) {
154
+ if (ignored(at))
155
+ return undefined;
156
+ if (registry.has(at))
157
+ return at;
158
+ at = at.parentElement;
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+ function outline(element) {
164
+ if (outlined === element) {
165
+ if (element)
166
+ position(element);
167
+ return;
168
+ }
169
+ if (outlined)
170
+ unoutline(outlined);
171
+ outlined = element;
172
+ if (element)
173
+ position(element);
174
+ }
175
+ function position(element) {
176
+ const entry = registry.get(element);
177
+ if (!entry || !element.isConnected)
178
+ return;
179
+ if (!entry.outline) {
180
+ const box = document.createElement('div');
181
+ box.className = HIGHLIGHT_CLASS;
182
+ Object.assign(box.style, {
183
+ position: 'fixed',
184
+ boxSizing: 'content-box',
185
+ pointerEvents: 'none',
186
+ zIndex: String(Number.MAX_SAFE_INTEGER),
187
+ borderStyle: 'solid',
188
+ borderRadius: '4px',
189
+ borderColor: highlightColor,
190
+ borderWidth: `${highlightWidth}px`,
191
+ contain: 'layout',
192
+ });
193
+ document.body.appendChild(box);
194
+ entry.outline = box;
195
+ }
196
+ const shape = element.getBoundingClientRect();
197
+ Object.assign(entry.outline.style, {
198
+ top: `${shape.top - highlightWidth}px`,
199
+ left: `${shape.left - highlightWidth}px`,
200
+ width: `${shape.width}px`,
201
+ height: `${shape.height}px`,
202
+ });
203
+ }
204
+ function unoutline(element) {
205
+ const entry = registry.get(element);
206
+ entry?.outline?.remove();
207
+ if (entry)
208
+ entry.outline = undefined;
209
+ if (outlined === element)
210
+ outlined = undefined;
211
+ }
212
+ function refresh() {
213
+ outline(armed() && cursor ? registeredAt(cursor.x, cursor.y) : undefined);
214
+ }
215
+ /**
216
+ * Swallows the page's own interaction while armed.
217
+ *
218
+ * ALT+clicking a "Delete patient" button has to open the dialog and nothing else, so the
219
+ * event is stopped in the capture phase - before the application's own listener - and on
220
+ * every mouse event, not only click: a hover handler that opens a menu would otherwise
221
+ * fire and cover the thing being clicked.
222
+ */
223
+ function swallow(event) {
224
+ trackModifiers(event);
225
+ if (!armed())
226
+ return;
227
+ const target = event.target;
228
+ if (target && withinIgnored(target))
229
+ return;
230
+ event.stopPropagation();
231
+ event.preventDefault();
232
+ }
233
+ function onClick(event) {
234
+ swallow(event);
235
+ cursor = { x: event.clientX, y: event.clientY };
236
+ if (!armed())
237
+ return;
238
+ const element = registeredAt(event.clientX, event.clientY);
239
+ if (!element)
240
+ return;
241
+ const keys = [...(registry.get(element)?.nodes.values() ?? [])].flat();
242
+ if (!keys.length)
243
+ return;
244
+ outline(undefined);
245
+ options.onClick(keys, element);
246
+ }
247
+ function listen() {
248
+ const on = (type, handler, passive = false) => {
249
+ const wrapped = handler;
250
+ document.addEventListener(type, wrapped, { capture: true, passive });
251
+ listeners.push(() => document.removeEventListener(type, wrapped, { capture: true }));
252
+ };
253
+ on('keydown', event => {
254
+ trackModifiers(event);
255
+ refresh();
256
+ });
257
+ on('keyup', event => {
258
+ trackModifiers(event);
259
+ refresh();
260
+ });
261
+ on('mousemove', event => {
262
+ trackModifiers(event);
263
+ cursor = { x: event.clientX, y: event.clientY };
264
+ refresh();
265
+ }, true);
266
+ on('scroll', () => refresh(), true);
267
+ on('click', onClick);
268
+ for (const type of ['mousedown', 'mouseup', 'mouseover', 'mouseout', 'mouseenter', 'mouseleave']) {
269
+ on(type, swallow);
270
+ }
271
+ // A window that loses focus never sees the keyup, so the modifier would stay held.
272
+ const onBlur = () => {
273
+ held.clear();
274
+ refresh();
275
+ };
276
+ window.addEventListener('blur', onBlur);
277
+ listeners.push(() => window.removeEventListener('blur', onBlur));
278
+ }
279
+ // ---- the public surface -----------------------------------------------------------
280
+ return {
281
+ /**
282
+ * Pass as `decorate` to the runtime. Everything else follows from this.
283
+ *
284
+ * The namespace comes from the runtime, not from `props`: props hold what the caller
285
+ * asked with, which for most templates is nothing at all, while this is where the
286
+ * string was actually resolved from. Marking with the former is what made the dialog
287
+ * open on the default namespace with nothing in it.
288
+ */
289
+ mark: (result, props, namespace) => mark(result, { key: props.key, ns: namespace || undefined, defaultValue: props.defaultValue }),
290
+ run() {
291
+ if (mutations)
292
+ return;
293
+ watching += 1;
294
+ if (watching > 1) {
295
+ console.warn(`Metaglotta: ${watching} in-context observers are running at once. They will take each other's marks and ALT+click will stop finding keys - stop the previous one.`);
296
+ }
297
+ scan(root);
298
+ listen();
299
+ mutations = new MutationObserver(records => {
300
+ let anythingRemoved = false;
301
+ for (const record of records) {
302
+ if (record.type === 'characterData')
303
+ scan(record.target);
304
+ if (record.type === 'attributes')
305
+ scan(record.target);
306
+ if (record.type === 'childList') {
307
+ record.addedNodes.forEach(node => scan(node));
308
+ anythingRemoved || (anythingRemoved = record.removedNodes.length > 0);
309
+ }
310
+ }
311
+ // Swept by what is still connected rather than by what was reported: a node
312
+ // moved and a node deleted look the same in a mutation record, and only the
313
+ // DOM knows which happened.
314
+ if (anythingRemoved)
315
+ forget();
316
+ });
317
+ mutations.observe(root, {
318
+ childList: true,
319
+ subtree: true,
320
+ characterData: true,
321
+ attributes: true,
322
+ attributeFilter: attributes,
323
+ });
324
+ },
325
+ stop() {
326
+ if (mutations)
327
+ watching -= 1;
328
+ mutations?.disconnect();
329
+ mutations = undefined;
330
+ for (const off of listeners)
331
+ off();
332
+ listeners = [];
333
+ for (const element of [...registry.keys()])
334
+ unoutline(element);
335
+ registry.clear();
336
+ held.clear();
337
+ cursor = undefined;
338
+ },
339
+ /**
340
+ * Where a key is on screen, in viewport coordinates.
341
+ *
342
+ * This is what a screenshot's boxes are drawn from, so the order matters: sorted by
343
+ * position in the document, because the dialog labels them in the order it gets them
344
+ * and a reader expects the first box to be the first occurrence.
345
+ */
346
+ findPositions(key, ns) {
347
+ const matching = [...registry.values()].filter(entry => [...entry.nodes.values()].flat().some(found => matches(found, key, ns)));
348
+ matching.sort((a, b) => a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
349
+ const positions = [];
350
+ for (const entry of matching) {
351
+ const shape = entry.element.getBoundingClientRect();
352
+ for (const found of [...entry.nodes.values()].flat()) {
353
+ if (!matches(found, key, ns))
354
+ continue;
355
+ positions.push({
356
+ key: found.key,
357
+ ns: found.ns ?? '',
358
+ position: { x: shape.x, y: shape.y, width: shape.width, height: shape.height },
359
+ });
360
+ }
361
+ }
362
+ return positions;
363
+ },
364
+ /** Outlines every place a key appears. Returns how to put it back. */
365
+ highlight(key, ns) {
366
+ const elements = [...registry.values()]
367
+ .filter(entry => [...entry.nodes.values()].flat().some(found => matches(found, key, ns)))
368
+ .map(entry => entry.element);
369
+ for (const element of elements)
370
+ position(element);
371
+ return {
372
+ unhighlight: () => {
373
+ for (const element of elements)
374
+ unoutline(element);
375
+ },
376
+ };
377
+ },
378
+ /**
379
+ * Hides the outlines and hands back how to restore them.
380
+ *
381
+ * For screenshots: the outlines are fixed-position divs on the body, so they would be
382
+ * captured over the very text being photographed.
383
+ */
384
+ hideOutlines() {
385
+ const hidden = [...registry.values()].map(entry => entry.outline).filter((box) => Boolean(box));
386
+ for (const box of hidden)
387
+ box.style.visibility = 'hidden';
388
+ return () => {
389
+ for (const box of hidden)
390
+ box.style.visibility = '';
391
+ };
392
+ },
393
+ /** Test seam. */
394
+ registeredCount: () => registry.size,
395
+ };
396
+ }
397
+ /** Undefined on either side means "any", which is how the dialog asks for everything. */
398
+ function matches(found, key, ns) {
399
+ if (key !== undefined && found.key !== key)
400
+ return false;
401
+ if (ns !== undefined && (found.ns ?? '') !== ns)
402
+ return false;
403
+ return true;
404
+ }
405
+ //# sourceMappingURL=observer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observer.js","sourceRoot":"","sources":["../src/observer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ {"type":"module"}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@worthy-ventures/metaglotta-observer",
3
+ "version": "1.0.0",
4
+ "description": "Traces rendered text back to the translation key that produced it, for in-context editing. Authoring only - never in a production bundle.",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "module": "dist-esm/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist-esm/index.js",
13
+ "require": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist/**/*",
19
+ "dist-esm/**/*",
20
+ "src/**/*",
21
+ "README.md"
22
+ ],
23
+ "sideEffects": false,
24
+ "scripts": {
25
+ "build": "npm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.esm.json && node -e \"require('fs').writeFileSync('dist-esm/package.json', JSON.stringify({type:'module'}))\"",
26
+ "test": "jest -c jest.config.js",
27
+ "clean": "rimraf dist dist-esm coverage"
28
+ },
29
+ "dependencies": {
30
+ "@worthy-ventures/metaglotta-runtime": "1.0.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { createObserver } from './observer.js';
2
+ export type { KeyPosition, ModifierName, Observer, ObserverOptions } from './observer.js';
3
+ export { mark, unmark, isMarked, readMarks, forgetInterned } from './marks.js';
4
+ export type { MarkedKey } from './marks.js';
@@ -0,0 +1,125 @@
1
+ import { forgetInterned, isMarked, mark, readMarks, unmark } from './marks.js';
2
+
3
+ /**
4
+ * The marks are the whole trick, so they are tested harder than anything else here.
5
+ *
6
+ * If they are wrong the failure is not a crash - it is ALT+click opening the dialog on the
7
+ * wrong key, or a page whose text has invisible junk in it that copies into somebody's
8
+ * clipboard. Both are quiet.
9
+ */
10
+ describe('marks', () => {
11
+ beforeEach(() => forgetInterned());
12
+
13
+ it('leaves the visible text exactly as it was', () => {
14
+ const marked = mark('Hello Ada', { key: 'greeting' });
15
+ expect(unmark(marked)).toBe('Hello Ada');
16
+ expect(marked.startsWith('Hello Ada')).toBe(true);
17
+ });
18
+
19
+ it('adds nothing a person can see', () => {
20
+ const marked = mark('Hello', { key: 'greeting' });
21
+ // Zero-width characters only, and no visible character beyond the text itself.
22
+ expect(marked.replace(/[‌‍]/g, '')).toBe('Hello');
23
+ });
24
+
25
+ it('reads the key back', () => {
26
+ const { keys } = readMarks(mark('Hello', { key: 'greeting' }));
27
+ expect(keys).toEqual([{ key: 'greeting', ns: undefined, defaultValue: undefined }]);
28
+ });
29
+
30
+ it('carries the namespace and the default value', () => {
31
+ const { keys } = readMarks(mark('Hello', { key: 'greeting', ns: 'login', defaultValue: 'Hi' }));
32
+ expect(keys).toEqual([{ key: 'greeting', ns: 'login', defaultValue: 'Hi' }]);
33
+ });
34
+
35
+ it('reads the text back without them', () => {
36
+ expect(readMarks(mark('Hello Ada', { key: 'greeting' })).text).toBe('Hello Ada');
37
+ });
38
+
39
+ /** Two strings interpolated into one text node is the commonest template there is. */
40
+ it('reads both keys when two marked strings were joined', () => {
41
+ const joined = mark('Hello', { key: 'greeting' }) + ' ' + mark('Ada', { key: 'name' });
42
+ const { text, keys } = readMarks(joined);
43
+
44
+ expect(text).toBe('Hello Ada');
45
+ expect(keys.map(k => k.key)).toEqual(['greeting', 'name']);
46
+ });
47
+
48
+ it('reads both when they were joined with nothing between them', () => {
49
+ const joined = mark('Hello', { key: 'a' }) + mark('Ada', { key: 'b' });
50
+ expect(readMarks(joined).keys.map(k => k.key)).toEqual(['a', 'b']);
51
+ });
52
+
53
+ /**
54
+ * The interning is what makes this usable rather than a curiosity: the encoded form is an
55
+ * index, so a long key costs the same as a short one and the DOM does not fill up with
56
+ * marks.
57
+ */
58
+ it('costs the same however long the key is', () => {
59
+ const short = mark('x', { key: 'a' });
60
+ const long = mark('x', { key: 'appointment_payment_amount_mismatch', ns: 'reception', defaultValue: 'A very long default value indeed' });
61
+
62
+ expect(long.length).toBe(short.length);
63
+ });
64
+
65
+ it('gives the same key the same index twice', () => {
66
+ expect(mark('x', { key: 'a' })).toBe(mark('x', { key: 'a' }));
67
+ });
68
+
69
+ it('tells two keys apart even past the first ten', () => {
70
+ const marked = Array.from({ length: 40 }, (_, i) => mark('x', { key: `key_${i}` }));
71
+ const read = marked.map(m => readMarks(m).keys[0]!.key);
72
+
73
+ expect(read).toEqual(Array.from({ length: 40 }, (_, i) => `key_${i}`));
74
+ });
75
+
76
+ it('handles a key that is not ASCII', () => {
77
+ const { keys } = readMarks(mark('x', { key: 'κλειδί', ns: 'ελληνικά' }));
78
+ expect(keys).toEqual([{ key: 'κλειδί', ns: 'ελληνικά', defaultValue: undefined }]);
79
+ });
80
+
81
+ it('marks an empty string, which is what the pipe renders for a missing key', () => {
82
+ const marked = mark('', { key: 'nowhere' });
83
+ expect(unmark(marked)).toBe('');
84
+ expect(readMarks(marked).keys.map(k => k.key)).toEqual(['nowhere']);
85
+ });
86
+
87
+ describe('text that was never marked', () => {
88
+ it('is not mistaken for marked', () => {
89
+ expect(isMarked('Hello Ada')).toBe(false);
90
+ expect(readMarks('Hello Ada')).toEqual({ text: 'Hello Ada', keys: [] });
91
+ });
92
+
93
+ /**
94
+ * Zero-width characters occur in real text - Persian and Hindi use the non-joiner,
95
+ * and every multi-person emoji is held together by joiners. A run of them is only a
96
+ * message if it decodes to an index this module actually handed out.
97
+ */
98
+ it('survives a zero-width joiner that came from real content', () => {
99
+ const family = '👨‍👩‍👧';
100
+ expect(readMarks(family).keys).toEqual([]);
101
+ });
102
+
103
+ it('survives a Persian non-joiner', () => {
104
+ expect(readMarks('می‌خواهم').keys).toEqual([]);
105
+ });
106
+
107
+ it('ignores a run that decodes to an index nobody handed out', () => {
108
+ // 27 characters: a well-formed run of three bytes that is not in the table.
109
+ const forged = '‍'.repeat(27);
110
+ expect(readMarks(forged).keys).toEqual([]);
111
+ });
112
+ });
113
+
114
+ it('knows a marked string from an unmarked one', () => {
115
+ expect(isMarked(mark('Hello', { key: 'a' }))).toBe(true);
116
+ expect(isMarked('Hello')).toBe(false);
117
+ expect(isMarked('')).toBe(false);
118
+ });
119
+
120
+ /** The marks must come out of anything shown to a person - a screenshot caption, a diff. */
121
+ it('strips every run, not just the first', () => {
122
+ const joined = mark('a', { key: 'a' }) + mark('b', { key: 'b' });
123
+ expect(unmark(joined)).toBe('ab');
124
+ });
125
+ });