text-sanctifier 1.3.0 → 1.3.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![stars](https://img.shields.io/github/stars/iWhatty/text-sanctifier?style=social)](https://github.com/iWhatty/text-sanctifier)
8
8
  [![types](https://img.shields.io/npm/types/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
9
9
 
10
- Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.40 KB gzipped.
10
+ Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.44 KB gzipped.
11
11
 
12
12
  ## Features
13
13
 
@@ -167,7 +167,7 @@ truncated: false
167
167
  | --- | --- |
168
168
  | `bidi_control` | U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 |
169
169
  | `zero_width` | U+200B–U+200D, U+2060 |
170
- | `nonbreaking_space` | U+00A0, U+2000–U+200A, U+202F, U+3000 (non-breaking and typographic spaces) |
170
+ | `nonbreaking_space` | U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000 (non-breaking and typographic spaces) |
171
171
  | `bom` | U+FEFF; `atStart` is true at offset 0, where a BOM is legitimate |
172
172
  | `nul` | U+0000 |
173
173
  | `c0_control` | U+0001–U+001F except tab, LF and CR; U+007F |
@@ -179,6 +179,18 @@ truncated: false
179
179
 
180
180
  `codePoint` is the full code point, so a tag character reports `0xE0041`, not a surrogate. `offset` is a 0-based UTF-16 index (for a code point above U+FFFF, that of its high surrogate); `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
181
181
 
182
+ **Choosing `maxFindings`:** the scan itself uses almost no memory, but every listed finding is an object, so the cost of `maxFindings` grows with the number of findings, not the size of the input. 16 MB of non-breaking spaces is 16.7 million findings. With a cap of 20, it scans in about 70 ms and uses 0.2 MB. With `Infinity`, expect roughly 100–200 bytes per finding: about 1.5 GB for 8 million. On untrusted input, keep a cap. Use `maxFindings: 0` for a counts-only report: it builds no finding objects, and `counts`, `total` and `truncated` stay exact.
183
+
184
+ ### `getNewlineStats(text: string): NewlineStats`
185
+
186
+ Counts each newline style in one pass, with no regex and no copy: `{ crlf, cr, lf, types, mixed }`. `cr` counts CRs not followed by LF, `lf` counts LFs not preceded by CR, `types` lists the styles present in the order `'CRLF'`, `'CR'`, `'LF'`, and `mixed` is true when there is more than one. Also exported from the browser build.
187
+
188
+ ```js
189
+ import { getNewlineStats } from 'text-sanctifier';
190
+
191
+ getNewlineStats('a\r\nb\n'); // { crlf: 1, cr: 0, lf: 1, types: ['CRLF', 'LF'], mixed: true }
192
+ ```
193
+
182
194
  `zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨‍👩‍👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as ✌️ and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16.
183
195
 
184
196
  The categories cover exactly what `nukeControls` and `purgeInvisibleChars` strip.
@@ -1,14 +1,14 @@
1
- function n(a={}){const c=!!a.purgeInvisibleChars,e=!!a.purgeEmojis,f=!!a.nukeControls,h=!!a.keyboardOnlyFilter,m=!!a.normalizeNewlines,l=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,d=!!a.preserveParagraphs,g=!!a.collapseSpaces,p=!!a.finalTrim,k=Number.isFinite(a.maxLength)&&0<=a.maxLength?a.maxLength:Infinity,q="truncate"===a.g||"noop"===a.g||"throw"===a.g?a.g:"throw";return z=>r(z,c,e,f,h,m,l,b,d,g,p,k,q)}n.strict=a=>r(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
2
- n.loose=a=>r(a,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);n.keyboardOnlyEmoji=a=>r(a,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);n.keyboardOnly=a=>r(a,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);
3
- function r(a,c=!1,e=!1,f=!1,h=!1,m=!1,l=!1,b=!1,d=!1,g=!1,p=!1,k=Infinity,q="throw"){if("string"!==typeof a)throw new TypeError("sanctifyText expects a string input.");"truncate"!==q&&"noop"!==q&&"throw"!==q&&(q="throw");if(a.length>k)switch(q){case "truncate":a=a.slice(0,k);break;case "noop":return a;default:throw new RangeError(`sanctifyText input length ${a.length} exceeds maxLength ${k}.`);}c&&(a=a.replace(t,""));e&&(a=a.replace(u,""));f&&(a=a.replace(v,""));h&&(a=w(a,e));m&&(a=a.replace(x,"\n"));
4
- l&&(a=a.replace(y,"$1"));b&&(c=a,a=d?c.replace(A,"\n\n"):c.replace(B,"\n"));g&&(a=a.replace(C," "));return p?a.trim():a}var t=/[\u00a0\u00ad\u034f\u061c\u115f\u1160\u180e\u2000-\u200f\u202a-\u202f\u2060-\u2064\u2066-\u2069\u3000\u3164\ufe00-\ufe0f\ufeff\uffa0\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu,D=/[^\x20-\x7E\r\n]+/gu;
5
- function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,e=>{u.lastIndex=0;if(u.test(e)){u.lastIndex=0;var f="";for(const h of e.matchAll(u))f+=h[0];e=f}else e="";return e})}var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;
6
- function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var u;try{u=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{u=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,A=/\n{3,}/g,C=/ {2,}/g,v=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
1
+ function n(a={}){const c=!!a.purgeInvisibleChars,d=!!a.purgeEmojis,e=!!a.nukeControls,g=!!a.keyboardOnlyFilter,f=!!a.normalizeNewlines,l=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,h=!!a.preserveParagraphs,k=!!a.collapseSpaces,r=!!a.finalTrim,m=Number.isFinite(a.maxLength)&&0<=a.maxLength?a.maxLength:Infinity,p="truncate"===a.g||"noop"===a.g||"throw"===a.g?a.g:"throw";return A=>q(A,c,d,e,g,f,l,b,h,k,r,m,p)}n.strict=a=>q(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
2
+ n.loose=a=>q(a,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);n.keyboardOnlyEmoji=a=>q(a,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);n.keyboardOnly=a=>q(a,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);
3
+ function q(a,c=!1,d=!1,e=!1,g=!1,f=!1,l=!1,b=!1,h=!1,k=!1,r=!1,m=Infinity,p="throw"){if("string"!==typeof a)throw new TypeError("sanctifyText expects a string input.");"truncate"!==p&&"noop"!==p&&"throw"!==p&&(p="throw");if(a.length>m)switch(p){case "truncate":a=a.slice(0,m);break;case "noop":return a;default:throw new RangeError(`sanctifyText input length ${a.length} exceeds maxLength ${m}.`);}c&&(a=a.replace(t,""));d&&(a=a.replace(u,""));e&&(a=a.replace(v,""));g&&(a=w(a,d));f&&(a=a.replace(x,"\n"));
4
+ l&&(a=a.replace(y,"$1"));b&&(c=a,a=h?c.replace(z,"\n\n"):c.replace(B,"\n"));k&&(a=a.replace(C," "));return r?a.trim():a}var t=/[\u00a0\u00ad\u034f\u061c\u115f\u1160\u180e\u2000-\u200f\u202a-\u202f\u205f\u2060-\u2064\u2066-\u2069\u3000\u3164\ufe00-\ufe0f\ufeff\uffa0\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu,D=/[^\x20-\x7E\r\n]+/gu;
5
+ function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,d=>{u.lastIndex=0;if(u.test(d)){u.lastIndex=0;var e="";for(const g of d.matchAll(u))e+=g[0];d=e}else d="";return d})}var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;
6
+ function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var u;try{u=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{u=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,z=/\n{3,}/g,C=/ {2,}/g,v=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
7
7
  function L(a,c){a.lastIndex=0;return a.test(c)}
8
- function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],e={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(v,a)&&(e.hasControlChars=!0,c.push("Control characters detected."));L(t,a)&&(e.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(u,a)&&(e.hasEmojis=!0,c.push("Emojis detected."));if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");var f=
9
- 0,h=0;let m=0;for(let g=0,p=a.length;g<p;g++){const k=a.charCodeAt(g);13===k?10===a.charCodeAt(g+1)?(f++,g++):h++:10===k&&m++}f={i:f,h,j:m};h=[];0<f.i&&h.push("CRLF");0<f.h&&h.push("CR");0<f.j&&h.push("LF");const {l,types:b}={...f,types:h,l:1<h.length};e.hasMixedNewlines=l;e.newlineStyle=l?"Mixed":b[0]||null;e.newlineStyle&&c.push(l?"Mixed newline styles detected.":`Consistent newline style: ${e.newlineStyle}`);a:{a=E(a);if(L(D,a))for(d of a)if(!("\n"===d||"\r"===d||" "<=d&&"~">=d||L(u,d))){var d=
10
- !0;break a}d=!1}d&&(e.hasNonKeyboardChars=!0,c.push("Non-keyboard characters detected."));return e}
11
- function N(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const e={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0,invisible_format:0,hangul_filler:0,variation_selector:0,tag:0},f=[];let h=0,m=1,l=0;for(let g=0,p=a.length;g<p;g++){var b=a.charCodeAt(g);if(32<=b&&127>b)continue;if(10===b){m++;l=g+1;continue}if(13===b){10!==a.charCodeAt(g+1)&&(m++,
12
- l=g+1);continue}const k=g;var d=b;if(56128===b){d=a.charCodeAt(g+1);if(!(56320<=d&&57343>=d))continue;d=65536+(b-55296<<10)+(d-56320);b=917504<=d&&917631>=d?"tag":917760<=d&&917999>=d?"variation_selector":null;g++}else b=128>b?0===b?"nul":32>b?9===b||10===b||13===b?null:"c0_control":127===b?"c0_control":null:159>=b?"c1_control":160===b?"nonbreaking_space":173===b||847===b||6158===b?"invisible_format":1564===b?"bidi_control":4447===b||4448===b?"hangul_filler":8192>b?null:8202>=b?"nonbreaking_space":
13
- 8205>=b?"zero_width":8207>=b||8234<=b&&8238>=b?"bidi_control":8239===b?"nonbreaking_space":8288===b?"zero_width":8289<=b&&8292>=b?"invisible_format":8294<=b&&8297>=b?"bidi_control":12288===b?"nonbreaking_space":12644===b||65440===b?"hangul_filler":65024<=b&&65039>=b?"variation_selector":65279===b?"bom":null;null!==b&&(e[b]++,h++,f.length<c&&(d={offset:k,line:m,column:k-l+1,codePoint:d,category:b},"bom"===b&&(d.atStart=0===k),f.push(d)))}return{findings:f,counts:e,total:h,truncated:h>f.length}}
14
- function O(a){return{purgeInvisibleChars:a.hasInvisibleChars,purgeEmojis:a.hasEmojis,nukeControls:a.hasControlChars,keyboardOnlyFilter:a.hasNonKeyboardChars,normalizeNewlines:a.hasMixedNewlines||"CRLF"===a.newlineStyle||"CR"===a.newlineStyle}}export { n as summonSanctifier, M as inspectText, O as getRecommendedSanctifierOptions, N as inspectTextDetailed };
8
+ function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],d={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(v,a)&&(d.hasControlChars=!0,c.push("Control characters detected."));L(t,a)&&(d.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(u,a)&&(d.hasEmojis=!0,c.push("Emojis detected."));const {mixed:e,types:g}=N(a);d.hasMixedNewlines=e;d.newlineStyle=
9
+ e?"Mixed":g[0]||null;d.newlineStyle&&c.push(e?"Mixed newline styles detected.":`Consistent newline style: ${d.newlineStyle}`);a:{a=E(a);if(L(D,a))for(f of a)if(!("\n"===f||"\r"===f||" "<=f&&"~">=f||L(u,f))){var f=!0;break a}f=!1}f&&(d.hasNonKeyboardChars=!0,c.push("Non-keyboard characters detected."));return d}
10
+ function O(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const d={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0,invisible_format:0,hangul_filler:0,variation_selector:0,tag:0},e=[];let g=0,f=1,l=0;for(let k=0,r=a.length;k<r;k++){var b=a.charCodeAt(k);if(32<=b&&127>b)continue;if(10===b){f++;l=k+1;continue}if(13===b){10!==a.charCodeAt(k+1)&&(f++,
11
+ l=k+1);continue}const m=k;var h=b;if(56128===b){h=a.charCodeAt(k+1);if(!(56320<=h&&57343>=h))continue;h=65536+(b-55296<<10)+(h-56320);b=917504<=h&&917631>=h?"tag":917760<=h&&917999>=h?"variation_selector":null;k++}else b=128>b?0===b?"nul":32>b?9===b||10===b||13===b?null:"c0_control":127===b?"c0_control":null:159>=b?"c1_control":160===b?"nonbreaking_space":173===b||847===b||6158===b?"invisible_format":1564===b?"bidi_control":4447===b||4448===b?"hangul_filler":8192>b?null:8202>=b?"nonbreaking_space":
12
+ 8205>=b?"zero_width":8207>=b||8234<=b&&8238>=b?"bidi_control":8239===b||8287===b?"nonbreaking_space":8288===b?"zero_width":8289<=b&&8292>=b?"invisible_format":8294<=b&&8297>=b?"bidi_control":12288===b?"nonbreaking_space":12644===b||65440===b?"hangul_filler":65024<=b&&65039>=b?"variation_selector":65279===b?"bom":null;null!==b&&(d[b]++,g++,e.length<c&&(h={offset:m,line:f,column:m-l+1,codePoint:h,category:b},"bom"===b&&(h.atStart=0===m),e.push(h)))}return{findings:e,counts:d,total:g,truncated:g>e.length}}
13
+ function N(a){if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");var c=0;let d=0,e=0;for(let g=0,f=a.length;g<f;g++){const l=a.charCodeAt(g);13===l?10===a.charCodeAt(g+1)?(c++,g++):d++:10===l&&e++}a={crlf:c,cr:d,lf:e};c=[];0<a.crlf&&c.push("CRLF");0<a.cr&&c.push("CR");0<a.lf&&c.push("LF");return{...a,types:c,mixed:1<c.length}}
14
+ function P(a){return{purgeInvisibleChars:a.hasInvisibleChars,purgeEmojis:a.hasEmojis,nukeControls:a.hasControlChars,keyboardOnlyFilter:a.hasNonKeyboardChars,normalizeNewlines:a.hasMixedNewlines||"CRLF"===a.newlineStyle||"CR"===a.newlineStyle}}export { n as summonSanctifier, M as inspectText, P as getRecommendedSanctifierOptions, O as inspectTextDetailed, N as getNewlineStats };
@@ -4,9 +4,11 @@ export type TrashFinding = import("./inspectText.js").TrashFinding;
4
4
  export type TrashCounts = import("./inspectText.js").TrashCounts;
5
5
  export type DetailedTrashReport = import("./inspectText.js").DetailedTrashReport;
6
6
  export type InspectDetailedOptions = import("./inspectText.js").InspectDetailedOptions;
7
+ export type NewlineStats = import("./inspectText.js").NewlineStats;
7
8
  export type SanctifyOptions = import("./sanctifyText.js").SanctifyOptions;
8
9
  import { inspectText } from './inspectText.js';
9
10
  import { inspectTextDetailed } from './inspectText.js';
11
+ import { getNewlineStats } from './inspectText.js';
10
12
  import { getRecommendedSanctifierOptions } from './inspectText.js';
11
13
  import { summonSanctifier } from './sanctifyText.js';
12
- export { inspectText, inspectTextDetailed, getRecommendedSanctifierOptions, summonSanctifier };
14
+ export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier };
@@ -9,7 +9,7 @@ export function inspectText(text: string): UnicodeTrashReport;
9
9
  * Categories:
10
10
  * - `bidi_control`: U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069
11
11
  * - `zero_width`: U+200B–U+200D, U+2060
12
- * - `nonbreaking_space`: non-breaking and typographic spaces U+00A0, U+2000–U+200A, U+202F, U+3000
12
+ * - `nonbreaking_space`: non-breaking and typographic spaces U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000
13
13
  * - `bom`: U+FEFF (`atStart` says whether it sits at offset 0)
14
14
  * - `nul`: U+0000
15
15
  * - `c0_control`: U+0001–U+001F except tab, LF and CR; and U+007F
@@ -29,23 +29,19 @@ export function inspectText(text: string): UnicodeTrashReport;
29
29
  */
30
30
  export function inspectTextDetailed(text: string, options?: InspectDetailedOptions): DetailedTrashReport;
31
31
  /**
32
- * Counts the number of different newline types in a string.
32
+ * @typedef {Object} NewlineStats
33
+ * @property {number} crlf - CR LF pairs.
34
+ * @property {number} cr - CRs not followed by LF.
35
+ * @property {number} lf - LFs not preceded by CR.
36
+ * @property {('CRLF'|'CR'|'LF')[]} types - Styles present, in the order CRLF, CR, LF.
37
+ * @property {boolean} mixed - True when more than one style is present.
38
+ */
39
+ /**
40
+ * Counts each newline style in one pass, with no regex and no copy.
33
41
  * @param {string} text
34
- * @returns {{
35
- * crlf: number,
36
- * cr: number,
37
- * lf: number,
38
- * types: string[],
39
- * mixed: boolean
40
- * }}
42
+ * @returns {NewlineStats}
41
43
  */
42
- export function getNewlineStats(text: string): {
43
- crlf: number;
44
- cr: number;
45
- lf: number;
46
- types: string[];
47
- mixed: boolean;
48
- };
44
+ export function getNewlineStats(text: string): NewlineStats;
49
45
  /**
50
46
  * Creates defaultOptions for summonSanctifier based on inspectText result
51
47
  * @param {!UnicodeTrashReport} report
@@ -62,6 +58,28 @@ export type UnicodeTrashReport = {
62
58
  hasNonKeyboardChars: boolean;
63
59
  summary: string[];
64
60
  };
61
+ export type NewlineStats = {
62
+ /**
63
+ * - CR LF pairs.
64
+ */
65
+ crlf: number;
66
+ /**
67
+ * - CRs not followed by LF.
68
+ */
69
+ cr: number;
70
+ /**
71
+ * - LFs not preceded by CR.
72
+ */
73
+ lf: number;
74
+ /**
75
+ * - Styles present, in the order CRLF, CR, LF.
76
+ */
77
+ types: ("CRLF" | "CR" | "LF")[];
78
+ /**
79
+ * - True when more than one style is present.
80
+ */
81
+ mixed: boolean;
82
+ };
65
83
  export type TrashCategory = "bidi_control" | "zero_width" | "nonbreaking_space" | "bom" | "nul" | "c0_control" | "c1_control" | "invisible_format" | "hangul_filler" | "variation_selector" | "tag";
66
84
  /**
67
85
  * One located character.
@@ -113,6 +131,7 @@ export type DetailedTrashReport = {
113
131
  export type InspectDetailedOptions = {
114
132
  /**
115
133
  * - Cap on listed findings (non-negative integer or Infinity).
134
+ * Each listed finding is an object, so memory grows with the number listed; 0 builds none (counts only).
116
135
  */
117
136
  maxFindings?: number;
118
137
  };
@@ -122,7 +122,7 @@ export function normalizeTypographicJank(text: string): string;
122
122
  *
123
123
  * Targets:
124
124
  * - Non-breaking spaces ( )
125
- * - Zero-width spaces and miscellaneous Unicode spaces ( –‍,  , ⁠,  , )
125
+ * - Zero-width spaces and miscellaneous Unicode spaces ( –‍,  ,  , ⁠,  , )
126
126
  * - Bidi controls: marks, embeddings/overrides and isolates (؜, ‎, ‏, ‪–‮, ⁦–⁩)
127
127
  * - Invisible format characters: soft hyphen, CGJ, Mongolian vowel separator, invisible operators (­, ͏, ᠎, ⁡–⁤)
128
128
  * - Hangul fillers (ᅟ, ᅠ, ㅤ, ᅠ)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "text-sanctifier",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
6
6
  "engines": {
package/src/index.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { inspectText } from './inspectText.js';
2
2
  import { inspectTextDetailed } from './inspectText.js';
3
+ import { getNewlineStats } from './inspectText.js';
3
4
  import { getRecommendedSanctifierOptions } from './inspectText.js';
4
5
  import { summonSanctifier } from './sanctifyText.js';
5
- export { inspectText, inspectTextDetailed, getRecommendedSanctifierOptions, summonSanctifier };
6
+ export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier };
6
7
  export type UnicodeTrashReport = import('./inspectText.js').UnicodeTrashReport;
7
8
  export type TrashCategory = import('./inspectText.js').TrashCategory;
8
9
  export type TrashFinding = import('./inspectText.js').TrashFinding;
9
10
  export type TrashCounts = import('./inspectText.js').TrashCounts;
10
11
  export type DetailedTrashReport = import('./inspectText.js').DetailedTrashReport;
11
12
  export type InspectDetailedOptions = import('./inspectText.js').InspectDetailedOptions;
13
+ export type NewlineStats = import('./inspectText.js').NewlineStats;
12
14
  export type SanctifyOptions = import('./sanctifyText.js').SanctifyOptions;
package/src/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // src/index.js
2
2
 
3
3
 
4
- import { inspectText, inspectTextDetailed, getRecommendedSanctifierOptions } from './inspectText.js';
5
- export { inspectText, inspectTextDetailed, getRecommendedSanctifierOptions };
4
+ import { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions } from './inspectText.js';
5
+ export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions };
6
6
 
7
7
  import { summonSanctifier } from './sanctifyText.js';
8
8
  export { summonSanctifier };
@@ -14,6 +14,7 @@ export { summonSanctifier };
14
14
  /** @typedef {import('./inspectText.js').TrashCounts} TrashCounts */
15
15
  /** @typedef {import('./inspectText.js').DetailedTrashReport} DetailedTrashReport */
16
16
  /** @typedef {import('./inspectText.js').InspectDetailedOptions} InspectDetailedOptions */
17
+ /** @typedef {import('./inspectText.js').NewlineStats} NewlineStats */
17
18
  /** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
18
19
 
19
20
 
@@ -167,6 +167,7 @@ export function inspectText(text) {
167
167
  /**
168
168
  * @typedef {Object} InspectDetailedOptions
169
169
  * @property {number} [maxFindings=100] - Cap on listed findings (non-negative integer or Infinity).
170
+ * Each listed finding is an object, so memory grows with the number listed; 0 builds none (counts only).
170
171
  */
171
172
 
172
173
  const DEFAULT_MAX_FINDINGS = 100;
@@ -194,7 +195,7 @@ function classifyCodeUnit(c) {
194
195
  if (c <= 0x200D) return 'zero_width';
195
196
  if (c <= 0x200F) return 'bidi_control';
196
197
  if (c >= 0x202A && c <= 0x202E) return 'bidi_control';
197
- if (c === 0x202F) return 'nonbreaking_space';
198
+ if (c === 0x202F || c === 0x205F) return 'nonbreaking_space';
198
199
  if (c === 0x2060) return 'zero_width';
199
200
  if (c >= 0x2061 && c <= 0x2064) return 'invisible_format';
200
201
  if (c >= 0x2066 && c <= 0x2069) return 'bidi_control';
@@ -225,7 +226,7 @@ const TRASH_HIGH_SURROGATE = 0xDB40;
225
226
  * Categories:
226
227
  * - `bidi_control`: U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069
227
228
  * - `zero_width`: U+200B–U+200D, U+2060
228
- * - `nonbreaking_space`: non-breaking and typographic spaces U+00A0, U+2000–U+200A, U+202F, U+3000
229
+ * - `nonbreaking_space`: non-breaking and typographic spaces U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000
229
230
  * - `bom`: U+FEFF (`atStart` says whether it sits at offset 0)
230
231
  * - `nul`: U+0000
231
232
  * - `c0_control`: U+0001–U+001F except tab, LF and CR; and U+007F
@@ -322,15 +323,18 @@ export function inspectTextDetailed(text, options = {}) {
322
323
  }
323
324
 
324
325
  /**
325
- * Counts the number of different newline types in a string.
326
+ * @typedef {Object} NewlineStats
327
+ * @property {number} crlf - CR LF pairs.
328
+ * @property {number} cr - CRs not followed by LF.
329
+ * @property {number} lf - LFs not preceded by CR.
330
+ * @property {('CRLF'|'CR'|'LF')[]} types - Styles present, in the order CRLF, CR, LF.
331
+ * @property {boolean} mixed - True when more than one style is present.
332
+ */
333
+
334
+ /**
335
+ * Counts each newline style in one pass, with no regex and no copy.
326
336
  * @param {string} text
327
- * @returns {{
328
- * crlf: number,
329
- * cr: number,
330
- * lf: number,
331
- * types: string[],
332
- * mixed: boolean
333
- * }}
337
+ * @returns {NewlineStats}
334
338
  */
335
339
  export function getNewlineStats(text) {
336
340
  if (typeof text !== 'string') {
@@ -276,7 +276,7 @@ export function sanctifyText(
276
276
  *
277
277
  * Targets:
278
278
  * - Non-breaking spaces ( )
279
- * - Zero-width spaces and miscellaneous Unicode spaces ( –‍,  , ⁠,  , )
279
+ * - Zero-width spaces and miscellaneous Unicode spaces ( –‍,  ,  , ⁠,  , )
280
280
  * - Bidi controls: marks, embeddings/overrides and isolates (؜, ‎, ‏, ‪–‮, ⁦–⁩)
281
281
  * - Invisible format characters: soft hyphen, CGJ, Mongolian vowel separator, invisible operators (­, ͏, ᠎, ⁡–⁤)
282
282
  * - Hangul fillers (ᅟ, ᅠ, ㅤ, ᅠ)
@@ -286,7 +286,7 @@ export function sanctifyText(
286
286
  * @param {string} text
287
287
  * @returns {string}
288
288
  */
289
- export const INVISIBLE_TRASH_REGEX = /[ ­͏؜ᅟᅠ᠎ -‏‪- ⁠-⁤⁦-⁩ ㅤ︀-️ᅠ\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu;
289
+ export const INVISIBLE_TRASH_REGEX = /[ ­͏؜ᅟᅠ᠎ -‏‪-  ⁠-⁤⁦-⁩ ㅤ︀-️ᅠ\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu;
290
290
  function purgeInvisibleTrash(text) {
291
291
  return text.replace(INVISIBLE_TRASH_REGEX, '');
292
292
  }