dsh-plugin-term-dictionary 0.0.0-stage → 1.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.
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The annotation layer: marks the terms the dictionary has really collected
5
+ * wherever they appear in the transcript.
6
+ *
7
+ * Why this is a CSS Custom Highlight and not wrapped text
8
+ * ------------------------------------------------------
9
+ * Marking terms by wrapping them in spans means writing into the host's message
10
+ * tree. React owns that tree: the next re-render discards the wrappers, a
11
+ * streaming reply re-renders constantly, and inserting nodes into a virtualized
12
+ * list fights its measurement. The Custom Highlight API paints `Range` objects
13
+ * through the style layer instead, so the DOM the renderer owns is never touched
14
+ * and nothing is left behind when the plugin unloads.
15
+ *
16
+ * What it marks, and what it deliberately does not
17
+ * ------------------------------------------------
18
+ * Only a term backed by one of the user's own entries (`source === "dictionary"`)
19
+ * is marked. A built-in glossary term is still hoverable and clickable, but it has
20
+ * no entry to open, and underlining every word the bundled lexicon can define
21
+ * would mark most of a technical reply — the same "do not make the dictionary
22
+ * bulky" rule the collector follows, applied to the page.
23
+ *
24
+ * The whole module degrades to a no-op where the API is missing: `supported` is
25
+ * false, `refresh` marks nothing, and hover and click keep working.
26
+ */
27
+
28
+ const { textRuns, readRegion, isProseBlock } = require("./interact.js");
29
+
30
+ /** The highlight registry key. One page-level layer, one name. */
31
+ const HIGHLIGHT_NAME = "term-dictionary-entry";
32
+
33
+ /**
34
+ * The rule that paints the highlight.
35
+ *
36
+ * Rendered as a component-owned `<style>` element rather than applied inline,
37
+ * because a `::highlight()` rule only exists in a stylesheet. Only theme tokens
38
+ * appear as colors, so light and dark follow without a second rule.
39
+ *
40
+ * The underline is `label-secondary`, not `brand-primary`. In the DARK theme the
41
+ * platform resolves `brand-primary` to `--dsw-static-neutral-bluish-50`, which is the
42
+ * very same value it gives `label-primary` — so a brand-coloured underline under
43
+ * brand-coloured text is invisible, and the marking the feature promises would simply
44
+ * not be there. `label-secondary` is measurably dimmer than the body text in both
45
+ * themes (bluish-700 on light, bluish-300 on dark), which is what makes a marked word
46
+ * readable as marked. The wash behind it comes from the platform's own hover tint,
47
+ * a translucent value in both themes (`#2631480f` light, `#ffffff14` dark).
48
+ */
49
+ const HIGHLIGHT_CSS =
50
+ `::highlight(${HIGHLIGHT_NAME}) {` +
51
+ " text-decoration: underline dotted var(--dsw-alias-label-secondary);" +
52
+ " text-decoration-thickness: 1px;" +
53
+ " text-underline-offset: 2px;" +
54
+ " background-color: var(--dsw-alias-interactive-bg-hover, transparent);" +
55
+ " }";
56
+
57
+ /**
58
+ * Map an offset in a run's collapsed body back to an offset in its text node.
59
+ *
60
+ * `textRuns` builds a run's body by skipping leading whitespace, collapsing every
61
+ * whitespace run to one space and dropping trailing whitespace. This walks the raw
62
+ * text with the same rules, so a range built from run offsets lands on the
63
+ * characters the detector actually matched instead of drifting by the width of the
64
+ * whitespace it removed.
65
+ *
66
+ * @param raw - the text node's value.
67
+ * @param bodyOffset - an offset into the run's `body`, from 0 to `body.length`.
68
+ * @returns the corresponding offset into `raw`.
69
+ */
70
+ function rawOffsetForBody(raw, bodyOffset) {
71
+ if (typeof raw !== "string" || raw === "") return 0;
72
+ const target = Math.max(0, Math.floor(bodyOffset));
73
+ let bodyIndex = 0;
74
+ let at = 0;
75
+ // Leading whitespace is trimmed off the body, so offset 0 is the first survivor.
76
+ while (at < raw.length && /\s/.test(raw[at])) at++;
77
+ if (target === 0) return at;
78
+ while (at < raw.length) {
79
+ if (/\s/.test(raw[at])) {
80
+ // The whole run stands for the single space the body carries.
81
+ if (bodyIndex === target) return at;
82
+ bodyIndex++;
83
+ while (at < raw.length && /\s/.test(raw[at])) at++;
84
+ if (bodyIndex === target) return at;
85
+ continue;
86
+ }
87
+ if (bodyIndex === target) return at;
88
+ bodyIndex++;
89
+ at++;
90
+ }
91
+ return raw.length;
92
+ }
93
+
94
+ /**
95
+ * Turn one detected span into DOM ranges, one per run it covers.
96
+ *
97
+ * A phrase can straddle inline elements — `event <strong>sourcing</strong>` is one
98
+ * dictionary term across two text nodes — and a highlight range cannot cross
99
+ * nodes, so such a term yields one range per run. They paint as one continuous
100
+ * marking.
101
+ *
102
+ * @param runs - runs from {@link textRuns}.
103
+ * @param start - the span's start offset in the runs' shared text.
104
+ * @param end - the span's end offset (exclusive).
105
+ * @returns `[{ node, start, end }]`, possibly empty.
106
+ */
107
+ function rangesAcrossRuns(runs, start, end) {
108
+ const segments = [];
109
+ for (const run of runs) {
110
+ if (run.end <= start) continue;
111
+ if (run.start >= end) break;
112
+ const from = Math.max(start, run.start) - run.start;
113
+ const to = Math.min(end, run.end) - run.start;
114
+ if (to <= from) continue;
115
+ const raw = run.node?.nodeValue ?? "";
116
+ const nodeStart = rawOffsetForBody(raw, from);
117
+ const nodeEnd = rawOffsetForBody(raw, to);
118
+ if (nodeEnd > nodeStart) segments.push({ node: run.node, start: nodeStart, end: nodeEnd });
119
+ }
120
+ return segments;
121
+ }
122
+
123
+ /**
124
+ * Collect the ranges to paint for one message block.
125
+ *
126
+ * @param block - a block from {@link readRegion}, or any element.
127
+ * @param detector - the current detector generation.
128
+ * @returns `[{ node, start, end, term, key, id }]`.
129
+ */
130
+ function rangesForBlock(block, detector) {
131
+ const element = block?.element ?? block;
132
+ if (element === null || element === undefined || detector === null || detector === undefined) return [];
133
+ const { runs, text } = textRuns(element);
134
+ if (text === "" || runs.length === 0) return [];
135
+ // `includeCandidates: false` keeps the weak evidence out: only a term backed by a
136
+ // dictionary or glossary entry is reported, which is exactly the set worth marking.
137
+ const found = detector.scan(text, { includeCandidates: false });
138
+ const collected = [];
139
+ for (const occurrence of found) {
140
+ if (occurrence.known !== true || occurrence.source !== "dictionary") continue;
141
+ for (const segment of rangesAcrossRuns(runs, occurrence.start, occurrence.end)) {
142
+ collected.push({ ...segment, term: occurrence.term, key: occurrence.key, id: occurrence.id });
143
+ }
144
+ }
145
+ return collected;
146
+ }
147
+
148
+ /**
149
+ * Build the page's annotation layer.
150
+ *
151
+ * @param options - `document`, `detector`, and an optional `logger`.
152
+ * @returns `{ supported, setDetector, refresh, clear, count }`.
153
+ */
154
+ function createTermHighlighter(options) {
155
+ const doc = options.document ?? globalThis.document;
156
+ const logger = options.logger;
157
+ const registry = globalThis.CSS?.highlights ?? null;
158
+ const HighlightConstructor = globalThis.Highlight ?? null;
159
+
160
+ /**
161
+ * Whether this page can paint a highlight at all. Probed rather than assumed:
162
+ * an older runtime must lose the marking, not the panel.
163
+ */
164
+ const supported =
165
+ registry !== null &&
166
+ typeof registry.set === "function" &&
167
+ typeof registry.delete === "function" &&
168
+ typeof HighlightConstructor === "function" &&
169
+ typeof doc?.createRange === "function";
170
+
171
+ let detector = options.detector ?? null;
172
+ let painted = 0;
173
+
174
+ /** Report a caught failure without breaking the page. */
175
+ function warn(message, error) {
176
+ logger?.warn?.(`term-dictionary: ${message}: ${error instanceof Error ? error.message : String(error)}`);
177
+ }
178
+
179
+ /**
180
+ * The segments behind the current paint.
181
+ *
182
+ * KEPT, not discarded, because they are the answer to a second question: "which term is the
183
+ * pointer on?" The marking already knows every occurrence as a `(node, start, end)` triple,
184
+ * and a caret is the same kind of coordinate — so the hit test is a lookup in this list rather
185
+ * than a second text walk with its own matching rules.
186
+ *
187
+ * That duplication is what the two-plugin split used to cost: the pointer layer had to fetch
188
+ * the terms over a route, walk the transcript again, and keep its own matcher, so the two
189
+ * could disagree about what counts as a marked term — and they did.
190
+ */
191
+ let segments = [];
192
+
193
+ /** Replace every painted range with the given segments. */
194
+ function paint(next) {
195
+ segments = next;
196
+ const built = [];
197
+ for (const segment of next) {
198
+ try {
199
+ const range = doc.createRange();
200
+ range.setStart(segment.node, segment.start);
201
+ range.setEnd(segment.node, segment.end);
202
+ built.push(range);
203
+ } catch (error) {
204
+ // The node was replaced between the read and the paint, which is normal
205
+ // while a reply streams: skip that segment and keep the rest.
206
+ warn("mapping a highlight range failed", error);
207
+ }
208
+ }
209
+ painted = built.length;
210
+ if (built.length === 0) {
211
+ registry.delete(HIGHLIGHT_NAME);
212
+ return 0;
213
+ }
214
+ registry.set(HIGHLIGHT_NAME, new HighlightConstructor(...built));
215
+ return built.length;
216
+ }
217
+
218
+ return {
219
+ supported,
220
+ /**
221
+ * The occurrences currently marked, as `{ node, start, end, term, key, id }`.
222
+ *
223
+ * The pointer layer reads this instead of walking the transcript itself.
224
+ * @returns the segments behind the last paint.
225
+ */
226
+ segments() {
227
+ return segments;
228
+ },
229
+ /**
230
+ * Swap in the detector built against the current dictionary.
231
+ * @param next - the replacement detector.
232
+ */
233
+ setDetector(next) {
234
+ detector = next ?? null;
235
+ },
236
+ /**
237
+ * Re-read the transcript and repaint the collected terms in it.
238
+ * @param region - the conversation region element, or null.
239
+ * @returns how many ranges are painted.
240
+ */
241
+ refresh(region) {
242
+ if (!supported) return 0;
243
+ try {
244
+ if (region === null || region === undefined) return paint([]);
245
+ const segments = [];
246
+ for (const block of readRegion(region).blocks) {
247
+ // Code and tool output are not prose, so a term that merely appears in a
248
+ // shell snippet is not marked as if it were part of the reply.
249
+ if (!isProseBlock(block)) continue;
250
+ for (const segment of rangesForBlock(block, detector)) segments.push(segment);
251
+ }
252
+ return paint(segments);
253
+ } catch (error) {
254
+ warn("refreshing the highlight failed", error);
255
+ return painted;
256
+ }
257
+ },
258
+ /** Remove the marking without touching the DOM. */
259
+ clear() {
260
+ if (!supported) return;
261
+ try {
262
+ registry.delete(HIGHLIGHT_NAME);
263
+ } catch (error) {
264
+ warn("clearing the highlight failed", error);
265
+ }
266
+ painted = 0;
267
+ },
268
+ /** How many ranges the last paint produced. */
269
+ count() {
270
+ return painted;
271
+ }
272
+ };
273
+ }
274
+
275
+ module.exports = {
276
+ createTermHighlighter,
277
+ rangesForBlock,
278
+ rangesAcrossRuns,
279
+ rawOffsetForBody,
280
+ HIGHLIGHT_NAME,
281
+ HIGHLIGHT_CSS
282
+ };