@zeldrisho/pi-web-fetch 0.8.2 → 0.9.1
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/CHANGELOG.md +19 -1
- package/README.md +1 -1
- package/package.json +8 -10
- package/src/abort.ts +3 -0
- package/src/cache.ts +50 -0
- package/src/content.ts +16 -0
- package/src/evidence.ts +10 -0
- package/src/extract.ts +183 -1
- package/src/fetch.ts +108 -11
- package/src/focus.ts +17 -0
- package/src/index.ts +10 -1
- package/src/inflight.ts +10 -0
- package/src/network-policy.ts +17 -0
- package/src/network-redirects.ts +35 -0
- package/src/network-transport.ts +46 -0
- package/src/network.ts +1 -0
- package/src/outline.ts +38 -0
- package/src/redact.ts +7 -0
- package/src/render.ts +3 -0
- package/src/service.ts +156 -17
package/src/network-redirects.ts
CHANGED
|
@@ -5,15 +5,20 @@ import { requestPinned, responseHeader } from "./network-transport";
|
|
|
5
5
|
|
|
6
6
|
/** Maximum number of HTTP redirects to follow before aborting. */
|
|
7
7
|
export const FETCH_MAX_REDIRECTS = 5;
|
|
8
|
+
|
|
8
9
|
const MAX_ORIGIN_CONCURRENCY = 4;
|
|
10
|
+
|
|
9
11
|
const MAX_RETRIES = 2;
|
|
12
|
+
|
|
10
13
|
const MAX_RETRY_DELAY_MS = 10_000;
|
|
14
|
+
|
|
11
15
|
const BASE_RETRY_DELAY_MS = 250;
|
|
12
16
|
|
|
13
17
|
interface OriginState {
|
|
14
18
|
active: number;
|
|
15
19
|
waiting: Array<() => void>;
|
|
16
20
|
}
|
|
21
|
+
|
|
17
22
|
const origins = new Map<string, OriginState>();
|
|
18
23
|
|
|
19
24
|
/** Optional dependencies for redirect handling (used for testing). */
|
|
@@ -28,21 +33,27 @@ export interface RedirectDependencies {
|
|
|
28
33
|
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** Creates the consistent abort error used by redirect and retry coordination. */
|
|
31
37
|
function abortError(): Error {
|
|
32
38
|
const error = new Error("Operation aborted.");
|
|
33
39
|
error.name = "AbortError";
|
|
40
|
+
|
|
34
41
|
return error;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/** Reserves an origin-concurrency slot and returns a function that releases it. */
|
|
37
45
|
async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() => void> {
|
|
38
46
|
const state = origins.get(origin) ?? { active: 0, waiting: [] };
|
|
39
47
|
origins.set(origin, state);
|
|
40
48
|
let reserved = false;
|
|
49
|
+
|
|
41
50
|
const release = () => {
|
|
42
51
|
state.active -= 1;
|
|
43
52
|
state.waiting.shift()?.();
|
|
53
|
+
|
|
44
54
|
if (state.active === 0 && state.waiting.length === 0) origins.delete(origin);
|
|
45
55
|
};
|
|
56
|
+
|
|
46
57
|
if (state.active >= MAX_ORIGIN_CONCURRENCY) {
|
|
47
58
|
await new Promise<void>((resolve, reject) => {
|
|
48
59
|
const enter = () => {
|
|
@@ -51,11 +62,14 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
|
|
|
51
62
|
reserved = true;
|
|
52
63
|
resolve();
|
|
53
64
|
};
|
|
65
|
+
|
|
54
66
|
const cancel = () => {
|
|
55
67
|
const index = state.waiting.indexOf(enter);
|
|
68
|
+
|
|
56
69
|
if (index >= 0) state.waiting.splice(index, 1);
|
|
57
70
|
reject(abortError());
|
|
58
71
|
};
|
|
72
|
+
|
|
59
73
|
if (signal.aborted) cancel();
|
|
60
74
|
else {
|
|
61
75
|
state.waiting.push(enter);
|
|
@@ -63,40 +77,52 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
|
|
|
63
77
|
}
|
|
64
78
|
});
|
|
65
79
|
}
|
|
80
|
+
|
|
66
81
|
if (signal.aborted) {
|
|
67
82
|
if (reserved) release();
|
|
68
83
|
throw abortError();
|
|
69
84
|
}
|
|
85
|
+
|
|
70
86
|
if (!reserved) state.active += 1;
|
|
87
|
+
|
|
71
88
|
return release;
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
/** Waits for a retry delay while allowing the caller to cancel immediately. */
|
|
74
92
|
async function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
75
93
|
await new Promise<void>((resolve, reject) => {
|
|
76
94
|
const timer = setTimeout(done, milliseconds);
|
|
95
|
+
|
|
77
96
|
function done(): void {
|
|
78
97
|
signal.removeEventListener("abort", cancel);
|
|
79
98
|
resolve();
|
|
80
99
|
}
|
|
100
|
+
|
|
81
101
|
function cancel(): void {
|
|
82
102
|
clearTimeout(timer);
|
|
83
103
|
reject(abortError());
|
|
84
104
|
}
|
|
105
|
+
|
|
85
106
|
if (signal.aborted) cancel();
|
|
86
107
|
else signal.addEventListener("abort", cancel, { once: true });
|
|
87
108
|
});
|
|
88
109
|
}
|
|
89
110
|
|
|
111
|
+
/** Parses a Retry-After header into a bounded delay in milliseconds. */
|
|
90
112
|
function retryAfterMilliseconds(value: string | undefined, now = Date.now()): number | undefined {
|
|
91
113
|
if (!value) return undefined;
|
|
92
114
|
const seconds = Number(value.trim());
|
|
115
|
+
|
|
93
116
|
if (Number.isFinite(seconds) && seconds >= 0)
|
|
94
117
|
return Math.min(seconds * 1_000, MAX_RETRY_DELAY_MS);
|
|
95
118
|
const date = Date.parse(value);
|
|
119
|
+
|
|
96
120
|
if (!Number.isFinite(date)) return undefined;
|
|
121
|
+
|
|
97
122
|
return Math.min(Math.max(0, date - now), MAX_RETRY_DELAY_MS);
|
|
98
123
|
}
|
|
99
124
|
|
|
125
|
+
/** Sends one origin-limited request and retries transient rate-limit responses. */
|
|
100
126
|
async function coordinatedRequest(
|
|
101
127
|
target: ValidatedTarget,
|
|
102
128
|
signal: AbortSignal,
|
|
@@ -107,17 +133,22 @@ async function coordinatedRequest(
|
|
|
107
133
|
dependencies.request ??
|
|
108
134
|
((value, requestSignal, requestHeaders) =>
|
|
109
135
|
requestPinned(value, requestSignal, { headers: requestHeaders }));
|
|
136
|
+
|
|
110
137
|
const random = dependencies.random ?? Math.random;
|
|
111
138
|
const sleep = dependencies.sleep ?? defaultSleep;
|
|
139
|
+
|
|
112
140
|
for (let attempt = 0; ; attempt += 1) {
|
|
113
141
|
const release = await acquireOrigin(target.url.origin, signal);
|
|
114
142
|
let response: IncomingMessage;
|
|
143
|
+
|
|
115
144
|
try {
|
|
116
145
|
response = await request(target, signal, headers);
|
|
117
146
|
} finally {
|
|
118
147
|
release();
|
|
119
148
|
}
|
|
149
|
+
|
|
120
150
|
const status = response.statusCode ?? 0;
|
|
151
|
+
|
|
121
152
|
if (![429, 503].includes(status) || attempt >= MAX_RETRIES) return response;
|
|
122
153
|
// The retry decision uses headers only. Drop an untrusted error body so it cannot
|
|
123
154
|
// consume unbounded bandwidth while the caller is backing off.
|
|
@@ -146,10 +177,13 @@ export async function requestFollowingRedirects(
|
|
|
146
177
|
for (let redirects = 0; redirects <= FETCH_MAX_REDIRECTS; redirects += 1) {
|
|
147
178
|
const response = await coordinatedRequest(target, signal, requestHeaders, dependencies);
|
|
148
179
|
const status = response.statusCode ?? 0;
|
|
180
|
+
|
|
149
181
|
if (![301, 302, 303, 307, 308].includes(status)) return { target, response };
|
|
150
182
|
|
|
151
183
|
const location = responseHeader(response, "location");
|
|
184
|
+
|
|
152
185
|
if (!location) throw new Error("web_fetch received a redirect without a Location header.");
|
|
186
|
+
|
|
153
187
|
if (redirects === FETCH_MAX_REDIRECTS)
|
|
154
188
|
throw new Error("web_fetch followed too many redirects.");
|
|
155
189
|
response.resume();
|
|
@@ -157,5 +191,6 @@ export async function requestFollowingRedirects(
|
|
|
157
191
|
// Validators describe the originally cached resource and must not leak to a redirect target.
|
|
158
192
|
requestHeaders = {};
|
|
159
193
|
}
|
|
194
|
+
|
|
160
195
|
throw new Error("web_fetch followed too many redirects.");
|
|
161
196
|
}
|
package/src/network-transport.ts
CHANGED
|
@@ -20,6 +20,7 @@ const encoder = new TextEncoder();
|
|
|
20
20
|
function abortedError(): Error {
|
|
21
21
|
const error = new Error("Operation aborted.");
|
|
22
22
|
error.name = "AbortError";
|
|
23
|
+
|
|
23
24
|
return error;
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -39,6 +40,7 @@ function responseTooLargeMessage(
|
|
|
39
40
|
const size = sizeIsExact
|
|
40
41
|
? `is ${formatSize(receivedBytes)}`
|
|
41
42
|
: `has reached at least ${formatSize(receivedBytes)}`;
|
|
43
|
+
|
|
42
44
|
return [
|
|
43
45
|
`web_fetch response ${size}, exceeding the ${formatSize(maxBytes)} raw download limit.`,
|
|
44
46
|
"maxCharacters only controls returned output.",
|
|
@@ -63,7 +65,9 @@ async function requestOnce(
|
|
|
63
65
|
headers: Readonly<Record<string, string>>,
|
|
64
66
|
): Promise<IncomingMessage> {
|
|
65
67
|
const family = isIP(address);
|
|
68
|
+
|
|
66
69
|
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
|
|
70
|
+
|
|
67
71
|
const lookup: LookupFunction = (_hostname, options, callback) => {
|
|
68
72
|
// Match dns.lookup's asynchronous callback contract. Calling back inline
|
|
69
73
|
// lets an immediately unreachable address emit a socket error before
|
|
@@ -73,18 +77,24 @@ async function requestOnce(
|
|
|
73
77
|
else callback(null, address, family);
|
|
74
78
|
});
|
|
75
79
|
};
|
|
80
|
+
|
|
76
81
|
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
82
|
+
|
|
77
83
|
return await new Promise((resolve, reject) => {
|
|
78
84
|
const controller = new AbortController();
|
|
79
85
|
let attemptExpired = false;
|
|
86
|
+
|
|
80
87
|
const timer = setTimeout(() => {
|
|
81
88
|
attemptExpired = true;
|
|
82
89
|
controller.abort();
|
|
83
90
|
}, attemptTimeoutMs);
|
|
91
|
+
|
|
84
92
|
const forwardAbort = () => controller.abort();
|
|
93
|
+
|
|
85
94
|
if (signal.aborted) controller.abort();
|
|
86
95
|
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
87
96
|
let settled = false;
|
|
97
|
+
|
|
88
98
|
const finish = (callback: () => void) => {
|
|
89
99
|
if (settled) return;
|
|
90
100
|
settled = true;
|
|
@@ -92,8 +102,10 @@ async function requestOnce(
|
|
|
92
102
|
signal.removeEventListener("abort", forwardAbort);
|
|
93
103
|
callback();
|
|
94
104
|
};
|
|
105
|
+
|
|
95
106
|
const unreachableError = () =>
|
|
96
107
|
new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`);
|
|
108
|
+
|
|
97
109
|
const outgoing = request(
|
|
98
110
|
target.url,
|
|
99
111
|
{
|
|
@@ -114,6 +126,7 @@ async function requestOnce(
|
|
|
114
126
|
} else finish(() => resolve(response));
|
|
115
127
|
},
|
|
116
128
|
);
|
|
129
|
+
|
|
117
130
|
outgoing.once("error", (error) => {
|
|
118
131
|
// The per-attempt deadline surfaces as a raw AbortError from the HTTP
|
|
119
132
|
// client; report it as an unreachable address instead so the next
|
|
@@ -148,14 +161,17 @@ export async function requestPinned(
|
|
|
148
161
|
const attemptTimeoutMs = options.attemptTimeoutMs ?? CONNECT_ATTEMPT_TIMEOUT_MS;
|
|
149
162
|
const addresses = target.addresses?.length ? target.addresses : [target.address];
|
|
150
163
|
let lastError: unknown;
|
|
164
|
+
|
|
151
165
|
for (const address of addresses) {
|
|
152
166
|
try {
|
|
153
167
|
return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
|
|
154
168
|
} catch (error) {
|
|
155
169
|
lastError = error;
|
|
170
|
+
|
|
156
171
|
if (signal.aborted) throw error;
|
|
157
172
|
}
|
|
158
173
|
}
|
|
174
|
+
|
|
159
175
|
throw lastError ?? new Error("web_fetch could not connect.");
|
|
160
176
|
}
|
|
161
177
|
|
|
@@ -168,9 +184,29 @@ export async function requestPinned(
|
|
|
168
184
|
*/
|
|
169
185
|
export function responseHeader(response: IncomingMessage, name: string): string | undefined {
|
|
170
186
|
const value = response.headers[name];
|
|
187
|
+
|
|
171
188
|
return Array.isArray(value) ? value[0] : value;
|
|
172
189
|
}
|
|
173
190
|
|
|
191
|
+
export function responseHeaderValues(response: IncomingMessage, name: string): string[] {
|
|
192
|
+
const values: string[] = [];
|
|
193
|
+
const lowerName = name.toLowerCase();
|
|
194
|
+
|
|
195
|
+
const rawHeaders = response.rawHeaders ?? [];
|
|
196
|
+
|
|
197
|
+
for (let index = 0; index + 1 < rawHeaders.length; index += 2) {
|
|
198
|
+
if (rawHeaders[index].toLowerCase() === lowerName) {
|
|
199
|
+
values.push(rawHeaders[index + 1]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (values.length > 0) return values;
|
|
204
|
+
|
|
205
|
+
const value = response.headers[name];
|
|
206
|
+
|
|
207
|
+
return value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
208
|
+
}
|
|
209
|
+
|
|
174
210
|
/**
|
|
175
211
|
* Read and buffer the complete response body.
|
|
176
212
|
*
|
|
@@ -189,27 +225,33 @@ export async function readResponseBytes(
|
|
|
189
225
|
signal?: AbortSignal,
|
|
190
226
|
): Promise<Uint8Array> {
|
|
191
227
|
const declared = Number(responseHeader(response, "content-length"));
|
|
228
|
+
|
|
192
229
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
193
230
|
response.destroy();
|
|
194
231
|
throw new Error(responseTooLargeMessage(declared, maxBytes, true));
|
|
195
232
|
}
|
|
233
|
+
|
|
196
234
|
// Once response headers arrive the connect deadline and caller signal are no
|
|
197
235
|
// longer wired to the socket, so a stalled body would otherwise hang the
|
|
198
236
|
// fetch forever. Keep the caller signal attached for the whole body read and
|
|
199
237
|
// drop the socket when it fires.
|
|
200
238
|
const forwardAbort = () => response.destroy();
|
|
239
|
+
|
|
201
240
|
if (signal?.aborted) response.destroy();
|
|
202
241
|
else signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
203
242
|
const chunks: Uint8Array[] = [];
|
|
204
243
|
let total = 0;
|
|
244
|
+
|
|
205
245
|
try {
|
|
206
246
|
for await (const value of response) {
|
|
207
247
|
const chunk = value instanceof Uint8Array ? value : encoder.encode(value);
|
|
208
248
|
total += chunk.byteLength;
|
|
249
|
+
|
|
209
250
|
if (total > maxBytes) {
|
|
210
251
|
response.destroy();
|
|
211
252
|
throw new Error(responseTooLargeMessage(total, maxBytes, false));
|
|
212
253
|
}
|
|
254
|
+
|
|
213
255
|
chunks.push(chunk);
|
|
214
256
|
}
|
|
215
257
|
} catch (error) {
|
|
@@ -218,15 +260,18 @@ export async function readResponseBytes(
|
|
|
218
260
|
} finally {
|
|
219
261
|
signal?.removeEventListener("abort", forwardAbort);
|
|
220
262
|
}
|
|
263
|
+
|
|
221
264
|
// Destroying the socket can end the stream without an error; detect a
|
|
222
265
|
// mid-read abort here as well so truncated bodies never look complete.
|
|
223
266
|
if (signal?.aborted) throw abortedError();
|
|
224
267
|
const output = new Uint8Array(total);
|
|
225
268
|
let offset = 0;
|
|
269
|
+
|
|
226
270
|
for (const chunk of chunks) {
|
|
227
271
|
output.set(chunk, offset);
|
|
228
272
|
offset += chunk.byteLength;
|
|
229
273
|
}
|
|
274
|
+
|
|
230
275
|
return output;
|
|
231
276
|
}
|
|
232
277
|
|
|
@@ -242,6 +287,7 @@ export async function readResponseBytes(
|
|
|
242
287
|
*/
|
|
243
288
|
export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
244
289
|
const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
|
|
290
|
+
|
|
245
291
|
try {
|
|
246
292
|
return new TextDecoder(charset || "utf-8").decode(bytes);
|
|
247
293
|
} catch {
|
package/src/network.ts
CHANGED
package/src/outline.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
2
|
+
|
|
2
3
|
const MAX_FALLBACK_HEADING_CHARACTERS = 60;
|
|
4
|
+
|
|
3
5
|
export const MAX_OUTLINE_HEADINGS = 12;
|
|
6
|
+
|
|
4
7
|
export const MAX_OUTLINE_HEADING_CHARACTERS = 120;
|
|
5
8
|
|
|
6
9
|
/** One bounded heading exposed as untrusted document-shape metadata. */
|
|
@@ -41,15 +44,20 @@ interface MarkdownFence {
|
|
|
41
44
|
length: number;
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/** Tracks whether the current Markdown line opens or closes a fenced code block. */
|
|
44
48
|
function updateFence(line: string, fence: MarkdownFence | undefined): MarkdownFence | undefined {
|
|
45
49
|
const match = FENCE.exec(line);
|
|
50
|
+
|
|
46
51
|
if (!match) return fence;
|
|
47
52
|
const marker = match[1];
|
|
53
|
+
|
|
48
54
|
if (!fence) return { character: marker[0], length: marker.length };
|
|
55
|
+
|
|
49
56
|
const closes =
|
|
50
57
|
marker[0] === fence.character &&
|
|
51
58
|
marker.length >= fence.length &&
|
|
52
59
|
!line.slice(match[0].length).trim();
|
|
60
|
+
|
|
53
61
|
return closes ? undefined : fence;
|
|
54
62
|
}
|
|
55
63
|
|
|
@@ -66,15 +74,20 @@ function isSpaceOrTab(character: string | undefined): boolean {
|
|
|
66
74
|
/** Parses the bounded ATX prefix with a linear scan to avoid backtracking on remote input. */
|
|
67
75
|
function parseAtxHeading(line: string): ParsedAtxHeading | undefined {
|
|
68
76
|
let index = 0;
|
|
77
|
+
|
|
69
78
|
while (index < 3 && isSpaceOrTab(line[index])) index += 1;
|
|
79
|
+
|
|
70
80
|
if (isSpaceOrTab(line[index])) return undefined;
|
|
71
81
|
|
|
72
82
|
const markerStart = index;
|
|
83
|
+
|
|
73
84
|
while (line[index] === "#") index += 1;
|
|
74
85
|
const level = index - markerStart;
|
|
86
|
+
|
|
75
87
|
if (level < 1 || level > 6 || !isSpaceOrTab(line[index])) return undefined;
|
|
76
88
|
|
|
77
89
|
while (isSpaceOrTab(line[index])) index += 1;
|
|
90
|
+
|
|
78
91
|
return { level, text: line.slice(index) };
|
|
79
92
|
}
|
|
80
93
|
|
|
@@ -86,6 +99,7 @@ function parseAtxHeading(line: string): ParsedAtxHeading | undefined {
|
|
|
86
99
|
*/
|
|
87
100
|
function countWords(value: string): number {
|
|
88
101
|
const trimmed = value.trim();
|
|
102
|
+
|
|
89
103
|
return trimmed ? trimmed.split(/\s+/).length : 0;
|
|
90
104
|
}
|
|
91
105
|
|
|
@@ -97,10 +111,12 @@ function countWords(value: string): number {
|
|
|
97
111
|
*/
|
|
98
112
|
function countDocumentWords(lines: string[]): number {
|
|
99
113
|
let fence: MarkdownFence | undefined;
|
|
114
|
+
|
|
100
115
|
return lines.reduce((total, line) => {
|
|
101
116
|
const wasInsideFence = Boolean(fence);
|
|
102
117
|
fence = updateFence(line, fence);
|
|
103
118
|
const heading = wasInsideFence || fence ? undefined : parseAtxHeading(line);
|
|
119
|
+
|
|
104
120
|
return total + countWords(heading ? cleanHeading(heading.text) : line);
|
|
105
121
|
}, 0);
|
|
106
122
|
}
|
|
@@ -113,12 +129,16 @@ function countDocumentWords(lines: string[]): number {
|
|
|
113
129
|
*/
|
|
114
130
|
function cleanHeading(value: string): string {
|
|
115
131
|
let contentEnd = value.length;
|
|
132
|
+
|
|
116
133
|
while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
|
|
117
134
|
|
|
118
135
|
let markerStart = contentEnd;
|
|
136
|
+
|
|
119
137
|
while (markerStart > 0 && value[markerStart - 1] === "#") markerStart -= 1;
|
|
138
|
+
|
|
120
139
|
if (markerStart < contentEnd && markerStart > 0 && isSpaceOrTab(value[markerStart - 1])) {
|
|
121
140
|
contentEnd = markerStart - 1;
|
|
141
|
+
|
|
122
142
|
while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
|
|
123
143
|
}
|
|
124
144
|
|
|
@@ -139,16 +159,21 @@ function collectAtxHeadings(lines: string[]): HeadingCollection {
|
|
|
139
159
|
for (const [lineNumber, line] of lines.entries()) {
|
|
140
160
|
const wasInsideFence = Boolean(fence);
|
|
141
161
|
fence = updateFence(line, fence);
|
|
162
|
+
|
|
142
163
|
if (wasInsideFence || fence) continue;
|
|
143
164
|
const heading = parseAtxHeading(line);
|
|
165
|
+
|
|
144
166
|
if (!heading) continue;
|
|
145
167
|
const text = cleanHeading(heading.text);
|
|
168
|
+
|
|
146
169
|
if (!text) continue;
|
|
147
170
|
total += 1;
|
|
171
|
+
|
|
148
172
|
if (locations.length <= MAX_OUTLINE_HEADINGS) {
|
|
149
173
|
locations.push({ level: heading.level, text, line: lineNumber, inferred: false });
|
|
150
174
|
}
|
|
151
175
|
}
|
|
176
|
+
|
|
152
177
|
return { locations, total };
|
|
153
178
|
}
|
|
154
179
|
|
|
@@ -162,6 +187,7 @@ function collectAtxHeadings(lines: string[]): HeadingCollection {
|
|
|
162
187
|
function looksLikeFallbackHeading(line: string, nextLine: string): boolean {
|
|
163
188
|
if (/^\s{4,}/.test(line)) return false;
|
|
164
189
|
const candidate = line.trim();
|
|
190
|
+
|
|
165
191
|
if (
|
|
166
192
|
!candidate ||
|
|
167
193
|
candidate.length > MAX_FALLBACK_HEADING_CHARACTERS ||
|
|
@@ -173,8 +199,10 @@ function looksLikeFallbackHeading(line: string, nextLine: string): boolean {
|
|
|
173
199
|
) {
|
|
174
200
|
return false;
|
|
175
201
|
}
|
|
202
|
+
|
|
176
203
|
if (!/^[A-Z]/.test(candidate) && !/[`()._]/.test(candidate)) return false;
|
|
177
204
|
const following = nextLine.trim();
|
|
205
|
+
|
|
178
206
|
return following.length > MAX_FALLBACK_HEADING_CHARACTERS || /[.!?]$/.test(following);
|
|
179
207
|
}
|
|
180
208
|
|
|
@@ -190,25 +218,32 @@ function collectFallbackHeadings(lines: string[]): HeadingCollection {
|
|
|
190
218
|
const nextNonEmptyIndexes = new Int32Array(lines.length);
|
|
191
219
|
nextNonEmptyIndexes.fill(-1);
|
|
192
220
|
let nextNonEmptyIndex = -1;
|
|
221
|
+
|
|
193
222
|
for (let lineNumber = lines.length - 1; lineNumber >= 0; lineNumber -= 1) {
|
|
194
223
|
nextNonEmptyIndexes[lineNumber] = nextNonEmptyIndex;
|
|
224
|
+
|
|
195
225
|
if (lines[lineNumber].trim()) nextNonEmptyIndex = lineNumber;
|
|
196
226
|
}
|
|
227
|
+
|
|
197
228
|
let fence: MarkdownFence | undefined;
|
|
198
229
|
|
|
199
230
|
for (const [lineNumber, line] of lines.entries()) {
|
|
200
231
|
const wasInsideFence = Boolean(fence);
|
|
201
232
|
fence = updateFence(line, fence);
|
|
233
|
+
|
|
202
234
|
if (wasInsideFence || fence) continue;
|
|
203
235
|
const followingIndex = nextNonEmptyIndexes[lineNumber];
|
|
204
236
|
const following = followingIndex === -1 ? "" : lines[followingIndex];
|
|
237
|
+
|
|
205
238
|
if (looksLikeFallbackHeading(line, following)) {
|
|
206
239
|
total += 1;
|
|
240
|
+
|
|
207
241
|
if (locations.length <= MAX_OUTLINE_HEADINGS) {
|
|
208
242
|
locations.push({ level: 2, text: cleanHeading(line), line: lineNumber, inferred: true });
|
|
209
243
|
}
|
|
210
244
|
}
|
|
211
245
|
}
|
|
246
|
+
|
|
212
247
|
return { locations, total };
|
|
213
248
|
}
|
|
214
249
|
|
|
@@ -222,8 +257,10 @@ export function createDocumentOutline(markdown: string): DocumentOutline {
|
|
|
222
257
|
const lines = markdown.split("\n");
|
|
223
258
|
const explicit = collectAtxHeadings(lines);
|
|
224
259
|
const collection = explicit.total > 0 ? explicit : collectFallbackHeadings(lines);
|
|
260
|
+
|
|
225
261
|
const headings = collection.locations.slice(0, MAX_OUTLINE_HEADINGS).map((heading, index) => {
|
|
226
262
|
const next = collection.locations[index + 1]?.line ?? lines.length;
|
|
263
|
+
|
|
227
264
|
return {
|
|
228
265
|
level: heading.level,
|
|
229
266
|
text: heading.text,
|
|
@@ -231,6 +268,7 @@ export function createDocumentOutline(markdown: string): DocumentOutline {
|
|
|
231
268
|
inferred: heading.inferred,
|
|
232
269
|
};
|
|
233
270
|
});
|
|
271
|
+
|
|
234
272
|
return {
|
|
235
273
|
totalWords: countDocumentWords(lines),
|
|
236
274
|
totalHeadings: collection.total,
|
package/src/redact.ts
CHANGED
|
@@ -17,14 +17,17 @@ const SENSITIVE_QUERY_KEYS = new Set([
|
|
|
17
17
|
"token",
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
+
/** Replaces sensitive query-parameter values and reports whether anything changed. */
|
|
20
21
|
function redactSensitiveParams(params: URLSearchParams): boolean {
|
|
21
22
|
let redacted = false;
|
|
23
|
+
|
|
22
24
|
for (const key of params.keys()) {
|
|
23
25
|
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
|
|
24
26
|
params.set(key, "REDACTED");
|
|
25
27
|
redacted = true;
|
|
26
28
|
}
|
|
27
29
|
}
|
|
30
|
+
|
|
28
31
|
return redacted;
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -37,6 +40,7 @@ function redactSensitiveParams(params: URLSearchParams): boolean {
|
|
|
37
40
|
*/
|
|
38
41
|
export function redactUrlForDisplay(value: string | URL): string {
|
|
39
42
|
let url: URL;
|
|
43
|
+
|
|
40
44
|
try {
|
|
41
45
|
url = value instanceof URL ? new URL(value.href) : new URL(value);
|
|
42
46
|
} catch {
|
|
@@ -46,9 +50,12 @@ export function redactUrlForDisplay(value: string | URL): string {
|
|
|
46
50
|
url.username = "";
|
|
47
51
|
url.password = "";
|
|
48
52
|
redactSensitiveParams(url.searchParams);
|
|
53
|
+
|
|
49
54
|
if (url.hash.length > 1) {
|
|
50
55
|
const fragmentParams = new URLSearchParams(url.hash.slice(1));
|
|
56
|
+
|
|
51
57
|
if (redactSensitiveParams(fragmentParams)) url.hash = fragmentParams.toString();
|
|
52
58
|
}
|
|
59
|
+
|
|
53
60
|
return url.href;
|
|
54
61
|
}
|
package/src/render.ts
CHANGED
|
@@ -13,14 +13,17 @@ export function formatCollapsibleOutput(
|
|
|
13
13
|
theme: RenderTheme,
|
|
14
14
|
): string {
|
|
15
15
|
const lines = output.replace(/\r\n/g, "\n").split("\n");
|
|
16
|
+
|
|
16
17
|
while (lines.at(-1) === "") lines.pop();
|
|
17
18
|
const totalLines = lines.length;
|
|
18
19
|
const visibleLines = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
|
|
19
20
|
let text = visibleLines.map((line) => theme.fg("toolOutput", line)).join("\n");
|
|
20
21
|
|
|
21
22
|
const remaining = totalLines - visibleLines.length;
|
|
23
|
+
|
|
22
24
|
if (remaining > 0) {
|
|
23
25
|
text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
|
|
24
26
|
}
|
|
27
|
+
|
|
25
28
|
return `\n${text}`;
|
|
26
29
|
}
|