pi-unsloth-webtools 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -39,6 +39,8 @@ Mirrors Unsloth Studio's `web_search` tool:
39
39
  - Transient engine failures (network errors or null responses) are retried once with a short
40
40
  backoff inside the same timeout budget (a retry that cannot fit in the remaining budget is
41
41
  skipped); timeouts and cancellations are never retried.
42
+ - Sweeps stop as soon as enough results are gathered: engines still in flight are aborted
43
+ instead of being allowed to run to their timeout.
42
44
 
43
45
  ### web_fetch
44
46
 
@@ -48,16 +50,22 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
48
50
  - URL validation: http/https only, no credentials or encoded hostnames, hostname/port checks (any
49
51
  port 1–65535 is permitted; SSRF protection is enforced at the resolved-IP layer, not by port
50
52
  allowlists).
53
+ Canonical public IPv4 literals are accepted like IPv6 literals; private literals are still
54
+ blocked at the resolved-IP layer (non-canonical numeric encodings like `0x7f.0.0.1` /
55
+ `013.0.0.1` / `2130706433` are rejected by URL validation before DNS).
51
56
  - DNS resolution with SSRF protection: every resolved address is validated against
52
57
  private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges, then the validated IP
53
58
  is pinned for the connection (custom `lookup` + SNI `servername`), so DNS cannot rebind between
54
59
  validation and fetch; resolution shares the caller's abort signal and the overall deadline,
55
60
  so a stuck resolver cannot outlive the fetch.
61
+ When a host publishes both IPv4 and IPv6 addresses, IPv4 is preferred (broken IPv6 routes
62
+ cannot stall a fetch), and a connection failure falls back to the next validated address for
63
+ the same host before giving up.
56
64
  - GitHub repo root pages are rewritten to the unauthenticated README API
57
65
  (`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
58
66
  (`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
59
67
  returning error-page content.
60
- - Up to 4 redirect hops, each re-validated and re-resolved against the same rules.
68
+ - Up to 5 total requests (initial fetch + up to 4 HTTP redirects or `<meta http-equiv="refresh">` refreshes), each re-validated and re-resolved against the same rules.
61
69
  - 512 KiB download cap (10 MiB for PDFs), overall deadline + per-hop socket timeouts, abort-aware
62
70
  (`signal` cancels mid-flight). Fetches cut off by a download cap are marked with a trailing
63
71
  truncation notice, so a partial page is not mistaken for a complete one.
@@ -124,6 +132,50 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
124
132
  deliberately pins the Studio snapshot — seven engines, bing disabled upstream, no vqd,
125
133
  no pagination — so engine behavior matches Studio rather than ddgs head.
126
134
 
135
+ ## When to use alternatives
136
+
137
+ This package is intentionally a faithful, zero-dependency port of Studio's pipeline. Use it
138
+ when you need deterministic, offline-friendly behavior with strong SSRF guarantees and test
139
+ parity with `unsloth/studio`. For other tradeoffs, prefer:
140
+
141
+ | Need | Use |
142
+ |---|---|
143
+ | Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | `pi-smart-fetch` (`wreq-js` `chrome_145`) |
144
+ | Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | `georgebashi/pi-web-fetch` (puppeteer + trafilatura) |
145
+ | Hosted search with semantic ranking and no scraping | `Brave Search API` / `Tavily` / `Exa` via `pi-ollama-web-search` |
146
+ | Prompt-focused page distillation to save context | `pi-web-fetch` `prompt` -> sub-agent or Claude Code `WebFetch(url,prompt)` |
147
+ | Batch fetching many URLs concurrently | `pi-smart-fetch` `batch_web_fetch` or call `web_fetch` in parallel |
148
+
149
+ Mixing is supported: `pi install npm:pi-unsloth-webtools npm:pi-smart-fetch` lets the model
150
+ choose the best tool per URL. No need to fork this package to add those features.
151
+
152
+ ## Configuration
153
+
154
+ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project overrides global):
155
+
156
+ ```json
157
+ {
158
+ "unslothWebTools": {
159
+ "maxResults": 5,
160
+ "maxChars": 50000,
161
+ "timeoutMs": 60000
162
+ },
163
+ "webFetch": {
164
+ "maxChars": 50000,
165
+ "timeoutMs": 15000
166
+ }
167
+ }
168
+ ```
169
+
170
+ | Key | Default | Description |
171
+ |---|---|---|
172
+ | `unslothWebTools.maxResults` | `5` | Default `maxResults` for `web_search` (clamped 1-20) |
173
+ | `unslothWebTools.maxChars` / `webFetch.maxChars` / `smartFetchDefaultMaxChars` | tool param | Default `maxChars` for `web_fetch` and `web_search` url mode |
174
+ | `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` | Default `timeoutMs` (>=1000) |
175
+ | `webSearch.maxResults` / `smartWebSearch.resultsPerQuery` | same as above | Legacy aliases for `maxResults` |
176
+
177
+ Tool params always win over file defaults.
178
+
127
179
  ## Development
