text-sanctifier 1.3.8 → 1.3.10

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.
@@ -14,10 +14,13 @@ import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from '
14
14
  import { resolve, join, sep, relative } from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { pathToFileURL } from 'node:url';
17
+ import { createHash } from 'node:crypto';
17
18
  import { inspectTextDetailed, summonSanctifier, revealInvisible } from '../src/index.js';
18
19
  import { getNewlineStats } from '../src/inspectText.js';
19
20
  import { SPACE_LOOKALIKES_REGEX } from '../src/sanctifyText.js';
20
- import { findHiddenRuns } from '../src/hiddenText.js';
21
+ import { scanHiddenSpans } from '../src/hiddenScan.js';
22
+ import { inspectTextInContext } from '../src/context.js';
23
+ import { classifyCodeUnit, classifyAstral } from '../src/categories.generated.js';
21
24
 
22
25
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
26
 
@@ -46,6 +49,7 @@ fix options:
46
49
  --normalize-spaces replace look-alike spaces (NBSP, narrow NBSP, typographic
47
50
  spaces) with an ordinary space instead of removing them
48
51
  --purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
52
+ --keep-benign keep contextually legitimate joiners and selectors when purging
49
53
  --nuke-controls remove C0/C1 control characters and bidi controls
50
54
  --normalize-newlines convert CRLF and CR to LF
51
55
  --strip-bom also remove a BOM at the start of a file (kept by default)
@@ -86,11 +90,12 @@ const BOOLEAN_FLAGS = {
86
90
  '--strip-bom': 'stripBom',
87
91
  '--normalize-spaces': 'normalizeSpaces',
88
92
  '--purge-invisible': 'purgeInvisible',
93
+ '--keep-benign': 'keepBenign',
89
94
  '--nuke-controls': 'nukeControls',
90
95
  '--normalize-newlines': 'normalizeNewlines',
91
96
  '--dry-run': 'dryRun',
92
97
  };
93
- const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
98
+ const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'keepBenign', 'nukeControls', 'normalizeNewlines', 'dryRun']);
94
99
  const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
95
100
  const FORMATS = new Set(['text', 'json', 'sarif']);
96
101
 
