reedach 0.1.1 → 0.1.2
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 +7 -7
- package/src/rules.js +9 -1
- package/src/style.js +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reedach",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Лёгкая проверка русского текста на канцелярит, штампы и воду: словарь правил, работает в браузере и в Node без зависимостей",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Dima Faller <dima@dimafaller.ru>",
|
package/src/markdown.js
CHANGED
|
@@ -4,18 +4,18 @@ const L='A-Za-zА-Яа-яЁё';
|
|
|
4
4
|
|
|
5
5
|
// One line of Markdown → text the checkers see, with every character mapped back to its offset in the source.
|
|
6
6
|
function inline(line,base,from,kind){
|
|
7
|
-
let text='';const map=[],put=(c,at)=>{text+=c;map.push(at);};
|
|
7
|
+
let text='';const map=[],gaps=[],put=(c,at)=>{text+=c;map.push(at);};
|
|
8
8
|
for(let i=from;i<line.length;i++){
|
|
9
9
|
const c=line[i],rest=line.slice(i);let m;
|
|
10
10
|
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==='!'&&(m=/^!\[[^\]]*\]\([^)]*\)/.exec(rest))){put(' ',base+i);i+=m[0].length-1;continue;}
|
|
13
|
-
if(c===']'&&(m=/^\]\([^)]*\)/.exec(rest))){i+=m[0].length-1;continue;}
|
|
11
|
+
if(c==='`'&&line.indexOf('`',i+1)>i){gaps.push(text.length);put(' ',base+i);i=line.indexOf('`',i+1);continue;}
|
|
12
|
+
if(c==='!'&&(m=/^!\[[^\]]*\]\((?:[^()]|\([^()]*\))*\)/.exec(rest))){gaps.push(text.length);put(' ',base+i);i+=m[0].length-1;continue;}
|
|
13
|
+
if(c===']'&&(m=/^\]\((?:[^()]|\([^()]*\))*\)/.exec(rest))){i+=m[0].length-1;continue;}
|
|
14
14
|
if(c==='<'&&(m=/^<\/?[a-z][^>]*>/i.exec(rest))){i+=m[0].length-1;continue;}
|
|
15
15
|
if(c==='['||c==='*'||c==='_'&&!(/[\p{L}\d]/u.test(line[i-1]||'')&&/[\p{L}\d]/u.test(line[i+1]||'')))continue;
|
|
16
16
|
put(c,base+i);
|
|
17
17
|
}
|
|
18
|
-
return {text,map,kind};
|
|
18
|
+
return {text,map,kind,gaps};
|
|
19
19
|
}
|
|
20
20
|
// Markdown source → paragraphs, list items and headings; code, images, galleries and tables are left alone.
|
|
21
21
|
export function sourceBlocks(src){
|
|
@@ -30,7 +30,7 @@ export function sourceBlocks(src){
|
|
|
30
30
|
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
31
|
if((m=/^\s*(?:[-*+]|\d+[.)])\s+/.exec(line.slice(quote)))){flush();cur=inline(line,start,quote+m[0].length,'li');continue;}
|
|
32
32
|
const part=inline(line,start,quote,'p');
|
|
33
|
-
if(cur){cur.text+=' '+part.text;cur.map.push(start-1,...part.map);}else cur=part;
|
|
33
|
+
if(cur){cur.gaps.push(cur.text.length,...part.gaps.map(g=>g+cur.text.length+1));cur.text+=' '+part.text;cur.map.push(start-1,...part.map);}else cur=part;
|
|
34
34
|
}
|
|
35
35
|
flush();return blocks;
|
|
36
36
|
}
|
|
@@ -53,6 +53,6 @@ export function mergeIssues(blocks,{spelling=[],style=[],local=[]}){
|
|
|
53
53
|
// Plain text → paragraphs split by blank lines; every UTF-16 unit keeps its own offset (emoji take two).
|
|
54
54
|
export function textBlocks(text){
|
|
55
55
|
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)});}
|
|
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",gaps:[...m[0].matchAll(/\n/g)].map(x=>x.index),map:Array.from({length:t.length},(_,i)=>m.index+i)});}
|
|
57
57
|
return blocks;
|
|
58
58
|
}
|
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:['в настоящее время','в настоящий момент','в наши дни','в наше время','в последнее время','в последние годы','нынче','до недавнего времени','в современном мире','в современных условиях']},
|
|
@@ -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
|
@@ -23,7 +23,47 @@ export const BUILTIN={
|
|
|
23
23
|
'thin-section':{category:'structure',message:'Под подзаголовком почти нет текста.',fix:'Допишите раздел или объедините его с соседним.'},
|
|
24
24
|
'ya-start':{category:'structure',message:'Много предложений начинаются с «Я»: текст звучит как рассказ о себе.',fix:'Начните часть предложений с того, что важно читателю: с дела, факта, результата.'},
|
|
25
25
|
'question-run':{category:'syntax',message:'Вопросы подряд выглядят как приём, а не как мысль.',fix:'Оставьте один вопрос или замените вопросы утверждением.'},
|
|
26
|
+
'word-double':{category:'repetition',message:'Слово стоит два раза подряд.',fix:'Уберите лишнее слово.'},
|
|
27
|
+
'typo-double-space':{category:'typography',message:'Два пробела подряд.',fix:'Оставьте один пробел.'},
|
|
28
|
+
'typo-space-before':{category:'typography',message:'Лишний пробел перед знаком препинания или внутри скобок и кавычек.',fix:'Уберите пробел: «двери, окна», «(окна)».'},
|
|
29
|
+
'typo-space-after':{category:'typography',message:'После запятой или точки с запятой нет пробела.',fix:'Поставьте пробел после знака.'},
|
|
30
|
+
'typo-quotes':{category:'typography',message:'Прямые кавычки в русском тексте выглядят как набор с клавиатуры.',fix:'Поставьте ёлочки: «Стандарт», а внутри других кавычек — „лапки“.'},
|
|
31
|
+
'typo-unpaired':{category:'typography',message:'У скобки или кавычки нет пары.',fix:'Закройте скобку или кавычку либо уберите лишнюю.'},
|
|
32
|
+
'typo-dash':{category:'typography',message:'Дефис вместо тире.',fix:'Между словами — длинное тире с пробелами: «Москва — столица». Между числами — короткое без пробелов: «5–10».'},
|
|
33
|
+
'typo-repeat-punct':{category:'typography',message:'Знак препинания повторяется.',fix:'Оставьте один знак. Можно только «?!», «?..», «!..» и многоточие.'},
|
|
34
|
+
'typo-initials':{category:'typography',message:'Инициалы слиплись.',fix:'Разделите пробелом: «И. И. Иванов».'},
|
|
35
|
+
'typo-mixed-script':{category:'typography',message:'В слове смешаны русские и латинские буквы: так бывает после копирования. Поиск и проверка орфографии такое слово не узнают.',fix:'Наберите слово заново на одной раскладке.'},
|
|
26
36
|
};
|
|
37
|
+
// Typography: [id, pattern]; a match must not span removed markup or a joined line break (see plain below).
|
|
38
|
+
const TYPO=[
|
|
39
|
+
['typo-double-space',/(?<=\S) {2,}(?=\S)/g],
|
|
40
|
+
['typo-space-before',/(?<=\S) +(?=[,;](?![-()DPp])|\)|»(?!\s|$)|[.!?](?=\s|$))|(?<=[^\s\d]) +(?=:(?=\s|$))|(?<=[(«]) +(?=\S)/g],
|
|
41
|
+
['typo-space-after',/,(?=[A-Za-zА-Яа-яЁё])|;(?)(?=[A-Za-zА-Яа-яЁё])/g],
|
|
42
|
+
['typo-dash',/(?<=\S) --? (?=\S)/g],
|
|
43
|
+
['typo-repeat-punct',/[!?]{3,}|!!|\?\?|,{2,}|;{2,}|(?<![.?!])\.\.(?!\.)|\.{4,}/g],
|
|
44
|
+
// «И.И. Иванов», «Иванов И.И.», «А.Пушкин»; «Ф.И.О.» and «до Н.Э.» are abbreviations, not initials.
|
|
45
|
+
['typo-initials',/(?<!\p{L})[А-ЯЁ]\.[А-ЯЁ]\.(?=\s?[А-ЯЁ][а-яё])|(?<=[А-ЯЁ][а-яё]+\s)[А-ЯЁ]\.[А-ЯЁ]\.(?!\p{L})|(?<![\p{L}.])[А-ЯЁ]\.(?=[А-ЯЁ][а-яё]{2,})/gu],
|
|
46
|
+
['typo-mixed-script',/\p{L}*(?:\p{Script=Cyrillic}\p{Script=Latin}|\p{Script=Latin}\p{Script=Cyrillic})\p{L}*/gu],
|
|
47
|
+
];
|
|
48
|
+
// Addresses, e-mails and HTML entities are not prose: no typography hints inside them.
|
|
49
|
+
const technical=(text,s)=>{const a=text.lastIndexOf(' ',s)+1,e=text.indexOf(' ',s);return /[/@&=]/.test(text.slice(a,e<0?text.length:e));};
|
|
50
|
+
// Brackets and guillemets without a pair. Not brackets: «а)», «1)», «п. 2)», smileys «:)», «:-(», «:))»;
|
|
51
|
+
// a lone «»» or ««» between spaces is a navigation arrow.
|
|
52
|
+
function unpaired(text){
|
|
53
|
+
const out=[],open=[];
|
|
54
|
+
for(let k=0;k<text.length;k++){const c=text[k],before=text.slice(Math.max(0,k-6),k);
|
|
55
|
+
if('()«»'.indexOf(c)<0)continue;
|
|
56
|
+
if((c==='('||c===')')&&/[:;=]-?[()]*$/.test(before))continue;
|
|
57
|
+
if((c==='«'||c==='»')&&/(?:^|\s)$/.test(before)&&/^(?:\s|$)/.test(text.slice(k+1)))continue;
|
|
58
|
+
if(c==='('||c==='«'){open.push(k);continue;}
|
|
59
|
+
const top=open.length?open[open.length-1]:-1;
|
|
60
|
+
if(top>=0&&text[top]===(c===')'?'(':'«')){open.pop();continue;}
|
|
61
|
+
if(c===')'&&/(?:^|\s)(?:\p{L}|\p{L}?\.?\s?[\d.]{1,4})$/u.test(before))continue;
|
|
62
|
+
out.push(k);
|
|
63
|
+
}
|
|
64
|
+
return [...out,...open];
|
|
65
|
+
}
|
|
66
|
+
const DOUBLE_OK=new Set(['да','нет','ну','так','вот','ни','ха','хе','ой','ах','ох','эх','бла','тук','кап']);
|
|
27
67
|
const PASSIVE=new RegExp('(?<!['+L+'])(?:был|была|было|были|будет|будут)\\s+['+L+']+(?:ан|ян|ен|ён|т)(?:а|о|ы)?(?!['+L+'])','gi');
|
|
28
68
|
const NOMINAL=new RegExp('(?:(?<!['+L+'])['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])\\s+){2,}['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])','gi');
|
|
29
69
|
const REPEAT_SKIP=new Set(['который','которая','которое','которые','которых','которым','потому','только','сейчас','больше','меньше','просто','сделать','делать','чтобы','когда','после','через','between','about']);
|
|
@@ -33,6 +73,12 @@ export function checkStyle(blocks,{maxSentenceWords=25,maxParagraphWords=120}={}
|
|
|
33
73
|
blocks.forEach((b,i)=>{
|
|
34
74
|
const text=b.text,low=norm(text),list=sentences(text);
|
|
35
75
|
for(const {rule,re} of compiled){if(!re)continue;re.lastIndex=0;for(const m of low.matchAll(re)){if(rule.start&&!list.some(s=>s.start<=m.index&&low.slice(s.start,m.index).trim()===''))continue;add(i,m.index,m.index+m[0].length,rule.id,rule);}}
|
|
76
|
+
// Blocks map every character to the source; gaps are spaces that stand for code, an image or a line break, not typos.
|
|
77
|
+
const gap=(s,e)=>(b.gaps||[]).some(j=>j>=s&&j<e),plain=(s,e)=>(!b.map||b.map[e-1]-b.map[s]===e-1-s)&&!gap(s,e);
|
|
78
|
+
for(const [id,re] of TYPO)for(const m of text.matchAll(re))if(plain(m.index,m.index+m[0].length)&&!technical(text,m.index))add(i,m.index,m.index+m[0].length,id,BUILTIN[id]);
|
|
79
|
+
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']);
|
|
80
|
+
for(const k of unpaired(text))add(i,k,k+1,'typo-unpaired',BUILTIN['typo-unpaired']);
|
|
81
|
+
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
82
|
if(b.kind==='h')return;
|
|
37
83
|
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
84
|
for(const s of list){
|