effect-browser 0.2.0-beta.8 → 0.3.0-beta.5

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 (202) hide show
  1. package/README.md +705 -1166
  2. package/dist/Browser-CM65Uo7f.mjs +437 -0
  3. package/dist/Browser-CM65Uo7f.mjs.map +1 -0
  4. package/dist/Browser.d.mts +115 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +202 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +295 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-DVP2IJWm.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-BERmf_gM.d.mts +117 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +123 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-D4281dIi.mjs +618 -0
  39. package/dist/Page-D4281dIi.mjs.map +1 -0
  40. package/dist/Page-DYTdVhYa.d.mts +593 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-CNNOU1Tx.d.mts +112 -0
  44. package/dist/Plan-Dtrb-8im.mjs +311 -0
  45. package/dist/Plan-Dtrb-8im.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +54 -0
  59. package/dist/Stage.mjs +133 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-BtEG93gR.mjs +420 -0
  62. package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
  63. package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-DAYjATgC.mjs +137 -0
  67. package/dist/context-DAYjATgC.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-Banfm_zc.mjs +5097 -0
  73. package/dist/page-Banfm_zc.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-CcL0K7S0.mjs +262 -0
  76. package/dist/url-CcL0K7S0.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +625 -0
  79. package/src/BrowserError.ts +225 -0
  80. package/src/BrowserEvent.ts +294 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +130 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +520 -0
  89. package/src/Plan.ts +350 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +223 -0
  94. package/src/Supervisor.ts +353 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +607 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/guard.inpage.ts +605 -0
  101. package/src/internal/input/guard.ts +183 -0
  102. package/src/internal/input/keyboard.ts +112 -0
  103. package/src/internal/input/keys.ts +127 -0
  104. package/src/internal/input/perform.ts +322 -0
  105. package/src/internal/input/pointer.ts +153 -0
  106. package/src/internal/input/replies.ts +159 -0
  107. package/src/internal/input/settle.ts +50 -0
  108. package/src/internal/input/style.ts +186 -0
  109. package/src/internal/input/targets.inpage.ts +293 -0
  110. package/src/internal/input/targets.ts +148 -0
  111. package/src/internal/page/bridge.ts +386 -0
  112. package/src/internal/page/context.ts +302 -0
  113. package/src/internal/page/lane.ts +301 -0
  114. package/src/internal/page/navigation.ts +183 -0
  115. package/src/internal/page/openings.ts +87 -0
  116. package/src/internal/page/page.ts +137 -0
  117. package/src/internal/page/url.ts +100 -0
  118. package/src/internal/page/viewport.ts +43 -0
  119. package/src/internal/pictures/capture.ts +778 -0
  120. package/src/internal/pictures/clock.ts +141 -0
  121. package/src/internal/pictures/pictures.ts +520 -0
  122. package/src/internal/pictures/transport.ts +242 -0
  123. package/src/internal/reading/choose.ts +100 -0
  124. package/src/internal/reading/context.inpage.ts +201 -0
  125. package/src/internal/reading/controls.inpage.ts +158 -0
  126. package/src/internal/reading/expect.inpage.ts +70 -0
  127. package/src/internal/reading/match.inpage.ts +92 -0
  128. package/src/internal/reading/names.inpage.ts +306 -0
  129. package/src/internal/reading/outline.inpage.ts +260 -0
  130. package/src/internal/reading/reading.ts +223 -0
  131. package/src/internal/reading/ready.inpage.ts +154 -0
  132. package/src/internal/reading/ready.ts +105 -0
  133. package/src/internal/reading/subjects.inpage.ts +188 -0
  134. package/src/internal/reading/text.inpage.ts +135 -0
  135. package/src/internal/reading/walk.inpage.ts +220 -0
  136. package/src/internal/supervisor/lifecycle.ts +265 -0
  137. package/src/internal/timeline/changes.inpage.ts +216 -0
  138. package/src/internal/timeline/changes.ts +100 -0
  139. package/src/internal/timeline/events.ts +93 -0
  140. package/src/internal/timeline/fold.inpage.ts +123 -0
  141. package/src/internal/timeline/history.inpage.ts +229 -0
  142. package/src/internal/timeline/marks.inpage.ts +207 -0
  143. package/src/internal/timeline/record.inpage.ts +133 -0
  144. package/src/internal/timeline/sight.inpage.ts +122 -0
  145. package/src/internal/timeline/window.ts +96 -0
  146. package/src/internal/timeline/words.ts +65 -0
  147. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  148. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  149. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  150. package/dist/Bootstrap.d.mts +0 -3
  151. package/dist/Bootstrap.mjs +0 -2
  152. package/dist/Browser-n-bKgBNN.d.mts +0 -372
  153. package/dist/Browser.mjs.map +0 -1
  154. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  155. package/dist/BrowserData.d.mts +0 -3
  156. package/dist/BrowserData.mjs +0 -695
  157. package/dist/BrowserData.mjs.map +0 -1
  158. package/dist/BrowserRuntime-CFe9uUTv.d.mts +0 -148
  159. package/dist/BrowserRuntime-HJ1yYlFS.mjs +0 -6741
  160. package/dist/BrowserRuntime-HJ1yYlFS.mjs.map +0 -1
  161. package/dist/BrowserRuntime.d.mts +0 -3
  162. package/dist/BrowserRuntime.mjs +0 -3
  163. package/dist/Capture.d.mts +0 -56
  164. package/dist/Capture.mjs +0 -678
  165. package/dist/Capture.mjs.map +0 -1
  166. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  167. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  168. package/dist/CaptureData.d.mts +0 -219
  169. package/dist/CaptureData.mjs +0 -2
  170. package/dist/Errors.d.mts +0 -158
  171. package/dist/Errors.mjs +0 -207
  172. package/dist/Errors.mjs.map +0 -1
  173. package/dist/Images-CaaPNeed.mjs +0 -76
  174. package/dist/Images-CaaPNeed.mjs.map +0 -1
  175. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  176. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  177. package/dist/PageControl.d.mts +0 -15
  178. package/dist/PageControl.mjs +0 -34
  179. package/dist/PageControl.mjs.map +0 -1
  180. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  181. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  182. package/dist/Plan-D8qQ48Ic.mjs +0 -512
  183. package/dist/Plan-D8qQ48Ic.mjs.map +0 -1
  184. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  185. package/dist/PlanData.d.mts +0 -3
  186. package/dist/PlanData.mjs +0 -268
  187. package/dist/PlanData.mjs.map +0 -1
  188. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  189. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  190. package/dist/PublicSession-Bs61Eal8.mjs +0 -5601
  191. package/dist/PublicSession-Bs61Eal8.mjs.map +0 -1
  192. package/dist/Testing.d.mts +0 -479
  193. package/dist/Testing.mjs +0 -2147
  194. package/dist/Testing.mjs.map +0 -1
  195. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  196. package/dist/Timeline.d.mts +0 -4
  197. package/dist/Timeline.mjs +0 -20
  198. package/dist/Timeline.mjs.map +0 -1
  199. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  200. package/dist/TimelineData.d.mts +0 -3
  201. package/dist/TimelineData.mjs +0 -482
  202. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,123 @@
