pi-bro 0.20.0 → 0.22.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/CHANGELOG.md +71 -0
- package/README.md +140 -64
- package/backend.ts +158 -232
- package/bro.ts +411 -1421
- package/config-ui.ts +325 -0
- package/docs/README.md +2 -0
- package/docs/guided-review.md +131 -0
- package/docs/pig-compatibility.md +1 -0
- package/package.json +13 -4
- package/prompt.ts +65 -11
- package/review-modal.ts +1272 -0
- package/review-source.ts +296 -0
- package/review-ui.ts +519 -0
- package/review.ts +586 -0
- package/settings.ts +505 -0
- package/sources.ts +367 -0
- package/util.ts +26 -0
package/sources.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { readFile, stat, realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, extname, sep } from "node:path";
|
|
3
|
+
import { request as httpRequest, type IncomingMessage } from "node:http";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { lookup } from "node:dns/promises";
|
|
6
|
+
import { BlockList, isIP } from "node:net";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import { extractText } from "unpdf";
|
|
9
|
+
import mammoth from "mammoth";
|
|
10
|
+
import { parseHTML } from "linkedom";
|
|
11
|
+
import { Defuddle } from "defuddle/node";
|
|
12
|
+
import { errorMessage, fileError, unquote } from "./util.ts";
|
|
13
|
+
|
|
14
|
+
export type BroSource = { text: string; label?: string };
|
|
15
|
+
|
|
16
|
+
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
17
|
+
export const MAX_WEB_BYTES = 5 * 1024 * 1024;
|
|
18
|
+
export const MAX_WEB_ELEMENTS = 100_000;
|
|
19
|
+
export const MAX_WEB_REDIRECTS = 5;
|
|
20
|
+
export const WEB_TIMEOUT_MS = 25_000;
|
|
21
|
+
export const MAX_TEXT_LENGTH = 100_000;
|
|
22
|
+
export const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
|
|
23
|
+
export const SNIFFABLE_FILE_EXTENSIONS = new Set([...TEXT_EXTENSIONS, ".pdf", ".docx"]);
|
|
24
|
+
export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
25
|
+
|
|
26
|
+
export async function extractDocumentText(input: string, cwd: string, signal?: AbortSignal): Promise<string> {
|
|
27
|
+
const requested = unquote(input.trim());
|
|
28
|
+
if (!requested) throw new Error("Use /bro file <path>.");
|
|
29
|
+
|
|
30
|
+
let root: string;
|
|
31
|
+
let path: string;
|
|
32
|
+
try {
|
|
33
|
+
root = await realpath(cwd);
|
|
34
|
+
path = await realpath(resolve(cwd, requested));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw fileError(requested, error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const fromRoot = relative(root, path);
|
|
40
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
41
|
+
throw new Error("Bro can read only files inside the current workspace.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let info;
|
|
45
|
+
try {
|
|
46
|
+
info = await stat(path);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw fileError(requested, error);
|
|
49
|
+
}
|
|
50
|
+
if (!info.isFile()) throw new Error(`Not a regular file: ${requested}`);
|
|
51
|
+
if (info.size > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
|
|
52
|
+
|
|
53
|
+
let buffer: Buffer;
|
|
54
|
+
try {
|
|
55
|
+
buffer = await readFile(path, { signal });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (signal?.aborted) throw new Error("Canceled.");
|
|
58
|
+
throw fileError(requested, error);
|
|
59
|
+
}
|
|
60
|
+
if (buffer.byteLength > MAX_FILE_BYTES) throw new Error("File is larger than Bro's 10 MiB limit.");
|
|
61
|
+
if (signal?.aborted) throw new Error("Canceled.");
|
|
62
|
+
|
|
63
|
+
const extension = extname(path).toLowerCase();
|
|
64
|
+
let text: string;
|
|
65
|
+
try {
|
|
66
|
+
if (TEXT_EXTENSIONS.has(extension)) {
|
|
67
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
68
|
+
} else if (extension === ".pdf") {
|
|
69
|
+
text = (await extractText(new Uint8Array(buffer), { mergePages: true })).text;
|
|
70
|
+
} else if (extension === ".docx") {
|
|
71
|
+
text = (await mammoth.extractRawText({ buffer })).value;
|
|
72
|
+
} else {
|
|
73
|
+
throw new Error("Unsupported file type. Use .md, .markdown, .txt, .pdf, or .docx.");
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof Error && error.message.startsWith("Unsupported file type.")) throw error;
|
|
77
|
+
throw new Error(`Could not extract text from ${requested}: ${errorMessage(error)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
text = text.trim();
|
|
81
|
+
if (!text) throw new Error("No readable text found. Scanned PDFs need OCR, which Bro does not support.");
|
|
82
|
+
if (text.length > MAX_TEXT_LENGTH) throw new Error("Extracted text is longer than Bro's 100,000-character limit.");
|
|
83
|
+
return text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function isWorkspaceFile(input: string, cwd: string): Promise<boolean> {
|
|
87
|
+
// ponytail: duplicates extractDocumentText's workspace guard rather than sharing its error semantics.
|
|
88
|
+
try {
|
|
89
|
+
const path = await realpath(resolve(cwd, input));
|
|
90
|
+
const fromRoot = relative(await realpath(cwd), path);
|
|
91
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const info = await stat(path);
|
|
95
|
+
return info.isFile() && SNIFFABLE_FILE_EXTENSIONS.has(extname(path).toLowerCase());
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const NON_PUBLIC_ADDRESSES = new BlockList();
|
|
102
|
+
for (const [network, prefix] of [
|
|
103
|
+
["0.0.0.0", 8],
|
|
104
|
+
["10.0.0.0", 8],
|
|
105
|
+
["100.64.0.0", 10],
|
|
106
|
+
["127.0.0.0", 8],
|
|
107
|
+
["169.254.0.0", 16],
|
|
108
|
+
["172.16.0.0", 12],
|
|
109
|
+
["192.0.0.0", 24],
|
|
110
|
+
["192.0.2.0", 24],
|
|
111
|
+
["192.31.196.0", 24],
|
|
112
|
+
["192.52.193.0", 24],
|
|
113
|
+
["192.88.99.0", 24],
|
|
114
|
+
["192.168.0.0", 16],
|
|
115
|
+
["192.175.48.0", 24],
|
|
116
|
+
["198.18.0.0", 15],
|
|
117
|
+
["198.51.100.0", 24],
|
|
118
|
+
["203.0.113.0", 24],
|
|
119
|
+
["224.0.0.0", 4],
|
|
120
|
+
["240.0.0.0", 4],
|
|
121
|
+
] as const) {
|
|
122
|
+
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
123
|
+
}
|
|
124
|
+
for (const [network, prefix] of [
|
|
125
|
+
["::", 128],
|
|
126
|
+
["::1", 128],
|
|
127
|
+
["64:ff9b::", 96],
|
|
128
|
+
["64:ff9b:1::", 48],
|
|
129
|
+
["100::", 64],
|
|
130
|
+
["2001::", 23],
|
|
131
|
+
["2001:db8::", 32],
|
|
132
|
+
["2002::", 16],
|
|
133
|
+
["3fff::", 20],
|
|
134
|
+
["5f00::", 16],
|
|
135
|
+
["fc00::", 7],
|
|
136
|
+
["fe80::", 10],
|
|
137
|
+
["ff00::", 8],
|
|
138
|
+
] as const) {
|
|
139
|
+
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isPublicWebAddress(address: string): boolean {
|
|
143
|
+
const family = isIP(address);
|
|
144
|
+
return family === 4
|
|
145
|
+
? !NON_PUBLIC_ADDRESSES.check(address, "ipv4")
|
|
146
|
+
: family === 6
|
|
147
|
+
? !NON_PUBLIC_ADDRESSES.check(address, "ipv6")
|
|
148
|
+
: false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseWebUrl(input: string): URL {
|
|
152
|
+
const requested = unquote(input.trim());
|
|
153
|
+
if (!requested) throw new Error("Use /bro url <url>.");
|
|
154
|
+
|
|
155
|
+
let url: URL;
|
|
156
|
+
try {
|
|
157
|
+
url = new URL(requested);
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error("That is not a valid URL. Use /bro url https://example.com/article.");
|
|
160
|
+
}
|
|
161
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
162
|
+
throw new Error("Bro can read only public HTTP or HTTPS webpages.");
|
|
163
|
+
}
|
|
164
|
+
if (url.username || url.password) {
|
|
165
|
+
throw new Error("Bro does not accept URLs containing usernames or passwords.");
|
|
166
|
+
}
|
|
167
|
+
url.hash = "";
|
|
168
|
+
return url;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function looksLikeWebUrl(input: string): boolean {
|
|
172
|
+
// Structurally http(s) only: credential or syntax problems must surface as url errors, not text leaks.
|
|
173
|
+
let url: URL;
|
|
174
|
+
try {
|
|
175
|
+
url = new URL(input);
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function parseWebRedirect(current: URL, location: string): URL {
|
|
183
|
+
const next = parseWebUrl(new URL(location, current).href);
|
|
184
|
+
if (current.protocol === "https:" && next.protocol !== "https:") {
|
|
185
|
+
throw new Error("Bro refused an insecure HTTPS-to-HTTP redirect.");
|
|
186
|
+
}
|
|
187
|
+
return next;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function headerValue(value: string | string[] | undefined): string {
|
|
191
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function resolvePublicAddress(hostname: string): Promise<{ address: string; family: 4 | 6 }> {
|
|
195
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
196
|
+
let addresses: Array<{ address: string; family: number }>;
|
|
197
|
+
try {
|
|
198
|
+
addresses = await lookup(host, { all: true, verbatim: true });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new Error(`Could not resolve webpage host: ${errorMessage(error)}`);
|
|
201
|
+
}
|
|
202
|
+
if (!addresses.length) throw new Error("The webpage host has no network address.");
|
|
203
|
+
if (addresses.some((item) => !isPublicWebAddress(item.address))) {
|
|
204
|
+
throw new Error("Bro cannot connect to local, private, or reserved network addresses.");
|
|
205
|
+
}
|
|
206
|
+
return { address: addresses[0].address, family: addresses[0].family === 6 ? 6 : 4 };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requestWebPage(url: URL, address: { address: string; family: 4 | 6 }, signal: AbortSignal): Promise<IncomingMessage> {
|
|
210
|
+
return new Promise((resolveResponse, rejectResponse) => {
|
|
211
|
+
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
212
|
+
url,
|
|
213
|
+
{
|
|
214
|
+
method: "GET",
|
|
215
|
+
signal,
|
|
216
|
+
headers: {
|
|
217
|
+
Accept: "text/html,application/xhtml+xml",
|
|
218
|
+
"Accept-Encoding": "identity",
|
|
219
|
+
"User-Agent": "pi-bro URL reader (+https://github.com/tranhoangnguyen03/pi-bro)",
|
|
220
|
+
},
|
|
221
|
+
lookup: (_hostname, options, callback) => {
|
|
222
|
+
if (options.all) callback(null, [address]);
|
|
223
|
+
else callback(null, address.address, address.family);
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
resolveResponse,
|
|
227
|
+
);
|
|
228
|
+
request.once("error", rejectResponse);
|
|
229
|
+
request.end();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function readWebBody(response: IncomingMessage): Promise<Buffer> {
|
|
234
|
+
const contentEncoding = headerValue(response.headers["content-encoding"]).trim().toLowerCase();
|
|
235
|
+
if (contentEncoding && contentEncoding !== "identity") {
|
|
236
|
+
response.destroy();
|
|
237
|
+
throw new Error(`Bro cannot read this page's ${contentEncoding} response encoding.`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const contentLength = Number.parseInt(headerValue(response.headers["content-length"]), 10);
|
|
241
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_WEB_BYTES) {
|
|
242
|
+
response.destroy();
|
|
243
|
+
throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const chunks: Buffer[] = [];
|
|
247
|
+
let size = 0;
|
|
248
|
+
try {
|
|
249
|
+
for await (const chunk of response) {
|
|
250
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
251
|
+
size += buffer.byteLength;
|
|
252
|
+
if (size > MAX_WEB_BYTES) throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
|
|
253
|
+
chunks.push(buffer);
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
response.destroy();
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
return Buffer.concat(chunks, size);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function decodeWebHtml(buffer: Buffer, contentType: string): string {
|
|
263
|
+
const headerCharset = /charset\s*=\s*["']?([^\s;"']+)/i.exec(contentType)?.[1];
|
|
264
|
+
const head = new TextDecoder("latin1").decode(buffer.subarray(0, 2048));
|
|
265
|
+
const metaCharset = /<meta[^>]+charset\s*=\s*["']?([^\s;"'>]+)/i.exec(head)?.[1]
|
|
266
|
+
?? /<meta[^>]+content\s*=\s*["'][^"']*charset=([^\s;"']+)/i.exec(head)?.[1];
|
|
267
|
+
const charset = headerCharset ?? metaCharset ?? "utf-8";
|
|
268
|
+
try {
|
|
269
|
+
return new TextDecoder(charset).decode(buffer);
|
|
270
|
+
} catch {
|
|
271
|
+
throw new Error(`Bro does not support this page's ${charset} character encoding.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function assertWebElementLimit(html: string): void {
|
|
276
|
+
let count = 0;
|
|
277
|
+
for (let index = 0; index < html.length - 1; index++) {
|
|
278
|
+
if (html.charCodeAt(index) !== 60) continue;
|
|
279
|
+
const next = html.charCodeAt(index + 1) | 32;
|
|
280
|
+
if (next >= 97 && next <= 122 && ++count > MAX_WEB_ELEMENTS) {
|
|
281
|
+
throw new Error("Webpage is too complex for Bro to read safely.");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function fetchPublicHtml(startUrl: URL, signal: AbortSignal): Promise<{ html: string; url: URL }> {
|
|
287
|
+
let url = startUrl;
|
|
288
|
+
const visited = new Set<string>();
|
|
289
|
+
|
|
290
|
+
for (let redirects = 0; ; redirects++) {
|
|
291
|
+
if (visited.has(url.href)) throw new Error("Webpage redirect loop detected.");
|
|
292
|
+
visited.add(url.href);
|
|
293
|
+
const address = await resolvePublicAddress(url.hostname);
|
|
294
|
+
let response: IncomingMessage;
|
|
295
|
+
try {
|
|
296
|
+
response = await requestWebPage(url, address, signal);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
throw new Error(`Could not fetch webpage: ${errorMessage(error)}`);
|
|
299
|
+
}
|
|
300
|
+
const status = response.statusCode ?? 0;
|
|
301
|
+
|
|
302
|
+
if (REDIRECT_STATUSES.has(status)) {
|
|
303
|
+
response.destroy();
|
|
304
|
+
if (redirects >= MAX_WEB_REDIRECTS) throw new Error("Webpage redirected too many times.");
|
|
305
|
+
const location = headerValue(response.headers.location);
|
|
306
|
+
if (!location) throw new Error(`Webpage returned HTTP ${status} without a redirect location.`);
|
|
307
|
+
url = parseWebRedirect(url, location);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (status < 200 || status >= 300) {
|
|
312
|
+
response.destroy();
|
|
313
|
+
if (status === 401 || status === 403) {
|
|
314
|
+
throw new Error(`Webpage returned HTTP ${status}. It may require a login or block automated readers.`);
|
|
315
|
+
}
|
|
316
|
+
if (status === 429) throw new Error("Webpage returned HTTP 429 and is limiting automated requests.");
|
|
317
|
+
throw new Error(`Webpage returned HTTP ${status}.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const contentType = headerValue(response.headers["content-type"]);
|
|
321
|
+
const mime = contentType.split(";", 1)[0].trim().toLowerCase();
|
|
322
|
+
if (mime !== "text/html" && mime !== "application/xhtml+xml") {
|
|
323
|
+
response.destroy();
|
|
324
|
+
throw new Error(`Unsupported webpage content type: ${mime || "missing"}.`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const html = decodeWebHtml(await readWebBody(response), contentType);
|
|
328
|
+
assertWebElementLimit(html);
|
|
329
|
+
return { html, url };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function extractWebHtml(html: string, url: string): Promise<BroSource> {
|
|
334
|
+
assertWebElementLimit(html);
|
|
335
|
+
const parsedUrl = parseWebUrl(url);
|
|
336
|
+
const { document } = parseHTML(html);
|
|
337
|
+
const result = await Defuddle(document, parsedUrl.href, {
|
|
338
|
+
markdown: true,
|
|
339
|
+
removeImages: true,
|
|
340
|
+
includeReplies: false,
|
|
341
|
+
useAsync: false,
|
|
342
|
+
});
|
|
343
|
+
const text = (result.contentMarkdown || result.content || "").trim();
|
|
344
|
+
if (!text) {
|
|
345
|
+
throw new Error("Bro found no readable page content. The page may require JavaScript, a login, or block automated readers.");
|
|
346
|
+
}
|
|
347
|
+
if (text.length > MAX_TEXT_LENGTH) {
|
|
348
|
+
throw new Error("Extracted webpage text is longer than Bro's 100,000-character limit.");
|
|
349
|
+
}
|
|
350
|
+
const title = result.title
|
|
351
|
+
? stripVTControlCharacters(result.title).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200)
|
|
352
|
+
: undefined;
|
|
353
|
+
return { text, label: [parsedUrl.hostname, title].filter(Boolean).join(" · ") };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function extractWebPage(input: string, signal?: AbortSignal): Promise<BroSource> {
|
|
357
|
+
const timeout = AbortSignal.timeout(WEB_TIMEOUT_MS);
|
|
358
|
+
const combinedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
359
|
+
try {
|
|
360
|
+
const fetched = await fetchPublicHtml(parseWebUrl(input), combinedSignal);
|
|
361
|
+
return await extractWebHtml(fetched.html, fetched.url.href);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (signal?.aborted) throw new Error("Canceled.");
|
|
364
|
+
if (timeout.aborted) throw new Error("Webpage took longer than 25 seconds to respond.");
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
package/util.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function errorMessage(error: unknown): string {
|
|
6
|
+
return error instanceof Error ? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function withDoctor(error: unknown): string {
|
|
10
|
+
const message = errorMessage(error);
|
|
11
|
+
return message.includes("/bro doctor") ? message : `${message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function fileError(path: string, error: unknown): Error {
|
|
15
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
16
|
+
if (code === "ENOENT") return new Error(`File not found: ${path}`);
|
|
17
|
+
if (code === "EACCES" || code === "EPERM") return new Error(`File is not readable: ${path}`);
|
|
18
|
+
return new Error(`Could not read ${path}: ${errorMessage(error)}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function unquote(value: string): string {
|
|
22
|
+
if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"') || (value[0] === "'" && value.at(-1) === "'"))) {
|
|
23
|
+
return value.slice(1, -1);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|