@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,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';
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.forgetInterned = exports.readMarks = exports.isMarked = exports.unmark = exports.mark = exports.createObserver = void 0;
4
+ var observer_js_1 = require("./observer.js");
5
+ Object.defineProperty(exports, "createObserver", { enumerable: true, get: function () { return observer_js_1.createObserver; } });
6
+ var marks_js_1 = require("./marks.js");
7
+ Object.defineProperty(exports, "mark", { enumerable: true, get: function () { return marks_js_1.mark; } });
8
+ Object.defineProperty(exports, "unmark", { enumerable: true, get: function () { return marks_js_1.unmark; } });
9
+ Object.defineProperty(exports, "isMarked", { enumerable: true, get: function () { return marks_js_1.isMarked; } });
10
+ Object.defineProperty(exports, "readMarks", { enumerable: true, get: function () { return marks_js_1.readMarks; } });
11
+ Object.defineProperty(exports, "forgetInterned", { enumerable: true, get: function () { return marks_js_1.forgetInterned; } });
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,6CAA+C;AAAtC,6GAAA,cAAc,OAAA;AAEvB,uCAA+E;AAAtE,gGAAA,IAAI,OAAA;AAAE,kGAAA,MAAM,OAAA;AAAE,oGAAA,QAAQ,OAAA;AAAE,qGAAA,SAAS,OAAA;AAAE,0GAAA,cAAc,OAAA"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Carrying a key inside the string it produced.
3
+ *
4
+ * The problem: a click lands on a DOM node, and nothing about that node says which
5
+ * translation key rendered it. The string may have been interpolated into a text node,
6
+ * concatenated with another, put in a `title` attribute, or handed to a chart library - 938
7
+ * call sites in these applications do the last one, and no amount of framework integration
8
+ * can follow a string somebody put in a canvas.
9
+ *
10
+ * So the key travels WITH the string, written in characters that occupy no space: a zero-width
11
+ * non-joiner for 0 and a zero-width joiner for 1. Appended to the rendered text, they are
12
+ * invisible, they survive concatenation and interpolation, and they can be read back out of
13
+ * `textContent` wherever the string ended up.
14
+ *
15
+ * Two things make that practical rather than merely clever:
16
+ *
17
+ * - Keys are INTERNED. Encoding `{"k":"appointment_payment_amount_mismatch","n":"reception"}`
18
+ * would add ~500 invisible characters to a string of thirty, and the DOM would be mostly
19
+ * marks. Instead each distinct key gets a number, and only the number is encoded - one to
20
+ * three digits. The table lives in this module for the life of the page, which is exactly
21
+ * as long as the marks in the DOM do.
22
+ * - Each byte is written as NINE characters, eight bits and a trailing zero. That makes an
23
+ * encoded run a multiple of nine, which is how a run of marks is told apart from the odd
24
+ * zero-width character that turns up in real text (Persian and Hindi both use them, and
25
+ * emoji sequences are full of joiners).
26
+ */
27
+ export type MarkedKey = {
28
+ key: string;
29
+ ns?: string;
30
+ defaultValue?: string;
31
+ };
32
+ /** What a marked string carries: an index into the table, or several. */
33
+ export declare function mark(text: string, key: MarkedKey): string;
34
+ /** Whether a string is worth decoding, cheaply enough to run on every text node. */
35
+ export declare function isMarked(text: string): boolean;
36
+ /** The text as a person sees it, with the marks taken out. */
37
+ export declare function unmark(text: string): string;
38
+ /**
39
+ * The keys a string carries, and the string without them.
40
+ *
41
+ * Anything that does not decode to a known index is ignored rather than reported: real text
42
+ * contains zero-width characters for its own reasons, and a run that happens to be a multiple
43
+ * of nine is a coincidence this must survive rather than a message.
44
+ */
45
+ export declare function readMarks(text: string): {
46
+ text: string;
47
+ keys: MarkedKey[];
48
+ };
49
+ /** Test seam: the table is a module global by design, and lives as long as the page. */
50
+ export declare function forgetInterned(): void;
package/dist/marks.js ADDED
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ /**
3
+ * Carrying a key inside the string it produced.
4
+ *
5
+ * The problem: a click lands on a DOM node, and nothing about that node says which
6
+ * translation key rendered it. The string may have been interpolated into a text node,
7
+ * concatenated with another, put in a `title` attribute, or handed to a chart library - 938
8
+ * call sites in these applications do the last one, and no amount of framework integration
9
+ * can follow a string somebody put in a canvas.
10
+ *
11
+ * So the key travels WITH the string, written in characters that occupy no space: a zero-width
12
+ * non-joiner for 0 and a zero-width joiner for 1. Appended to the rendered text, they are
13
+ * invisible, they survive concatenation and interpolation, and they can be read back out of
14
+ * `textContent` wherever the string ended up.
15
+ *
16
+ * Two things make that practical rather than merely clever:
17
+ *
18
+ * - Keys are INTERNED. Encoding `{"k":"appointment_payment_amount_mismatch","n":"reception"}`
19
+ * would add ~500 invisible characters to a string of thirty, and the DOM would be mostly
20
+ * marks. Instead each distinct key gets a number, and only the number is encoded - one to
21
+ * three digits. The table lives in this module for the life of the page, which is exactly
22
+ * as long as the marks in the DOM do.
23
+ * - Each byte is written as NINE characters, eight bits and a trailing zero. That makes an
24
+ * encoded run a multiple of nine, which is how a run of marks is told apart from the odd
25
+ * zero-width character that turns up in real text (Persian and Hindi both use them, and
26
+ * emoji sequences are full of joiners).
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.mark = mark;
30
+ exports.isMarked = isMarked;
31
+ exports.unmark = unmark;
32
+ exports.readMarks = readMarks;
33
+ exports.forgetInterned = forgetInterned;
34
+ /** 0 and 1. Zero-width, so they render as nothing at all. */
35
+ const ZERO = '‌';
36
+ const ONE = '‍';
37
+ /** Between two encoded numbers, when two marked strings ended up in one node. */
38
+ const SEPARATOR = '\n';
39
+ const BITS_PER_BYTE = 9;
40
+ const RUN = new RegExp(`(?:[${ZERO}${ONE}]{${BITS_PER_BYTE}})+`, 'g');
41
+ /**
42
+ * Keys seen this page, so only an index has to be encoded.
43
+ *
44
+ * Module-level and never pruned, which is right: an entry costs a string, and a mark in the
45
+ * DOM is only meaningful while the page that wrote it is still open.
46
+ */
47
+ const interned = [];
48
+ function intern(value) {
49
+ const existing = interned.indexOf(value);
50
+ if (existing !== -1)
51
+ return existing;
52
+ interned.push(value);
53
+ return interned.length - 1;
54
+ }
55
+ /**
56
+ * ASCII only, deliberately.
57
+ *
58
+ * What gets encoded is never the key itself - it is an index and a separator, so digits and
59
+ * one newline. That means no TextEncoder, which jsdom does not always provide, and no
60
+ * multi-byte handling to get wrong. The key's own characters live in the interning table,
61
+ * where they are just a JavaScript string.
62
+ */
63
+ function encodeText(text) {
64
+ let bits = '';
65
+ for (let at = 0; at < text.length; at += 1) {
66
+ bits += (text.charCodeAt(at) & 0x7f).toString(2).padStart(8, '0') + '0';
67
+ }
68
+ return [...bits].map(bit => (bit === '1' ? ONE : ZERO)).join('');
69
+ }
70
+ function decodeText(marks) {
71
+ let text = '';
72
+ for (let at = 0; at + BITS_PER_BYTE <= marks.length; at += BITS_PER_BYTE) {
73
+ const bits = [...marks.slice(at, at + 8)].map(character => (character === ONE ? '1' : '0')).join('');
74
+ text += String.fromCharCode(parseInt(bits, 2));
75
+ }
76
+ return text;
77
+ }
78
+ /** What a marked string carries: an index into the table, or several. */
79
+ function mark(text, key) {
80
+ return text + encodeText(String(intern(JSON.stringify([key.key, key.ns ?? '', key.defaultValue ?? '']))) + SEPARATOR);
81
+ }
82
+ /** Whether a string is worth decoding, cheaply enough to run on every text node. */
83
+ function isMarked(text) {
84
+ return text.includes(ZERO) || text.includes(ONE);
85
+ }
86
+ /** The text as a person sees it, with the marks taken out. */
87
+ function unmark(text) {
88
+ return text.replace(RUN, '');
89
+ }
90
+ /**
91
+ * The keys a string carries, and the string without them.
92
+ *
93
+ * Anything that does not decode to a known index is ignored rather than reported: real text
94
+ * contains zero-width characters for its own reasons, and a run that happens to be a multiple
95
+ * of nine is a coincidence this must survive rather than a message.
96
+ */
97
+ function readMarks(text) {
98
+ const keys = [];
99
+ for (const run of text.match(RUN) ?? []) {
100
+ for (const encoded of decodeText(run).split(SEPARATOR)) {
101
+ if (!encoded)
102
+ continue;
103
+ const index = Number(encoded);
104
+ if (!Number.isInteger(index) || index < 0 || index >= interned.length)
105
+ continue;
106
+ try {
107
+ const [key, ns, defaultValue] = JSON.parse(interned[index]);
108
+ keys.push({ key, ns: ns || undefined, defaultValue: defaultValue || undefined });
109
+ }
110
+ catch {
111
+ // An entry this module wrote cannot fail to parse; if it somehow does, the
112
+ // right answer is one fewer key rather than a broken page.
113
+ }
114
+ }
115
+ }
116
+ return { text: unmark(text), keys };
117
+ }
118
+ /** Test seam: the table is a module global by design, and lives as long as the page. */
119
+ function forgetInterned() {
120
+ interned.length = 0;
121
+ }
122
+ //# sourceMappingURL=marks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"marks.js","sourceRoot":"","sources":["../src/marks.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,77 @@
1
+ import type { TranslateProps } from '@worthy-ventures/metaglotta-runtime';
2
+ import { type MarkedKey } from './marks.js';
3
+ /**
4
+ * Watches the page for marked text, and turns a click on it back into a key.
5
+ *
6
+ * Three jobs, and they are separate on purpose:
7
+ *
8
+ * 1. MARK - every string the runtime produces gets its key appended in invisible characters.
9
+ * This is the `decorate` hook, and it is the only thing that touches the runtime.
10
+ * 2. SCAN - as marked text arrives in the DOM, read the keys out, take the marks back OUT of
11
+ * the node so nothing can copy them into a clipboard, and remember which element the keys
12
+ * belong to.
13
+ * 3. POINT - while the modifier is held, outline whatever is under the cursor and swallow the
14
+ * click, so ALT+clicking a "Delete" button opens the dialog rather than deleting anything.
15
+ *
16
+ * Authoring only. Nothing in a production build should ever import this.
17
+ */
18
+ export type ModifierName = 'Alt' | 'Control' | 'Shift' | 'Meta';
19
+ export type KeyPosition = {
20
+ key: string;
21
+ ns: string;
22
+ position: {
23
+ x: number;
24
+ y: number;
25
+ width: number;
26
+ height: number;
27
+ };
28
+ };
29
+ export type ObserverOptions = {
30
+ /** What to do with an armed click. The target is the element the keys were found on. */
31
+ onClick: (keys: MarkedKey[], target: HTMLElement) => void;
32
+ /** Held to arm it. All of them, if more than one. Default: Alt. */
33
+ keys?: ModifierName[];
34
+ /** Attributes whose value may be a translation. */
35
+ attributes?: string[];
36
+ /** Where to watch. Default: the whole document. */
37
+ root?: Element | Document;
38
+ /** A subtree to leave alone entirely - the editing dialog's own, for one. */
39
+ ignore?: (element: Element) => boolean;
40
+ highlightColor?: string;
41
+ highlightWidth?: number;
42
+ };
43
+ export declare function createObserver(options: ObserverOptions): {
44
+ /**
45
+ * Pass as `decorate` to the runtime. Everything else follows from this.
46
+ *
47
+ * The namespace comes from the runtime, not from `props`: props hold what the caller
48
+ * asked with, which for most templates is nothing at all, while this is where the
49
+ * string was actually resolved from. Marking with the former is what made the dialog
50
+ * open on the default namespace with nothing in it.
51
+ */
52
+ mark: (result: string, props: TranslateProps, namespace: string) => string;
53
+ run(): void;
54
+ stop(): void;
55
+ /**
56
+ * Where a key is on screen, in viewport coordinates.
57
+ *
58
+ * This is what a screenshot's boxes are drawn from, so the order matters: sorted by
59
+ * position in the document, because the dialog labels them in the order it gets them
60
+ * and a reader expects the first box to be the first occurrence.
61
+ */
62
+ findPositions(key?: string, ns?: string): KeyPosition[];
63
+ /** Outlines every place a key appears. Returns how to put it back. */
64
+ highlight(key?: string, ns?: string): {
65
+ unhighlight: () => void;
66
+ };
67
+ /**
68
+ * Hides the outlines and hands back how to restore them.
69
+ *
70
+ * For screenshots: the outlines are fixed-position divs on the body, so they would be
71
+ * captured over the very text being photographed.
72
+ */
73
+ hideOutlines(): () => void;
74
+ /** Test seam. */
75
+ registeredCount: () => number;
76
+ };
77
+ export type Observer = ReturnType<typeof createObserver>;