128
180
 
129
181
  ```sh
@@ -158,7 +210,8 @@ The suite ports Unsloth Studio's own tests for these tools:
158
210
  - `test/entities.test.ts`: `decodeHtmlEntities` parity with CPython `html.unescape`,
159
211
  legacy refs, longest-prefix rule, Windows-1252 numeric mappings, invalid codepoints
160
212
  - `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
161
- result-health sweep (at least three engines must return well-formed results)
213
+ result-health sweep (at least two engines must return well-formed results; engines
214
+ that block or reset connections from datacenter IPs count as unhealthy, not failures)
162
215
 
163
216
  The seams (`seams.resolve` / `seams.request` / `rawFetch`) replace the network stack
164
217
  with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host`
package/agent-dir.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ function expandAgentDir(value: string): string {
5
+ const trimmed = value.trim();
6
+ if (trimmed === "~") return homedir();
7
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(homedir(), trimmed.slice(2));
8
+ return trimmed;
9
+ }
10
+
11
+ export function agentDir(): string {
12
+ const env = (process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR)?.trim();
13
+ if (env) return expandAgentDir(env);
14
+ try {
15
+ const home = homedir();
16
+ if (home) return join(home, ".pi", "agent");
17
+ } catch {}
18
+ return "";
19
+ }
package/cache.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile, chmod } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { agentDir } from "./agent-dir.ts";
6
+ import { canonicalizeHref } from "./engines.ts";
7
+
8
+ const CACHE_TTL_MS = 60 * 60 * 1000;
9
+ const MAX_ENTRIES = 128;
10
+ const MAX_BYTES = 128 * 1024 * 1024;
11
+ const STALE_MAX_AGE_MS = 24 * CACHE_TTL_MS;
12
+
13
+ function cacheDir(): string {
14
+ const env = process.env.PI_UNSLOTH_CACHE_DIR?.trim();
15
+ if (env) return env;
16
+ const base = agentDir();
17
+ if (base) return join(base, "pi-unsloth-cache");
18
+ return join(tmpdir(), "pi-unsloth-cache");
19
+ }
20
+
21
+ function cacheKey(url: string): string {
22
+ const canon = canonicalizeHref(url) || url;
23
+ return createHash("sha256").update(canon).digest("hex");
24
+ }
25
+
26
+ function cachePath(key: string): string {
27
+ return join(cacheDir(), `${key}.json`);
28
+ }
29
+
30
+ async function secureChmod(path: string, mode: number): Promise<void> {
31
+ if (process.platform === "win32") return;
32
+ try {
33
+ await chmod(path, mode);
34
+ } catch {}
35
+ }
36
+
37
+ async function unlinkSafe(path: string): Promise<boolean> {
38
+ try {
39
+ await unlink(path);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ async function ensureDir(): Promise<void> {
47
+ const dir = cacheDir();
48
+ try {
49
+ await mkdir(dir, { recursive: true, mode: 0o700 });
50
+ await secureChmod(dir, 0o700);
51
+ } catch {}
52
+ }
53
+
54
+ export interface CacheEntry {
55
+ url: string;
56
+ body: string;
57
+ contentType: string;
58
+ timestamp: number;
59
+ }
60
+
61
+ export async function getCached(url: string): Promise<CacheEntry | null> {
62
+ const key = cacheKey(url);
63
+ const path = cachePath(key);
64
+ try {
65
+ const raw = await readFile(path, "utf8");
66
+ const entry = JSON.parse(raw) as CacheEntry;
67
+ if (!entry || typeof entry.body !== "string" || typeof entry.contentType !== "string" || typeof entry.timestamp !== "number") return null;
68
+ if (!Number.isFinite(entry.timestamp)) return null;
69
+ return entry;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export async function setCached(url: string, body: string, contentType: string): Promise<void> {
76
+ await ensureDir();
77
+ const key = cacheKey(url);
78
+ const path = cachePath(key);
79
+ const entry: CacheEntry = { url: canonicalizeHref(url) || url, body, contentType, timestamp: Date.now() };
80
+ const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
81
+ try {
82
+ await writeFile(tmpPath, JSON.stringify(entry), { mode: 0o600 });
83
+ await secureChmod(tmpPath, 0o600);
84
+ await rename(tmpPath, path);
85
+ await secureChmod(path, 0o600);
86
+ } catch {
87
+ await unlinkSafe(tmpPath);
88
+ return;
89
+ }
90
+ await prune();
91
+ }
92
+
93
+ async function prune(): Promise<void> {
94
+ const dir = cacheDir();
95
+ let files: string[];
96
+ try {
97
+ files = await readdir(dir);
98
+ } catch {
99
+ return;
100
+ }
101
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
102
+ if (!jsonFiles.length) return;
103
+ const now = Date.now();
104
+ const entries: { file: string; size: number; timestamp: number }[] = [];
105
+ let total = 0;
106
+ for (const file of jsonFiles) {
107
+ const full = join(dir, file);
108
+ try {
109
+ const s = await stat(full);
110
+ let timestamp = s.mtimeMs;
111
+ try {
112
+ const raw = await readFile(full, "utf8");
113
+ const entry = JSON.parse(raw) as CacheEntry;
114
+ if (entry && typeof entry.timestamp === "number" && Number.isFinite(entry.timestamp)) timestamp = entry.timestamp;
115
+ } catch {}
116
+ if (now - timestamp >= STALE_MAX_AGE_MS) {
117
+ await unlinkSafe(full);
118
+ continue;
119
+ }
120
+ entries.push({ file: full, size: s.size, timestamp });
121
+ total += s.size;
122
+ } catch {}
123
+ }
124
+ const expired = entries.filter((e) => now - e.timestamp >= CACHE_TTL_MS).sort((a, b) => a.timestamp - b.timestamp);
125
+ for (const e of expired) {
126
+ if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) break;
127
+ if (await unlinkSafe(e.file)) {
128
+ total -= e.size;
129
+ const idx = entries.indexOf(e);
130
+ if (idx !== -1) entries.splice(idx, 1);
131
+ }
132
+ }
133
+ if (entries.length <= MAX_ENTRIES && total <= MAX_BYTES) return;
134
+ entries.sort((a, b) => a.timestamp - b.timestamp);
135
+ let idx = 0;
136
+ while (idx < entries.length && (entries.length > MAX_ENTRIES || total > MAX_BYTES)) {
137
+ const e = entries[idx];
138
+ if (await unlinkSafe(e.file)) {
139
+ total -= e.size;
140
+ entries.splice(idx, 1);
141
+ } else {
142
+ idx++;
143
+ }
144
+ }
145
+ }
146
+
147
+ export function isFresh(entry: CacheEntry, now = Date.now()): boolean {
148
+ return now - entry.timestamp < CACHE_TTL_MS;
149
+ }
150
+
151
+ export function staleNotice(entry: CacheEntry): string {
152
+ const date = new Date(entry.timestamp).toISOString().slice(0, 10);
153
+ return `\n\n*STALE cache from ${date} — network fetch failed, serving cached copy*`;
154
+ }
package/engines.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { appendFile, chmod, mkdir } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join } from "node:path";
2
4
  import { collapseWhitespace, decodeHtmlEntities, feedHtml } from "./html-to-md.ts";
3
5
  import type { AttrDict } from "./html-to-md.ts";
4
6
  import { randomUserAgent } from "./user-agents.ts";
7
+ import { agentDir } from "./agent-dir.ts";
8
+ import { MAX_SIGNAL_TIMEOUT_MS } from "./web-access.ts";
5
9
  export class EmptySweepError extends Error {
6
10
  constructor() {
7
11
  super("No results found");
@@ -9,8 +13,10 @@ export class EmptySweepError extends Error {
9
13
  }
10
14
 
11
15
  export class SearchTimeoutError extends Error {
12
- constructor() {
16
+ providers: string[];
17
+ constructor(providers: string[] = []) {
13
18
  super("timed out");
19
+ this.providers = providers;
14
20
  }
15
21
  }
16
22
 
@@ -556,7 +562,8 @@ async function httpFetch(
556
562
  .join("; ")
557
563
  : null;
558
564
  if (cookie) headers["Cookie"] = cookie;
559
- const signals: AbortSignal[] = [AbortSignal.timeout(options.timeoutMs)];
565
+ const timeoutMs = Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, options.timeoutMs));
566
+ const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)];
560
567
  if (options.signal) signals.push(options.signal);
561
568
  let response: Response;
562
569
  try {
@@ -840,6 +847,40 @@ export function rankResults(docs: SearchResult[], query: string): SearchResult[]
840
847
  }
841
848
  return [...wiki, ...both, ...titleOnly, ...bodyOnly, ...neither];
842
849
  }
850
+ async function recordSweepStats(query: string, maxResults: number, started: number, timedOutProviders: string[], resultCount: number): Promise<void> {
851
+ const flag = process.env.PI_UNSLOTH_WEBTOOLS_STATS?.trim();
852
+ if (!flag) return;
853
+ const lower = flag.toLowerCase();
854
+ if (lower === "0" || lower === "false" || lower === "no" || lower === "off") return;
855
+ const base = agentDir();
856
+ if (!base) return;
857
+ const isDefaultFlag = lower === "1" || lower === "true" || lower === "yes" || lower === "on";
858
+ const statsPath = isDefaultFlag ? join(base, "pi-unsloth-webtools-stats.jsonl") : isAbsolute(flag) ? flag : join(base, flag);
859
+ try {
860
+ const dir = dirname(statsPath);
861
+ await mkdir(dir, { recursive: true, mode: 0o700 });
862
+ if (process.platform !== "win32") {
863
+ try {
864
+ await chmod(dir, 0o700);
865
+ } catch {}
866
+ }
867
+ const sortedProviders = [...timedOutProviders].sort();
868
+ const entry = JSON.stringify({
869
+ ts: new Date().toISOString(),
870
+ query,
871
+ maxResults,
872
+ durationMs: Date.now() - started,
873
+ timedOutProviders: sortedProviders,
874
+ resultCount,
875
+ });
876
+ await appendFile(statsPath, entry + "\n", { mode: 0o600 });
877
+ if (process.platform !== "win32") {
878
+ try {
879
+ await chmod(statsPath, 0o600);
880
+ } catch {}
881
+ }
882
+ } catch {}
883
+ }
843
884
 
844
885
  function shuffledEngines(): Engine[] {
845
886
  const shuffled = [...TEXT_ENGINES];
@@ -858,12 +899,22 @@ export async function autoTextSearch(
858
899
  timeoutMs: number,
859
900
  signal?: AbortSignal,
860
901
  ): Promise<SearchResult[]> {
902
+ const started = Date.now();
861
903
  const engines = shuffledEngines();
862
- const deadline = Date.now() + timeoutMs;
904
+ const deadline = started + timeoutMs;
863
905
  const seenProviders = new Set<string>();
864
906
  const aggregator = new ResultsAggregator();
865
907
  const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
866
- let timedOut = false;
908
+ const controller = new AbortController();
909
+ let onAbort: (() => void) | undefined;
910
+ if (signal) {
911
+ if (signal.aborted) controller.abort();
912
+ else {
913
+ onAbort = () => controller.abort();
914
+ signal.addEventListener("abort", onAbort, { once: true });
915
+ }
916
+ }
917
+ const timedOutProviders = new Set<string>();
867
918
  let cancelled = false;
868
919
  const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
869
920
  const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults / 10) + 1);
@@ -872,19 +923,27 @@ export async function autoTextSearch(
872
923
  const run = async (engine: Engine) => {
873
924
  let results: SearchResult[] | null = null;
874
925
  for (let attempt = 0; attempt < 2 && results === null; attempt++) {
926
+ if (controller.signal.aborted) {
927
+ if (signal?.aborted) cancelled = true;
928
+ return;
929
+ }
875
930
  const budgetLeft = deadline - Date.now();
876
931
  if (budgetLeft <= 0) return;
877
932
  if (attempt > 0 && budgetLeft < ENGINE_RETRY_BACKOFF_MS) return;
878
933
  const remaining = Math.max(1, budgetLeft);
934
+ const engineSignal = signal
935
+ ? AbortSignal.any([signal, controller.signal])
936
+ : controller.signal;
879
937
  try {
880
- results = await engine.search(query, ctx, remaining, signal);
938
+ results = await engine.search(query, ctx, remaining, engineSignal);
881
939
  } catch (e) {
882
940
  if (e instanceof SearchCancelled) {
941
+ if (controller.signal.aborted && !signal?.aborted) return;
883
942
  cancelled = true;
884
943
  return;
885
944
  }
886
945
  if (e instanceof SearchTimeoutError) {
887
- timedOut = true;
946
+ timedOutProviders.add(engine.name);
888
947
  return;
889
948
  }
890
949
  }
@@ -895,15 +954,20 @@ export async function autoTextSearch(
895
954
  cancelled = true;
896
955
  return;
897
956
  }
957
+ if (controller.signal.aborted) return;
898
958
  }
899
959
  }
900
960
  if (results && results.length) {
901
961
  aggregator.extend(results);
902
962
  seenProviders.add(engine.provider);
963
+ if (aggregator.size >= maxResults) controller.abort();
903
964
  }
904
965
  };
905
966
  while (i < engines.length) {
906
- if (aggregator.size >= maxResults || cancelled) break;
967
+ if (aggregator.size >= maxResults || cancelled) {
968
+ controller.abort();
969
+ break;
970
+ }
907
971
  const engine = engines[i++];
908
972
  if (seenProviders.has(engine.provider)) continue;
909
973
  pending.push(run(engine));
@@ -913,9 +977,18 @@ export async function autoTextSearch(
913
977
  }
914
978
  }
915
979
  await Promise.allSettled(pending);
980
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
916
981
  if (cancelled) throw new SearchCancelled();
917
982
  const results = rankResults(aggregator.extractDicts(), query);
918
- if (results.length) return results.slice(0, maxResults);
919
- if (timedOut) throw new SearchTimeoutError();
983
+ if (results.length) {
984
+ void recordSweepStats(query, maxResults, started, [...timedOutProviders], results.length);
985
+ return results.slice(0, maxResults);
986
+ }
987
+ if (timedOutProviders.size) {
988
+ const sorted = [...timedOutProviders].sort();
989
+ void recordSweepStats(query, maxResults, started, sorted, 0);
990
+ throw new SearchTimeoutError(sorted);
991
+ }
992
+ void recordSweepStats(query, maxResults, started, [], 0);
920
993
  throw new EmptySweepError();
921
994
  }
package/html-to-md.ts CHANGED
@@ -228,8 +228,11 @@ interface HtmlHandlers {
228
228
  handleCharRef(name: string): void;
229
229
  }
230
230
 
231
- const START_TAG_NAME_RE = /^[a-zA-Z][^\s/>]*/;
232
- const ATTR_NAME_RE = /^[^\s=/>]+/;
231
+ const START_TAG_NAME_RE = /[a-zA-Z][^\s/>]*/y;
232
+ const ATTR_NAME_RE = /[^\s=/>]+/y;
233
+ const ENTITY_NAMED_RE = /&([A-Za-z][A-Za-z0-9.-]*);/y;
234
+ const ENTITY_NUMERIC_RE = /&#([xX][0-9a-fA-F]+|[0-9]+);?/y;
235
+ const ENTITY_LEGACY_RE = /&([A-Za-z][A-Za-z0-9.-]*)(?=[^A-Za-z0-9]|$)/y;
233
236
 
234
237
  const RAW_TEXT_TAGS = [
235
238
  "script",
@@ -242,9 +245,23 @@ const RAW_TEXT_TAGS = [
242
245
  "noframes",
243
246
  ];
244
247
 
245
- const RAW_TEXT_CLOSERS: Record<string, RegExp> = Object.fromEntries(
246
- RAW_TEXT_TAGS.map((name) => [name, new RegExp(`</${name}\\s*>`, "i")]),
247
- );
248
+ function findRawTextClose(
249
+ input: string,
250
+ lowerInput: string,
251
+ from: number,
252
+ name: string,
253
+ ): { start: number; end: number } | null {
254
+ const needle = `</${name}`;
255
+ let pos = from;
256
+ while (true) {
257
+ const hit = lowerInput.indexOf(needle, pos);
258
+ if (hit === -1) return null;
259
+ let j = hit + needle.length;
260
+ while (j < input.length && /\s/.test(input[j])) j++;
261
+ if (j < input.length && input[j] === ">") return { start: hit, end: j + 1 };
262
+ pos = hit + 1;
263
+ }
264
+ }
248
265
 
249
266
  function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, boolean] {
250
267
  const attrs: AttrDict = {};
@@ -257,10 +274,11 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
257
274
  pos++;
258
275
  continue;
259
276
  }
260
- const nameMatch = ATTR_NAME_RE.exec(input.slice(pos));
277
+ ATTR_NAME_RE.lastIndex = pos;
278
+ const nameMatch = ATTR_NAME_RE.exec(input);
261
279
  if (!nameMatch) return [attrs, -1, false];
262
280
  const name = nameMatch[0].toLowerCase();
263
- pos += nameMatch[0].length;
281
+ pos = nameMatch.index + nameMatch[0].length;
264
282
  while (pos < input.length && /\s/.test(input[pos])) pos++;
265
283
  let value: string | null = null;
266
284
  if (pos < input.length && input[pos] === "=") {
@@ -285,64 +303,69 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
285
303
  }
286
304
 
287
305
  function scanTag(
288
- html: string,
306
+ input: string,
289
307
  i: number,
290
308
  ): { end: number; kind: "comment" | "decl" | "end" | "start" | "startend"; name?: string; attrs?: AttrDict } | null {
291
- const rest = html.slice(i + 1);
292
- if (rest.startsWith("!--")) {
293
- const close = html.indexOf("-->", i + 4);
309
+ if (input.startsWith("!--", i + 1)) {
310
+ const close = input.indexOf("-->", i + 4);
294
311
  if (close === -1) return null;
295
312
  return { end: close + 3, kind: "decl" };
296
313
  }
297
- if (rest.startsWith("!") || rest.startsWith("?")) {
314
+ const next = input[i + 1];
315
+ if (next === "!" || next === "?") {
298
316
  let j = i + 2;
299
- while (j < html.length && html[j] !== ">") j++;
300
- if (j >= html.length) return null;
317
+ while (j < input.length && input[j] !== ">") j++;
318
+ if (j >= input.length) return null;
301
319
  return { end: j + 1, kind: "decl" };
302
320
  }
303
- if (rest.startsWith("/")) {
321
+ if (next === "/") {
304
322
  let j = i + 2;
305
- while (j < html.length && /[\s>]/.test(html[j]) === false) j++;
306
- const name = html.slice(i + 2, j).toLowerCase();
323
+ while (j < input.length && !/[\s>]/.test(input[j])) j++;
324
+ const name = input.slice(i + 2, j).toLowerCase();
307
325
  if (!name) return null;
308
- while (j < html.length && html[j] !== ">") j++;
309
- if (j >= html.length) return null;
326
+ while (j < input.length && input[j] !== ">") j++;
327
+ if (j >= input.length) return null;
310
328
  return { end: j + 1, kind: "end", name };
311
329
  }
312
- const nameMatch = START_TAG_NAME_RE.exec(rest);
330
+ START_TAG_NAME_RE.lastIndex = i + 1;
331
+ const nameMatch = START_TAG_NAME_RE.exec(input);
313
332
  if (!nameMatch) return null;
314
333
  const name = nameMatch[0].toLowerCase();
315
- const [attrs, next, selfClosing] = parseAttrsUntilClose(html, i + 1 + nameMatch[0].length);
316
- if (next === -1) return null;
317
- return { end: next, kind: selfClosing ? "startend" : "start", name, attrs };
334
+ const [attrs, nextPos, selfClosing] = parseAttrsUntilClose(input, i + 1 + nameMatch[0].length);
335
+ if (nextPos === -1) return null;
336
+ return { end: nextPos, kind: selfClosing ? "startend" : "start", name, attrs };
318
337
  }
319
338
 
320
339
 
321
340
  export function feedHtml(input: string, handlers: HtmlHandlers): void {
322
- const emitText = (text: string) => {
323
- if (!text) return;
324
- let pos = 0;
325
- while (pos < text.length) {
326
- const amp = text.indexOf("&", pos);
327
- if (amp === -1) {
328
- handlers.handleData(text.slice(pos));
341
+ let lowerInput: string | null = null;
342
+ const emitTextRange = (start: number, end: number) => {
343
+ if (end <= start) return;
344
+ let pos = start;
345
+ while (pos < end) {
346
+ const amp = input.indexOf("&", pos);
347
+ if (amp === -1 || amp >= end) {
348
+ handlers.handleData(input.slice(pos, end));
329
349
  return;
330
350
  }
331
- if (amp > pos) handlers.handleData(text.slice(pos, amp));
332
- const named = /^&([A-Za-z][A-Za-z0-9.-]*);/.exec(text.slice(amp));
333
- if (named) {
351
+ if (amp > pos) handlers.handleData(input.slice(pos, amp));
352
+ ENTITY_NAMED_RE.lastIndex = amp;
353
+ const named = ENTITY_NAMED_RE.exec(input);
354
+ if (named && named.index === amp && amp + named[0].length <= end) {
334
355
  handlers.handleEntityRef(named[1]);
335
356
  pos = amp + named[0].length;
336
357
  continue;
337
358
  }
338
- const numeric = /^&#([xX][0-9a-fA-F]+|[0-9]+);?/.exec(text.slice(amp));
339
- if (numeric) {
359
+ ENTITY_NUMERIC_RE.lastIndex = amp;
360
+ const numeric = ENTITY_NUMERIC_RE.exec(input);
361
+ if (numeric && numeric.index === amp && amp + numeric[0].length <= end) {
340
362
  handlers.handleCharRef(numeric[1]);
341
363
  pos = amp + numeric[0].length;
342
364
  continue;
343
365
  }
344
- const legacy = /^&([A-Za-z][A-Za-z0-9.-]*)(?=[^A-Za-z0-9]|$)/.exec(text.slice(amp));
345
- if (legacy) {
366
+ ENTITY_LEGACY_RE.lastIndex = amp;
367
+ const legacy = ENTITY_LEGACY_RE.exec(input);
368
+ if (legacy && legacy.index === amp && amp + legacy[0].length <= end) {
346
369
  handlers.handleEntityRef(legacy[1]);
347
370
  pos = amp + legacy[0].length;
348
371
  continue;
@@ -364,28 +387,28 @@ export function feedHtml(input: string, handlers: HtmlHandlers): void {
364
387
  i++;
365
388
  continue;
366
389
  }
367
- emitText(input.slice(textStart, i));
390
+ emitTextRange(textStart, i);
368
391
  if (tag.kind === "start") {
369
- const rawCloser = RAW_TEXT_CLOSERS[tag.name!];
370
- if (rawCloser) {
371
- const after = input.slice(tag.end);
372
- const closeMatch = rawCloser.exec(after);
373
- if (closeMatch) {
374
- handlers.handleStartTag(tag.name!, tag.attrs!);
375
- emitText(after.slice(0, closeMatch.index));
376
- handlers.handleEndTag(tag.name!);
377
- textStart = tag.end + closeMatch.index + closeMatch[0].length;
392
+ const rawName = tag.name!;
393
+ if (RAW_TEXT_TAGS.includes(rawName)) {
394
+ lowerInput ??= input.toLowerCase();
395
+ const close = findRawTextClose(input, lowerInput, tag.end, rawName);
396
+ if (close) {
397
+ handlers.handleStartTag(rawName, tag.attrs!);
398
+ emitTextRange(tag.end, close.start);
399
+ handlers.handleEndTag(rawName);
400
+ textStart = close.end;
378
401
  i = textStart;
379
402
  continue;
380
403
  }
381
404
  }
382
- handlers.handleStartTag(tag.name!, tag.attrs!);
405
+ handlers.handleStartTag(rawName, tag.attrs!);
383
406
  } else if (tag.kind === "startend") handlers.handleStartEndTag(tag.name!, tag.attrs!);
384
407
  else if (tag.kind === "end") handlers.handleEndTag(tag.name!);
385
408
  textStart = tag.end;
386
409
  i = tag.end;
387
410
  }
388
- emitText(input.slice(textStart));
411
+ emitTextRange(textStart, input.length);
389
412
  }
390
413
 
391
414
  function popMarksAbove(marks: number[], index: number): void {
@@ -1063,8 +1086,13 @@ function visibleLineChars(line: string): number {
1063
1086
  let openBracket = false;
1064
1087
  while (i < n) {
1065
1088
  if (line[i] === "\\") {
1066
- total += 2;
1067
- i += 2;
1089
+ if (i + 1 < n) {
1090
+ total += 2;
1091
+ i += 2;
1092
+ } else {
1093
+ total += 1;
1094
+ i += 1;
1095
+ }
1068
1096
  continue;
1069
1097
  }
1070
1098
  if (line[i] === "[") openBracket = true;
@@ -1074,7 +1102,11 @@ function visibleLineChars(line: string): number {
1074
1102
  while (j < n && depth) {
1075
1103
  const char = line[j];
1076
1104
  if (char === "\\") {
1077
- j += 2;
1105
+ if (j + 1 < n) {
1106
+ j += 2;
1107
+ } else {
1108
+ j += 1;
1109
+ }
1078
1110
  continue;
1079
1111
  }
1080
1112
  depth += (char === "(" ? 1 : 0) - (char === ")" ? 1 : 0);