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.
- package/ADDITIONAL_TERMS.md +122 -122
- package/LICENSE +694 -694
- package/README.md +440 -437
- package/bin/text-sanctifier.js +122 -42
- package/dist/text-sanctifier.min.js +1 -1
- package/dist/types/hiddenScan.d.ts +30 -0
- package/dist/types/hiddenText.d.ts +44 -1
- package/dist/types/sanctifyText.d.ts +1 -1
- package/dist/types/version.generated.d.ts +1 -1
- package/package.json +89 -83
- package/robots.txt +79 -79
- package/src/hiddenScan.js +171 -0
- package/src/hiddenText.js +96 -233
- package/src/index.d.ts +22 -22
- package/src/index.js +29 -29
- package/src/sanctifyText.js +4 -4
- package/src/version.generated.js +5 -5
package/bin/text-sanctifier.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
* @
|
|
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:
|
|
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,
|
|
458
|
-
const
|
|
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
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
2
|
+
export const VERSION: "1.3.10";
|
package/package.json
CHANGED
|
@@ -1,83 +1,89 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "text-sanctifier",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "
|
|
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
|
-
"
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|