xlvane 1.0.1 → 1.1.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/dist/cli.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { It as suggest, Sn as XlvError, _ as isoDate, an as parseRange, b as readWorkbook, g as inspect } from "./options-B7UhudO_.js";
3
- import { a as appendRows, c as fromCSV, d as fromJSON, f as pickSheet, h as toJSON, l as inferValue, n as insertRows, o as editCells, p as readTable, s as diff, t as deleteRows, u as toCSV } from "./rows-Cwte1t8m.js";
4
- import { i as readInput } from "./node-files-uazELnqk.js";
2
+ import { On as XlvError, Vt as suggest, _ as isoDate, b as readWorkbook, dn as parseRange, g as inspect } from "./options-Ba6r4zP1.js";
3
+ import { a as appendRows, c as fromCSV, d as fromJSON, f as pickSheet, h as toJSON, l as inferValue, n as insertRows, o as editCells, p as readTable, s as diff, t as deleteRows, u as toCSV } from "./rows-Biu_ddwz.js";
4
+ import { i as readInput } from "./node-files-CsQVpKgP.js";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
- import { chmod, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { chmod, lstat, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
7
  import process from "node:process";
8
- import { parseArgs, styleText } from "node:util";
8
+ import { createRequire } from "node:module";
9
9
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
10
+ // src/cli/node-util.ts
11
+ /** The `node:util` module. */
12
+ const nodeUtil = createRequire(import.meta.url)("node:util");
10
13
  // src/cli/args.ts
11
14
  /** Options every command accepts. */
12
15
  const GLOBAL_OPTIONS = {
@@ -64,7 +67,7 @@ function parseCommandArgs(spec, args) {
64
67
  ...o.short ? { short: o.short } : {},
65
68
  ...o.multiple ? { multiple: true } : {}
66
69
  };
67
- const { tokens } = parseArgs({
70
+ const { tokens } = nodeUtil.parseArgs({
68
71
  args: [...args],
69
72
  options: config,
70
73
  strict: false,
@@ -166,6 +169,259 @@ function headerOption(args, command) {
166
169
  option: "--header"
167
170
  });
168
171
  }
172
+ // src/cli/east-asian-width.ts
173
+ /**
174
+ * Code points whose East_Asian_Width is Wide (W) or Fullwidth (F), including the unassigned code
175
+ * points that default to W, as sorted pairs of first and last code point. Unicode 17.0.0.
176
+ */
177
+ const EAST_ASIAN_WIDE = [
178
+ 4352,
179
+ 4447,
180
+ 8986,
181
+ 8987,
182
+ 9001,
183
+ 9002,
184
+ 9193,
185
+ 9196,
186
+ 9200,
187
+ 9200,
188
+ 9203,
189
+ 9203,
190
+ 9725,
191
+ 9726,
192
+ 9748,
193
+ 9749,
194
+ 9776,
195
+ 9783,
196
+ 9800,
197
+ 9811,
198
+ 9855,
199
+ 9855,
200
+ 9866,
201
+ 9871,
202
+ 9875,
203
+ 9875,
204
+ 9889,
205
+ 9889,
206
+ 9898,
207
+ 9899,
208
+ 9917,
209
+ 9918,
210
+ 9924,
211
+ 9925,
212
+ 9934,
213
+ 9934,
214
+ 9940,
215
+ 9940,
216
+ 9962,
217
+ 9962,
218
+ 9970,
219
+ 9971,
220
+ 9973,
221
+ 9973,
222
+ 9978,
223
+ 9978,
224
+ 9981,
225
+ 9981,
226
+ 9989,
227
+ 9989,
228
+ 9994,
229
+ 9995,
230
+ 10024,
231
+ 10024,
232
+ 10060,
233
+ 10060,
234
+ 10062,
235
+ 10062,
236
+ 10067,
237
+ 10069,
238
+ 10071,
239
+ 10071,
240
+ 10133,
241
+ 10135,
242
+ 10160,
243
+ 10160,
244
+ 10175,
245
+ 10175,
246
+ 11035,
247
+ 11036,
248
+ 11088,
249
+ 11088,
250
+ 11093,
251
+ 11093,
252
+ 11904,
253
+ 11929,
254
+ 11931,
255
+ 12019,
256
+ 12032,
257
+ 12245,
258
+ 12272,
259
+ 12350,
260
+ 12353,
261
+ 12438,
262
+ 12441,
263
+ 12543,
264
+ 12549,
265
+ 12591,
266
+ 12593,
267
+ 12686,
268
+ 12688,
269
+ 12773,
270
+ 12783,
271
+ 12830,
272
+ 12832,
273
+ 12871,
274
+ 12880,
275
+ 42124,
276
+ 42128,
277
+ 42182,
278
+ 43360,
279
+ 43388,
280
+ 44032,
281
+ 55203,
282
+ 63744,
283
+ 64255,
284
+ 65040,
285
+ 65049,
286
+ 65072,
287
+ 65106,
288
+ 65108,
289
+ 65126,
290
+ 65128,
291
+ 65131,
292
+ 65281,
293
+ 65376,
294
+ 65504,
295
+ 65510,
296
+ 94176,
297
+ 94180,
298
+ 94192,
299
+ 94198,
300
+ 94208,
301
+ 101589,
302
+ 101631,
303
+ 101662,
304
+ 101760,
305
+ 101874,
306
+ 110576,
307
+ 110579,
308
+ 110581,
309
+ 110587,
310
+ 110589,
311
+ 110590,
312
+ 110592,
313
+ 110882,
314
+ 110898,
315
+ 110898,
316
+ 110928,
317
+ 110930,
318
+ 110933,
319
+ 110933,
320
+ 110948,
321
+ 110951,
322
+ 110960,
323
+ 111355,
324
+ 119552,
325
+ 119638,
326
+ 119648,
327
+ 119670,
328
+ 126980,
329
+ 126980,
330
+ 127183,
331
+ 127183,
332
+ 127374,
333
+ 127374,
334
+ 127377,
335
+ 127386,
336
+ 127488,
337
+ 127490,
338
+ 127504,
339
+ 127547,
340
+ 127552,
341
+ 127560,
342
+ 127568,
343
+ 127569,
344
+ 127584,
345
+ 127589,
346
+ 127744,
347
+ 127776,
348
+ 127789,
349
+ 127797,
350
+ 127799,
351
+ 127868,
352
+ 127870,
353
+ 127891,
354
+ 127904,
355
+ 127946,
356
+ 127951,
357
+ 127955,
358
+ 127968,
359
+ 127984,
360
+ 127988,
361
+ 127988,
362
+ 127992,
363
+ 128062,
364
+ 128064,
365
+ 128064,
366
+ 128066,
367
+ 128252,
368
+ 128255,
369
+ 128317,
370
+ 128331,
371
+ 128334,
372
+ 128336,
373
+ 128359,
374
+ 128378,
375
+ 128378,
376
+ 128405,
377
+ 128406,
378
+ 128420,
379
+ 128420,
380
+ 128507,
381
+ 128591,
382
+ 128640,
383
+ 128709,
384
+ 128716,
385
+ 128716,
386
+ 128720,
387
+ 128722,
388
+ 128725,
389
+ 128728,
390
+ 128732,
391
+ 128735,
392
+ 128747,
393
+ 128748,
394
+ 128756,
395
+ 128764,
396
+ 128992,
397
+ 129003,
398
+ 129008,
399
+ 129008,
400
+ 129292,
401
+ 129338,
402
+ 129340,
403
+ 129349,
404
+ 129351,
405
+ 129535,
406
+ 129648,
407
+ 129660,
408
+ 129664,
409
+ 129674,
410
+ 129678,
411
+ 129734,
412
+ 129736,
413
+ 129736,
414
+ 129741,
415
+ 129756,
416
+ 129759,
417
+ 129770,
418
+ 129775,
419
+ 129784,
420
+ 131072,
421
+ 196605,
422
+ 196608,
423
+ 262141
424
+ ];
169
425
  // src/cli/output.ts
170
426
  /**
171
427
  * Whether to colour a stream. `util.styleText` respects NO_COLOR, NODE_DISABLE_COLORS,
@@ -207,8 +463,15 @@ var Output = class {
207
463
  this.color = color;
208
464
  this.width = width;
209
465
  }
466
+ /**
467
+ * Colour codes are written with {@link STYLE_MARK} in place of ESC, and an ESC already in the
468
+ * text (from a file) is escaped, so that the final filter ({@link terminalSafe}) can tell the
469
+ * CLI's own colour codes from the same bytes in a sheet name or cell (D-H01).
470
+ */
210
471
  style(format, text) {
211
- return this.color && text !== "" ? styleText(format, text, { validateStream: false }) : text;
472
+ if (!this.color || text === "") return text;
473
+ const safe = text.includes("\x1B") ? text.split("\x1B").join("\\x1b") : text;
474
+ return nodeUtil.styleText(format, safe, { validateStream: false }).split("\x1B").join(STYLE_MARK);
212
475
  }
213
476
  bold(text) {
214
477
  return this.style("bold", text);
@@ -230,11 +493,11 @@ var Output = class {
230
493
  }
231
494
  /**
232
495
  * An aligned table. Columns are cut with "…" to fit `width`; the widest columns shrink first.
233
- * Width is counted in UTF-16 code units, so wide characters may misalign (documented).
496
+ * Widths are display columns ({@link displayWidth}), so CJK text and emoji stay aligned.
234
497
  */
235
498
  table(headers, rows, indent = "", align = []) {
236
499
  const count = headers.length;
237
- const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => oneLine(r[i] ?? "").length)));
500
+ const widths = headers.map((h, i) => Math.max(displayWidth(oneLine(h)), ...rows.map((r) => displayWidth(oneLine(r[i] ?? "")))));
238
501
  const gap = 2;
239
502
  const available = Math.max(count * 4, this.width - indent.length - gap * (count - 1));
240
503
  let total = widths.reduce((a, b) => a + b, 0);
@@ -248,7 +511,8 @@ var Output = class {
248
511
  const line = (cells, head) => indent + cells.map((c, i) => {
249
512
  const w = widths[i] ?? 0;
250
513
  const text = fit(oneLine(c), w);
251
- const padded = align[i] === "right" && !head ? text.padStart(w) : i === count - 1 ? text : text.padEnd(w);
514
+ const pad = " ".repeat(Math.max(0, w - displayWidth(text)));
515
+ const padded = align[i] === "right" && !head ? pad + text : i === count - 1 ? text : text + pad;
252
516
  return head ? this.bold(padded) : padded;
253
517
  }).join(" ".repeat(gap)).trimEnd();
254
518
  return [line(headers, true), ...rows.map((r) => line(r, false))].join("\n");
@@ -262,7 +526,8 @@ function oneLine(text) {
262
526
  * which includes the 8-bit CSI U+009B) written as `\x1b`, and bidirectional formatting characters
263
527
  * written as `\u202e`, so text from a file, such as a cell
264
528
  * holding `_x001B_]0;title_x0007_`, cannot move the cursor, change the window title or hide
265
- * output (docs/decisions.md D-R02). Used for all human-readable output; `--json` is unchanged.
529
+ * output (docs/decisions.md D-R02). Used for all human-readable output; `--json` writes the same
530
+ * characters as `\uXXXX` escapes instead (D-H01).
266
531
  */
267
532
  function escapeControls(text) {
268
533
  return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`).replace(BIDI, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
@@ -274,34 +539,110 @@ function escapeControls(text) {
274
539
  * `\u202e` in human-readable output, like other control characters (D-R08).
275
540
  */
276
541
  const BIDI = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
277
- /** The colour sequences `Output` writes (styleText: bold, dim, red, green, yellow, cyan and their resets). */
278
- const OWN_STYLES = /\u001b\[(?:1|2|22|31|32|33|36|39)m/y;
542
+ /**
543
+ * Stands for ESC in the colour codes `Output` writes until {@link terminalSafe} turns it back:
544
+ * NUL and 16 random hexadecimal digits, new for each process, so text from a file cannot contain
545
+ * it by chance or design. Before 1.1.1 the filter kept every ESC sequence that looked like one of
546
+ * the CLI's colours, including ones from a sheet name (D-H01).
547
+ */
548
+ const STYLE_MARK = `\u0000${Array.from(globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("")}`;
549
+ /** The colour sequences `Output` writes (styleText: bold, dim, red, green, yellow, cyan and their resets), after the mark. */
550
+ const OWN_STYLE = /^\[(?:1|2|22|31|32|33|36|39)m/;
551
+ const MARKED_STYLE = new RegExp(`${STYLE_MARK}\\[[0-9;]*m`, "g");
279
552
  /**
280
553
  * Final filter for human-readable output: escapes every control character (see
281
- * {@link escapeControls}) except line feeds, tabs and, when `keepStyles` is true, the exact
282
- * colour sequences that `Output` itself writes. Applied to everything the CLI prints for people.
554
+ * {@link escapeControls}) except line feeds and tabs. When `keepStyles` is true, the colour codes
555
+ * that `Output` wrote (marked with {@link STYLE_MARK}) are turned into ESC sequences; any ESC in
556
+ * the text itself is escaped. Applied to everything the CLI prints for people.
283
557
  */
284
558
  function terminalSafe(text, keepStyles) {
559
+ if (!text.includes(STYLE_MARK)) return controlsSafe(text);
560
+ const parts = text.split(STYLE_MARK);
561
+ let result = controlsSafe(parts[0] ?? "");
562
+ for (const part of parts.slice(1)) {
563
+ const own = OWN_STYLE.exec(part)?.[0];
564
+ if (own === void 0) result += controlsSafe(part);
565
+ else result += (keepStyles ? `\u001b${own}` : "") + controlsSafe(part.slice(own.length));
566
+ }
567
+ return result;
568
+ }
569
+ /** Escapes the control characters of text (see {@link escapeControls}) except line feeds and tabs. */
570
+ function controlsSafe(text) {
285
571
  let result = "";
286
572
  let start = 0;
287
573
  for (let i = 0; i < text.length; i++) {
288
574
  const code = text.charCodeAt(i);
289
575
  if (!(code < 32 && code !== 10 && code !== 9 || code >= 127 && code <= 159 || code === 1564 || code === 8206 || code === 8207 || code >= 8234 && code <= 8238 || code >= 8294 && code <= 8297)) continue;
290
- if (keepStyles && code === 27) {
291
- OWN_STYLES.lastIndex = i;
292
- const m = OWN_STYLES.exec(text);
293
- if (m !== null) {
294
- i += m[0].length - 1;
295
- continue;
296
- }
297
- }
298
576
  result += text.slice(start, i) + escapeControls(text[i]);
299
577
  start = i + 1;
300
578
  }
301
579
  return start === 0 ? text : result + text.slice(start);
302
580
  }
581
+ /** Cuts text to `width` display columns with "…", between grapheme clusters. */
303
582
  function fit(text, width) {
304
- return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
583
+ if (displayWidth(text) <= width) return text;
584
+ let result = "";
585
+ let used = 0;
586
+ for (const part of graphemes(text)) {
587
+ const w = displayWidth(part);
588
+ if (used + w > width - 1) break;
589
+ result += part;
590
+ used += w;
591
+ }
592
+ return `${result}…`;
593
+ }
594
+ /**
595
+ * Grapheme clusters of a text (Intl.Segmenter), or its code points when the Node.js build has no
596
+ * Intl support, so that cutting never splits a surrogate pair or a letter from its accent.
597
+ */
598
+ function graphemes(text) {
599
+ if (segmenter === void 0) return Array.from(text);
600
+ return Array.from(segmenter.segment(text), (s) => s.segment);
601
+ }
602
+ const segmenter = typeof Intl === "object" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : void 0;
603
+ /**
604
+ * The number of terminal columns a text takes, counted as Node.js counts them for its own output
605
+ * (src/node_i18n.cc GetColumnWidth, v22.23.4; F-UNICODE-01): East Asian Wide and Fullwidth
606
+ * characters (Unicode East_Asian_Width W and F) and emoji with Emoji_Presentation take two
607
+ * columns; control and format characters (except the soft hyphen), nonspacing and enclosing marks
608
+ * and emoji modifiers that are not Wide take none; every other character, including East Asian
609
+ * Ambiguous ones, takes one. Each code point of an emoji sequence is counted, as Node.js does by
610
+ * default, so a terminal that draws the sequence as one picture shows it narrower. Colour codes
611
+ * (`ESC [ … m`, as `Output` writes them) take no columns, as Node.js strips them first.
612
+ */
613
+ function displayWidth(text) {
614
+ if (text.includes("\x1B")) text = text.replace(/\u001b\[[0-9;]*m/g, "");
615
+ if (text.includes(STYLE_MARK)) text = text.replace(MARKED_STYLE, "");
616
+ let width = 0;
617
+ for (let i = 0; i < text.length; i++) {
618
+ const code = text.charCodeAt(i);
619
+ if (code >= 32 && code < 127) width++;
620
+ else {
621
+ for (const ch of text.slice(i)) width += codePointWidth(ch.codePointAt(0) ?? 0, ch);
622
+ break;
623
+ }
624
+ }
625
+ return width;
626
+ }
627
+ function codePointWidth(code, ch) {
628
+ if (isEastAsianWide(code) || EMOJI_PRESENTATION.test(ch)) return 2;
629
+ if (code !== 173 && ZERO_WIDTH.test(ch)) return 0;
630
+ return 1;
631
+ }
632
+ const EMOJI_PRESENTATION = /^\p{Emoji_Presentation}$/u;
633
+ const ZERO_WIDTH = /^[\p{Cc}\p{Cf}\p{Me}\p{Mn}\p{Emoji_Modifier}]$/u;
634
+ /** Binary search in the sorted [first, last, first, last, …] ranges of EAST_ASIAN_WIDE. */
635
+ function isEastAsianWide(code) {
636
+ if (code < 4352) return false;
637
+ let lo = 0;
638
+ let hi = EAST_ASIAN_WIDE.length / 2 - 1;
639
+ while (lo <= hi) {
640
+ const mid = lo + hi >> 1;
641
+ if (code < EAST_ASIAN_WIDE[mid * 2]) hi = mid - 1;
642
+ else if (code > EAST_ASIAN_WIDE[mid * 2 + 1]) lo = mid + 1;
643
+ else return true;
644
+ }
645
+ return false;
305
646
  }
306
647
  /** Quotes an argument for a POSIX shell when needed, so "Try:" commands can be copied. */
307
648
  function quoteArg(arg) {
@@ -333,12 +674,12 @@ function humanSize(bytes) {
333
674
  if (bytes < 1e6) return `${(bytes / 1e3).toFixed(1)} kB`;
334
675
  return `${(bytes / 1e6).toFixed(1)} MB`;
335
676
  }
336
- /** Wraps a paragraph at `width` columns; lines after the first start with `indent`. */
677
+ /** Wraps a paragraph at `width` display columns; lines after the first start with `indent`. */
337
678
  function wrap(text, width, indent = "", firstIndent = indent) {
338
679
  const lines = [];
339
680
  let line = "";
340
681
  let prefix = firstIndent;
341
- for (const word of text.split(/\s+/).filter((w) => w !== "")) if (line !== "" && prefix.length + line.length + 1 + word.length > width) {
682
+ for (const word of text.split(/\s+/).filter((w) => w !== "")) if (line !== "" && displayWidth(prefix) + displayWidth(line) + 1 + displayWidth(word) > width) {
342
683
  lines.push(prefix + line);
343
684
  prefix = indent;
344
685
  line = word;
@@ -1015,7 +1356,7 @@ const DEFAULT_LIMIT$1 = 50;
1015
1356
  function showCell(c) {
1016
1357
  if (c === null) return "(empty)";
1017
1358
  const value = c.value === null ? "" : typeof c.value === "string" ? JSON.stringify(c.value) : String(c.value);
1018
- return c.formula !== null ? `=${c.formula}${value !== "" ? ` (${value})` : ""}` : value === "" ? "(no value)" : value;
1359
+ return c.formula !== null ? `=${escapeControls(c.formula)}${value !== "" ? ` (${value})` : ""}` : value === "" ? "(no value)" : value;
1019
1360
  }
1020
1361
  function kindLabel(out, kind) {
1021
1362
  const text = kind.padEnd(7);
@@ -1074,14 +1415,14 @@ const diffCommand = {
1074
1415
  result: r,
1075
1416
  exitCode: r.equal ? 0 : 1,
1076
1417
  render: (out) => {
1077
- if (r.equal) return `${out.green("No differences")} between ${a} and ${b}.`;
1418
+ if (r.equal) return `${out.green("No differences")} between ${escapeControls(a)} and ${escapeControls(b)}.`;
1078
1419
  const s = r.summary;
1079
- const lines = [`${out.bold(`${a} → ${b}`)}: ${plural(s.cellsChanged, "cell")} changed, ${String(s.cellsAdded)} added, ${String(s.cellsRemoved)} removed` + (s.sheetsAdded + s.sheetsRemoved > 0 ? `; sheets added: ${String(s.sheetsAdded)}, removed: ${String(s.sheetsRemoved)}` : "")];
1080
- for (const name of r.sheetsAdded) lines.push(`${out.green("added sheet")} "${name}"`);
1081
- for (const name of r.sheetsRemoved) lines.push(`${out.red("removed sheet")} "${name}"`);
1420
+ const lines = [`${out.bold(`${escapeControls(a)} → ${escapeControls(b)}`)}: ${plural(s.cellsChanged, "cell")} changed, ${String(s.cellsAdded)} added, ${String(s.cellsRemoved)} removed` + (s.sheetsAdded + s.sheetsRemoved > 0 ? `; sheets added: ${String(s.sheetsAdded)}, removed: ${String(s.sheetsRemoved)}` : "")];
1421
+ for (const name of r.sheetsAdded) lines.push(`${out.green("added sheet")} "${escapeControls(name)}"`);
1422
+ for (const name of r.sheetsRemoved) lines.push(`${out.red("removed sheet")} "${escapeControls(name)}"`);
1082
1423
  for (const sheet of r.sheets) {
1083
1424
  if (sheet.cells.length === 0 && !sheet.state) continue;
1084
- lines.push("", out.bold(`Sheet "${sheet.name}"`));
1425
+ lines.push("", out.bold(`Sheet "${escapeControls(sheet.name)}"`));
1085
1426
  if (sheet.state) lines.push(` ${out.yellow("changed")} visibility: ${sheet.state.before} → ${sheet.state.after}`);
1086
1427
  for (const c of sheet.cells.slice(0, limit)) {
1087
1428
  const style = c.kind === "changed" && showCell(c.before) === showCell(c.after) ? " (formatting)" : "";
@@ -1438,17 +1779,17 @@ const inspectCommand = {
1438
1779
  }
1439
1780
  };
1440
1781
  function renderReport(out, file, r, verbose) {
1441
- const lines = [`${out.bold(file)}: ${r.format}, ${plural(r.sheetCount, "sheet")}, date system ${r.dateSystem}${r.conformance === "strict" ? ", Strict conformance" : ""}`];
1782
+ const lines = [`${out.bold(escapeControls(file))}: ${r.format}, ${plural(r.sheetCount, "sheet")}, date system ${r.dateSystem}${r.conformance === "strict" ? ", Strict conformance" : ""}`];
1442
1783
  if (r.hasMacros) lines.push("Contains macros (not run).");
1443
1784
  if (r.externalLinkCount > 0) lines.push(`Links to ${plural(r.externalLinkCount, "other workbook")} (not followed).`);
1444
1785
  const props = Object.entries(r.properties);
1445
1786
  if (props.length > 0) {
1446
1787
  const keyWidth = Math.max(...props.map(([k]) => k.length));
1447
- lines.push(out.bold("Properties:"), ...props.map(([k, v]) => ` ${k.padEnd(keyWidth)} ${v}`));
1788
+ lines.push(out.bold("Properties:"), ...props.map(([k, v]) => ` ${k.padEnd(keyWidth)} ${escapeControls(String(v))}`));
1448
1789
  }
1449
1790
  const names = verbose ? r.definedNames : r.definedNames.filter((n) => !(n.hidden && n.name.startsWith("_xlnm.")));
1450
1791
  if (names.length > 0) {
1451
- const shown = names.slice(0, 5).map((n) => `${n.name}${n.sheet === null ? "" : ` (sheet "${n.sheet}")`}${n.hidden ? " (hidden)" : ""} =${n.refersTo}`);
1792
+ const shown = names.slice(0, 5).map((n) => `${escapeControls(n.name)}${n.sheet === null ? "" : ` (sheet "${escapeControls(n.sheet)}")`}${n.hidden ? " (hidden)" : ""} =${escapeControls(n.refersTo)}`);
1452
1793
  const more = names.length > 5 || r.definedNamesTruncated ? `, … (${r.definedNamesTruncated ? "more than " : ""}${String(names.length)} in total; see --json)` : "";
1453
1794
  lines.push(`${out.bold("Defined names:")} ${shown.join(", ")}${more}`);
1454
1795
  }
@@ -1456,16 +1797,16 @@ function renderReport(out, file, r, verbose) {
1456
1797
  lines.push("");
1457
1798
  const state = s.state === "visible" ? "" : ` (${s.state})`;
1458
1799
  if (s.kind !== "worksheet") {
1459
- lines.push(`${out.bold(`Sheet "${s.name}"`)}${state}: ${s.kind}, no cells`);
1800
+ lines.push(`${out.bold(`Sheet "${escapeControls(s.name)}"`)}${state}: ${s.kind}, no cells`);
1460
1801
  continue;
1461
1802
  }
1462
1803
  if (s.usedRange === null) {
1463
- lines.push(`${out.bold(`Sheet "${s.name}"`)}${state}: empty`);
1804
+ lines.push(`${out.bold(`Sheet "${escapeControls(s.name)}"`)}${state}: empty`);
1464
1805
  continue;
1465
1806
  }
1466
- lines.push(`${out.bold(`Sheet "${s.name}"`)}${state}: ${s.usedRange}, ${plural(s.rowCount, "row")} × ${plural(s.columnCount, "column")}, ${plural(s.cellCount, "cell")}, ${plural(s.formulaCount, "formula")}`);
1807
+ lines.push(`${out.bold(`Sheet "${escapeControls(s.name)}"`)}${state}: ${s.usedRange}, ${plural(s.rowCount, "row")} × ${plural(s.columnCount, "column")}, ${plural(s.cellCount, "cell")}, ${plural(s.formulaCount, "formula")}`);
1467
1808
  lines.push(s.headerRow ? ` Header row: ${String(s.headerRow.row)} (${s.headerRow.confidence} confidence)` : " Header row: none detected");
1468
- if (s.mergedRangeCount > 0) lines.push(` Merged: ${s.mergedRanges.slice(0, 10).join(", ")}${s.mergedRangeCount > 10 ? `, … (${String(s.mergedRangeCount)} in total)` : ""}`);
1809
+ if (s.mergedRangeCount > 0) lines.push(` Merged: ${escapeControls(s.mergedRanges.slice(0, 10).join(", "))}${s.mergedRangeCount > 10 ? `, … (${String(s.mergedRangeCount)} in total)` : ""}`);
1469
1810
  if (s.columns.length > 0) {
1470
1811
  lines.push(out.table([
1471
1812
  "Column",
@@ -1480,23 +1821,23 @@ function renderReport(out, file, r, verbose) {
1480
1821
  ]), " "));
1481
1822
  if (s.columnsTruncated) lines.push(` … ${plural(s.columnCount - s.columns.length, "more column")}; use --max-columns ${String(s.columnCount)} to see them`);
1482
1823
  }
1483
- if (s.formulas.length > 0) lines.push(` Formulas: ${s.formulas.slice(0, 3).map((f) => `${f.cell} =${f.formula ?? "(shared)"}`).join(", ")}`);
1824
+ if (s.formulas.length > 0) lines.push(` Formulas: ${s.formulas.slice(0, 3).map((f) => `${f.cell} =${escapeControls(f.formula ?? "(shared)")}`).join(", ")}`);
1484
1825
  if (s.richTextCellCount > 0) lines.push(` Rich text: ${plural(s.richTextCellCount, "cell")} (${s.richTextCells.slice(0, 5).join(", ")}${s.richTextCellCount > 5 ? ", …" : ""})`);
1485
1826
  if (s.styledColumns.length > 0) lines.push(` Column styles: ${s.styledColumns.slice(0, 10).join(", ")}${s.styledColumns.length > 10 ? ", …" : ""}`);
1486
1827
  if (s.hyperlinkCount > 0) {
1487
- const shown = s.hyperlinks.slice(0, 3).map((l) => `${l.ref} → ${l.target ?? ""}${l.location === null ? "" : `${l.target === null ? "" : "#"}${l.location}`}`);
1828
+ const shown = s.hyperlinks.slice(0, 3).map((l) => `${l.ref} → ${escapeControls(l.target ?? "")}${l.location === null ? "" : `${l.target === null ? "" : "#"}${escapeControls(l.location)}`}`);
1488
1829
  lines.push(` Hyperlinks: ${plural(s.hyperlinkCount, "link")} (${shown.join(", ")}${s.hyperlinkCount > 3 ? ", …" : ""})`);
1489
1830
  }
1490
1831
  if (s.imageCount > 0) lines.push(` Images: ${plural(s.imageCount, "picture")} (${s.images.slice(0, 5).map((i) => `${i.cell ?? "absolute"} ${i.format ?? "other"}`).join(", ")}${s.imageCount > 5 ? ", …" : ""})`);
1491
1832
  if (s.noteCount > 0) lines.push(` Notes: ${plural(s.noteCount, "note")} (${s.notes.slice(0, 5).join(", ")}${s.noteCount > 5 ? ", …" : ""})`);
1492
- if (s.tables.length > 0) lines.push(` Tables: ${s.tables.slice(0, 5).map((t) => `${t.name} ${t.ref}`).join(", ")}${s.tables.length > 5 ? ", …" : ""}`);
1833
+ if (s.tables.length > 0) lines.push(` Tables: ${s.tables.slice(0, 5).map((table) => `${escapeControls(table.name)} ${escapeControls(table.ref)}`).join(", ")}${s.tables.length > 5 ? ", …" : ""}`);
1493
1834
  if (s.dataValidationCount > 0 || s.conditionalFormatCount > 0) {
1494
1835
  const parts = [s.dataValidationCount > 0 ? plural(s.dataValidationCount, "data validation") : null, s.conditionalFormatCount > 0 ? plural(s.conditionalFormatCount, "conditional format rule") : null];
1495
1836
  lines.push(` Rules: ${parts.filter((p) => p !== null).join(", ")}`);
1496
1837
  }
1497
1838
  if (s.protected) lines.push(" Protected: yes");
1498
1839
  if (s.printArea !== null || s.pageBreakCount > 0) {
1499
- const parts = [s.printArea === null ? null : `print area ${s.printArea.join(", ")}`, s.pageBreakCount > 0 ? plural(s.pageBreakCount, "page break") : null];
1840
+ const parts = [s.printArea === null ? null : `print area ${escapeControls(s.printArea.join(", "))}`, s.pageBreakCount > 0 ? plural(s.pageBreakCount, "page break") : null];
1500
1841
  lines.push(` Printing: ${parts.filter((p) => p !== null).join(", ")}`);
1501
1842
  }
1502
1843
  }
@@ -1547,10 +1888,11 @@ async function sourceFiles(root) {
1547
1888
  const path = join(dir, name);
1548
1889
  let info;
1549
1890
  try {
1550
- info = await stat(path);
1891
+ info = await lstat(path);
1551
1892
  } catch {
1552
1893
  continue;
1553
1894
  }
1895
+ if (info.isSymbolicLink()) continue;
1554
1896
  if (info.isDirectory()) await walk(path);
1555
1897
  else if (info.isFile() && SOURCE.test(name) && info.size <= MAX_FILE_BYTES) files.push(path);
1556
1898
  }
@@ -1579,6 +1921,75 @@ function scanSource(file, text) {
1579
1921
  });
1580
1922
  return findings;
1581
1923
  }
1924
+ /**
1925
+ * Offsets `[start, end)` of the string value (with its quotes) at `path` in valid JSON text, the
1926
+ * one JSON.parse reads when keys repeat, or `null`. Keys are compared after decoding their
1927
+ * escapes. Values off the path are skipped without recursion.
1928
+ */
1929
+ function stringValueSpan(text, path) {
1930
+ let i = 0;
1931
+ const space = () => {
1932
+ while (i < text.length && " \r\n".includes(text[i])) i++;
1933
+ };
1934
+ /** Skips a string starting at i (on its opening quote); returns its raw text. */
1935
+ const string = () => {
1936
+ const start = i++;
1937
+ while (text[i] !== "\"") i += text[i] === "\\" ? 2 : 1;
1938
+ i++;
1939
+ return text.slice(start, i);
1940
+ };
1941
+ /** Skips any value starting at i, iteratively. */
1942
+ const skip = () => {
1943
+ let depth = 0;
1944
+ do {
1945
+ const c = text[i];
1946
+ if (c === "\"") string();
1947
+ else {
1948
+ if (c === "{" || c === "[") depth++;
1949
+ else if (c === "}" || c === "]") depth--;
1950
+ i++;
1951
+ if (depth === 0 && c !== "{" && c !== "[") while (i < text.length && !",]} \r\n".includes(text[i])) i++;
1952
+ }
1953
+ } while (depth > 0 && i < text.length);
1954
+ };
1955
+ const value = (level) => {
1956
+ space();
1957
+ if (level === path.length) {
1958
+ if (text[i] !== "\"") {
1959
+ skip();
1960
+ return null;
1961
+ }
1962
+ const start = i;
1963
+ string();
1964
+ return [start, i];
1965
+ }
1966
+ if (text[i] !== "{") {
1967
+ skip();
1968
+ return null;
1969
+ }
1970
+ i++;
1971
+ let found = null;
1972
+ space();
1973
+ if (text[i] === "}") {
1974
+ i++;
1975
+ return null;
1976
+ }
1977
+ for (;;) {
1978
+ space();
1979
+ const key = JSON.parse(string());
1980
+ space();
1981
+ i++;
1982
+ if (key === path[level]) found = value(level + 1);
1983
+ else {
1984
+ space();
1985
+ skip();
1986
+ }
1987
+ space();
1988
+ if (text[i++] === "}") return found;
1989
+ }
1990
+ };
1991
+ return value(0);
1992
+ }
1582
1993
  function jsonError(path, cause) {
1583
1994
  return new XlvError("XLV_E_JSON_INVALID", `"${path}" is not a valid package.json.`, {
1584
1995
  hint: "Fix the JSON syntax of package.json and run the command again.",
@@ -1626,11 +2037,14 @@ async function run$1(ctx) {
1626
2037
  }
1627
2038
  let write = null;
1628
2039
  if (changes.length > 0 && flag(ctx.args, "force")) {
1629
- let updated = text;
1630
- for (const c of changes) {
1631
- const escaped = c.from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1632
- updated = updated.replace(new RegExp(`("exceljs"\\s*:\\s*)"${escaped}"`), `$1"${ALIAS}"`);
2040
+ const spans = changes.map((c) => stringValueSpan(text, [c.section, "exceljs"])).filter((s) => s !== null);
2041
+ let updated = "";
2042
+ let at = 0;
2043
+ for (const [start, end] of spans.sort((a, b) => a[0] - b[0])) {
2044
+ updated += `${text.slice(at, start)}"${ALIAS}"`;
2045
+ at = end;
1633
2046
  }
2047
+ updated += text.slice(at);
1634
2048
  write = await writeOutput(ctx, pkgPath, updated, {
1635
2049
  force: true,
1636
2050
  dryRun: flag(ctx.args, "dry-run")
@@ -2031,14 +2445,14 @@ async function run(argv, io) {
2031
2445
  const err = new Output(useColor(io.env, io.stderrIsTTY), io.stderrIsTTY ? width : 100);
2032
2446
  const stdout = (text) => io.stdout(terminalSafe(text, out.color));
2033
2447
  const stderr = (text) => io.stderr(terminalSafe(text, err.color));
2034
- const toJson = (value) => `${io.stdoutIsTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value)}\n`;
2448
+ const toJson = (value) => `${jsonSafe(io.stdoutIsTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value))}\n`;
2035
2449
  const json = optionArgs(argv).includes("--json");
2036
2450
  const [first, ...rest] = argv;
2037
2451
  let spec;
2038
2452
  const state = { lastFile: null };
2039
2453
  try {
2040
2454
  if (first === void 0 || first === "--json" && rest.length === 0) {
2041
- io.stdout(welcome(out, io.version, COMMANDS));
2455
+ stdout(welcome(out, io.version, COMMANDS));
2042
2456
  return EXIT.ok;
2043
2457
  }
2044
2458
  if (first === "--version" || first === "-v") {
@@ -2048,7 +2462,7 @@ async function run(argv, io) {
2048
2462
  if (first === "help" || first === "--help" || first === "-h") {
2049
2463
  const topic = rest.find((a) => !a.startsWith("-"));
2050
2464
  if (topic === void 0) {
2051
- io.stdout(welcome(out, io.version, COMMANDS));
2465
+ stdout(welcome(out, io.version, COMMANDS));
2052
2466
  return EXIT.ok;
2053
2467
  }
2054
2468
  stdout(commandHelp(out, findCommand(topic)));
@@ -2223,6 +2637,15 @@ function formatError(err, error, tryCommand, verbose = false) {
2223
2637
  ""
2224
2638
  ].join("\n");
2225
2639
  }
2640
+ /**
2641
+ * JSON text with DEL, the C1 controls (which include the 8-bit CSI U+009B), the bidirectional
2642
+ * formatting characters and U+2028/U+2029 written as `\uXXXX` escapes. JSON.stringify already
2643
+ * escapes C0 controls; these characters can occur only inside strings, so the escaped text is
2644
+ * valid JSON with the same value, and a terminal showing it does not act on them (D-H01).
2645
+ */
2646
+ function jsonSafe(json) {
2647
+ return json.replace(/[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
2648
+ }
2226
2649
  // src/cli/bin.ts
2227
2650
  function version() {
2228
2651
  try {