reedach 0.1.1 → 0.2.0
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/package.json +1 -1
- package/src/markdown.js +27 -9
- package/src/rules.js +10 -2
- package/src/style.js +57 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reedach",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Лёгкая проверка русского текста на канцелярит, штампы и воду: словарь правил, работает в браузере и в Node без зависимостей",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Dima Faller <dima@dimafaller.ru>",
|
package/src/markdown.js
CHANGED
|
@@ -2,20 +2,37 @@
|
|
|
2
2
|
// Markdown source → plain blocks for the checkers, and all hints merged back onto source offsets.
|
|
3
3
|
const L='A-Za-zА-Яа-яЁё';
|
|
4
4
|
|
|
5
|
+
// Link or image target "(…)" starting at p → index of its closing ")", or -1; one level of inner
|
|
6
|
+
// parentheses is allowed: "(a_(b))". A scan gives the same answer to every position it passed outside
|
|
7
|
+
// inner parentheses, so the answers are kept and a line full of unclosed "](" stays linear.
|
|
8
|
+
function closing(line,p,memo){
|
|
9
|
+
if(line[p]!=='(')return -1;
|
|
10
|
+
const passed=[];let depth=0,end=-1;
|
|
11
|
+
for(let k=p+1;k<line.length;k++){
|
|
12
|
+
if(!depth){if(memo.has(k)){end=memo.get(k);break;}passed.push(k);}
|
|
13
|
+
const c=line[k];
|
|
14
|
+
if(c==='('){if(depth)break;depth=1;}
|
|
15
|
+
else if(c===')'){if(!depth){end=k;break;}depth=0;}
|
|
16
|
+
}
|
|
17
|
+
for(const k of passed)memo.set(k,end);
|
|
18
|
+
return end;
|
|
19
|
+
}
|
|
5
20
|
// One line of Markdown → text the checkers see, with every character mapped back to its offset in the source.
|
|
21
|
+
// The next ">" and "]" are looked up once and reused: unclosed "<a" or "![" must not rescan the rest of the line.
|
|
6
22
|
function inline(line,base,from,kind){
|
|
7
|
-
let text='';const map=[],put=(c,at)=>{text+=c;map.push(at);};
|
|
23
|
+
let text='',gt=-1,bracket=-1,end;const map=[],gaps=[],memo=new Map(),put=(c,at)=>{text+=c;map.push(at);};
|
|
24
|
+
const next=(ch,i,last)=>{if(last>=i)return last;const k=line.indexOf(ch,i);return k<0?Infinity:k;};
|
|
8
25
|
for(let i=from;i<line.length;i++){
|
|
9
|
-
const c=line[i]
|
|
26
|
+
const c=line[i];
|
|
10
27
|
if(c==='\\'&&i+1<line.length){i++;put(line[i],base+i);continue;}
|
|
11
|
-
if(c==='`'&&line.indexOf('`',i+1)>i){put(' ',base+i);i=line.indexOf('`',i+1);continue;}
|
|
12
|
-
if(c==='!'&&
|
|
13
|
-
if(c===']'&&(
|
|
14
|
-
if(c==='<'
|
|
28
|
+
if(c==='`'&&line.indexOf('`',i+1)>i){gaps.push(text.length);put(' ',base+i);i=line.indexOf('`',i+1);continue;}
|
|
29
|
+
if(c==='!'&&line[i+1]==='['&&(bracket=next(']',i+2,bracket))<Infinity&&(end=closing(line,bracket+1,memo))>=0){gaps.push(text.length);put(' ',base+i);i=end;continue;}
|
|
30
|
+
if(c===']'&&(end=closing(line,i+1,memo))>=0){i=end;continue;}
|
|
31
|
+
if(c==='<'&&/^<\/?[a-z]/i.test(line.slice(i,i+3))&&(gt=next('>',i,gt))<Infinity){i=gt;continue;}
|
|
15
32
|
if(c==='['||c==='*'||c==='_'&&!(/[\p{L}\d]/u.test(line[i-1]||'')&&/[\p{L}\d]/u.test(line[i+1]||'')))continue;
|
|
16
33
|
put(c,base+i);
|
|
17
34
|
}
|
|
18
|
-
return {text,map,kind};
|
|
35
|
+
return {text,map,kind,gaps};
|
|
19
36
|
}
|
|
20
37
|
// Markdown source → paragraphs, list items and headings; code, images, galleries and tables are left alone.
|
|
21
38
|
export function sourceBlocks(src){
|
|
@@ -30,7 +47,8 @@ export function sourceBlocks(src){
|
|
|
30
47
|
if((m=/^\s{0,3}#{1,6}\s+/.exec(line))){flush();const b=inline(line,start,m[0].length,'h');if(b.text.trim())blocks.push(b);continue;}
|
|
31
48
|
if((m=/^\s*(?:[-*+]|\d+[.)])\s+/.exec(line.slice(quote)))){flush();cur=inline(line,start,quote+m[0].length,'li');continue;}
|
|
32
49
|
const part=inline(line,start,quote,'p');
|
|
33
|
-
|
|
50
|
+
// No push(...list): a line of 100k+ characters would overflow the call stack.
|
|
51
|
+
if(cur){const at=cur.text.length+1;cur.gaps.push(at-1);for(const g of part.gaps)cur.gaps.push(g+at);cur.text+=' '+part.text;cur.map.push(start-1);for(const k of part.map)cur.map.push(k);}else cur=part;
|
|
34
52
|
}
|
|
35
53
|
flush();return blocks;
|
|
36
54
|
}
|
|
@@ -53,6 +71,6 @@ export function mergeIssues(blocks,{spelling=[],style=[],local=[]}){
|
|
|
53
71
|
// Plain text → paragraphs split by blank lines; every UTF-16 unit keeps its own offset (emoji take two).
|
|
54
72
|
export function textBlocks(text){
|
|
55
73
|
const blocks=[];
|
|
56
|
-
for(const m of String(text).matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]*)*/g)){const t=m[0].replace(/\n/g," ");if(t.trim())blocks.push({text:t,kind:"p",map:Array.from({length:t.length},(_,i)=>m.index+i)});}
|
|
74
|
+
for(const m of String(text).matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]*)*/g)){const t=m[0].replace(/\n/g," ");if(t.trim())blocks.push({text:t,kind:"p",gaps:[...m[0].matchAll(/\n/g)].map(x=>x.index),map:Array.from({length:t.length},(_,i)=>m.index+i)});}
|
|
57
75
|
return blocks;
|
|
58
76
|
}
|
package/src/rules.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Our own style rules, written from public advice on information style.
|
|
2
|
-
export const CATEGORIES={borrowing:'Заимствование',generalization:'Обобщение',euphemism:'Эвфемизм',preachy:'Назидание',filler:'Вода',slang:'Жаргон',stamp:'Штамп',bureaucratese:'Канцелярит',vague:'Неконкретно',evaluation:'Оценка',modality:'Модальность',intro:'Вводное',time:'Паразит времени',pleonasm:'Плеоназм',pronoun:'Местоимение',adjective:'Прилагательное',kotory:'«Который»',syntax:'Синтаксис',passive:'Страдательный залог',nominalization:'Отглагольное',repetition:'Повтор',length:'Длина',numbers:'Числа',structure:'Структура'};
|
|
2
|
+
export const CATEGORIES={borrowing:'Заимствование',generalization:'Обобщение',euphemism:'Эвфемизм',preachy:'Назидание',filler:'Вода',slang:'Жаргон',stamp:'Штамп',bureaucratese:'Канцелярит',vague:'Неконкретно',evaluation:'Оценка',modality:'Модальность',intro:'Вводное',time:'Паразит времени',pleonasm:'Плеоназм',pronoun:'Местоимение',adjective:'Прилагательное',kotory:'«Который»',syntax:'Синтаксис',passive:'Страдательный залог',nominalization:'Отглагольное',repetition:'Повтор',length:'Длина',numbers:'Числа',structure:'Структура',mistake:'Частая ошибка',colloquial:'Просторечие',typography:'Типографика'};
|
|
3
3
|
export const RULES=[
|
|
4
4
|
// Time parasites
|
|
5
5
|
{id:'time-parasite-start',category:'time',start:true,message:'Вступление о нынешнем времени ничего не добавляет к предложению.',fix:'Убрать его или назвать конкретную дату или факт.',words:['в настоящее время','в настоящий момент','в наши дни','в наше время','в последнее время','в последние годы','нынче','до недавнего времени','в современном мире','в современных условиях']},
|
|
@@ -35,7 +35,7 @@ export const RULES=[
|
|
|
35
35
|
{id:'pronoun-dannyy',category:'pronoun',message:'Слово данный звучит канцелярски и ничего не уточняет.',fix:'Заменить на слово этот или назвать предмет прямо.',words:['данный','данная','данное','данную','данной','данного','данном','данному']},
|
|
36
36
|
// Bureaucratese and nominalization
|
|
37
37
|
{id:'bureaucratese-verbs',category:'bureaucratese',message:'Канцелярский оборот звучит как отписка и скрывает суть.',fix:'Заменить простым глаголом или словом: делать, для, чтобы, это.',words:['осуществл*','имеет место','имеют место','имел место','имело место','в рамках','в целях','с целью','на предмет','посредством','является','являются','являться','вышеуказанный','вышеупомянутый','нижеследующий']},
|
|
38
|
-
{id:'bureaucratese-latinisms',category:'bureaucratese',message:'Иностранное слово там, где есть простое русское, затрудняет чтение.',fix:'Заменить обычным
|
|
38
|
+
{id:'bureaucratese-latinisms',category:'bureaucratese',message:'Иностранное слово там, где есть простое русское, затрудняет чтение.',fix:'Заменить обычным русским словом.',swap:{'коммуникац':'общение, связь','компетенц':'умение, знания, круг обязанностей','минимизир':'сократить, уменьшить','минимизац':'сокращение, уменьшение','конкретизир':'уточнить','релевантн':'подходящий, нужный, уместный','нерелевантн':'неподходящий, лишний','конкурентоспособн':'не уступает другим, выгоднее','стагнац':'застой','конъюнктур':'положение на рынке, спрос','деградац':'упадок, ухудшение','имплементир':'внедрить, ввести','имплементац':'внедрение','консолидир':'объединить, собрать','консолидац':'объединение','синерги':'совместная польза, выгода от объединения','функционал':'возможности, функции'},words:['коммуникац*','компетенц*','минимизир*','минимизац*','конкретизир*','релевантн*','нерелевантн*','конкурентоспособн*','стагнац*','конъюнктур*','деградац*','имплементир*','имплементац*','консолидир*','консолидац*','синерги*','функционал']},
|
|
39
39
|
{id:'nominalization-support-verbs',category:'nominalization',message:'Глагол плюс существительное длиннее и тяжелее одного глагола.',fix:'Заменить одним глаголом: монтировать вместо производить монтаж.',words:['принимать участие','принять участие','принял участие','приняли участие','принимаю участие','принимаем участие','принимает участие','принимают участие','оказать помощь','оказывать помощь','оказали помощь','оказываем помощь','оказание помощи','оказать услуги','оказывать услуги','оказали услуги','оказываем услуги','оказание услуг','производить монтаж','произвести монтаж','произвели монтаж','производим монтаж','производить замену','произвести замену','произвели замену','производим замену','производить ремонт','произвести ремонт','произвели ремонт','производим ремонт','проводить работы','проводим работы','проводят работы','провести работы','провели работы']},
|
|
40
40
|
// Stamps
|
|
41
41
|
{id:'stamp-cliche',category:'stamp',message:'Оборот затёрт: читатель видит штамп раньше смысла.',fix:'Написать конкретный факт или действие без оборота.',words:['сочета* в себе','на каждом шагу','в сухом остатке','на помощь приходят','на помощь приходит','легко и просто','без промедления','прямиком','расширить географию','расширили географию','играет важную роль','играют важную роль','играет ключевую роль','играют ключевую роль','играет большую роль','играют большую роль','как показывает практика','на высоком уровне','на должном уровне','на новый уровень','время покажет']},
|
|
@@ -88,4 +88,12 @@ export const RULES=[
|
|
|
88
88
|
{id:'euphemism-soft',category:'euphemism',message:'Мягкое слово прячет суть дела.',fix:'Назвать, что именно случилось и по чьей вине.',words:['форс-мажорн*','форс-мажор','политес']},
|
|
89
89
|
{id:'preachy-refusal',category:'preachy',message:'Список тех, с кем автор не работает, звучит как ультиматум.',fix:'Написать, с кем работаешь и чем это удобно обеим сторонам.',words:['не работаю с','не работаем с','в обязательном порядке']},
|
|
90
90
|
{id:'self-praise',category:'evaluation',message:'Похвала себе без доказательства читается как самореклама.',fix:'Показать результат: проект, срок, отзыв.',words:['мы не новички','нам можно доверить','мы серьезная фирма','никогда не срываю сроки','мы делаем лучше всех']},
|
|
91
|
+
// Common spelling and grammar mistakes, and colloquial forms
|
|
92
|
+
{id:'mistake-spelling',category:'mistake',message:'Частая ошибка: слово пишется слитно, раздельно или через дефис иначе.',fix:'Правильно: в общем, в принципе, вкратце, из-за, из-под, как будто, впоследствии, наподобие, то есть, в смысле.',words:['вообщем','вобщем','впринципе','в кратце','из за','из под','как-будто','в последствии','на подобии','тоесть','всмысле']},
|
|
93
|
+
{id:'mistake-po-case',category:'mistake',message:'Предлог по в значении после чего требует предложного падежа.',fix:'Писать по приезде, по прибытии, по окончании, по завершении, по истечении, по возвращении.',words:['по приезду','по прибытию','по окончанию','по завершению','по истечению','по возвращению']},
|
|
94
|
+
{id:'mistake-soglasno',category:'mistake',message:'После согласно нужен дательный падеж, а не родительный.',fix:'Писать согласно договору, приказу, графику, плану, закону, решению, правилам.',words:['согласно договора','согласно приказа','согласно графика','согласно плана','согласно закона','согласно решения','согласно распоряжения','согласно требований','согласно условий','согласно правил']},
|
|
95
|
+
{id:'mistake-oplata',category:'mistake',message:'Оплачивают что-то, а платят за что-то: оплатить за услугу смешивает два оборота.',fix:'Писать оплатить работу или заплатить за работу. Оплатить за меня или за месяц вперёд — верно.',words:['оплатить за','оплатил за','оплатила за','оплатили за','оплачу за','оплатите за','оплачивать за','оплачиваем за','оплачивают за']},
|
|
96
|
+
{id:'mistake-oboih',category:'mistake',message:'Со словами женского рода пишут обеих, обеим, обеими.',fix:'Писать обеих сторон, обеим сторонам, обеими руками.',words:['обоих сторон','обоим сторонам','обоими сторонами','обоих рук','обоими руками']},
|
|
97
|
+
{id:'mistake-context',category:'mistake',message:'Если речь о времени или причине, пишется иначе: в течение, в продолжение, вследствие, несмотря на.',fix:'Оставить как есть, только если речь о течении реки, продолжении книги, следствии по делу или о том, кто куда смотрит.',words:['в течении','в продолжении','в следствии','не смотря на']},
|
|
98
|
+
{id:'colloquial-forms',category:'colloquial',message:'Просторечная форма: в письменном тексте выглядит как ошибка.',fix:'Литературно: их, класть или положить, поезжай, ездят, хотят, ляг, сейчас, его.',words:['ихн*','ложить','ложит','ложишь','ложат','ложим','ложите','ложил','ложила','ложили','ехай','ехайте','едь','едьте','ездию','ездиют','хочут','ляжь','ляжьте','победю','щас','евонн*','евоный','евоная']},
|
|
91
99
|
];
|
package/src/style.js
CHANGED
|
@@ -7,6 +7,8 @@ const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
|
7
7
|
const tokenSource=t=>t.endsWith('*')?esc(norm(t.slice(0,-1)))+'['+L+']*':esc(norm(t));
|
|
8
8
|
function compile(rule){const alts=(rule.words||[]).map(w=>w.trim().split(/\s+/).map(tokenSource).join('\\s+'));return alts.length?new RegExp('(?<!['+L+'])(?:'+alts.join('|')+')(?!['+L+'])','g'):null;}
|
|
9
9
|
const compiled=RULES.map(rule=>({rule,re:compile(rule)}));
|
|
10
|
+
// A rule with swap gives the replacement for the exact word found; the longest matching stem wins.
|
|
11
|
+
const swapped=(rule,word)=>{if(!rule.swap)return rule;const k=Object.keys(rule.swap).filter(k=>word.startsWith(k)).sort((a,b)=>b.length-a.length)[0];return k?{...rule,fix:'Заменить обычным словом: '+rule.swap[k]+'.'}:rule;};
|
|
10
12
|
export const sentences=text=>[...text.matchAll(/[^.!?…]+(?:[.!?…]+|$)/g)].map(m=>({start:m.index,end:m.index+m[0].length,text:m[0]})).filter(s=>s.text.trim());
|
|
11
13
|
const wordCount=s=>(s.match(new RegExp('['+L+'0-9]+(?:-['+L+']+)*','g'))||[]).length;
|
|
12
14
|
const trimmed=(s,offset=0)=>{const lead=s.text.length-s.text.trimStart().length;return {start:s.start+offset+lead,end:s.start+offset+s.text.trimEnd().length};};
|
|
@@ -23,7 +25,51 @@ export const BUILTIN={
|
|
|
23
25
|
'thin-section':{category:'structure',message:'Под подзаголовком почти нет текста.',fix:'Допишите раздел или объедините его с соседним.'},
|
|
24
26
|
'ya-start':{category:'structure',message:'Много предложений начинаются с «Я»: текст звучит как рассказ о себе.',fix:'Начните часть предложений с того, что важно читателю: с дела, факта, результата.'},
|
|
25
27
|
'question-run':{category:'syntax',message:'Вопросы подряд выглядят как приём, а не как мысль.',fix:'Оставьте один вопрос или замените вопросы утверждением.'},
|
|
28
|
+
'word-double':{category:'repetition',message:'Слово стоит два раза подряд.',fix:'Уберите лишнее слово.'},
|
|
29
|
+
'typo-double-space':{category:'typography',message:'Два пробела подряд.',fix:'Оставьте один пробел.'},
|
|
30
|
+
'typo-space-before':{category:'typography',message:'Лишний пробел перед знаком препинания или внутри скобок и кавычек.',fix:'Уберите пробел: «двери, окна», «(окна)».'},
|
|
31
|
+
'typo-space-after':{category:'typography',message:'После запятой или точки с запятой нет пробела.',fix:'Поставьте пробел после знака.'},
|
|
32
|
+
'typo-quotes':{category:'typography',message:'Прямые кавычки в русском тексте выглядят как набор с клавиатуры.',fix:'Поставьте ёлочки: «Стандарт», а внутри других кавычек — „лапки“.'},
|
|
33
|
+
'typo-unpaired':{category:'typography',message:'У скобки или кавычки нет пары.',fix:'Закройте скобку или кавычку либо уберите лишнюю.'},
|
|
34
|
+
'typo-dash':{category:'typography',message:'Дефис вместо тире.',fix:'Между словами — длинное тире с пробелами: «Москва — столица». Между числами — короткое без пробелов: «5–10».'},
|
|
35
|
+
'typo-repeat-punct':{category:'typography',message:'Знак препинания повторяется.',fix:'Оставьте один знак. Можно только «?!», «?..», «!..» и многоточие.'},
|
|
36
|
+
'typo-initials':{category:'typography',message:'Инициалы слиплись.',fix:'Разделите пробелом: «И. И. Иванов».'},
|
|
37
|
+
'typo-mixed-script':{category:'typography',message:'В слове смешаны русские и латинские буквы: так бывает после копирования. Поиск и проверка орфографии такое слово не узнают.',fix:'Наберите слово заново на одной раскладке.'},
|
|
26
38
|
};
|
|
39
|
+
// Typography: [id, pattern]; a match must not span removed markup or a joined line break (see plain below).
|
|
40
|
+
const TYPO=[
|
|
41
|
+
['typo-double-space',/(?<=\S) {2,}(?=\S)/g],
|
|
42
|
+
['typo-space-before',/(?<=\S) +(?=[,;](?![-()DPp])|\)|»(?!\s|$)|[.!?](?=\s|$))|(?<=[^\s\d]) +(?=:(?=\s|$))|(?<=[(«]) +(?=\S)/g],
|
|
43
|
+
['typo-space-after',/,(?=[A-Za-zА-Яа-яЁё])|;(?)(?=[A-Za-zА-Яа-яЁё])/g],
|
|
44
|
+
['typo-dash',/(?<=\S) --? (?=\S)/g],
|
|
45
|
+
['typo-repeat-punct',/[!?]{3,}|!!|\?\?|,{2,}|;{2,}|(?<![.?!])\.\.(?!\.)|\.{4,}/g],
|
|
46
|
+
// «И.И. Иванов», «Иванов И.И.», «А.Пушкин»; «Ф.И.О.» and «до Н.Э.» are abbreviations, not initials.
|
|
47
|
+
['typo-initials',/(?<!\p{L})[А-ЯЁ]\.[А-ЯЁ]\.(?=\s?[А-ЯЁ][а-яё])|(?<=[А-ЯЁ][а-яё]+\s)[А-ЯЁ]\.[А-ЯЁ]\.(?!\p{L})|(?<![\p{L}.])[А-ЯЁ]\.(?=[А-ЯЁ][а-яё]{2,})/gu],
|
|
48
|
+
];
|
|
49
|
+
// Mixed scripts are checked word by word: one pattern over the whole text backtracked for seconds on a long word.
|
|
50
|
+
const MIXED=/\p{Script=Cyrillic}\p{Script=Latin}|\p{Script=Latin}\p{Script=Cyrillic}/u;
|
|
51
|
+
// Addresses, e-mails and HTML entities are not prose: no typography hints inside them.
|
|
52
|
+
// Marked once per block (a looked-up word per hint made a long text with many hints quadratic).
|
|
53
|
+
const technical=text=>{const marks=new Uint8Array(text.length);for(const m of text.matchAll(/[^ ]+/g))if(/[/@&=]/.test(m[0]))marks.fill(1,m.index,m.index+m[0].length);return s=>marks[s]===1;};
|
|
54
|
+
// The last sentence starting at or before `at` (sentences are in order), by binary search.
|
|
55
|
+
const sentenceAt=(list,at)=>{let lo=0,hi=list.length-1,hit=null;while(lo<=hi){const mid=(lo+hi)>>1;if(list[mid].start<=at){hit=list[mid];lo=mid+1;}else hi=mid-1;}return hit;};
|
|
56
|
+
// Brackets and guillemets without a pair. Not brackets: «а)», «1)», «п. 2)», smileys «:)», «:-(», «:))»;
|
|
57
|
+
// a lone «»» or ««» between spaces is a navigation arrow.
|
|
58
|
+
function unpaired(text){
|
|
59
|
+
const out=[],open=[];
|
|
60
|
+
for(let k=0;k<text.length;k++){const c=text[k],before=text.slice(Math.max(0,k-6),k);
|
|
61
|
+
if('()«»'.indexOf(c)<0)continue;
|
|
62
|
+
if((c==='('||c===')')&&/[:;=]-?[()]*$/.test(before))continue;
|
|
63
|
+
if((c==='«'||c==='»')&&/(?:^|\s)$/.test(before)&&/^(?:\s|$)/.test(text.slice(k+1)))continue;
|
|
64
|
+
if(c==='('||c==='«'){open.push(k);continue;}
|
|
65
|
+
const top=open.length?open[open.length-1]:-1;
|
|
66
|
+
if(top>=0&&text[top]===(c===')'?'(':'«')){open.pop();continue;}
|
|
67
|
+
if(c===')'&&/(?:^|\s)(?:\p{L}|\p{L}?\.?\s?[\d.]{1,4})$/u.test(before))continue;
|
|
68
|
+
out.push(k);
|
|
69
|
+
}
|
|
70
|
+
return [...out,...open];
|
|
71
|
+
}
|
|
72
|
+
const DOUBLE_OK=new Set(['да','нет','ну','так','вот','ни','ха','хе','ой','ах','ох','эх','бла','тук','кап']);
|
|
27
73
|
const PASSIVE=new RegExp('(?<!['+L+'])(?:был|была|было|были|будет|будут)\\s+['+L+']+(?:ан|ян|ен|ён|т)(?:а|о|ы)?(?!['+L+'])','gi');
|
|
28
74
|
const NOMINAL=new RegExp('(?:(?<!['+L+'])['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])\\s+){2,}['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])','gi');
|
|
29
75
|
const REPEAT_SKIP=new Set(['который','которая','которое','которые','которых','которым','потому','только','сейчас','больше','меньше','просто','сделать','делать','чтобы','когда','после','через','between','about']);
|
|
@@ -32,7 +78,17 @@ export function checkStyle(blocks,{maxSentenceWords=25,maxParagraphWords=120}={}
|
|
|
32
78
|
const issues=[],add=(block,start,end,id,rule)=>issues.push({block,start,end,id,category:rule.category,label:CATEGORIES[rule.category]||rule.category,message:rule.message,fix:rule.fix});
|
|
33
79
|
blocks.forEach((b,i)=>{
|
|
34
80
|
const text=b.text,low=norm(text),list=sentences(text);
|
|
35
|
-
|
|
81
|
+
// A start-only rule needs nothing but spaces between the sentence start and the match.
|
|
82
|
+
for(const {rule,re} of compiled){if(!re)continue;re.lastIndex=0;for(const m of low.matchAll(re)){if(rule.start){const s=sentenceAt(list,m.index);if(!s||low.slice(s.start,m.index).trim()!=='')continue;}add(i,m.index,m.index+m[0].length,rule.id,swapped(rule,m[0]));}}
|
|
83
|
+
// Blocks map every character to the source; gaps are spaces that stand for code, an image or a line break, not typos.
|
|
84
|
+
// before[k] = gaps before offset k, so "a gap inside s..e" costs the same however many gaps the block has.
|
|
85
|
+
const before=new Uint32Array(text.length+1);for(const j of b.gaps||[])if(j>=0&&j<text.length)before[j+1]++;for(let k=1;k<=text.length;k++)before[k]+=before[k-1];
|
|
86
|
+
const gap=(s,e)=>before[e]-before[s]>0,plain=(s,e)=>(!b.map||b.map[e-1]-b.map[s]===e-1-s)&&!gap(s,e),tech=technical(text);
|
|
87
|
+
for(const [id,re] of TYPO)for(const m of text.matchAll(re))if(plain(m.index,m.index+m[0].length)&&!tech(m.index))add(i,m.index,m.index+m[0].length,id,BUILTIN[id]);
|
|
88
|
+
for(const m of text.matchAll(/\p{L}+/gu))if(MIXED.test(m[0])&&plain(m.index,m.index+m[0].length)&&!tech(m.index))add(i,m.index,m.index+m[0].length,'typo-mixed-script',BUILTIN['typo-mixed-script']);
|
|
89
|
+
for(const m of text.matchAll(/"[^"\n]*"/g))if(/[А-Яа-яЁё]/.test(m[0]))add(i,m.index,m.index+m[0].length,'typo-quotes',BUILTIN['typo-quotes']);
|
|
90
|
+
for(const k of unpaired(text))add(i,k,k+1,'typo-unpaired',BUILTIN['typo-unpaired']);
|
|
91
|
+
for(const m of low.matchAll(new RegExp('(?<!['+L+'])(['+L+']+)\\s+\\1(?![-'+L+'])','g')))if(!DOUBLE_OK.has(m[1])&&plain(m.index,m.index+m[0].length))add(i,m.index,m.index+m[0].length,'word-double',BUILTIN['word-double']);
|
|
36
92
|
if(b.kind==='h')return;
|
|
37
93
|
for(let n=1;n<list.length;n++)if(/\?\s*$/.test(list[n].text)&&/\?\s*$/.test(list[n-1].text)){const r=trimmed(list[n]);add(i,r.start,r.end,'question-run',BUILTIN['question-run']);}
|
|
38
94
|
for(const s of list){
|