humanizer-lint 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,45 @@
1
1
  import { sanitizeMarkdown } from '../utils/sanitizer.mjs';
2
2
  import { mean, punctuationRates } from '../utils/metrics-core.mjs';
3
- import { tokenizeText } from '../utils/tokenizer.mjs';
3
+ import { isCJKText, tokenizeText } from '../utils/tokenizer.mjs';
4
4
 
5
- const ID_WORDS = new Set(['yang', 'dan', 'di', 'ini', 'untuk', 'dengan', 'dari', 'pada', 'dalam', 'tidak', 'akan', 'atau', 'juga', 'oleh', 'adalah', 'sebagai', 'bahwa', 'dapat', 'karena', 'tersebut']);
6
- const EN_WORDS = new Set(['the', 'and', 'of', 'to', 'in', 'is', 'that', 'for', 'it', 'with', 'as', 'on', 'are', 'this', 'by', 'be', 'at', 'or', 'from', 'an']);
5
+ const HAN = /[\u4e00-\u9fff]/gu;
6
+ const KANA = /[\u3040-\u30ff]/u;
7
+ const ARABIC = /[\u0600-\u06ff]/u;
8
+ const ID_EXCLUSIVE_WORDS = new Set(['tidak', 'karena', 'bisa', 'seperti', 'dari', 'bahwa']);
9
+ const MS_EXCLUSIVE_WORDS = new Set(['tak', 'kerana', 'boleh', 'bagi', 'daripada', 'bahawa', 'beliau']);
7
10
 
8
11
  function round(value, places = 4) {
9
12
  const factor = 10 ** places;
10
13
  return Math.round(value * factor) / factor;
11
14
  }
12
15
 
