@linxiraos/pi-utils 1.1.27 → 1.2.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 +4 -6
- 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 +31 -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/index.d.ts +1 -0
- 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 +10 -0
- package/dist/types/readability/types.d.ts +2 -0
- package/dist/types/release-assets.d.ts +22 -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/package.json +3 -3
- 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 +84 -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/index.ts +1 -0
- 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 +42 -18
- package/src/marked-list.ts +73 -0
- package/src/math-delimiters.ts +16 -0
- package/src/module-timer.ts +5 -5
- package/src/postmortem.ts +1 -1
- package/src/process-name.ts +2 -2
- 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/release-assets.ts +40 -0
- package/src/runtime-install.ts +2 -2
- package/src/stream.ts +34 -13
- package/src/temp.ts +7 -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/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. */
|
|
@@ -971,13 +972,30 @@ function parseList(lines: string[], index: number, lexer: Lexer): { token: Token
|
|
|
971
972
|
|
|
972
973
|
function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
973
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
|
+
}
|
|
974
990
|
let i = 0;
|
|
975
991
|
while (i < lines.length) {
|
|
976
|
-
const remaining = lines.slice(i).join("");
|
|
977
992
|
let custom: Tokens.Generic | undefined;
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
+
}
|
|
981
999
|
}
|
|
982
1000
|
if (custom?.raw) {
|
|
983
1001
|
output.push(custom);
|
|
@@ -1006,9 +1024,10 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1006
1024
|
let raw = line;
|
|
1007
1025
|
let text = "";
|
|
1008
1026
|
i++;
|
|
1027
|
+
const closer = new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*(?:\\n|$)$`);
|
|
1009
1028
|
while (i < lines.length) {
|
|
1010
1029
|
const next = lines[i++]!;
|
|
1011
|
-
if (
|
|
1030
|
+
if (closer.test(next)) {
|
|
1012
1031
|
raw += next;
|
|
1013
1032
|
break;
|
|
1014
1033
|
}
|
|
@@ -1090,10 +1109,16 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1090
1109
|
i++;
|
|
1091
1110
|
if (line.trimStart().startsWith("<!--")) {
|
|
1092
1111
|
while (!raw.includes("-->") && i < lines.length) raw += lines[i++]!;
|
|
1093
|
-
} else if (tag
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
+
}
|
|
1097
1122
|
}
|
|
1098
1123
|
}
|
|
1099
1124
|
const value = i < lines.length && /^\s*\n$/.test(lines[i]!) ? stripFinalNewline(raw) : raw;
|
|
@@ -1129,9 +1154,8 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1129
1154
|
}
|
|
1130
1155
|
if (i + 1 < lines.length && /^ {0,3}(=+|-+)[ \t]*(?:\n|$)$/.test(lines[i + 1]!)) {
|
|
1131
1156
|
let fallback: Tokens.Heading | undefined | false;
|
|
1132
|
-
|
|
1133
|
-
if (
|
|
1134
|
-
if (!override || fallback === false) {
|
|
1157
|
+
if (lheadingOverride) fallback = lheadingOverride.call(lexer.tokenizer, src.slice(lineStarts![i]));
|
|
1158
|
+
if (!lheadingOverride || fallback === false) {
|
|
1135
1159
|
const raw = line + lines[i + 1]!;
|
|
1136
1160
|
const text = stripFinalNewline(line);
|
|
1137
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
|
+
}
|
package/src/math-delimiters.ts
CHANGED
|
@@ -235,6 +235,22 @@ export class MathBlockScan {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
const blockScansByContext = new WeakMap<object, MathBlockScan>();
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The own-line display block at the start of `src`, for a marked block tokenizer: `src` is the part of
|
|
242
|
+
* `context.source` that starts at `context.end - src.length`. One {@link MathBlockScan} is kept per context, which
|
|
243
|
+
* marked keeps for one `blockTokens` source, so asking at every block start takes linear time even when an opener
|
|
244
|
+
* never closes. Without `context.source` it is {@link mathBlockAt}.
|
|
245
|
+
*/
|
|
246
|
+
export function mathBlockInContext(context: { source?: string; end?: number }, src: string): MathBlock | undefined {
|
|
247
|
+
const source = context.source;
|
|
248
|
+
if (source === undefined) return mathBlockAt(src);
|
|
249
|
+
let scan = blockScansByContext.get(context);
|
|
250
|
+
if (!scan) blockScansByContext.set(context, (scan = new MathBlockScan(source)));
|
|
251
|
+
return scan.at((context.end ?? source.length) - src.length);
|
|
252
|
+
}
|
|
253
|
+
|
|
238
254
|
/**
|
|
239
255
|
* The display opener alone on the line at `from` (up to 3 leading spaces,
|
|
240
256
|
* trailing spaces or tabs; the source's last line may be unterminated), or
|
package/src/module-timer.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* **Coverage limits:**
|
|
38
38
|
* - TS/TSX only — intercepting `node_modules` CJS `.js`/`.cjs` and forcing ESM
|
|
39
39
|
* breaks their default-export detection, so they are left to Bun's default path.
|
|
40
|
-
* - **Dev runs only.** In the compiled `
|
|
40
|
+
* - **Dev runs only.** In the compiled `zetacode` binary every module is pre-bundled
|
|
41
41
|
* into bunfs, so `onLoad` never fires; profile with a `bun --preload` dev run.
|
|
42
42
|
*/
|
|
43
43
|
import { readFileSync } from "node:fs";
|
|
@@ -51,19 +51,19 @@ import { moduleLoadBuffer } from "./timing-buffer";
|
|
|
51
51
|
// timing lives) is uniformly TypeScript, so a TS-only filter is both safe and
|
|
52
52
|
// sufficient. Declaration files are imported as text assets, never evaluated.
|
|
53
53
|
const MODULE_LOADER_FILTER = /^(?!.*\.d\.[mc]?ts$).*\.[mc]?tsx?$/;
|
|
54
|
-
const MODULE_COMPLETE_KEY: symbol = Symbol.for("
|
|
55
|
-
const MODULE_BODY_START_KEY: symbol = Symbol.for("
|
|
54
|
+
const MODULE_COMPLETE_KEY: symbol = Symbol.for("zeta.moduleLoadComplete");
|
|
55
|
+
const MODULE_BODY_START_KEY: symbol = Symbol.for("zeta.moduleBodyStart");
|
|
56
56
|
const STATIC_IMPORT_PATTERN =
|
|
57
57
|
/\b(?:import|export)\s+(?:type\s+)?(?:[^"']*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
58
58
|
|
|
59
59
|
type CompleteStore = Record<symbol, ((path: string) => void) | undefined>;
|
|
60
60
|
|
|
61
61
|
function bodyStartMarker(path: string): string {
|
|
62
|
-
return `;globalThis[Symbol.for("
|
|
62
|
+
return `;globalThis[Symbol.for("zeta.moduleBodyStart")]?.(${JSON.stringify(path)});\n`;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function completionMarker(path: string): string {
|
|
66
|
-
return `\n;globalThis[Symbol.for("
|
|
66
|
+
return `\n;globalThis[Symbol.for("zeta.moduleLoadComplete")]?.(${JSON.stringify(path)});\n`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
function instrumentContents(path: string, contents: string): string {
|
package/src/postmortem.ts
CHANGED
|
@@ -332,7 +332,7 @@ function faultWorkerIpcChannels(err: Error): void {
|
|
|
332
332
|
/**
|
|
333
333
|
* Graceful shutdown driven by `process.stdout`'s own `error` event.
|
|
334
334
|
*
|
|
335
|
-
* A closed stdout consumer (`
|
|
335
|
+
* A closed stdout consumer (`zetacode --help | head`, an ACP client dropping the
|
|
336
336
|
* pipe) delivers the broken-pipe write here — attributable to stdout by
|
|
337
337
|
* construction, unlike a process-wide `syscall: "write"` match that a closed
|
|
338
338
|
* subprocess stdin or socket would also satisfy — so it runs cleanup and exits
|
package/src/process-name.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Set the OS-visible process name (`/proc/self/comm`) so `
|
|
3
|
-
* `
|
|
2
|
+
* Set the OS-visible process name (`/proc/self/comm`) so `zetacode` shows up as
|
|
3
|
+
* `zetacode` — not `bun` — in `ps`, `pgrep`, `killall`, `top`, `htop`, and systemd.
|
|
4
4
|
*
|
|
5
5
|
* Bun's `process.title` setter only stores the value on the JS side; unlike
|
|
6
6
|
* Node/libuv it never calls `prctl(PR_SET_NAME)`, so the kernel's `comm` stays
|
package/src/prompt.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LRUCache } from "./lru";
|
|
1
2
|
import type { HelperDelegate, Template } from "./template";
|
|
2
3
|
import * as Handlebars from "./template";
|
|
3
4
|
|
|
@@ -526,7 +527,8 @@ export function registerPartial(name: string, fn: Template): void {
|
|
|
526
527
|
handlebars.registerPartial(name, fn);
|
|
527
528
|
}
|
|
528
529
|
|
|
529
|
-
|
|
530
|
+
// Bounded: some callers compile template text that already embeds user arguments.
|
|
531
|
+
const compiledTemplateCache = new LRUCache<string, (context: TemplateContext) => string>({ max: 256 });
|
|
530
532
|
|
|
531
533
|
export function compile(template: string): (context: TemplateContext) => string {
|
|
532
534
|
// Keyed on the raw template so repeat renders skip parsing.
|