@frixaco/anitrack 0.0.7 → 0.0.9

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 (3) hide show
  1. package/index.ts +358 -108
  2. package/package.json +11 -7
  3. package/progress-bar.ts +100 -0
package/index.ts CHANGED
@@ -2,15 +2,14 @@
2
2
  // TORRENT SEARCH APP
3
3
  // - Search input with loading bar
4
4
  // - Virtual windowing results list
5
- // - Keyboard navigation (j/k/h/l)
5
+ // - Keyboard navigation (j/k/h/l/tab)
6
6
 
7
7
  import { existsSync } from "fs";
8
8
  import { COLORS } from "./colors";
9
- import { Button, Column, Input, Row, run, onKey } from "@frixaco/letui";
10
- import { $, ff, whenSettled } from "@frixaco/letui";
9
+ import { Button, Column, Input, Row, Text, run, onKey } from "@frixaco/letui";
10
+ import { $, ff } from "@frixaco/letui";
11
11
  import { LoadingBar } from "./progress-bar";
12
12
 
13
- // --- Types ---
14
13
  type ScrapeResultItem = {
15
14
  title: string;
16
15
  size: string;
@@ -18,173 +17,390 @@ type ScrapeResultItem = {
18
17
  magnet: string;
19
18
  };
20
19
 
21
- type ScrapeResult = {
22
- results: ScrapeResultItem[];
23
- };
24
-
25
- type TorrentDetails = {
26
- id: number;
27
- info_hash: string;
28
- name: string;
29
- files: unknown[];
30
- };
31
-
32
- type TorrentResponse = {
33
- id: number;
34
- details: TorrentDetails;
35
- };
36
-
37
- // --- State ---
38
20
  const results = $<ScrapeResultItem[]>([]);
39
21
  const loading = $(false);
40
22
  const selectedIndex = $(0);
23
+ const focusTarget = $<"input" | "results">("input");
24
+ const MPV_SOCKET_WAIT_MS = 5000;
41
25
 
42
- // --- Loading Bars ---
43
26
  const loadingBar = LoadingBar({
44
- dotColor: COLORS.default.green,
45
- trackColor: COLORS.default.bg_alt,
27
+ dotColor: COLORS.default.cyan,
28
+ trackColor: COLORS.default.bg_highlight,
46
29
  });
47
30
 
48
- // --- API ---
31
+ function toScrapeResults(payload: unknown): ScrapeResultItem[] {
32
+ const rawResults =
33
+ payload && typeof payload === "object" ? (payload as any).results : undefined;
34
+ if (!Array.isArray(rawResults)) return [];
35
+
36
+ const normalized: ScrapeResultItem[] = [];
37
+ for (const item of rawResults) {
38
+ if (!item || typeof item !== "object") continue;
39
+
40
+ normalized.push({
41
+ title: String((item as any).title ?? ""),
42
+ size: String((item as any).size ?? ""),
43
+ date: String((item as any).date ?? ""),
44
+ magnet: String((item as any).magnet ?? ""),
45
+ });
46
+ }
47
+ return normalized;
48
+ }
49
+
50
+ function toStreamTarget(payload: unknown): { infoHash: string; fileIndex: number } | null {
51
+ if (!payload || typeof payload !== "object") return null;
52
+ const details = (payload as any).details;
53
+ if (!details || typeof details !== "object") return null;
54
+
55
+ const infoHash = details.info_hash;
56
+ const files = details.files;
57
+
58
+ if (typeof infoHash !== "string" || !Array.isArray(files) || files.length === 0) {
59
+ return null;
60
+ }
61
+
62
+ return {
63
+ infoHash,
64
+ fileIndex: files.length - 1,
65
+ };
66
+ }
67
+
68
+ function resetResultsToInput(): void {
69
+ results([]);
70
+ selectedIndex(0);
71
+ focusTarget("input");
72
+ }
73
+
49
74
  async function fetchResults(query: string) {
50
75
  loading(true);
51
76
  loadingBar.start();
52
77
 
53
- const response = await fetch(
54
- `https://scrape.anitrack.frixaco.com/scrape?q=${query}`,
55
- );
56
- const data = (await response.json()) as ScrapeResult;
57
-
58
- results(data.results);
59
- selectedIndex(0);
60
- loading(false);
61
- loadingBar.stop();
78
+ try {
79
+ const response = await fetch(
80
+ `https://scrape.anitrack.frixaco.com/scrape?q=${encodeURIComponent(query)}`,
81
+ );
82
+ if (!response.ok) {
83
+ throw new Error(`Search failed with status ${response.status}`);
84
+ }
85
+ const payload = await response.json();
86
+ const parsedResults = toScrapeResults(payload);
87
+ results(parsedResults);
88
+ selectedIndex(0);
89
+ focusTarget(parsedResults.length > 0 ? "results" : "input");
90
+ } catch {
91
+ resetResultsToInput();
92
+ } finally {
93
+ loading(false);
94
+ loadingBar.stop();
95
+ }
62
96
  }
63
97
 
64
98
  async function streamResult(magnet: string) {
65
99
  loadingBar.start();
66
100
 
67
- const response = await fetch("https://rqbit.anitrack.frixaco.com/torrents", {
68
- method: "post",
69
- body: magnet,
70
- });
71
- const data = (await response.json()) as TorrentResponse;
72
- const streamUrl = `https://rqbit.anitrack.frixaco.com/torrents/${data.details.info_hash}/stream/${
73
- data.details.files.length - 1
74
- }`;
75
-
76
- const ipcPath = `/tmp/mpv-socket-${Date.now()}`;
77
- Bun.spawn({
78
- cmd: ["mpv", `--input-ipc-server=${ipcPath}`, streamUrl],
79
- stdout: "ignore",
80
- stderr: "ignore",
81
- });
101
+ try {
102
+ const response = await fetch("https://rqbit.anitrack.frixaco.com/torrents", {
103
+ method: "post",
104
+ body: magnet,
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(`Stream failed with status ${response.status}`);
108
+ }
82
109
 
83
- // Poll until socket exists (mpv fully initialized)
84
- while (!existsSync(ipcPath)) {
85
- await Bun.sleep(50);
86
- }
110
+ const payload = await response.json();
111
+ const target = toStreamTarget(payload);
112
+ if (!target) return;
87
113
 
88
- loadingBar.stop();
89
- }
114
+ const streamUrl = `https://rqbit.anitrack.frixaco.com/torrents/${target.infoHash}/stream/${target.fileIndex}`;
90
115
 
91
- // --- Styles ---
92
- const borderStyle = {
93
- color: COLORS.default.fg,
94
- style: "square" as const,
95
- };
116
+ const ipcPath = `/tmp/mpv-socket-${Date.now()}`;
117
+ Bun.spawn({
118
+ cmd: ["mpv", `--input-ipc-server=${ipcPath}`, streamUrl],
119
+ stdout: "ignore",
120
+ stderr: "ignore",
121
+ });
122
+
123
+ const deadline = Date.now() + MPV_SOCKET_WAIT_MS;
124
+ while (!existsSync(ipcPath) && Date.now() < deadline) {
125
+ await Bun.sleep(50);
126
+ }
127
+ } catch {
128
+ // Keep UI alive even when backend endpoints are unavailable.
129
+ } finally {
130
+ loadingBar.stop();
131
+ }
132
+ }
96
133
 
97
134
  const focusedBorderStyle = {
98
135
  color: COLORS.default.green,
99
- style: "square" as const,
136
+ style: "rounded" as const,
100
137
  };
101
138
 
102
- // --- Nodes ---
139
+ const titleLine = Text({
140
+ text: "ANITRACK // TORRENT SEARCH + STREAM STAGING",
141
+ foreground: COLORS.default.cyan,
142
+ });
143
+
144
+ const subtitleLine = Text({
145
+ text: "keyboard-first flow with wrapped panels and live result status",
146
+ foreground: COLORS.default.grey,
147
+ });
148
+
149
+ const searchStatusLine = Text({
150
+ text: "",
151
+ foreground: COLORS.default.fg,
152
+ });
153
+
154
+ const resultsSummaryLine = Text({
155
+ text: "",
156
+ foreground: COLORS.default.fg,
157
+ });
158
+
159
+ const helpLine = Text({
160
+ text: "/ focus input Tab switch pane j/k navigate Enter stream q quit",
161
+ foreground: COLORS.default.grey,
162
+ });
163
+
103
164
  const searchInput = Input({
104
165
  placeholder: "Search torrents...",
105
- border: borderStyle,
166
+ border: undefined,
106
167
  padding: "1 0",
107
- onSubmit: (val) => fetchResults(val),
108
- onFocus: (self) => self.setStyle({ border: focusedBorderStyle }),
109
- onBlur: (self) => self.setStyle({ border: borderStyle }),
168
+ foreground: COLORS.default.fg,
169
+ height: 3,
170
+ minWidth: 28,
171
+ onSubmit: (val) => {
172
+ const query = val.trim();
173
+ if (query.length === 0) {
174
+ resetResultsToInput();
175
+ return;
176
+ }
177
+ fetchResults(query);
178
+ },
179
+ onFocus: (self) => {
180
+ focusTarget("input");
181
+ self.setStyle({ border: focusedBorderStyle });
182
+ },
183
+ onBlur: (self) => self.setStyle({ border: undefined }),
110
184
  });
111
- whenSettled(() => searchInput.focus());
112
185
 
113
- const loadingBars = Row({ flexGrow: 1 }, [loadingBar.node]);
186
+ const loadingBars = Row({}, [loadingBar.node]);
187
+ const searchInputRow = Row({ minHeight: 3, alignItems: "stretch" }, [searchInput]);
188
+
189
+ const resultsList = Column({ padding: "1 0", flexGrow: 1 }, []);
190
+
191
+ const searchPanel = Column(
192
+ {
193
+ gap: 1,
194
+ padding: "0 0",
195
+ flexGrow: 1,
196
+ flexBasis: 32,
197
+ minWidth: 28,
198
+ },
199
+ [titleLine, subtitleLine, searchInputRow, loadingBars, searchStatusLine],
200
+ );
201
+
202
+ const resultsPanel = Column(
203
+ {
204
+ gap: 1,
205
+ padding: "0 0",
206
+ flexGrow: 2,
207
+ flexBasis: 50,
208
+ minWidth: 40,
209
+ minHeight: 14,
210
+ },
211
+ [resultsSummaryLine, resultsList, helpLine],
212
+ );
213
+
214
+ const root = Column(
215
+ {
216
+ flexGrow: 1,
217
+ gap: 1,
218
+ padding: "1 1",
219
+ },
220
+ [
221
+ Row(
222
+ {
223
+ flexGrow: 1,
224
+ gap: 1,
225
+ flexWrap: "wrap",
226
+ alignItems: "stretch",
227
+ },
228
+ [searchPanel, resultsPanel],
229
+ ),
230
+ ],
231
+ );
114
232
 
115
- const resultsList = Column({ gap: 1, padding: "1 0", flexGrow: 1 }, []);
116
-
117
- const root = Column({ border: borderStyle, gap: 1, padding: "1 0" }, [
118
- Column({ padding: "1 0" }, [searchInput, loadingBars]),
119
- resultsList,
120
- ]);
121
-
122
- // --- Keep track of result buttons for focus management ---
123
233
  let resultButtons: ReturnType<typeof Button>[] = [];
234
+ let visibleStartIndex = 0;
235
+ let lastResultsSnapshot: ScrapeResultItem[] | null = null;
236
+ const resultHeights = new Map<number, number>();
124
237
 
125
- // --- Reactive effects ---
238
+ function resultLabel(item: ScrapeResultItem, isActive: boolean): string {
239
+ return `${isActive ? "▶ " : " "}${item.title}`;
240
+ }
126
241
 
127
- // Update results list with virtual windowing
128
242
  ff(() => {
129
243
  const all = results();
130
244
  const selected = selectedIndex();
245
+ const isLoading = loading();
246
+ const activePane = focusTarget();
247
+
248
+ titleLine.setStyle({
249
+ foreground:
250
+ activePane === "input" ? COLORS.default.green : COLORS.default.cyan,
251
+ });
252
+ searchStatusLine.setText(
253
+ isLoading
254
+ ? "status: searching remote scrape endpoint..."
255
+ : activePane === "input"
256
+ ? "status: input armed; submit query to populate results"
257
+ : "status: results active; input still available with /",
258
+ );
259
+ searchStatusLine.setStyle({
260
+ foreground:
261
+ isLoading
262
+ ? COLORS.default.yellow
263
+ : activePane === "input"
264
+ ? COLORS.default.green
265
+ : COLORS.default.grey,
266
+ });
267
+ resultsSummaryLine.setText(
268
+ all.length === 0
269
+ ? "results: empty"
270
+ : `results: ${all.length} items selected: ${Math.min(all.length, selected + 1)}/${all.length}`,
271
+ );
272
+ resultsSummaryLine.setStyle({
273
+ foreground:
274
+ all.length === 0
275
+ ? COLORS.default.grey
276
+ : activePane === "results"
277
+ ? COLORS.default.green
278
+ : COLORS.default.cyan,
279
+ });
280
+
281
+ if (all !== lastResultsSnapshot) {
282
+ resultHeights.clear();
283
+ lastResultsSnapshot = all;
284
+ }
285
+
286
+ for (let i = 0; i < resultButtons.length; i++) {
287
+ const measuredHeight = Math.floor(resultButtons[i]?.frameHeight() ?? 0);
288
+ if (measuredHeight > 0) {
289
+ resultHeights.set(visibleStartIndex + i, measuredHeight);
290
+ }
291
+ }
131
292
 
132
293
  if (all.length === 0) {
294
+ visibleStartIndex = 0;
295
+ resultButtons = [];
133
296
  resultsList.setChildren?.([]);
297
+ if (focusTarget() === "results") {
298
+ focusInput();
299
+ }
134
300
  return;
135
301
  }
136
302
 
137
- // Use actual computed frame height from Taffy
138
- const availableHeight = resultsList.frameHeight();
303
+ const clampedSelected = Math.max(0, Math.min(selected, all.length - 1));
304
+ if (clampedSelected !== selected) {
305
+ selectedIndex(clampedSelected);
306
+ return;
307
+ }
139
308
 
140
- // Each item: border(2) + text(1) = 3, plus gap(1) between items
141
- const itemHeight = 3;
142
- const visibleCount = Math.max(1, Math.floor(availableHeight / itemHeight));
309
+ const availableHeight = Math.max(1, Math.floor(resultsList.frameHeight()));
310
+ const fallbackHeight = Math.max(
311
+ 1,
312
+ Math.floor(
313
+ resultHeights.get(selected) ??
314
+ resultButtons[0]?.frameHeight() ??
315
+ searchInput.frameHeight(),
316
+ ),
317
+ );
318
+ const itemHeightAt = (index: number) =>
319
+ Math.max(1, Math.floor(resultHeights.get(index) ?? fallbackHeight));
320
+
321
+ let start = selected;
322
+ let usedHeight = 0;
323
+ while (start >= 0) {
324
+ const height = itemHeightAt(start);
325
+ if (usedHeight + height > availableHeight) {
326
+ if (usedHeight > 0) {
327
+ start += 1;
328
+ }
329
+ break;
330
+ }
331
+ usedHeight += height;
332
+ if (start === 0) break;
333
+ start -= 1;
334
+ }
335
+ start = Math.max(0, start);
336
+
337
+ let end = start;
338
+ let windowHeight = 0;
339
+ while (end < all.length) {
340
+ const height = itemHeightAt(end);
341
+ if (windowHeight + height > availableHeight) {
342
+ if (end === start) {
343
+ end += 1;
344
+ }
345
+ break;
346
+ }
347
+ windowHeight += height;
348
+ end += 1;
349
+ }
143
350
 
144
- // Calculate window with selection at bottom (scroll only when needed)
145
- let start = selected - visibleCount + 1;
146
- start = Math.max(0, Math.min(start, all.length - visibleCount));
147
- const end = Math.min(start + visibleCount, all.length);
148
351
  const visible = all.slice(start, end);
352
+ visibleStartIndex = start;
149
353
 
150
354
  resultButtons = visible.map((item, i) => {
151
355
  const globalIdx = start + i;
152
356
  const isActive = globalIdx === selected;
153
357
  return Button({
154
- text: `${isActive ? "▶ " : " "}${item.title}`,
155
- border: isActive ? focusedBorderStyle : borderStyle,
156
- padding: "1 0",
157
- onClick: () => streamResult(item.magnet),
358
+ text: resultLabel(item, isActive),
359
+ border: undefined,
360
+ foreground: isActive ? COLORS.default.green : COLORS.default.fg,
361
+ onFocus: () => {
362
+ focusTarget("results");
363
+ if (selectedIndex() !== globalIdx) {
364
+ selectedIndex(globalIdx);
365
+ }
366
+ },
367
+ onClick: () => {
368
+ focusTarget("results");
369
+ selectedIndex(globalIdx);
370
+ streamResult(item.magnet);
371
+ },
158
372
  });
159
373
  });
160
374
 
161
375
  resultsList.setChildren?.(resultButtons);
162
376
 
163
- // Focus the selected button
164
- const selectedVisibleIndex = selected - start;
165
- if (resultButtons[selectedVisibleIndex]) {
166
- resultButtons[selectedVisibleIndex].focus();
377
+ if (focusTarget() === "results") {
378
+ focusSelectedResult();
167
379
  }
168
380
  });
169
381
 
170
- // --- Keyboard navigation ---
171
- onKey("/", () => searchInput.focus());
172
-
382
+ onKey("/", () => focusInput());
383
+ onKey("\t", () => toggleFocusTarget());
384
+ onKey("\x1b[Z", () => toggleFocusTarget());
173
385
  onKey("j", () => selectNext());
174
- onKey("\x1b[B", () => selectNext()); // Arrow Down
175
-
386
+ onKey("\x1b[B", () => selectNext());
176
387
  onKey("k", () => selectPrev());
177
- onKey("\x1b[A", () => selectPrev()); // Arrow Up
178
-
388
+ onKey("\x1b[A", () => selectPrev());
179
389
  onKey("l", () => selectLast());
180
- onKey("\x1b[C", () => selectLast()); // Arrow Right - jump to end
181
-
390
+ onKey("\x1b[C", () => selectLast());
182
391
  onKey("h", () => selectFirst());
183
- onKey("\x1b[D", () => selectFirst()); // Arrow Left - jump to start
392
+ onKey("\x1b[D", () => selectFirst());
393
+
394
+ const app = run(root, {
395
+ debug: true,
396
+ });
184
397
 
185
- onKey("q", () => app.quit());
398
+ onKey("q", () => {
399
+ app.quit();
400
+ });
186
401
 
187
402
  function selectNext() {
403
+ if (focusTarget() !== "results") return;
188
404
  const max = results().length - 1;
189
405
  if (selectedIndex() < max) {
190
406
  selectedIndex(selectedIndex() + 1);
@@ -192,21 +408,55 @@ function selectNext() {
192
408
  }
193
409
 
194
410
  function selectPrev() {
411
+ if (focusTarget() !== "results") return;
195
412
  if (selectedIndex() > 0) {
196
413
  selectedIndex(selectedIndex() - 1);
197
414
  }
198
415
  }
199
416
 
200
417
  function selectFirst() {
418
+ if (focusTarget() !== "results") return;
201
419
  selectedIndex(0);
202
420
  }
203
421
 
204
422
  function selectLast() {
423
+ if (focusTarget() !== "results") return;
205
424
  const max = results().length - 1;
206
425
  if (max >= 0) {
207
426
  selectedIndex(max);
208
427
  }
209
428
  }
210
429
 
211
- // --- Start app ---
212
- const app = run(root, { debug: true });
430
+ function focusInput() {
431
+ focusTarget("input");
432
+ searchInput.focus();
433
+ }
434
+
435
+ function focusSelectedResult() {
436
+ if (results().length === 0) {
437
+ focusInput();
438
+ return;
439
+ }
440
+ focusTarget("results");
441
+ const selectedVisibleIndex = selectedIndex() - visibleStartIndex;
442
+ const selectedButton = resultButtons[selectedVisibleIndex];
443
+ if (selectedButton) {
444
+ selectedButton.focus();
445
+ return;
446
+ }
447
+ resultButtons[0]?.focus();
448
+ }
449
+
450
+ function toggleFocusTarget() {
451
+ if (results().length === 0) {
452
+ focusInput();
453
+ return;
454
+ }
455
+ if (focusTarget() === "input") {
456
+ focusSelectedResult();
457
+ } else {
458
+ focusInput();
459
+ }
460
+ }
461
+
462
+ focusInput();
package/package.json CHANGED
@@ -1,25 +1,29 @@
1
1
  {
2
2
  "name": "@frixaco/anitrack",
3
- "version": "0.0.7",
4
- "module": "index.ts",
3
+ "version": "0.0.9",
5
4
  "type": "module",
6
5
  "bin": {
7
6
  "anitrack": "./index.ts"
8
7
  },
9
8
  "files": [
10
9
  "index.ts",
11
- "colors.ts"
10
+ "colors.ts",
11
+ "progress-bar.ts",
12
+ "README.md"
12
13
  ],
14
+ "engines": {
15
+ "bun": ">=1.0.0"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
13
20
  "scripts": {
14
21
  "dev": "bun run index.ts"
15
22
  },
16
23
  "dependencies": {
17
- "@frixaco/letui": "^0.0.11"
24
+ "@frixaco/letui": "^0.0.12"
18
25
  },
19
26
  "devDependencies": {
20
27
  "@types/bun": "1.3.9"
21
- },
22
- "peerDependencies": {
23
- "typescript": "^5"
24
28
  }
25
29
  }
@@ -0,0 +1,100 @@
1
+ import type { Node } from "@frixaco/letui";
2
+ import { $, ff, Row, Text } from "@frixaco/letui";
3
+
4
+ export type LoadingBarProps = {
5
+ dotColor: number;
6
+ trackColor: number;
7
+ flexGrow?: number;
8
+ interval?: number; // ms between frames
9
+ };
10
+
11
+ export type LoadingBarController = {
12
+ node: Node;
13
+ start: () => void;
14
+ stop: () => void;
15
+ };
16
+
17
+ export function LoadingBar(props: LoadingBarProps): LoadingBarController {
18
+ const { dotColor, trackColor, flexGrow = 1, interval = 80 } = props;
19
+
20
+ const position = $(0);
21
+ const direction = $(1); // 1 = right, -1 = left
22
+ const active = $(false);
23
+ let timer: Timer | null = null;
24
+
25
+ const leftTrack = Text({
26
+ text: "",
27
+ background: trackColor,
28
+ foreground: trackColor,
29
+ });
30
+ const dot = Text({ text: "", background: dotColor, foreground: dotColor });
31
+ const rightTrack = Text({
32
+ text: "",
33
+ background: trackColor,
34
+ foreground: trackColor,
35
+ });
36
+
37
+ const node = Row({ flexGrow }, [leftTrack, dot, rightTrack]);
38
+
39
+ // React to position changes
40
+ ff(() => {
41
+ const isActive = active();
42
+ const pos = position();
43
+ const width = node.frameWidth();
44
+ if (width === 0 || !isActive) return;
45
+
46
+ const maxPos = width - 1;
47
+ const clampedPos = Math.max(0, Math.min(pos, maxPos));
48
+
49
+ leftTrack.setText?.(" ".repeat(clampedPos));
50
+ dot.setText?.(" ");
51
+ rightTrack.setText?.(" ".repeat(maxPos - clampedPos));
52
+ });
53
+
54
+ function clearTimer() {
55
+ if (timer) {
56
+ clearInterval(timer);
57
+ timer = null;
58
+ }
59
+ }
60
+
61
+ function start() {
62
+ if (active()) return;
63
+ active(true);
64
+ position(0);
65
+ direction(1);
66
+
67
+ timer = setInterval(() => {
68
+ const width = node.frameWidth();
69
+ if (width === 0) return;
70
+
71
+ const maxPos = width - 1;
72
+ const pos = position();
73
+ const dir = direction();
74
+
75
+ const step = 12;
76
+ const nextPos = pos + dir * step;
77
+ if (nextPos >= maxPos) {
78
+ direction(-1);
79
+ position(maxPos);
80
+ } else if (nextPos <= 0) {
81
+ direction(1);
82
+ position(0);
83
+ } else {
84
+ position(nextPos);
85
+ }
86
+ }, interval);
87
+ }
88
+
89
+ function stop() {
90
+ clearTimer();
91
+ active(false);
92
+ position(0);
93
+ // Clear all three segments
94
+ leftTrack.setText?.("");
95
+ dot.setText?.("");
96
+ rightTrack.setText?.("");
97
+ }
98
+
99
+ return { node, start, stop };
100
+ }