13
- function detectLanguage(words) {
14
- let id = 0;
15
- let en = 0;
16
- for (const word of words) {
17
- if (ID_WORDS.has(word)) id += 1;
18
- if (EN_WORDS.has(word)) en += 1;
16
+ function detectLanguage(text, words, rules) {
17
+ if (KANA.test(text)) return 'ja';
18
+ if (ARABIC.test(text)) return 'ar';
19
+ const hanCount = (text.match(HAN) ?? []).length;
20
+ if (hanCount > 0 && isCJKText(text)) return 'zh';
21
+
22
+ const scores = new Map();
23
+ for (const [language, rule] of Object.entries(rules)) {
24
+ let score = 0;
25
+ const stopwords = new Set(rule.stopwords ?? []);
26
+ for (const word of words) if (stopwords.has(word)) score += 1;
27
+ scores.set(language, score);
28
+ }
29
+ if (scores.has('id') && scores.has('ms')) {
30
+ const idExclusive = words.filter((word) => ID_EXCLUSIVE_WORDS.has(word)).length;
31
+ const msExclusive = words.filter((word) => MS_EXCLUSIVE_WORDS.has(word)).length;
32
+ scores.set('id', scores.get('id') + idExclusive * 2);
33
+ scores.set('ms', scores.get('ms') + msExclusive * 2);
19
34
  }
20
- return en > id ? 'en' : 'id';
35
+
36
+ const [language, score] = [...scores.entries()].sort((left, right) => right[1] - left[1])[0] ?? ['universal', 0];
37
+ return score >= Math.max(2, Math.ceil(words.length * 0.04)) ? language : 'universal';
38
+ }
39
+
40
+ function mattrThreshold(thresholds, language, tokenized) {
41
+ if (typeof thresholds.mattr?.[language] === 'number') return thresholds.mattr[language];
42
+ return isCJKText(tokenized.words.join('')) ? 0.68 : 0.72;
21
43
  }
22
44
 
23
45
  function locationFor(sentence) {
@@ -29,6 +51,7 @@ function regexFor(pattern) {
29
51
  }
30
52
 
31
53
  function patternViolations(tokenized, rules, wordCount) {
54
+ if (!rules?.patterns) return [];
32
55
  const violations = [];
33
56
  for (const rule of rules.patterns) {
34
57
  const candidates = [];
@@ -122,7 +145,7 @@ function structuralViolations(tokenized, metrics) {
122
145
  return violations;
123
146
  }
124
147
 
125
- function statisticalViolations(metrics, thresholds, language) {
148
+ function statisticalViolations(metrics, thresholds, mattrTarget) {
126
149
  const violations = [];
127
150
  const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
128
151
  if (metrics.burstinessCV < cvTarget) {
@@ -152,12 +175,12 @@ function statisticalViolations(metrics, thresholds, language) {
152
175
  fixInstruction: 'Change neighboring sentence lengths by splitting or merging only where the content allows it.',
153
176
  });
154
177
  }
155
- if (metrics.mattr < thresholds.mattr[language]) {
178
+ if (metrics.mattr < mattrTarget) {
156
179
  violations.push({
157
180
  type: 'LOW_LEXICAL_DIVERSITY',
158
181
  location: 'Whole text',
159
182
  actual: round(metrics.mattr),
160
- target: thresholds.mattr[language],
183
+ target: mattrTarget,
161
184
  fixInstruction: 'Replace repeated generic wording with precise terms already supported by the source.',
162
185
  });
163
186
  }
@@ -244,7 +267,7 @@ function voiceDeviation(metrics, tokenized, text, profile) {
244
267
  return result < 0.0005 ? 0 : result;
245
268
  }
246
269
 
247
- function scoreResult(metrics, violations, language, thresholds) {
270
+ function scoreResult(metrics, violations, thresholds, mattrTarget) {
248
271
  const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
249
272
  let burstinessPenalty = metrics.burstinessCV < cvTarget ? Math.min(25, Math.round((cvTarget - metrics.burstinessCV) * 100)) : 0;
250
273
  if (metrics.sentenceCount >= 8 && (metrics.shortSentenceRatio < thresholds.shortSentenceRatio || metrics.longSentenceRatio < thresholds.longSentenceRatio)) {
@@ -259,7 +282,7 @@ function scoreResult(metrics, violations, language, thresholds) {
259
282
  const morphologyPenalty = Math.min(15,
260
283
  (violations.some((violation) => violation.type === 'EXCESSIVE_NOMINALIZATION') ? 7.5 : 0)
261
284
  + Math.min(15, violations.filter((violation) => violation.type === 'PASSIVE_BLOATING').length * 7.5));
262
- const vocabularyPenalty = metrics.mattr < thresholds.mattr[language] ? 5 : 0;
285
+ const vocabularyPenalty = metrics.mattr < mattrTarget ? 5 : 0;
263
286
  const entropyPenalty = violations.some((violation) => ['LOW_ENTROPY_REPETITION', 'SYMMETRIC_PARAGRAPHS', 'REPETITIVE_STARTERS'].includes(violation.type)) ? 5 : 0;
264
287
  const profilePenalty = violations.some((violation) => violation.type === 'VOICE_PROFILE_DEVIATION') ? 5 : 0;
265
288
  const vocabularyAndProfilePenalty = Math.min(10, vocabularyPenalty + entropyPenalty + profilePenalty);
@@ -267,25 +290,27 @@ function scoreResult(metrics, violations, language, thresholds) {
267
290
  }
268
291
 
269
292
  export function createAnalyzer({ rules, defaultProfile, calculateMetrics, calculateMetricsAsync }) {
270
- if (!rules?.id || !rules?.en || !defaultProfile || typeof calculateMetrics !== 'function') {
293
+ if (!rules || !defaultProfile || typeof calculateMetrics !== 'function') {
271
294
  throw new TypeError('createAnalyzer requires rules, defaultProfile, and calculateMetrics');
272
295
  }
273
296
 
274
297
  const prepare = (input, options) => {
275
298
  const sanitized = sanitizeMarkdown(input);
276
- const initial = tokenizeText(sanitized, options.language ?? 'auto');
277
- const language = options.language && options.language !== 'auto' ? options.language : detectLanguage(initial.words);
278
- if (!rules[language]) throw new Error(`Unsupported language: ${language}`);
299
+ const requestedLanguage = String(options.language ?? 'auto').toLowerCase();
300
+ const initial = tokenizeText(sanitized, requestedLanguage);
301
+ const language = requestedLanguage === 'auto' ? detectLanguage(sanitized, initial.words, rules) : requestedLanguage;
279
302
  return {
280
303
  sanitized,
281
304
  language,
282
- tokenized: language === (options.language ?? 'auto') ? initial : tokenizeText(sanitized, language),
305
+ rulePack: rules[language] ? language : 'universal',
306
+ tokenized: language === requestedLanguage ? initial : tokenizeText(sanitized, language),
283
307
  };
284
308
  };
285
309
 
286
- const analyzePrepared = ({ sanitized, language, tokenized }, options, metrics) => {
310
+ const analyzePrepared = ({ sanitized, language, rulePack, tokenized }, options, metrics) => {
287
311
  const profile = options.profile ?? defaultProfile;
288
312
  const thresholds = { ...defaultProfile.thresholds, ...(profile.thresholds ?? {}) };
313
+ const mattrTarget = mattrThreshold(thresholds, language, tokenized);
289
314
  const patternMatches = patternViolations(tokenized, rules[language], metrics.wordCount);
290
315
 
291
316
  if (metrics.wordCount < thresholds.minimumWords || metrics.sentenceCount < thresholds.minimumSentences) {
@@ -294,13 +319,14 @@ export function createAnalyzer({ rules, defaultProfile, calculateMetrics, calcul
294
319
  status: 'SKIP',
295
320
  score: 100,
296
321
  language,
322
+ rulePack,
297
323
  warning: 'Text too short for statistical stylometry (< 3 sentences)',
298
324
  metrics: Object.fromEntries(Object.entries({ ...metrics, nominalizationRatio: 0, passiveRatio: 0, voiceProfileDeviation: 0 }).map(([key, value]) => [key, typeof value === 'number' ? round(value) : value])),
299
325
  violations,
300
326
  };
301
327
  }
302
328
 
303
- const morphology = morphologyViolations(tokenized, rules[language].morphology);
329
+ const morphology = morphologyViolations(tokenized, rules[language]?.morphology);
304
330
  metrics.nominalizationRatio = morphology.nominalizationRatio;
305
331
  metrics.passiveRatio = morphology.passiveRatio;
306
332
  metrics.voiceProfileDeviation = voiceDeviation(metrics, tokenized, sanitized, profile);
@@ -319,9 +345,9 @@ export function createAnalyzer({ rules, defaultProfile, calculateMetrics, calcul
319
345
  ...structuralViolations(tokenized, metrics),
320
346
  ...morphology.violations,
321
347
  ...profileViolations,
322
- ...statisticalViolations(metrics, thresholds, language),
348
+ ...statisticalViolations(metrics, thresholds, mattrTarget),
323
349
  ];
324
- const score = scoreResult(metrics, violations, language, thresholds);
350
+ const score = scoreResult(metrics, violations, thresholds, mattrTarget);
325
351
  const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
326
352
  const forbidden = violations.some((violation) => violation.type === 'SLOP_PHRASE' || violation.type === 'DEFINITIONAL_OPENING');
327
353
  const rhythmPass = metrics.adjacentDelta >= thresholds.adjacentDelta;
@@ -332,6 +358,7 @@ export function createAnalyzer({ rules, defaultProfile, calculateMetrics, calcul
332
358
  status,
333
359
  score,
334
360
  language,
361
+ rulePack,
335
362
  metrics: Object.fromEntries(Object.entries(metrics).map(([key, value]) => [key, typeof value === 'number' ? round(value) : value])),
336
363
  violations,
337
364
  };
@@ -1,82 +1,149 @@
1
- const ABBREVIATIONS = {
2
- id: ['dll.', 'dsb.', 'dst.', 'dkk.', 'hlm.', 'no.', 'dr.', 'prof.', 'ir.', 'hj.', 'pt.', 'cv.', 'tbk.', 'rp.', 'tgl.', 's.d.', 'a.n.', 'u.p.'],
3
- en: ['mr.', 'mrs.', 'ms.', 'dr.', 'prof.', 'sr.', 'jr.', 'vs.', 'etc.', 'e.g.', 'i.e.', 'fig.', 'inc.', 'ltd.', 'co.', 'st.'],
4
- };
5
-
6
- const DOT = '\uE000';
7
- const DECIMAL = '\uE001';
8
- const ELLIPSIS = '\uE002';
9
-
10
- function escapeRegExp(value) {
11
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
- }
13
-
14
- function protectAbbreviations(text, language) {
15
- const items = language === 'auto'
16
- ? [...new Set([...ABBREVIATIONS.id, ...ABBREVIATIONS.en])]
17
- : ABBREVIATIONS[language] ?? ABBREVIATIONS.en;
18
- let protectedText = text
19
- .replace(/\.{2,}/g, ELLIPSIS)
20
- .replace(/(\d)\.(\d)/g, `$1${DECIMAL}$2`);
21
- for (const abbreviation of items.sort((a, b) => b.length - a.length)) {
22
- protectedText = protectedText.replace(
23
- new RegExp(`\\b${escapeRegExp(abbreviation)}`, 'gi'),
24
- (match) => match.replaceAll('.', DOT),
25
- );
26
- }
27
- return protectedText;
28
- }
29
-
30
- export function tokenizeWords(text) {
31
- const normalized = String(text ?? '').replace(/—|--/g, ' ');
32
- return (normalized.toLowerCase().match(/token_entity|[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)?(?:-[\p{L}\p{N}]+)*/gu) ?? []);
33
- }
34
-
35
- export function tokenizeText(input, language = 'auto') {
36
- const paragraphs = [];
37
- const sentences = [];
38
- const rawParagraphs = String(input ?? '').replace(/\r\n?/g, '\n').split(/\n\s*\n+/).map((value) => value.trim()).filter(Boolean);
39
-
40
- for (const [paragraphOffset, rawParagraph] of rawParagraphs.entries()) {
41
- const protectedText = protectAbbreviations(rawParagraph.replace(/\s+/g, ' '), language);
42
- const markedText = protectedText.replace(/([.!?](?:["')\]”’]+)?)\s+/gu, '$1\n');
43
- const parts = markedText.split('\n').map((value) => value
44
- .replaceAll(DOT, '.')
45
- .replaceAll(DECIMAL, '.')
46
- .replaceAll(ELLIPSIS, '...')
47
- .trim()).filter(Boolean);
48
- const paragraphSentences = [];
49
-
50
- for (const text of parts) {
51
- const words = tokenizeWords(text);
52
- if (!words.length) continue;
53
- const sentence = {
54
- text,
55
- words,
56
- wordCount: words.length,
57
- paragraphIndex: paragraphOffset + 1,
58
- sentenceIndex: sentences.length + 1,
59
- sentenceInParagraph: paragraphSentences.length + 1,
60
- };
61
- paragraphSentences.push(sentence);
62
- sentences.push(sentence);
63
- }
64
-
65
- if (paragraphSentences.length) {
66
- paragraphs.push({
67
- text: rawParagraph,
68
- paragraphIndex: paragraphOffset + 1,
69
- sentences: paragraphSentences,
70
- words: paragraphSentences.flatMap((sentence) => sentence.words),
71
- });
72
- }
73
- }
74
-
75
- return {
76
- paragraphs,
77
- sentences,
78
- words: sentences.flatMap((sentence) => sentence.words),
79
- };
80
- }
81
-
82
- export { ABBREVIATIONS };
1
+ const ABBREVIATIONS = {
2
+ id: ['dll.', 'dsb.', 'dst.', 'dkk.', 'hlm.', 'no.', 'dr.', 'prof.', 'ir.', 'hj.', 'pt.', 'cv.', 'tbk.', 'rp.', 'tgl.', 's.d.', 'a.n.', 'u.p.'],
3
+ en: ['mr.', 'mrs.', 'ms.', 'dr.', 'prof.', 'sr.', 'jr.', 'vs.', 'etc.', 'e.g.', 'i.e.', 'fig.', 'inc.', 'ltd.', 'co.', 'st.'],
4
+ eu: ['p.ej.', 'sr.', 'sra.', 'z.b.', 'bzw.', 'm.a.w.'],
5
+ };
6
+
7
+ const DOT = '\uE000';
8
+ const DECIMAL = '\uE001';
9
+ const ELLIPSIS = '\uE002';
10
+ const CJK_PATTERN = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f\uac00-\ud7af]/u;
11
+ const SPACED_TERMINAL_PUNCTUATION = /([.!?\u061f]+(?:["')\]”’]+)?)(?=\s|$)/gu;
12
+ const CJK_TERMINAL_PUNCTUATION = /([\u3002\uff01\uff1f]+(?:["')\]”’]+)?)(?=\s|$|[\p{L}\p{N}])/gu;
13
+ const WORD_CONNECTOR = /^(?:[-'’])$/u;
14
+
15
+ function escapeRegExp(value) {
16
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ }
18
+
19
+ export function normalizeLocale(language) {
20
+ const value = String(language ?? '').trim().toLowerCase();
21
+ if (!value || value === 'auto' || value === 'universal') return 'und';
22
+ if (!/^[a-z]{2}$/u.test(value)) return 'und';
23
+ try {
24
+ return Intl.getCanonicalLocales(value)[0] ?? 'und';
25
+ } catch {
26
+ return 'und';
27
+ }
28
+ }
29
+
30
+ export function isCJKText(text) {
31
+ return CJK_PATTERN.test(String(text ?? ''));
32
+ }
33
+
34
+ function segmenter(locale, granularity) {
35
+ if (typeof Intl?.Segmenter !== 'function') return null;
36
+ try {
37
+ return new Intl.Segmenter(normalizeLocale(locale), { granularity });
38
+ } catch {
39
+ return new Intl.Segmenter('und', { granularity });
40
+ }
41
+ }
42
+
43
+ function abbreviationsFor(language) {
44
+ if (language === 'id') return [...ABBREVIATIONS.id, ...ABBREVIATIONS.en, ...ABBREVIATIONS.eu];
45
+ if (language === 'en') return [...ABBREVIATIONS.en, ...ABBREVIATIONS.id, ...ABBREVIATIONS.eu];
46
+ return Object.values(ABBREVIATIONS).flat();
47
+ }
48
+
49
+ function protectAbbreviations(text, language) {
50
+ let protectedText = text
51
+ .replace(/\.{2,}/g, ELLIPSIS)
52
+ .replace(/(\d)\.(\d)/g, `$1${DECIMAL}$2`);
53
+ for (const abbreviation of [...new Set(abbreviationsFor(language))].sort((a, b) => b.length - a.length)) {
54
+ protectedText = protectedText.replace(
55
+ new RegExp(`\\b${escapeRegExp(abbreviation)}`, 'giu'),
56
+ (match) => match.replaceAll('.', DOT),
57
+ );
58
+ }
59
+ return protectedText;
60
+ }
61
+
62
+ function restoreProtectedText(text) {
63
+ return text
64
+ .replaceAll(DOT, '.')
65
+ .replaceAll(DECIMAL, '.')
66
+ .replaceAll(ELLIPSIS, '...');
67
+ }
68
+
69
+ function splitTerminalPunctuation(text) {
70
+ return text
71
+ .replace(SPACED_TERMINAL_PUNCTUATION, '$1\n')
72
+ .replace(CJK_TERMINAL_PUNCTUATION, '$1\n');
73
+ }
74
+
75
+ export function splitSentences(text, language = 'auto') {
76
+ const protectedText = protectAbbreviations(String(text ?? '').replace(/\s+/g, ' ').trim(), language);
77
+ if (!protectedText) return [];
78
+ const sentenceSegmenter = segmenter(language, 'sentence');
79
+ const segments = sentenceSegmenter
80
+ ? [...sentenceSegmenter.segment(protectedText)].map((entry) => entry.segment)
81
+ : [protectedText];
82
+ return segments
83
+ .flatMap((segment) => splitTerminalPunctuation(segment).split('\n'))
84
+ .map((segment) => restoreProtectedText(segment).trim())
85
+ .filter(Boolean);
86
+ }
87
+
88
+ function fallbackWords(text) {
89
+ return String(text ?? '').toLowerCase().match(/token_entity|[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)?(?:-[\p{L}\p{N}]+)*/gu) ?? [];
90
+ }
91
+
92
+ export function tokenizeWords(text, language = 'auto') {
93
+ const normalized = String(text ?? '').replace(/—|--/g, ' ');
94
+ const wordSegmenter = segmenter(language, 'word');
95
+ if (!wordSegmenter) return fallbackWords(normalized);
96
+
97
+ const words = [];
98
+ let previousEnd = -1;
99
+ for (const entry of wordSegmenter.segment(normalized)) {
100
+ if (!entry.isWordLike) continue;
101
+ const word = entry.segment.toLowerCase();
102
+ const between = previousEnd < 0 ? '' : normalized.slice(previousEnd, entry.index);
103
+ if (words.length && WORD_CONNECTOR.test(between)) words[words.length - 1] += `${between}${word}`;
104
+ else words.push(word);
105
+ previousEnd = entry.index + entry.segment.length;
106
+ }
107
+ return words;
108
+ }
109
+
110
+ export function tokenizeText(input, language = 'auto') {
111
+ const paragraphs = [];
112
+ const sentences = [];
113
+ const rawParagraphs = String(input ?? '').replace(/\r\n?/g, '\n').split(/\n\s*\n+/).map((value) => value.trim()).filter(Boolean);
114
+
115
+ for (const [paragraphOffset, rawParagraph] of rawParagraphs.entries()) {
116
+ const paragraphSentences = [];
117
+ for (const text of splitSentences(rawParagraph, language)) {
118
+ const words = tokenizeWords(text, language);
119
+ if (!words.length) continue;
120
+ const sentence = {
121
+ text,
122
+ words,
123
+ wordCount: words.length,
124
+ paragraphIndex: paragraphOffset + 1,
125
+ sentenceIndex: sentences.length + 1,
126
+ sentenceInParagraph: paragraphSentences.length + 1,
127
+ };
128
+ paragraphSentences.push(sentence);
129
+ sentences.push(sentence);
130
+ }
131
+
132
+ if (paragraphSentences.length) {
133
+ paragraphs.push({
134
+ text: rawParagraph,
135
+ paragraphIndex: paragraphOffset + 1,
136
+ sentences: paragraphSentences,
137
+ words: paragraphSentences.flatMap((sentence) => sentence.words),
138
+ });
139
+ }
140
+ }
141
+
142
+ return {
143
+ paragraphs,
144
+ sentences,
145
+ words: sentences.flatMap((sentence) => sentence.words),
146
+ };
147
+ }
148
+
149
+ export { ABBREVIATIONS };