1
+ /**
2
+ * In the page: one element's history in the change record, and its fold over a window. A fold gives
3
+ * what the element showed at the window's start and at its end, how often that changed between,
4
+ * the lowest and highest of a number that changed more than once, and when it last changed before
5
+ * the window. Only what was seen in view as it changed is told. It touches no DOM, so it also runs
6
+ * outside a page. See `names.inpage.ts` for what a page-side part may use.
7
+ */
8
+
9
+ /** What an element shows: its words, `null` while it shows nothing, `undefined` until judged. */
10
+ export type Shown = string | null | undefined;
11
+
12
+ export interface Sample {
13
+ readonly at: number;
14
+ shown: Shown;
15
+ /** Whether it was in view as the page rendered it; undefined until then. */
16
+ seen: boolean | undefined;
17
+ }
18
+
19
+ /** One element's history, oldest change first. */
20
+ export interface Track {
21
+ key: object;
22
+ readonly kind: "content" | "value" | "title";
23
+ /** What it showed before its oldest kept change; undefined when that is unknown. */
24
+ initial: Shown;
25
+ /** Before this, the record let some of its changes go. */
26
+ known: number;
27
+ readonly samples: Array<Sample>;
28
+ }
29
+
30
+ export type Kind = "text" | "appeared" | "disappeared" | "brief" | "value" | "title";
31
+
32
+ /** One element's changes over a window, before the page names the element. */
33
+ export interface Folded {
34
+ readonly track: Track;
35
+ readonly kind: Kind;
36
+ readonly startedAt: number;
37
+ readonly at: number;
38
+ readonly before: string | undefined;
39
+ readonly after: string | undefined;
40
+ readonly count: number;
41
+ readonly lowest: string | undefined;
42
+ readonly highest: string | undefined;
43
+ readonly earlier: number | undefined;
44
+ }
45
+
46
+ export const fold = () => {
47
+ /** The one number some words show, such as 61240 in "$61,240"; none for none or several. */
48
+ const number = (words: string): ReadonlyArray<number> => {
49
+ const found = words.match(/[-+−]?\d[\d,]*(?:\.\d+)?/g);
50
+
51
+ const value = Number(
52
+ found?.length === 1 ? found[0]?.replaceAll(",", "").replace("−", "-") : "",
53
+ );
54
+
55
+ return found?.length === 1 && Number.isFinite(value) ? [value] : [];
56
+ };
57
+
58
+ /** A track's changes after `since`, up to `until`, if one was seen in view. */
59
+ const over = (track: Track, since: number, until: number): Folded | undefined => {
60
+ const inside = track.samples.filter((sample) => sample.at > since && sample.at <= until);
61
+ const first = inside[0];
62
+ const last = inside.at(-1);
63
+
64
+ if (first === undefined || last === undefined || !inside.some((sample) => sample.seen === true))
65
+ return undefined;
66
+ const prior = track.samples[track.samples.indexOf(first) - 1];
67
+ // What it showed at `since` is lost when the record let go of changes after it.
68
+ const lost = track.known <= since ? track.known : undefined;
69
+
70
+ const start =
71
+ prior === undefined ? (lost === undefined ? undefined : track.initial) : prior.shown;
72
+
73
+ const words = [start, ...inside.map((sample) => sample.shown)].filter(
74
+ (each) => typeof each === "string",
75
+ );
76
+
77
+ const numbers = words.flatMap(number);
78
+ const ranged = inside.length > 1 && numbers.length === words.length;
79
+
80
+ const kind: Kind =
81
+ track.kind !== "content"
82
+ ? track.kind
83
+ : last.shown === null
84
+ ? start === null
85
+ ? "brief"
86
+ : "disappeared"
87
+ : start === null
88
+ ? "appeared"
89
+ : "text";
90
+
91
+ return {
92
+ track,
93
+ kind,
94
+ startedAt: first.at,
95
+ at: last.at,
96
+ before: kind === "disappeared" ? words.at(-1) : (start ?? undefined),
97
+ after: kind === "brief" ? words.at(-1) : (last.shown ?? undefined),
98
+ count: inside.length,
99
+ lowest: ranged ? words[numbers.indexOf(Math.min(...numbers))] : undefined,
100
+ highest: ranged ? words[numbers.indexOf(Math.max(...numbers))] : undefined,
101
+ earlier: prior?.at ?? (lost === Number.NEGATIVE_INFINITY ? undefined : lost),
102
+ };
103
+ };
104
+
105
+ /**
106
+ * How readily a track gives way when the record is full: one that holds no change first, then
107
+ * one that keeps changing, one never seen in view and one that has gone; news never does.
108
+ */
109
+ const rank = ({ samples: [only, ...more] }: Track): number =>
110
+ only === undefined
111
+ ? 4
112
+ : more.length > 0
113
+ ? 3
114
+ : only.seen === false
115
+ ? 2
116
+ : only.shown === null
117
+ ? 1
118
+ : 0;
119
+
120
+ return { number, over, rank };
121
+ };
122
+
123
+ export type Fold = ReturnType<typeof fold>;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * In the page: a document's record of what changed, element by element, within fixed bounds. It
3
+ * touches no DOM, so it also runs outside a page.
4
+ *
5
+ * - **A change** is a new state: other words, or presence gained or lost. Within one batch of
6
+ * mutations the latest state stands, so an element moved in one step has not changed.
7
+ * - **Bounds.** 256 elements, each with its latest 32 changes, kept a minute. On a busier page an
8
+ * element that keeps changing gives way first, then one never seen in view, then one that has
9
+ * gone, so news stays. What gives way, and what finds no room, is counted, and the record says it
10
+ * is whole only after the latest such loss.
11
+ *
12
+ * Each element's history is folded over a window by `fold.inpage.ts`. See `names.inpage.ts` for
13
+ * what a page-side part may use.
14
+ */
15
+ import type { Fold, Sample, Shown, Track } from "./fold.inpage.ts";
16
+
17
+ /** How much the record keeps: elements, changes of each, and for how long. */
18
+ export interface Bounds {
19
+ readonly tracks: number;
20
+ readonly samples: number;
21
+ readonly retention: number;
22
+ }
23
+
24
+ export const history = (
25
+ folding: Fold,
26
+ bounds: Bounds = { tracks: 256, samples: 32, retention: 60_000 },
27
+ ) => {
28
+ const { tracks: maxTracks, samples: maxSamples, retention } = bounds;
29
+ const tracks = new Map<object, Track>();
30
+ // Keys that gave way or found no room: their later changes are counted, not kept.
31
+ let refused = new WeakSet<object>();
32
+ // When the record let changes go, and how many, and the latest such time; keys whose tracks went.
33
+ let losses: Array<[number, number]> = [];
34
+ let lastLoss = Number.NEGATIVE_INFINITY;
35
+ const gone: Array<object> = [];
36
+ let begun: number | undefined;
37
+
38
+ // Losses at one time count together. Beyond 256 times the first noted go, so a count falls short
39
+ // rather than over.
40
+ const lose = (at: number, count: number) => {
41
+ const last = losses.at(-1);
42
+
43
+ if (count <= 0) return;
44
+ lastLoss = Math.max(lastLoss, at);
45
+ if (last?.[0] === at) last[1] += count;
46
+ else losses.push([at, count]);
47
+ if (losses.length > 256) losses.shift();
48
+ };
49
+
50
+ const latest = (track: Track) => track.samples.at(-1)?.at ?? Number.NEGATIVE_INFINITY;
51
+
52
+ const drop = (track: Track) => {
53
+ tracks.delete(track.key);
54
+ gone.push(track.key);
55
+ };
56
+
57
+ // How often the tracks' samples have changed, and when a search found none to give way, which
58
+ // holds until they change again, so a burst of newcomers pays for one search.
59
+ let version = 0;
60
+ let barren = -1;
61
+
62
+ /** What gives way for a newcomer: one that holds no change, then one that keeps changing. */
63
+ const victim = (): Track | undefined => {
64
+ let chosen: Track | undefined;
65
+ let rank = 0;
66
+
67
+ for (const track of barren === version ? [] : tracks.values()) {
68
+ const next = folding.rank(track);
69
+
70
+ if (next > rank) [chosen, rank] = [track, next];
71
+ if (rank === 4) break;
72
+ }
73
+ if (chosen === undefined) barren = version;
74
+
75
+ return chosen;
76
+ };
77
+
78
+ const evict = (track: Track) => {
79
+ drop(track);
80
+ if (track.samples.length > 0) refused.add(track.key);
81
+ for (const sample of track.samples) lose(sample.at, 1);
82
+ };
83
+
84
+ /** A key the record cannot keep: its change is counted. */
85
+ const refuse = (key: object, at: number): undefined => {
86
+ refused.add(key);
87
+ lose(at, 1);
88
+
89
+ return undefined;
90
+ };
91
+
92
+ /**
93
+ * A new state for `key` at `at`: its sample, or none when it is no change or finds no room.
94
+ * `initial` says what a key not yet kept showed before.
95
+ */
96
+ const note = (
97
+ key: object,
98
+ kind: Track["kind"],
99
+ at: number,
100
+ shown: Shown,
101
+ initial: () => Shown,
102
+ ): Sample | undefined => {
103
+ let track = tracks.get(key);
104
+
105
+ if (track === undefined) {
106
+ const full = !refused.has(key) && tracks.size >= maxTracks;
107
+ const out = full ? victim() : undefined;
108
+
109
+ if (refused.has(key) || (full && out === undefined)) return refuse(key, at);
110
+ const before = initial();
111
+
112
+ if (shown !== undefined && shown === before) return undefined;
113
+ if (out !== undefined) evict(out);
114
+ track = { key, kind, initial: before, known: Number.NEGATIVE_INFINITY, samples: [] };
115
+ tracks.set(key, track);
116
+ }
117
+ const { samples } = track;
118
+
119
+ version++;
120
+
121
+ if (samples.at(-1)?.at === at) samples.pop();
122
+ const previous = samples.length === 0 ? track.initial : samples.at(-1)?.shown;
123
+
124
+ if (shown !== undefined && shown === previous) return undefined;
125
+ const oldest = samples.length >= maxSamples ? samples.shift() : undefined;
126
+
127
+ if (oldest !== undefined) {
128
+ track.initial = oldest.shown;
129
+ track.known = oldest.at;
130
+ lose(oldest.at, 1);
131
+ }
132
+ const sample: Sample = { at, shown, seen: undefined };
133
+
134
+ samples.push(sample);
135
+
136
+ return sample;
137
+ };
138
+
139
+ /** Judge a sample as the page rendered it; a state that repeats the one before is no change. */
140
+ const settle = (key: object, sample: Sample, shown: Shown, seen: boolean) => {
141
+ const track = tracks.get(key);
142
+
143
+ // `null` is a state, what shows nothing, so only `undefined` is unjudged.
144
+ version++;
145
+ if (sample.shown === undefined) sample.shown = shown;
146
+ if (sample.seen === undefined) sample.seen = seen;
147
+ if (track === undefined) return;
148
+ let previous = track.initial;
149
+
150
+ const changes = track.samples.filter((each) => {
151
+ const same = each.shown !== undefined && each.shown === previous;
152
+
153
+ if (!same) previous = each.shown;
154
+
155
+ return !same;
156
+ });
157
+
158
+ track.samples.splice(0, track.samples.length, ...changes);
159
+ };
160
+
161
+ /**
162
+ * The window after `since`, up to `until`: where the record is whole, how many changes in it the
163
+ * record let go, and each element's changes, oldest first by when they began.
164
+ */
165
+ const read = (since: number, until: number, now: number) => {
166
+ for (const track of tracks.values()) if (latest(track) < now - retention) drop(track);
167
+
168
+ return {
169
+ from: Math.min(until, Math.max(begun ?? now, now - retention, lastLoss)),
170
+ dropped: losses.reduce(
171
+ (sum, [at, count]) => (at > since && at <= until ? sum + count : sum),
172
+ 0,
173
+ ),
174
+ folded: [...tracks.values()]
175
+ .flatMap((track) => folding.over(track, since, until) ?? [])
176
+ .toSorted((left, right) => left.startedAt - right.startedAt),
177
+ };
178
+ };
179
+
180
+ /** When something in view last changed, or something not yet judged; for a wait for stillness. */
181
+ const changing = (): number =>
182
+ Math.max(
183
+ Number.NEGATIVE_INFINITY,
184
+ ...Array.from(
185
+ tracks.values(),
186
+ (track) =>
187
+ track.samples.findLast((sample) => sample.seen !== false)?.at ?? Number.NEGATIVE_INFINITY,
188
+ ),
189
+ );
190
+
191
+ return {
192
+ begin: (at: number) => {
193
+ begun = at;
194
+ },
195
+ begun: () => begun !== undefined,
196
+ clear: () => {
197
+ for (const track of tracks.values()) drop(track);
198
+ refused = new WeakSet();
199
+ losses = [];
200
+ lastLoss = Number.NEGATIVE_INFINITY;
201
+ begun = undefined;
202
+ },
203
+ changing,
204
+ /** Keys whose tracks went since this was last asked, for the recorder to stop watching. */
205
+ forgotten: (): ReadonlyArray<object> => gone.splice(0),
206
+ has: (key: object) => tracks.has(key),
207
+ /** Whether the record has let `key` go: its later changes are counted, never kept. */
208
+ refuses: (key: object) => refused.has(key),
209
+ lose,
210
+ note,
211
+ read,
212
+ /** Each element's history, and when the record let changes go, for a read to judge causes. */
213
+ recent: () => ({ tracks: [...tracks.values()], losses: losses.map(([at]) => at) }),
214
+ /** A track moves to the element that took its key's place, as a re-rendered price does. */
215
+ rekey: (from: object, to: object): boolean => {
216
+ const track = tracks.get(from);
217
+
218
+ if (track === undefined || tracks.has(to)) return false;
219
+ tracks.delete(from);
220
+ track.key = to;
221
+ tracks.set(to, track);
222
+
223
+ return true;
224
+ },
225
+ settle,
226
+ };
227
+ };
228
+
229
+ export type History = ReturnType<typeof history>;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * In the page: what one batch of mutations changed, marked for the record as cheaply as it can be.
3
+ *
4
+ * - **An element whose own text changed.** One that holds only text, as most prices and labels do,
5
+ * is read whole at once, since that text is all it shows, and the batch is undone to say what it
6
+ * said before; any other is read as it renders. Once followed, a text-only element's changes are
7
+ * taken as its text's, with no look at the nodes that came and went.
8
+ * - **An element added** is read as it renders; one that moved in the same batch did not arrive.
9
+ * What came with text its parent gained is part of the parent's change, and what takes a removed
10
+ * element's place, as a re-rendered price does, takes its history. **An element removed** is
11
+ * noted only if the record follows it, so a removal it never saw is not told.
12
+ * - **`hidden`, `open`, `aria-hidden`, `class` or `style`** may show or hide an element. The old
13
+ * value says whether it was hidden before, except for a class, where only the element's state as
14
+ * the record last saw it can: what it follows, what alerts, status lines, dialogs and popovers
15
+ * showed when it started, and what an earlier class change showed. A style that neither hid it
16
+ * nor hides it, such as a transform, shows nothing new.
17
+ * - **The document's title.**
18
+ *
19
+ * See `names.inpage.ts` for what a page-side part may use.
20
+ */
21
+ import type { Names } from "../reading/names.inpage.ts";
22
+ import type { Shown } from "./fold.inpage.ts";
23
+ import type { History } from "./history.inpage.ts";
24
+ import type { Sight } from "./sight.inpage.ts";
25
+
26
+ interface Batch {
27
+ readonly at: number;
28
+ readonly owners: Set<Element>;
29
+ readonly arrived: Set<Node>;
30
+ readonly left: Set<Node>;
31
+ }
32
+
33
+ export const marks = (names: Names, kept: History, seeing: Sight) => {
34
+ const { clean, isElement, isHtml } = names;
35
+ const unwritten = new Set("SCRIPT STYLE NOSCRIPT TEMPLATE TITLE".split(" "));
36
+ const hiding = /display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d])/i;
37
+ // Where removed elements were, which gives their context, and the title as last seen.
38
+ let places = new WeakMap<object, Element>();
39
+ let title = "";
40
+
41
+ const words = (text: string | null | undefined): Shown => clean(text, 200) || null;
42
+
43
+ const content = (element: Element, at: number, shown: Shown, initial: () => Shown) => {
44
+ const sample = kept.note(element, "content", at, shown, initial);
45
+
46
+ if (sample !== undefined) seeing.look(element, sample);
47
+ };
48
+
49
+ /** What a text-only element said before this batch: its text with the batch undone. */
50
+ const before = (leaf: Element, records: ReadonlyArray<MutationRecord>): Shown => {
51
+ let nodes: Array<Node> = Array.from(leaf.childNodes);
52
+ const old = new Map<Node, string>();
53
+
54
+ for (const record of records.toReversed())
55
+ if (record.type === "characterData") old.set(record.target, record.oldValue ?? "");
56
+ else {
57
+ const added = new Set(Array.from(record.addedNodes));
58
+
59
+ nodes = nodes.filter((node) => !added.has(node));
60
+ const next = record.nextSibling === null ? -1 : nodes.indexOf(record.nextSibling);
61
+
62
+ nodes.splice(next === -1 ? nodes.length : next, 0, ...Array.from(record.removedNodes));
63
+ }
64
+
65
+ return words(nodes.map((node) => old.get(node) ?? node.textContent).join(""));
66
+ };
67
+
68
+ const attribute = (
69
+ element: Element,
70
+ { attributeName: name, oldValue: old }: MutationRecord,
71
+ at: number,
72
+ ) => {
73
+ // Whether the old value hid it; a class can say only through the page's style.
74
+ const was =
75
+ name === "hidden"
76
+ ? old !== null
77
+ : name === "aria-hidden"
78
+ ? old === "true"
79
+ : name === "open"
80
+ ? element.tagName === "DIALOG" && old === null
81
+ : name === "style" && hiding.test(old ?? "");
82
+
83
+ if (name === "style" && !was && !hiding.test(element.getAttribute("style") ?? "")) return;
84
+ const state = seeing.state(element);
85
+
86
+ if (!kept.has(element) && !was && state === undefined && name !== "open")
87
+ return seeing.glance(element);
88
+ content(element, at, undefined, () =>
89
+ was || state === false
90
+ ? null
91
+ : name === "open" && old === null
92
+ ? words(element.querySelector(":scope > summary")?.textContent)
93
+ : words(element.textContent),
94
+ );
95
+ };
96
+
97
+ /** What one mutation added to and removed from `target`. */
98
+ const nodes = (change: MutationRecord, target: Element, batch: Batch) => {
99
+ const added = Array.from(change.addedNodes).slice(0, 64);
100
+ const removed = Array.from(change.removedNodes).slice(0, 64);
101
+
102
+ const text = [...added, ...removed].some(
103
+ (node) => !isElement(node) && /\S/.test(node.textContent ?? ""),
104
+ );
105
+
106
+ kept.lose(batch.at, Math.max(change.addedNodes.length, change.removedNodes.length) - 64);
107
+ if (text) batch.owners.add(target);
108
+ removed.forEach((gone, index) => {
109
+ const node = added[index];
110
+
111
+ if (!isElement(gone)) return;
112
+ batch.left.add(gone);
113
+ if (node !== undefined && isElement(node) && kept.rekey(gone, node)) seeing.unwatch(gone);
114
+ else if (kept.has(gone)) {
115
+ places.set(gone, target);
116
+ const sample = kept.note(gone, "content", batch.at, null, () => undefined);
117
+
118
+ if (sample !== undefined) sample.seen = seeing.viewed(gone);
119
+ seeing.unwatch(gone);
120
+ }
121
+ });
122
+ for (const node of added)
123
+ if (
124
+ isElement(node) &&
125
+ isHtml(node) &&
126
+ !unwritten.has(node.tagName) &&
127
+ !node.isContentEditable
128
+ )
129
+ if (kept.has(node) || (!text && !batch.left.has(node))) {
130
+ batch.arrived.add(node);
131
+ content(node, batch.at, undefined, () => null);
132
+ }
133
+ };
134
+
135
+ /** Whether `element` is inside something this batch added, which tells it with its own words. */
136
+ const inside = (element: Element, arrived: ReadonlySet<Node>): boolean => {
137
+ for (let up = element.parentElement; arrived.size > 0 && up !== null; up = up.parentElement)
138
+ if (arrived.has(up)) return true;
139
+
140
+ return false;
141
+ };
142
+
143
+ const ownerOf = (change: MutationRecord): Node | null =>
144
+ change.type === "characterData" ? change.target.parentElement : change.target;
145
+
146
+ /** Mark what one batch of mutations changed, at `at`. */
147
+ const mark = (records: ReadonlyArray<MutationRecord>, at: number) => {
148
+ const batch: Batch = { at, owners: new Set(), arrived: new Set(), left: new Set() };
149
+ let byOwner: Map<Node | null, Array<MutationRecord>> | undefined;
150
+
151
+ for (const change of records) {
152
+ const target = ownerOf(change);
153
+
154
+ if (target === null || !isElement(target) || inside(target, batch.arrived)) continue;
155
+ // A text-only element the record follows, or has let go, changes only its text, as a
156
+ // ticking price does, so its nodes need no look: each would cost this world a wrapper.
157
+ const known = kept.has(target) || kept.refuses(target);
158
+
159
+ if (change.type !== "attributes")
160
+ if (change.type === "characterData" || (target.childElementCount === 0 && known))
161
+ batch.owners.add(target);
162
+ else nodes(change, target, batch);
163
+ else if (isHtml(target) && target !== document.body && target !== document.documentElement)
164
+ attribute(target, change, at);
165
+ }
166
+ for (const owner of batch.owners) {
167
+ const leaf = owner.childElementCount === 0;
168
+
169
+ // What the record let go is counted, with no word of it read.
170
+ if (kept.refuses(owner)) kept.lose(at, 1);
171
+ if (kept.refuses(owner) || !isHtml(owner) || unwritten.has(owner.tagName)) continue;
172
+ // What is typed into an editable region is a field's value, told masked as one.
173
+ if (owner === document.body || owner.isContentEditable) continue;
174
+ if (batch.arrived.has(owner) || inside(owner, batch.arrived)) continue;
175
+ content(owner, at, leaf ? words(owner.textContent) : undefined, () => {
176
+ byOwner ??= Map.groupBy(records, ownerOf);
177
+
178
+ return leaf ? before(owner, byOwner.get(owner) ?? []) : undefined;
179
+ });
180
+ }
181
+ if (document.title === title) return;
182
+ const previous = words(title);
183
+
184
+ const sample = kept.note(
185
+ document,
186
+ "title",
187
+ at,
188
+ words((title = document.title)),
189
+ () => previous,
190
+ );
191
+
192
+ if (sample !== undefined) sample.seen = true;
193
+ };
194
+
195
+ return {
196
+ mark,
197
+ /** Where a removed element was, which gives its context. */
198
+ placeOf: (key: object): Element | undefined => places.get(key),
199
+ /** Start afresh, from the document's title now. */
200
+ reset: () => {
201
+ places = new WeakMap();
202
+ title = document.title;
203
+ },
204
+ };
205
+ };
206
+
207
+ export type Marks = ReturnType<typeof marks>;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * In the page: the change recorder. It starts at a read of the page's changes, and from then on at
3
+ * the start of each document the page loads, once that document is parsed; it stops in a document
4
+ * nobody has read for two minutes. While it runs, a MutationObserver hands each batch to
5
+ * `marks.inpage.ts`, and an IntersectionObserver judges as the page renders whether what was marked
6
+ * was in view (`sight.inpage.ts`). A field reports its value as it is edited, a secret one only as
7
+ * `••••`, and trusted pointer and key input is kept, for a read to name causes by. Nothing unmarked
8
+ * is read: no walk, no words, no layout. See `names.inpage.ts` for what a page-side part may use.
9
+ */
10
+ import type { Names } from "../reading/names.inpage.ts";
11
+ import type { Texts } from "../reading/text.inpage.ts";
12
+ import type { History } from "./history.inpage.ts";
13
+ import type { Marks } from "./marks.inpage.ts";
14
+ import type { Sight } from "./sight.inpage.ts";
15
+
16
+ export const record = (
17
+ names: Names,
18
+ texts: Texts,
19
+ kept: History,
20
+ seeing: Sight,
21
+ marking: Marks,
22
+ ) => {
23
+ const { isElement, isInput } = names;
24
+ const epoch = () => performance.timeOrigin + performance.now();
25
+
26
+ const declared =
27
+ "[role=alert],[role=status],[role=log],[role=dialog],[role=alertdialog],[aria-live],dialog,[popover]";
28
+
29
+ let running = false;
30
+ let lastRead = 0;
31
+ // What fields held as they took focus, and the latest trusted input, newest last.
32
+ let focused = new WeakMap<Element, string>();
33
+ const inputs: Array<{ readonly at: number; readonly target: Element }> = [];
34
+
35
+ const targetOf = (event: Event): Element | undefined => {
36
+ const target = event.composedPath()[0];
37
+
38
+ return target instanceof Node && isElement(target) ? target : undefined;
39
+ };
40
+
41
+ /** A field's value as the record keeps it: a secret one's as `••••`, a box's checked or not. */
42
+ const valueOf = (field: Element): string | undefined =>
43
+ isInput(field) && (field.type === "checkbox" || field.type === "radio")
44
+ ? field.checked
45
+ ? "checked"
46
+ : "not checked"
47
+ : texts.shown(field, true);
48
+
49
+ const onFocus = (event: Event) => {
50
+ const field = targetOf(event);
51
+ const value = field === undefined ? undefined : valueOf(field);
52
+
53
+ if (field !== undefined && value !== undefined) focused.set(field, value);
54
+ };
55
+
56
+ const onValue = (event: Event) => {
57
+ const field = targetOf(event);
58
+ const value = field === undefined ? undefined : valueOf(field);
59
+
60
+ if (field === undefined || value === undefined) return;
61
+ const sample = kept.note(field, "value", epoch(), value, () => focused.get(field) ?? null);
62
+
63
+ if (sample !== undefined) seeing.look(field, sample);
64
+ };
65
+
66
+ const onInput = (event: Event) => {
67
+ const target = targetOf(event);
68
+
69
+ if (!event.isTrusted || target === undefined) return;
70
+ inputs.push({ at: performance.timeOrigin + event.timeStamp, target });
71
+ if (inputs.length > 8) inputs.shift();
72
+ };
73
+
74
+ const listeners = [
75
+ ["focusin", onFocus],
76
+ ["input", onValue],
77
+ ["change", onValue],
78
+ ["pointerdown", onInput],
79
+ ["keydown", onInput],
80
+ ] as const;
81
+
82
+ const options = { capture: true, passive: true };
83
+
84
+ const stop = () => {
85
+ running = false;
86
+ observer.disconnect();
87
+ for (const [type, listener] of listeners) document.removeEventListener(type, listener, options);
88
+ seeing.clear();
89
+ kept.clear();
90
+ kept.forgotten();
91
+ inputs.length = 0;
92
+ focused = new WeakMap();
93
+ };
94
+
95
+ const observer = new MutationObserver((records) => {
96
+ const at = epoch();
97
+
98
+ if (at - lastRead > 120_000) stop();
99
+ else marking.mark(records, at);
100
+ });
101
+
102
+ const begin = () => {
103
+ if (!running || kept.begun()) return;
104
+ observer.observe(document, {
105
+ subtree: true,
106
+ childList: true,
107
+ characterData: true,
108
+ characterDataOldValue: true,
109
+ attributeFilter: ["hidden", "open", "aria-hidden", "class", "style"],
110
+ attributeOldValue: true,
111
+ });
112
+ for (const [type, listener] of listeners) document.addEventListener(type, listener, options);
113
+ marking.reset();
114
+ for (const element of Array.from(document.querySelectorAll(declared)).slice(0, 64))
115
+ seeing.glance(element, true);
116
+ kept.begin(epoch());
117
+ };
118
+
119
+ return {
120
+ /** Record from now, once the document is parsed; each read keeps the record going. */
121
+ start: () => {
122
+ lastRead = epoch();
123
+ if (running) return;
124
+ running = true;
125
+ if (document.readyState === "loading")
126
+ document.addEventListener("DOMContentLoaded", begin, { once: true });
127
+ else begin();
128
+ },
129
+ inputs: (): ReadonlyArray<{ readonly at: number; readonly target: Element }> => inputs,
130
+ };
131
+ };
132
+
133
+ export type Recorder = ReturnType<typeof record>;