@@ -106,6 +111,7 @@ function parseArgs(argv) {
106
111
  stripBom: false,
107
112
  normalizeSpaces: false,
108
113
  purgeInvisible: false,
114
+ keepBenign: false,
109
115
  nukeControls: false,
110
116
  normalizeNewlines: false,
111
117
  dryRun: false,
@@ -378,18 +384,22 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
378
384
  // --- Reading ---
379
385
 
380
386
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
387
+ const utf8Lenient = new TextDecoder('utf-8', { ignoreBOM: true });
381
388
 
382
389
  /**
383
- * Decodes UTF-8, keeping a leading BOM. Returns null for binary or non-UTF-8 data.
390
+ * Decodes UTF-8, keeping a leading BOM. With `sniff`, data with a NUL in its first
391
+ * BINARY_SNIFF_BYTES is binary and not decoded. Bytes that are not UTF-8 become U+FFFD
392
+ * and set `lossy`, so one bad byte cannot hide the rest of a file from `check`.
384
393
  * @param {Uint8Array} bytes
385
- * @returns {{ text: string|null, reason?: string }}
394
+ * @param {boolean} sniff
395
+ * @returns {{ text: string|null, reason?: string, lossy?: boolean }}
386
396
  */
387
- function decode(bytes) {
388
- if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
397
+ function decode(bytes, sniff) {
398
+ if (sniff && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
389
399
  try {
390
400
  return { text: utf8.decode(bytes) };
391
401
  } catch {
392
- return { text: null, reason: 'not UTF-8' };
402
+ return { text: utf8Lenient.decode(bytes), lossy: true };
393
403
  }
394
404
  }
395
405
 
@@ -454,46 +464,56 @@ const RUN_NOUNS = { tag: 'tag character', variation_selector: 'variation selecto
454
464
  * @param {boolean} stripBom
455
465
  */
456
466
  function inspectAiConfig(text, maxFindings, stripBom) {
457
- const full = inspect(text, Infinity, stripBom);
458
- const { runs, benign } = findHiddenRuns(text);
459
-
460
- const kept = [];
461
- let b = 0;
462
- for (const f of full.findings) {
463
- while (b < benign.length && benign[b][1] <= f.offset) b++;
464
- if (b < benign.length && benign[b][0] <= f.offset) { full.counts[f.category]--; continue; }
465
- kept.push(f);
466
- }
467
-
468
- // Every run character is a finding, so each run starts at a kept finding
469
- const hiddenRuns = [];
467
+ const full = inspect(text, 0, stripBom);
468
+ const findings = [];
470
469
  const loose = [];
471
- let r = 0;
472
- for (const f of kept) {
473
- while (r < runs.length && runs[r].end <= f.offset) r++;
474
- const run = runs[r];
475
- if (!run || f.offset < run.offset) { loose.push(f); continue; }
476
- if (f.offset !== run.offset) continue;
477
- const entry = { kind: run.kind, offset: run.offset, line: f.line, column: f.column, codePoint: f.codePoint, count: run.count };
470
+ let looseCount = 0;
471
+ let position = 0, line = 1, lineStart = 0;
472
+ const scan = (end, inRun, benign) => {
473
+ while (position < end) {
474
+ const offset = position;
475
+ const codePoint = text.codePointAt(position);
476
+ position += codePoint > 0xFFFF ? 2 : 1;
477
+ if (codePoint === 10 || (codePoint === 13 && text.charCodeAt(position) !== 10)) {
478
+ line++; lineStart = position; continue;
479
+ }
480
+ const category = codePoint > 0xFFFF ? classifyAstral(codePoint) : classifyCodeUnit(codePoint);
481
+ if (!category || (offset === 0 && codePoint === BOM && !stripBom)) continue;
482
+ if (benign) { full.counts[category]--; full.total--; continue; }
483
+ if (findings.length < maxFindings || (!inRun && loose.length < maxFindings)) {
484
+ const f = { offset, line, column: offset - lineStart + 1, codePoint, category };
485
+ if (category === 'bom') f.atStart = offset === 0;
486
+ if (findings.length < maxFindings) findings.push(f);
487
+ if (!inRun && loose.length < maxFindings) loose.push(f);
488
+ }
489
+ if (!inRun) looseCount++;
490
+ }
491
+ };
492
+ // Decode beyond the cut point by one marker so slicing matches the original output.
493
+ const hidden = scanHiddenSpans(text, { maxRuns: maxFindings, maxDecodedLength: MAX_DECODED + 10 }, (span) => {
494
+ scan(span.offset, false, false);
495
+ scan(span.end, !span.benign, span.benign);
496
+ });
497
+ scan(text.length, false, false);
498
+ const hiddenRuns = hidden.runs.map((run) => {
499
+ const entry = { kind: run.kind, offset: run.offset, line: run.line, column: run.column, codePoint: text.codePointAt(run.offset), count: run.count };
478
500
  if (run.decoded !== undefined) {
479
501
  entry.decoded = run.decoded.slice(0, MAX_DECODED);
480
- entry.decodedTruncated = run.decoded.length > MAX_DECODED;
502
+ entry.decodedTruncated = run.decodedTruncated || run.decoded.length > MAX_DECODED;
481
503
  }
482
- hiddenRuns.push(entry);
483
- }
484
-
485
- const findings = kept.slice(0, maxFindings);
504
+ return entry;
505
+ });
486
506
  return {
487
507
  report: {
488
508
  ...full,
489
509
  findings,
490
- total: kept.length,
491
- truncated: kept.length > findings.length,
492
- hiddenRuns: hiddenRuns.slice(0, maxFindings),
493
- hiddenRunCount: hiddenRuns.length,
510
+ truncated: full.total > findings.length,
511
+ hiddenRuns,
512
+ hiddenRunCount: hidden.total,
494
513
  },
495
514
  hiddenRuns,
496
515
  loose,
516
+ entryCount: hidden.total + looseCount,
497
517
  };
498
518
  }
499
519
 
@@ -526,6 +546,30 @@ const CATEGORY_RULES = {
526
546
  const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
527
547
  const INFORMATION_URI = 'https://github.com/iWhatty/text-sanctifier';
528
548
  const ABSOLUTE_PATH = /^(?:[a-zA-Z]:)?[\\/]/;
549
+ const fingerprints = new WeakMap();
550
+
551
+ function fingerprintFindings(label, text, findings, cwd) {
552
+ const path = label === STDIN_LABEL ? 'stdin' : toSlash(relative(cwd, resolve(cwd, label)));
553
+ let previousLine = 0, start = 0, end = 0;
554
+ let occurrences = new Map();
555
+ const hashOccurrences = new Map();
556
+ for (const finding of findings) {
557
+ if (finding.line !== previousLine) {
558
+ start = finding.offset - finding.column + 1;
559
+ end = start;
560
+ while (end < text.length && text.charCodeAt(end) !== 10 && text.charCodeAt(end) !== 13) end++;
561
+ occurrences = new Map();
562
+ previousLine = finding.line;
563
+ }
564
+ const index = occurrences.get(finding.category) || 0;
565
+ occurrences.set(finding.category, index + 1);
566
+ const hash = createHash('sha256').update(JSON.stringify([finding.category, path, text.slice(start, end), index])).digest('hex');
567
+ const count = (hashOccurrences.get(hash) || 0) + 1;
568
+ hashOccurrences.set(hash, count);
569
+ fingerprints.set(finding, count === 1 ? hash :
570
+ createHash('sha256').update(JSON.stringify([hash, count])).digest('hex'));
571
+ }
572
+ }
529
573
 
530
574
  /** @param {string} label */
531
575
  function sarifArtifact(label) {
@@ -555,6 +599,7 @@ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
555
599
  const run = runAt.get(x.offset);
556
600
  results.push({
557
601
  ruleId: x.category,
602
+ partialFingerprints: { 'textSanctifier/v1': fingerprints.get(x) },
558
603
  ruleIndex: ruleIds.indexOf(x.category),
559
604
  level: CATEGORY_RULES[x.category][0],
560
605
  message: { text: `${hex(x.codePoint)} ${x.category}${run ? `, start of a ${describeRun(run)}` : ''}` },
@@ -612,6 +657,8 @@ async function main(argv) {
612
657
 
613
658
  const cwd = process.cwd();
614
659
  let paths = opts.paths;
660
+ // --ai-config with no paths searches the current directory even when stdin is not a
661
+ // terminal, so a CI job with no stdin still scans the repository; `-` reads stdin.
615
662
  if (paths.length === 0 && opts.aiConfig) {
616
663
  paths = ['.'];
617
664
  } else if (paths.length === 0) {
@@ -627,6 +674,8 @@ async function main(argv) {
627
674
  let errors = pathErrors;
628
675
  /** @type {Array<{ path: string, reason: string }>} */
629
676
  const skipped = [];
677
+ /** @type {string[]} */
678
+ const notUtf8 = [];
630
679
 
631
680
  /**
632
681
  * Reads every input in order, calling `visit(label, text, input)` for each decodable one.
@@ -643,12 +692,24 @@ async function main(argv) {
643
692
  errors++;
644
693
  continue;
645
694
  }
646
- const { text, reason } = decode(bytes);
695
+ // Files that look binary are skipped. Stdin and AI configuration files are text
696
+ // by intent, so a NUL in one is a finding, not a reason to skip it.
697
+ const { text, reason, lossy } = decode(bytes, input.path !== STDIN && !opts.aiConfig);
647
698
  if (text === null) {
648
699
  skipped.push({ path: label, reason });
649
700
  if (input.explicit) warn(`${label}: skipped (${reason})`);
650
701
  continue;
651
702
  }
703
+ if (lossy) {
704
+ // fix never rewrites bytes it could not decode
705
+ if (opts.command === 'fix') {
706
+ skipped.push({ path: label, reason: 'not UTF-8' });
707
+ warn(`${label}: skipped (not UTF-8)`);
708
+ continue;
709
+ }
710
+ notUtf8.push(label);
711
+ warn(`${label}: not valid UTF-8; checked with invalid bytes read as U+FFFD`);
712
+ }
652
713
  visit(label, text, input);
653
714
  }
654
715
  };
@@ -669,16 +730,18 @@ async function main(argv) {
669
730
  const report = inspect(text, opts.maxFindings, opts.stripBom);
670
731
  total += report.total;
671
732
  if (report.total === 0) return;
733
+ if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
672
734
  files.push({ path: label, ...report });
673
735
  for (const x of report.findings) lines.push(findingLine(label, x));
674
736
  if (report.truncated) lines.push(`${label}: ${report.total - report.findings.length} more not listed (--max-findings)`);
675
737
  return;
676
738
  }
677
739
 
678
- const { report, hiddenRuns, loose } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
740
+ const { report, hiddenRuns, loose, entryCount } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
679
741
  total += report.total;
680
742
  hiddenRunCount += report.hiddenRunCount;
681
743
  if (report.total === 0) return;
744
+ if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
682
745
  files.push({ path: label, ...report });
683
746
  // Text output lists each run once, in place of its characters
684
747
  const entries = [
@@ -686,19 +749,22 @@ async function main(argv) {
686
749
  ...loose.map((x) => ({ offset: x.offset, text: findingLine(label, x) })),
687
750
  ].sort((a, b) => a.offset - b.offset);
688
751
  for (const e of entries.slice(0, opts.maxFindings)) lines.push(e.text);
689
- if (entries.length > opts.maxFindings) lines.push(`${label}: ${entries.length - opts.maxFindings} more not listed (--max-findings)`);
752
+ if (entryCount > opts.maxFindings) lines.push(`${label}: ${entryCount - opts.maxFindings} more not listed (--max-findings)`);
690
753
  });
691
754
 
692
755
  const kind = opts.aiConfig ? 'AI configuration file' : 'file';
693
756
  if (opts.format === 'json') {
694
- const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped };
757
+ const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped, notUtf8 };
695
758
  if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
696
759
  out(JSON.stringify(summary, null, 2));
697
760
  } else if (opts.format === 'sarif') {
698
761
  out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, succeeded: errors === 0 }), null, 2));
699
762
  } else {
700
763
  for (const line of lines) out(line);
701
- if (opts.aiConfig && scanned === 0 && skipped.length === 0) out('No AI configuration files found.');
764
+ if (opts.aiConfig && scanned === 0 && skipped.length === 0) {
765
+ out('No AI configuration files found.');
766
+ if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
767
+ }
702
768
  else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}.`);
703
769
  else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}.`);
704
770
  }
@@ -734,7 +800,21 @@ async function main(argv) {
734
800
  scanned++;
735
801
  // The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
736
802
  const leadingBom = text.charCodeAt(0) === BOM;
737
- let cleaned = clean(leadingBom ? text.slice(1) : text);
803
+ const body = leadingBom ? text.slice(1) : text;
804
+ let cleaned;
805
+ if (opts.keepBenign && opts.purgeInvisible) {
806
+ const benign = inspectTextInContext(body, { maxFindings: Infinity }).findings.filter((f) => f.benign);
807
+ let start = 0;
808
+ const parts = [];
809
+ for (const f of benign) {
810
+ parts.push(clean(body.slice(start, f.offset)));
811
+ const end = f.offset + (f.codePoint > 0xFFFF ? 2 : 1);
812
+ parts.push(body.slice(f.offset, end));
813
+ start = end;
814
+ }
815
+ parts.push(clean(body.slice(start)));
816
+ cleaned = parts.join('');
817
+ } else cleaned = clean(body);
738
818
  if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
739
819
 
740
820
  if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
@@ -3,4 +3,4 @@ var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u2
3
3
  `){return e.replace(Q,r)}var re=/[ \t]*(\n+)[ \t]*/g;function ne(e){return e.replace(re,"$1")}var te=/\n{2,}/g,ie=/\n{3,}/g;function oe(e,r){return r?e.replace(ie,`
4
4
 
5
5
  `):e.replace(te,`
6
- `)}var ue=/ {2,}/g;function se(e){return e.replace(ue," ")}function le(e){return e.replace(y,"")}function ae(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of C(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var fe="1.3.8";export{fe as VERSION,b as getNewlineStats,K as getRecommendedSanctifierOptions,j as inspectText,C as inspectTextDetailed,ae as revealInvisible,T as sanctifyKeyboardOnly,N as sanctifyKeyboardOnlyEmoji,w as sanctifyLoose,L as sanctifyPasted,R as sanctifyStrict,W as summonSanctifier};
6
+ `)}var ue=/ {2,}/g;function se(e){return e.replace(ue," ")}function le(e){return e.replace(y,"")}function ae(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of C(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var fe="1.3.10";export{fe as VERSION,b as getNewlineStats,K as getRecommendedSanctifierOptions,j as inspectText,C as inspectTextDetailed,ae as revealInvisible,T as sanctifyKeyboardOnly,N as sanctifyKeyboardOnlyEmoji,w as sanctifyLoose,L as sanctifyPasted,R as sanctifyStrict,W as summonSanctifier};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Decodes text[start, end) as decodeTagText does, stopping before the output would
3
+ * pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
4
+ * @param {string} text
5
+ * @param {number} start
6
+ * @param {number} end
7
+ * @param {number} limit
8
+ * @returns {{ decoded: string, truncated: boolean }}
9
+ */
10
+ export function decodeRange(text: string, start: number, end: number, limit: number): {
11
+ decoded: string;
12
+ truncated: boolean;
13
+ };
14
+ /**
15
+ * @param {string} text
16
+ * @param {HiddenScanOptions} [options]
17
+ * @param {(span: {offset: number, end: number, benign: boolean, kind: HiddenRunKind}) => void} [onSpan] - internal streaming visitor
18
+ * @returns {HiddenTextScan}
19
+ */
20
+ export function scanHiddenSpans(text: string, options?: HiddenScanOptions, onSpan?: (span: {
21
+ offset: number;
22
+ end: number;
23
+ benign: boolean;
24
+ kind: HiddenRunKind;
25
+ }) => void): HiddenTextScan;
26
+ export type HiddenRunKind = import("./hiddenText.js").HiddenRunKind;
27
+ export type HiddenRun = import("./hiddenText.js").HiddenRun;
28
+ export type HiddenRunCounts = import("./hiddenText.js").HiddenRunCounts;
29
+ export type HiddenTextScan = import("./hiddenText.js").HiddenTextScan;
30
+ export type HiddenScanOptions = import("./hiddenText.js").HiddenScanOptions;
@@ -1,3 +1,43 @@
1
+ /** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
2
+ /**
3
+ * @typedef {Object} HiddenRun
4
+ * @property {HiddenRunKind} kind
5
+ * @property {number} offset - UTF-16 index of the first character
6
+ * @property {number} end - UTF-16 index just past the last character
7
+ * @property {number} count - number of code points in the run
8
+ * @property {number} line - 1-based line of the first character (as in inspectTextDetailed)
9
+ * @property {number} column - 1-based column of the first character, in UTF-16 units
10
+ * @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
11
+ * at most `maxDecodedLength` UTF-16 units
12
+ * @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
13
+ */
14
+ /**
15
+ * @typedef {Object} HiddenRunCounts
16
+ * @property {number} tag
17
+ * @property {number} variation_selector
18
+ * @property {number} invisible_operator
19
+ * @property {number} reserved_tag_plane
20
+ */
21
+ /**
22
+ * @typedef {Object} HiddenTextScan
23
+ * @property {HiddenRun[]} runs - runs that may hide text, in order, at most `maxRuns` long
24
+ * @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
25
+ * sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
26
+ * @property {HiddenRunCounts} counts - every run per kind, listed or not
27
+ * @property {HiddenRunCounts} codePointCounts - code points in those runs per kind, listed or not
28
+ * @property {number} supplementarySelectorCount - code points U+E0100–U+E01EF in those runs, which
29
+ * (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
30
+ * @property {number} total - sum of `counts`
31
+ * @property {number} benignTotal - every benign span, listed or not
32
+ * @property {boolean} truncated - true when runs or benign spans were left out of the lists
33
+ */
34
+ /**
35
+ * @typedef {Object} HiddenScanOptions
36
+ * @property {number} [maxRuns=Infinity] - cap on listed runs, and separately on listed benign spans
37
+ * (non-negative integer or Infinity). Counts stay exact.
38
+ * @property {number} [maxDecodedLength=Infinity] - cap on each run's `decoded` text, in UTF-16 units.
39
+ * For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
40
+ */
1
41
  /**
2
42
  * Decodes tag characters to the ASCII they shadow: U+E0020–U+E007E become
3
43
  * U+0020–U+007E. Anything else (U+E0001 LANGUAGE TAG, U+E007F CANCEL TAG, unassigned
@@ -13,7 +53,10 @@ export function decodeTagText(text: string): string;
13
53
  * legitimate and listed under `benign` instead of `runs`:
14
54
  * - the subdivision flags of England, Scotland and Wales: U+1F3F4 followed by the
15
55
  * tags for "gbeng", "gbsct" or "gbwls" and U+E007F
16
- * - a single U+FE0E or U+FE0F (text or emoji presentation) after another character
56
+ * - a single U+FE0E or U+FE0F (text or emoji presentation) after an
57
+ * Extended_Pictographic character, or between #, * or a digit and U+20E3 (a keycap),
58
+ * the same rule as text-sanctifier/context. After a letter it is a run: one selector
59
+ * per letter can carry a bit each.
17
60
  *
18
61
  * `line` and `column` are counted as in inspectTextDetailed. Every run and benign
19
62
  * span is counted; only the first `maxRuns` of each are listed. With the caps set,
@@ -52,7 +52,7 @@ export function sanctifyPasted(text: string): string;
52
52
  * Brutal text normalizer and invisible trash scrubber,
53
53
  * configurable to kill whatever ghosts you want dead.
54
54
  *
55
- * ⚠️ Note: This is plain-text normalization/filtering — not an HTML/XSS sanitizer.
55
+ * Note: This is plain-text normalization/filtering — not an HTML/XSS sanitizer.
56
56
  *
57
57
  * @param {string} text
58
58
  * @param {boolean} [purgeInvisibleChars=false] - Remove ZWSP, NBSP, bidi, tags, variation selectors, etc.
@@ -1,2 +1,2 @@
1
1
  /** The installed version of text-sanctifier, as in its package.json. */
2
- export const VERSION: "1.3.8";
2
+ export const VERSION: "1.3.10";
package/package.json CHANGED
@@ -1,83 +1,89 @@
1
- {
2
- "name": "text-sanctifier",
3
- "version": "1.3.8",
4
- "type": "module",
5
- "description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
6
- "engines": {
7
- "node": ">=18"
8
- },
9
- "main": "./src/index.js",
10
- "module": "./src/index.js",
11
- "browser": "./dist/text-sanctifier.min.js",
12
- "bin": {
13
- "text-sanctifier": "bin/text-sanctifier.js"
14
- },
15
- "files": [
16
- "src",
17
- "bin",
18
- "dist/text-sanctifier.min.js",
19
- "dist/types",
20
- "LICENSE",
21
- "README.md",
22
- "ADDITIONAL_TERMS.md",
23
- "robots.txt"
24
- ],
25
- "exports": {
26
- ".": {
27
- "types": "./dist/types/index.d.ts",
28
- "default": "./src/index.js"
29
- },
30
- "./hidden": {
31
- "types": "./dist/types/hiddenText.d.ts",
32
- "default": "./src/hiddenText.js"
33
- },
34
- "./context": {
35
- "types": "./dist/types/context.d.ts",
36
- "default": "./src/context.js"
37
- },
38
- "./browser": {
39
- "types": "./dist/types/index.d.ts",
40
- "default": "./dist/text-sanctifier.min.js"
41
- }
42
- },
43
- "types": "./dist/types/index.d.ts",
44
- "sideEffects": false,
45
- "scripts": {
46
- "gen": "node scripts/gen-categories.js && node scripts/gen-version.js && node scripts/gen-virama.js",
47
- "gen:check": "node scripts/gen-categories.js --check && node scripts/gen-version.js --check && node scripts/gen-virama.js --check",
48
- "build": "node scripts/build.js",
49
- "build:types": "tsc -p tsconfig.types.json",
50
- "test:types": "tsc -p tests/types/tsconfig.json",
51
- "test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js",
52
- "test-min": "node tests/sanctifyText.test.min.js",
53
- "ci": "npm run gen:check && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
54
- "size": "node scripts/check-size.js"
55
- },
56
- "keywords": [
57
- "text",
58
- "sanitize",
59
- "normalize",
60
- "invisible",
61
- "scrubber",
62
- "unicode",
63
- "clean",
64
- "sanctify"
65
- ],
66
- "author": "WATT3D <WATT3D@protonmail.com>",
67
- "license": "SEE LICENSE IN LICENSE",
68
- "repository": {
69
- "type": "git",
70
- "url": "git+https://github.com/iWhatty/text-sanctifier.git"
71
- },
72
- "bugs": {
73
- "url": "https://github.com/iWhatty/text-sanctifier/issues"
74
- },
75
- "homepage": "https://github.com/iWhatty/text-sanctifier#readme",
76
- "directories": {
77
- "test": "tests"
78
- },
79
- "devDependencies": {
80
- "esbuild": "^0.25.3",
81
- "typescript": "^5.9.3"
82
- }
83
- }
1
+ {
2
+ "name": "text-sanctifier",
3
+ "version": "1.3.10",
4
+ "type": "module",
5
+ "description": "Find and remove invisible Unicode in web input, model-bound text and repositories.",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "main": "./src/index.js",
10
+ "module": "./src/index.js",
11
+ "browser": "./dist/text-sanctifier.min.js",
12
+ "bin": {
13
+ "text-sanctifier": "bin/text-sanctifier.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "bin",
18
+ "dist/text-sanctifier.min.js",
19
+ "dist/types",
20
+ "LICENSE",
21
+ "README.md",
22
+ "ADDITIONAL_TERMS.md",
23
+ "robots.txt"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/types/index.d.ts",
28
+ "default": "./src/index.js"
29
+ },
30
+ "./hidden": {
31
+ "types": "./dist/types/hiddenText.d.ts",
32
+ "default": "./src/hiddenText.js"
33
+ },
34
+ "./context": {
35
+ "types": "./dist/types/context.d.ts",
36
+ "default": "./src/context.js"
37
+ },
38
+ "./browser": {
39
+ "types": "./dist/types/index.d.ts",
40
+ "default": "./dist/text-sanctifier.min.js"
41
+ }
42
+ },
43
+ "types": "./dist/types/index.d.ts",
44
+ "sideEffects": false,
45
+ "scripts": {
46
+ "gen": "node scripts/gen-categories.js && node scripts/gen-version.js && node scripts/gen-virama.js && node scripts/gen-docs.js",
47
+ "gen:check": "node scripts/gen-categories.js --check && node scripts/gen-version.js --check && node scripts/gen-virama.js --check && node scripts/gen-docs.js --check",
48
+ "build": "node scripts/build.js",
49
+ "build:types": "tsc -p tsconfig.types.json",
50
+ "test:types": "tsc -p tests/types/tsconfig.json",
51
+ "test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js && node tests/release.test.js",
52
+ "test-min": "node tests/sanctifyText.test.min.js",
53
+ "check:self": "node bin/text-sanctifier.js check .",
54
+ "check:names": "node scripts/check-names.js",
55
+ "ci": "npm run gen:check && npm run check:names && npm run check:self && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
56
+ "size": "node scripts/check-size.js",
57
+ "bench": "node --expose-gc scripts/bench.js",
58
+ "release:prep": "node scripts/release.js prep",
59
+ "release:verify": "node scripts/release.js verify",
60
+ "hooks:install": "git config core.hooksPath scripts/hooks"
61
+ },
62
+ "keywords": [
63
+ "text",
64
+ "sanitize",
65
+ "normalize",
66
+ "invisible",
67
+ "scrubber",
68
+ "unicode",
69
+ "clean",
70
+ "sanctify"
71
+ ],
72
+ "author": "WATT3D <WATT3D@protonmail.com>",
73
+ "license": "SEE LICENSE IN LICENSE",
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "git+https://github.com/iWhatty/text-sanctifier.git"
77
+ },
78
+ "bugs": {
79
+ "url": "https://github.com/iWhatty/text-sanctifier/issues"
80
+ },
81
+ "homepage": "https://github.com/iWhatty/text-sanctifier#readme",
82
+ "directories": {
83
+ "test": "tests"
84
+ },
85
+ "devDependencies": {
86
+ "esbuild": "^0.25.3",
87
+ "typescript": "^5.9.3"
88
+ }
89
+ }