@linxiraos/pi-utils 1.1.26 → 1.2.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 +6 -4
- package/README.md +1 -1
- package/dist/types/ar/index.d.ts +1 -0
- package/dist/types/ar/zip-package.d.ts +21 -0
- package/dist/types/color.d.ts +2 -0
- package/dist/types/dirs.d.ts +37 -12
- package/dist/types/docx/converter.d.ts +2 -0
- package/dist/types/dom/core.d.ts +15 -4
- package/dist/types/dom/parser.d.ts +5 -3
- package/dist/types/dom/selector.d.ts +7 -1
- package/dist/types/logger/rotating-file.d.ts +4 -2
- package/dist/types/loop-phase.d.ts +12 -7
- package/dist/types/marked/core.d.ts +7 -6
- package/dist/types/marked-list.d.ts +38 -0
- package/dist/types/math-delimiters.d.ts +48 -0
- package/dist/types/procmgr.d.ts +14 -20
- package/dist/types/readability/types.d.ts +2 -0
- package/dist/types/temp.d.ts +7 -0
- package/dist/types/turndown/types.d.ts +7 -0
- package/dist/types/vterm/buffer.d.ts +16 -5
- package/dist/types/vterm/query-responder.d.ts +15 -0
- package/package.json +2 -2
- package/src/ar/index.ts +1 -0
- package/src/ar/open.ts +29 -8
- package/src/ar/zip-package.ts +57 -0
- package/src/chalk.ts +66 -23
- package/src/color.ts +6 -0
- package/src/dirs.ts +93 -24
- package/src/docx/converter.ts +26 -12
- package/src/dom/core.ts +354 -111
- package/src/dom/parser.ts +42 -19
- package/src/dom/selector.ts +328 -95
- package/src/fetch-retry.ts +2 -0
- package/src/format.ts +5 -1
- package/src/json-lexer.ts +54 -0
- package/src/json-parse.ts +21 -6
- package/src/logger/rotating-file.ts +5 -3
- package/src/logger.ts +39 -23
- package/src/loop-phase.ts +66 -21
- package/src/marked/core.ts +45 -19
- package/src/marked-list.ts +73 -0
- package/src/math-delimiters.ts +125 -5
- package/src/module-timer.ts +5 -5
- package/src/postmortem.ts +1 -1
- package/src/process-name.ts +2 -2
- package/src/procmgr.ts +58 -4
- package/src/prompt.ts +3 -1
- package/src/ptree.ts +28 -35
- package/src/readability/readability.ts +349 -39
- package/src/readability/types.ts +2 -0
- package/src/runtime-install.ts +2 -2
- package/src/sqlite.ts +58 -1
- package/src/stream.ts +34 -13
- package/src/temp.ts +13 -0
- package/src/template.ts +7 -4
- package/src/timing-buffer.ts +1 -1
- package/src/tls-fetch.ts +1 -1
- package/src/turndown/gfm.ts +16 -5
- package/src/turndown/html.ts +28 -8
- package/src/turndown/service.ts +43 -15
- package/src/turndown/types.ts +7 -0
- package/src/vterm/buffer.ts +31 -11
- package/src/vterm/query-responder.ts +31 -2
- package/src/vterm/terminal.ts +213 -100
package/src/json-lexer.ts
CHANGED
|
@@ -32,6 +32,16 @@ export const LBRACKET = 0x5b;
|
|
|
32
32
|
export const RBRACKET = 0x5d;
|
|
33
33
|
const U = 0x75;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Minimum remaining string length for bulk-skipping ordinary characters in
|
|
37
|
+
* {@link JsonLexer.string} with two engine `indexOf` scans (nearest quote or
|
|
38
|
+
* backslash) instead of one `charCodeAt` per character. Below this the
|
|
39
|
+
* `indexOf` setup costs more than the per-char loop on quote-dense input;
|
|
40
|
+
* long string runs — the multi-KB tool-argument payloads that dominate
|
|
41
|
+
* streaming re-parses — scan several times faster above it.
|
|
42
|
+
*/
|
|
43
|
+
const STRING_BULK_SCAN_MIN = 32;
|
|
44
|
+
|
|
35
45
|
/** Valid chars after `\` in a strict JSON escape: `" \ / b f n r t u`. */
|
|
36
46
|
export const VALID_ESCAPE_CHAR = new Uint8Array(128);
|
|
37
47
|
for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1;
|
|
@@ -176,6 +186,17 @@ const enum QuoteLook {
|
|
|
176
186
|
*/
|
|
177
187
|
export class JsonLexer {
|
|
178
188
|
pos: number;
|
|
189
|
+
/**
|
|
190
|
+
* Shared across {@link string} calls: `src` has no backslash in
|
|
191
|
+
* `[#backslashFrom, #backslashAt)` and `#backslashAt` is a backslash or
|
|
192
|
+
* `src.length`. An escape-free run can extend past its string's closing
|
|
193
|
+
* quote to the end of the document, so searching per string would rescan
|
|
194
|
+
* the same tail for every sibling string (quadratic on objects with many
|
|
195
|
+
* short values). `src` is immutable, so the range never goes stale; a
|
|
196
|
+
* cursor outside it (e.g. a rollback) re-searches.
|
|
197
|
+
*/
|
|
198
|
+
#backslashFrom = 0;
|
|
199
|
+
#backslashAt = -1;
|
|
179
200
|
|
|
180
201
|
constructor(
|
|
181
202
|
readonly src: string,
|
|
@@ -218,7 +239,40 @@ export class JsonLexer {
|
|
|
218
239
|
// malformed structure fails loudly instead of silently swallowing
|
|
219
240
|
// commas/colons or sibling members.
|
|
220
241
|
const lenient = quote === SQUOTE || this.mode === "streaming";
|
|
242
|
+
// Any delimiter code unit, not just `"`/`'`; a value above 0xFFFF matches
|
|
243
|
+
// no code unit, so such a string never closes.
|
|
244
|
+
const quoteChar = String.fromCharCode(quote);
|
|
245
|
+
// Next quote / backslash at or after the cursor, cached across iterations
|
|
246
|
+
// and re-searched only once the cursor passes them, so every character is
|
|
247
|
+
// scanned at most once per kind and the bulk path stays linear even on
|
|
248
|
+
// escape-dense input (re-searching the far closing quote after each
|
|
249
|
+
// escape would be quadratic). `n` means "none before end of input". The
|
|
250
|
+
// backslash search result is shared across calls via `#backslashAt`.
|
|
251
|
+
let nextQuote = quoteChar.charCodeAt(0) === quote ? -1 : n;
|
|
252
|
+
let nextBackslash = -1;
|
|
221
253
|
while (i < n) {
|
|
254
|
+
// Bulk-skip a run of ordinary characters: jump to the nearest quote or
|
|
255
|
+
// backslash instead of one `charCodeAt` per character. Only entered on
|
|
256
|
+
// an ordinary character, so back-to-back escapes never pay an `indexOf`;
|
|
257
|
+
// short tails keep the per-char loop. Landing on a special char falls
|
|
258
|
+
// through to the unchanged handling below, so tokens are identical.
|
|
259
|
+
const c0 = s.charCodeAt(i);
|
|
260
|
+
if (c0 !== BACKSLASH && c0 !== quote && n - i > STRING_BULK_SCAN_MIN) {
|
|
261
|
+
if (nextQuote < i) {
|
|
262
|
+
const q = s.indexOf(quoteChar, i);
|
|
263
|
+
nextQuote = q === -1 ? n : q;
|
|
264
|
+
}
|
|
265
|
+
if (nextBackslash < i) {
|
|
266
|
+
if (i < this.#backslashFrom || i > this.#backslashAt) {
|
|
267
|
+
const b = s.indexOf("\\", i);
|
|
268
|
+
this.#backslashFrom = i;
|
|
269
|
+
this.#backslashAt = b === -1 ? n : b;
|
|
270
|
+
}
|
|
271
|
+
nextBackslash = this.#backslashAt;
|
|
272
|
+
}
|
|
273
|
+
i = nextQuote < nextBackslash ? nextQuote : nextBackslash;
|
|
274
|
+
if (i >= n) break;
|
|
275
|
+
}
|
|
222
276
|
const cc = s.charCodeAt(i);
|
|
223
277
|
if (cc !== BACKSLASH && cc !== quote) {
|
|
224
278
|
i++;
|
package/src/json-parse.ts
CHANGED
|
@@ -309,15 +309,30 @@ export function parseJsonWithRepair<T>(json: string): T {
|
|
|
309
309
|
export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
|
|
310
310
|
const trimmed = partialJson?.trimStart();
|
|
311
311
|
if (!trimmed) return {} as T;
|
|
312
|
+
// A container can only be strict JSON once its closer arrived; skip the
|
|
313
|
+
// doomed full-buffer JSON.parse (and its SyntaxError) while it streams.
|
|
314
|
+
const first = trimmed.charCodeAt(0);
|
|
315
|
+
if ((first !== 0x7b && first !== 0x5b) || canCloseContainer(trimmed)) {
|
|
316
|
+
try {
|
|
317
|
+
return JSON.parse(trimmed) as T;
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
312
320
|
try {
|
|
313
|
-
return
|
|
321
|
+
return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
|
|
314
322
|
} catch {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
323
|
+
return {} as T;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** True when the last non-whitespace character is `}` or `]`. */
|
|
328
|
+
function canCloseContainer(text: string): boolean {
|
|
329
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
330
|
+
const code = text.charCodeAt(i);
|
|
331
|
+
// JSON whitespace: space, \t, \n, \r
|
|
332
|
+
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue;
|
|
333
|
+
return code === 0x7d || code === 0x5d;
|
|
320
334
|
}
|
|
335
|
+
return false;
|
|
321
336
|
}
|
|
322
337
|
|
|
323
338
|
/**
|
|
@@ -109,11 +109,13 @@ export class RotatingFileSink {
|
|
|
109
109
|
/**
|
|
110
110
|
* Append one already-formatted log record. A batching sink buffers it unless
|
|
111
111
|
* `flushNow` is set or the buffer reached its size threshold; either way the
|
|
112
|
-
* whole buffer, this record included, is then written in order.
|
|
112
|
+
* whole buffer, this record included, is then written in order. `day` is the
|
|
113
|
+
* record's `localDay` key, so callers that already took the record's `Date`
|
|
114
|
+
* reuse it.
|
|
113
115
|
*/
|
|
114
|
-
write(line: string, flushNow = false): void {
|
|
116
|
+
write(line: string, flushNow = false, day = localDay(new Date())): void {
|
|
115
117
|
if (this.#closed) return;
|
|
116
|
-
this.#selectFile(
|
|
118
|
+
this.#selectFile(day);
|
|
117
119
|
const record = `${line}${os.EOL}`;
|
|
118
120
|
const bytes = Buffer.byteLength(record, "utf8");
|
|
119
121
|
this.#pending += record;
|
package/src/logger.ts
CHANGED
|
@@ -209,9 +209,28 @@ function jsonReplacer(_key: string, value: unknown): unknown {
|
|
|
209
209
|
return value;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
// Values visited by the Error pre-scan before falling back to the replacer.
|
|
213
|
+
const ERROR_SCAN_BUDGET = 128;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether `value` may contain an {@link Error} that {@link jsonReplacer} must
|
|
217
|
+
* unwrap. Walks what `JSON.stringify` serializes (arrays and own enumerable
|
|
218
|
+
* keys); objects with a `toJSON` other than `Date` may return an Error, and an
|
|
219
|
+
* exhausted budget answers true.
|
|
220
|
+
*/
|
|
221
|
+
function mayContainError(root: unknown): boolean {
|
|
222
|
+
const stack: unknown[] = [root];
|
|
223
|
+
let budget = ERROR_SCAN_BUDGET;
|
|
224
|
+
while (stack.length > 0) {
|
|
225
|
+
const value = stack.pop();
|
|
226
|
+
if (value === null || typeof value !== "object") continue;
|
|
227
|
+
if (value instanceof Error) return true;
|
|
228
|
+
if (--budget < 0) return true;
|
|
229
|
+
if (value instanceof Date) continue;
|
|
230
|
+
if ("toJSON" in value && typeof value.toJSON === "function") return true;
|
|
231
|
+
for (const child of Object.values(value)) stack.push(child);
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
215
234
|
}
|
|
216
235
|
|
|
217
236
|
function padTimestampPart(value: number, width = 2): string {
|
|
@@ -232,33 +251,30 @@ function formatLocalTimestamp(date: Date): string {
|
|
|
232
251
|
|
|
233
252
|
const FORMAT_TOKEN_PATTERN = /%[scdjifoO%]/;
|
|
234
253
|
|
|
235
|
-
|
|
254
|
+
/** Serialize one log record: timestamp, level, pid, message, then context fields. */
|
|
255
|
+
function formatLogRecord(
|
|
236
256
|
level: LogLevel,
|
|
237
257
|
message: string,
|
|
238
258
|
context: Record<string, unknown> | undefined,
|
|
239
|
-
|
|
259
|
+
timestamp: string,
|
|
260
|
+
): string {
|
|
240
261
|
const metadata =
|
|
241
262
|
!FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
|
|
242
|
-
const info = Object.assign({}, metadata, { level, message }) as NormalizedLogInfo;
|
|
243
|
-
if (metadata?.message) info.message = `${message} ${metadata.message}`;
|
|
244
|
-
if (metadata?.stack) info.stack = metadata.stack;
|
|
245
|
-
if (metadata?.cause) info.cause = metadata.cause;
|
|
246
|
-
return info;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function formatLogInfo(info: NormalizedLogInfo): string {
|
|
250
|
-
const timestamp = formatLocalTimestamp(new Date());
|
|
251
|
-
info.timestamp = timestamp;
|
|
252
263
|
const entry: Record<string, unknown> = {
|
|
253
264
|
timestamp,
|
|
254
|
-
level
|
|
265
|
+
level,
|
|
255
266
|
pid: process.pid,
|
|
256
|
-
message:
|
|
267
|
+
message: metadata?.message ? `${message} ${metadata.message}` : message,
|
|
257
268
|
};
|
|
258
|
-
|
|
259
|
-
|
|
269
|
+
if (metadata) {
|
|
270
|
+
for (const key of Object.keys(metadata)) {
|
|
271
|
+
if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = metadata[key];
|
|
272
|
+
}
|
|
273
|
+
// An Error passed as the context carries these as non-enumerable fields.
|
|
274
|
+
if (metadata.stack) entry.stack = metadata.stack;
|
|
275
|
+
if (metadata.cause) entry.cause = metadata.cause;
|
|
260
276
|
}
|
|
261
|
-
return JSON.stringify(entry, jsonReplacer)
|
|
277
|
+
return mayContainError(entry) ? JSON.stringify(entry, jsonReplacer) : JSON.stringify(entry);
|
|
262
278
|
}
|
|
263
279
|
|
|
264
280
|
/** Longest a buffered file record waits before it is written. */
|
|
@@ -361,9 +377,9 @@ function emitLocally(level: LogLevel, message: string, context: Record<string, u
|
|
|
361
377
|
const rank = LOG_LEVEL_RANK[level];
|
|
362
378
|
const file = rank <= transports.fileLevelRank ? transports.file : undefined;
|
|
363
379
|
if (!file && !transports.console) return;
|
|
364
|
-
const
|
|
365
|
-
const line =
|
|
366
|
-
file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting);
|
|
380
|
+
const now = new Date();
|
|
381
|
+
const line = formatLogRecord(level, message, context, formatLocalTimestamp(now));
|
|
382
|
+
file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
|
|
367
383
|
if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
|
|
368
384
|
}
|
|
369
385
|
|
package/src/loop-phase.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
/** A label and its exclusive in-window time, as named by {@link takeLoopPhaseAttribution}. */
|
|
2
|
+
export interface LoopPhaseAttribution {
|
|
3
|
+
readonly label: string;
|
|
4
|
+
/** Total time this label was the innermost active phase inside the late window [start, end]. */
|
|
5
|
+
readonly ms: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
9
|
+
* Process-global synchronous loop phases. The watchdog arms a late window and
|
|
10
|
+
* reads attribution via {@link takeLoopPhaseAttribution}; attribution totals
|
|
11
|
+
* only the innermost label's time inside that window. A label is named only
|
|
12
|
+
* when its total outweighs all unlabeled time, otherwise the block remains
|
|
13
|
+
* "unknown". Disarmed push/pop do not read the clock.
|
|
6
14
|
*
|
|
7
15
|
* This is deliberately a process-global stack and not part of the logger span
|
|
8
16
|
* machinery: `main.ts` ends timing spans before the interactive TUI starts, so
|
|
@@ -16,19 +24,31 @@
|
|
|
16
24
|
* work, push/pop around each synchronous chunk, not across the await.
|
|
17
25
|
*/
|
|
18
26
|
const stack: string[] = [];
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
let
|
|
27
|
+
const defaultClock = () => performance.now();
|
|
28
|
+
let windowStart = Number.POSITIVE_INFINITY;
|
|
29
|
+
let segmentStart = windowStart;
|
|
30
|
+
let clock = defaultClock;
|
|
31
|
+
const totals = new Map<string, number>();
|
|
32
|
+
let labeledMs = 0;
|
|
33
|
+
|
|
34
|
+
function boundary(t: number): void {
|
|
35
|
+
const ms = Math.max(0, t - Math.max(segmentStart, windowStart));
|
|
36
|
+
const label = stack[stack.length - 1];
|
|
37
|
+
if (label !== undefined && ms > 0) {
|
|
38
|
+
totals.set(label, (totals.get(label) ?? 0) + ms);
|
|
39
|
+
labeledMs += ms;
|
|
40
|
+
}
|
|
41
|
+
segmentStart = t;
|
|
42
|
+
}
|
|
25
43
|
|
|
26
44
|
export function pushLoopPhase(label: string): void {
|
|
45
|
+
if (windowStart !== Number.POSITIVE_INFINITY) boundary(clock());
|
|
27
46
|
stack.push(label);
|
|
28
|
-
recentPhase = label;
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
export function popLoopPhase(): void {
|
|
50
|
+
if (stack.length === 0) return;
|
|
51
|
+
if (windowStart !== Number.POSITIVE_INFINITY) boundary(clock());
|
|
32
52
|
stack.pop();
|
|
33
53
|
}
|
|
34
54
|
|
|
@@ -36,14 +56,39 @@ export function currentLoopPhase(): string | undefined {
|
|
|
36
56
|
return stack[stack.length - 1];
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
/** Run `fn` under `label`. For an async `fn`, only its synchronous prefix (up to its first await) is labeled; label each continuation separately. */
|
|
60
|
+
export function withLoopPhase<T>(label: string, fn: () => T): T {
|
|
61
|
+
pushLoopPhase(label);
|
|
62
|
+
try {
|
|
63
|
+
return fn();
|
|
64
|
+
} finally {
|
|
65
|
+
popLoopPhase();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Arm an attribution window starting at `start` (a deadline on `now`'s clock). Called with no arguments it disarms and clears. */
|
|
70
|
+
export function resetLoopPhaseWindow(start = Number.POSITIVE_INFINITY, now: () => number = defaultClock): void {
|
|
71
|
+
windowStart = segmentStart = start;
|
|
72
|
+
clock = now;
|
|
73
|
+
totals.clear();
|
|
74
|
+
labeledMs = 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Attribute the window [start, end] and disarm it. Returns undefined when no window is armed or no label outweighs unlabeled time. */
|
|
78
|
+
export function takeLoopPhaseAttribution(end?: number): LoopPhaseAttribution | undefined {
|
|
79
|
+
if (windowStart === Number.POSITIVE_INFINITY) return undefined;
|
|
80
|
+
const t = end ?? clock();
|
|
81
|
+
boundary(t);
|
|
82
|
+
const unknownMs = Math.max(0, t - windowStart - labeledMs);
|
|
83
|
+
let winner: string | undefined;
|
|
84
|
+
let winnerMs = 0;
|
|
85
|
+
for (const [label, ms] of totals) {
|
|
86
|
+
if (ms >= winnerMs) {
|
|
87
|
+
winner = label;
|
|
88
|
+
winnerMs = ms;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const result = winner !== undefined && winnerMs > unknownMs ? { label: winner, ms: winnerMs } : undefined;
|
|
92
|
+
resetLoopPhaseWindow();
|
|
93
|
+
return result;
|
|
49
94
|
}
|
package/src/marked/core.ts
CHANGED
|
@@ -248,14 +248,15 @@ export type TokensList = Token[] & { links: Links };
|
|
|
248
248
|
export interface TokenizerThis {
|
|
249
249
|
lexer: Lexer;
|
|
250
250
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
251
|
+
* The whole source the lexer works through. The `src` a tokenizer receives is the part of it before `end` that
|
|
252
|
+
* starts at `end - src.length`. For a block tokenizer, `source` is the text passed to one `blockTokens` call and
|
|
253
|
+
* `end` its length. For an inline tokenizer, it is the inline source, such as a paragraph's text: inside a link
|
|
254
|
+
* label or the text of emphasis, `end` is where that text ends, right before its closer, and a token there must
|
|
255
|
+
* not reach past it. Tokenizers get one context object per source, the same for every call while the lexer works
|
|
256
|
+
* through it (and, inline, the labels and emphasis inside it), so a tokenizer can keep state for it in a `WeakMap`.
|
|
256
257
|
*/
|
|
257
258
|
source?: string;
|
|
258
|
-
/**
|
|
259
|
+
/** Where the text the lexer works through ends in `source` (see `source`). */
|
|
259
260
|
end?: number;
|
|
260
261
|
}
|
|
261
262
|
/** A tokenizer extension callback. */
|
|
@@ -820,8 +821,10 @@ function isBlockStart(lines: string[], index: number): boolean {
|
|
|
820
821
|
return false;
|
|
821
822
|
}
|
|
822
823
|
|
|
824
|
+
// `[^\n]`, not `.`: `.` stops at U+2028 and U+2029, which end no Markdown line,
|
|
825
|
+
// and the match then skips the text in front of them.
|
|
823
826
|
function lineArray(src: string): string[] {
|
|
824
|
-
return src.match(
|
|
827
|
+
return src.match(/[^\n]*(?:\n|$)/g)?.filter((line, index, all) => line !== "" || index < all.length - 1) ?? [];
|
|
825
828
|
}
|
|
826
829
|
function stripFinalNewline(value: string): string {
|
|
827
830
|
return value.endsWith("\n") ? value.slice(0, -1) : value;
|
|
@@ -969,13 +972,30 @@ function parseList(lines: string[], index: number, lexer: Lexer): { token: Token
|
|
|
969
972
|
|
|
970
973
|
function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
971
974
|
const lines = lineArray(src);
|
|
975
|
+
// `lines` tiles `src` exactly, so the unconsumed source at line `i` is
|
|
976
|
+
// `src.slice(lineStarts[i])`. Only block extensions and an lheading override
|
|
977
|
+
// read it; computing offsets once keeps the loop linear.
|
|
978
|
+
const blockExtensions = lexer.extensions.block;
|
|
979
|
+
const context: TokenizerThis = { lexer, source: src, end: src.length };
|
|
980
|
+
const lheadingOverride = lexer.tokenizerOverrides.lheading;
|
|
981
|
+
let lineStarts: number[] | undefined;
|
|
982
|
+
if (blockExtensions.length > 0 || lheadingOverride) {
|
|
983
|
+
lineStarts = [];
|
|
984
|
+
let offset = 0;
|
|
985
|
+
for (let index = 0; index < lines.length; index++) {
|
|
986
|
+
lineStarts.push(offset);
|
|
987
|
+
offset += lines[index]!.length;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
972
990
|
let i = 0;
|
|
973
991
|
while (i < lines.length) {
|
|
974
|
-
const remaining = lines.slice(i).join("");
|
|
975
992
|
let custom: Tokens.Generic | undefined;
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
993
|
+
if (blockExtensions.length > 0) {
|
|
994
|
+
const remaining = src.slice(lineStarts![i]);
|
|
995
|
+
for (const extension of blockExtensions) {
|
|
996
|
+
custom = extension.tokenizer.call(context, remaining, output);
|
|
997
|
+
if (custom?.raw) break;
|
|
998
|
+
}
|
|
979
999
|
}
|
|
980
1000
|
if (custom?.raw) {
|
|
981
1001
|
output.push(custom);
|
|
@@ -1004,9 +1024,10 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1004
1024
|
let raw = line;
|
|
1005
1025
|
let text = "";
|
|
1006
1026
|
i++;
|
|
1027
|
+
const closer = new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*(?:\\n|$)$`);
|
|
1007
1028
|
while (i < lines.length) {
|
|
1008
1029
|
const next = lines[i++]!;
|
|
1009
|
-
if (
|
|
1030
|
+
if (closer.test(next)) {
|
|
1010
1031
|
raw += next;
|
|
1011
1032
|
break;
|
|
1012
1033
|
}
|
|
@@ -1088,10 +1109,16 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1088
1109
|
i++;
|
|
1089
1110
|
if (line.trimStart().startsWith("<!--")) {
|
|
1090
1111
|
while (!raw.includes("-->") && i < lines.length) raw += lines[i++]!;
|
|
1091
|
-
} else if (tag
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1112
|
+
} else if (tag) {
|
|
1113
|
+
// A closing tag never spans lines, so testing each appended line is
|
|
1114
|
+
// equivalent to re-testing the accumulated block.
|
|
1115
|
+
const closeTag = new RegExp(`</${tag}>`, "i");
|
|
1116
|
+
if (!closeTag.test(raw)) {
|
|
1117
|
+
while (i < lines.length) {
|
|
1118
|
+
const next = lines[i++]!;
|
|
1119
|
+
raw += next;
|
|
1120
|
+
if (closeTag.test(next)) break;
|
|
1121
|
+
}
|
|
1095
1122
|
}
|
|
1096
1123
|
}
|
|
1097
1124
|
const value = i < lines.length && /^\s*\n$/.test(lines[i]!) ? stripFinalNewline(raw) : raw;
|
|
@@ -1127,9 +1154,8 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1127
1154
|
}
|
|
1128
1155
|
if (i + 1 < lines.length && /^ {0,3}(=+|-+)[ \t]*(?:\n|$)$/.test(lines[i + 1]!)) {
|
|
1129
1156
|
let fallback: Tokens.Heading | undefined | false;
|
|
1130
|
-
|
|
1131
|
-
if (
|
|
1132
|
-
if (!override || fallback === false) {
|
|
1157
|
+
if (lheadingOverride) fallback = lheadingOverride.call(lexer.tokenizer, src.slice(lineStarts![i]));
|
|
1158
|
+
if (!lheadingOverride || fallback === false) {
|
|
1133
1159
|
const raw = line + lines[i + 1]!;
|
|
1134
1160
|
const text = stripFinalNewline(line);
|
|
1135
1161
|
const tokens: Token[] = [];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List-continuation grammar of `@linxiraos/pi-utils/marked`, for streaming
|
|
3
|
+
* renderers that freeze a lexed prefix at blank-line boundaries (TUI and
|
|
4
|
+
* collab web).
|
|
5
|
+
*
|
|
6
|
+
* marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
|
|
7
|
+
* blank lines only when the remaining source matches
|
|
8
|
+
* `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
|
|
9
|
+
* where `marker` is the exact bullet char for unordered lists (`\${char}`) or
|
|
10
|
+
* 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
|
|
11
|
+
* The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
|
|
12
|
+
* sits at the start of a top-level list token's raw.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A list's marker, read from the start of its raw: bullet in group 1, ordered delimiter in group 2. */
|
|
16
|
+
const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a same-marker item could still continue the list `listRaw` at
|
|
20
|
+
* `tailStart` (directly after the blank line ending the list), now or after
|
|
21
|
+
* any append to `text`.
|
|
22
|
+
*
|
|
23
|
+
* Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
|
|
24
|
+
* lex(full text) for the current text and every append-only extension of it,
|
|
25
|
+
* because a frozen prefix is sticky. At a blank-line cut directly after a
|
|
26
|
+
* top-level `list` token, the only construct that can straddle the cut is a
|
|
27
|
+
* continuation item of that list: marked consumed the blank line into the last
|
|
28
|
+
* item's raw and re-ran `listItemRegex` at exactly `tailStart`, merging a
|
|
29
|
+
* same-marker item into one renumbered loose list. The cut is safe only when
|
|
30
|
+
* that regex can NEVER match at `tailStart`, no matter what is appended later.
|
|
31
|
+
*
|
|
32
|
+
* Append-only growth means "closed" may only be concluded from a present
|
|
33
|
+
* character that contradicts every possible continuation (tail "1x" can never
|
|
34
|
+
* grow into an ordered item, but tail "1" can become "1. c"). Running out of
|
|
35
|
+
* text mid-marker therefore answers "may continue".
|
|
36
|
+
*
|
|
37
|
+
* Returns true when the tail could still continue the list (or the list's
|
|
38
|
+
* marker is unrecognizable) — the conservative "don't freeze" answer. marked
|
|
39
|
+
* may break the list anyway when the matching line is also an hr (`- - -`);
|
|
40
|
+
* treating that as "may continue" merely skips a freeze, never corrupts one.
|
|
41
|
+
*/
|
|
42
|
+
export function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
|
|
43
|
+
const marker = LIST_MARKER_RE.exec(listRaw);
|
|
44
|
+
if (marker === null) return true; // unrecognized list shape — stay conservative
|
|
45
|
+
const n = text.length;
|
|
46
|
+
let i = tailStart;
|
|
47
|
+
// `listItemRegex` allows up to 3 leading spaces.
|
|
48
|
+
while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
|
|
49
|
+
if (i >= n) return true;
|
|
50
|
+
const bullet = marker[1];
|
|
51
|
+
if (bullet !== undefined) {
|
|
52
|
+
if (text[i] !== bullet) return false; // wrong marker char — closed forever
|
|
53
|
+
i++;
|
|
54
|
+
} else {
|
|
55
|
+
// Ordered: 1-9 digits, then the same `.`/`)` delimiter.
|
|
56
|
+
let digits = 0;
|
|
57
|
+
while (i < n && digits < 10) {
|
|
58
|
+
const c = text.charCodeAt(i);
|
|
59
|
+
if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
|
|
60
|
+
digits++;
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
|
|
64
|
+
if (i >= n) return true; // delimiter (or more digits) may still arrive
|
|
65
|
+
if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
// After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
|
|
69
|
+
// bare newline, or end-of-input (which appends can still extend).
|
|
70
|
+
if (i >= n) return true;
|
|
71
|
+
const after = text.charCodeAt(i);
|
|
72
|
+
return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
|
|
73
|
+
}
|