reedach 0.3.3 → 0.4.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/README.md CHANGED
@@ -17,10 +17,35 @@ import {checkStyle, sourceBlocks, mergeIssues} from 'reedach';
17
17
  const markdown = 'В настоящее время мы осуществляем доставку хлеба.';
18
18
  const blocks = sourceBlocks(markdown);
19
19
  const issues = mergeIssues(blocks, {local: checkStyle(blocks)});
20
- // [{start, end, id, category, label, message, fix, replace?}, …] — start и end указывают в исходный текст,
20
+ // [{start, end, id, category, label, message, fix, replace?, edit?}, …] — start и end указывают в исходный текст,
21
21
  // replace — варианты замены, если они есть
22
22
  ```
23
23
 
24
+ Готовые правки. `replace` — варианты для фрагмента `start`…`end`; кнопка ставит вариант
25
+ на место фрагмента, с заглавной буквы, если фрагмент начинался с заглавной. У замечаний, где
26
+ в правиле нет замены, ядро даёт её само, если её можно сделать механически: «счет на оплату» →
27
+ `["счет"]`, «согласно договора» → `["согласно договору"]`, «Мы и» → `[" "]`.
28
+
29
+ `edit` — одна готовая правка, когда она не укладывается в `replace`: убрать фрагмент или
30
+ поменять кусок шире фрагмента замечания (знак после него, «, что» за оборотом):
31
+
32
+ ```js
33
+ // «Не секрет, что цены растут.» → «Цены растут.»
34
+ {start: 0, end: 9, id: 'intro-obviousness', …, edit: {start: 0, end: 16, text: 'Ц', remove: true}}
35
+ // «Мы купили дом. Который стоит у реки.» → «Мы купили дом, который стоит у реки.»
36
+ {start: 15, end: 22, id: 'syntax-fragment-start', …, edit: {start: 13, end: 22, text: ', который'}}
37
+ ```
38
+
39
+ Применить правку — заменить в исходном тексте `edit.start`…`edit.end` (те же координаты, что
40
+ у `start` и `end`) на `edit.text`. Заглавная буква, запятые и пробелы уже учтены.
41
+ `remove: true` — это «Убрать»: фрагмент уходит вместе с запятыми и пробелами, которые остались
42
+ бы лишними, а если он начинал предложение, следующее слово получает заглавную (тогда `text` —
43
+ эта буква). Убрать можно только там, где без фрагмента фраза остаётся грамотной: вводное между
44
+ запятыми, «Не секрет, что» с его «, что», целое предложение «Спасибо за внимание!», определение
45
+ перед словом («Успешный проект» → «Проект», но не «Проект был успешным»). В остальных случаях
46
+ замечание остаётся советом. Внутри правки нет разметки Markdown, иначе правки нет.
47
+ У одного замечания бывает или `replace`, или `edit`.
48
+
24
49
  Без Markdown: `checkStyle([{text: '…', kind: 'p'}])`, где `kind` — `p` (абзац), `li` (пункт
25
50
  списка) или `h` (заголовок). Настройки длины: `checkStyle(blocks, {maxSentenceWords: 25,
26
51
  maxParagraphWords: 120})`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reedach",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Лёгкая проверка русского текста на канцелярит, штампы и воду: словарь правил, работает в браузере и в Node без зависимостей",
5
5
  "license": "MIT",
6
6
  "author": "Dima Faller <dima@dimafaller.ru>",
package/src/fixes.js ADDED
@@ -0,0 +1,222 @@
1
+ // Ready edits for hints that come without the rule's replacement, by the rule's id and the words found, not by the
2
+ // advice's words (moved from the Yokey keyboard: ReedachFixes.kt and the «Убрать» of ReedachRemarks.kt). Two kinds:
3
+ // a replacement made of the words found or from the tables here («счет на оплату» → «счет», «согласно договора» →
4
+ // «согласно договору», « » → « »), and «Убрать» where the sentence still reads without the fragment — some only next
5
+ // to the words that make it safe (a parenthetical set off by commas, a whole sentence, «Не секрет, что» with its
6
+ // «, что»). The fragment of an edit can be wider than the hint's: the punctuation that goes with it. The rest stays
7
+ // an advice: the list and the reasons in docs/handoff-fixes-from-keyboard.md.
8
+ const norm=s=>s.toLowerCase().replace(/ё/g,'е');
9
+ const SP='[ \\u00A0]',SPACES='  ',space=c=>c!==undefined&&SPACES.includes(c);
10
+ // after these (and spaces) a sentence starts; before these a fragment ends its sentence
11
+ const OPENING='.!?…\n«"(',CLOSING='.!?…;:)»"\n';
12
+ const THAT_AFTER=new RegExp('^'+SP+'*(?:,'+SP+'*что(?![а-яёА-ЯЁ])|:)');
13
+ const BUT_AFTER=new RegExp('^'+SP+'*,'+SP+'*(?:но|что)(?![а-яёА-ЯЁ])');
14
+ const END_MARKS=/^[.!?…]+/;
15
+ const WORD_AFTER=new RegExp('^'+SP+'*([\\p{L}\\d][\\p{L}\\d\\-]*)','u');
16
+ const WORD_BEFORE=new RegExp('([\\p{L}—\\-]+)'+SP+'*$','u');
17
+ const DATE_FROM=new RegExp('^'+SP+'+[сС]о?'+SP);
18
+ const INFINITIVE=new RegExp('^'+SP+'*(?:[а-яё]+'+SP+'+)?[а-яё]+(?:ть|ти|чь)(?:ся|сь)?(?![а-яё])');
19
+ const AGREEING=/(?:ое|ее|ой|ую|ая|ий|ый|ие|ые|ого|ему|ому|ом|ем|им|ым)$/;
20
+ const CONJUNCTIONS=new Set(['и','а','но','или','либо','да']);
21
+ // «Какой-то из них» is a pronoun, not a modifier; «на большое количество» does not take «много»
22
+ const PREPOSITIONS=new Set(['в','во','на','за','про','через','под','подо','о','об','обо','с','со','к','ко','по','для','из','изо','от','ото','до','у','без','при','над','перед','между','среди']);
23
+ // before these an adjective or an adverb is the predicate («проект был успешным»): it does not go
24
+ const LINKING=new Set(['был','была','было','были','будет','будут','быть','стал','стала','стало','стали','становится','является','являются','это','—','-']);
25
+ const upper=c=>/\p{Lu}/u.test(c||''),lower=c=>/\p{Ll}/u.test(c||'');
26
+ // a replacement from the rules is lower case: at the start of a sentence it takes the fragment's capital
27
+ const matchCase=(original,r)=>upper(original[0])&&lower(r[0])?r[0].toUpperCase()+r.slice(1):r;
28
+
29
+ // «повтор* снова» fits «повторить снова»: as many words, a key word with * is the start of the word; «*»: any fragment
30
+ function fits(key,fragment){
31
+ if(key==='*')return true;
32
+ const k=key.split(' '),w=norm(fragment).split(/\s+/);
33
+ return k.length===w.length&&k.every((t,i)=>t.endsWith('*')?w[i].startsWith(t.slice(0,-1)):w[i]===t);
34
+ }
35
+ const longest=(table,fragment)=>{let best=null;for(const k of Object.keys(table))if(fits(k,fragment)&&(best===null||k.length>best.length))best=k;return best===null?undefined:table[best];};
36
+ // $1, $2 stand for the words found, as written
37
+ const fill=(template,fragment)=>{const words=fragment.trim().split(/\s+/);return template.replace(/\$(\d)/g,(_,n)=>words[n-1]??'');};
38
+ const each=(value,...keys)=>Object.fromEntries(keys.map(k=>[k,value]));
39
+
40
+ // a replacement by the words found
41
+ const REPLACE={
42
+ // «что что»: the word once
43
+ 'word-double':{'*':'$1'},
44
+ // the main noun is the first word in every phrase of the rule («счет на оплату», «январь месяц»)
45
+ 'pleonasm-dependent':each('$1','ареал* обитания','коллег* по работе','счет* на оплату','прейскурант цен','прейскуранта цен','период* времени','промежутк* времени','январь месяц','февраль месяц','март месяц','апрель месяц','май месяц','июнь месяц','июль месяц','август месяц','сентябрь месяц','октябрь месяц','ноябрь месяц','декабрь месяц'),
46
+ // the pairs that have no replacement in the rule: the word that carries the meaning stays
47
+ 'pleonasm-pairs':{...each('$1','повтор* снова','подн* вверх','спуск* вниз','спуст* вниз','вернуть* назад','вернул* назад','возвраща* назад','сво* собственн*','вдруг внезапно','ежедневно каждый день','повтор* еще раз'),'каждый день ежедневно':'$1 $2',
48
+ ...each('$2','предварительн* предоплат*','главн* приоритет*','свободн* ваканси*','совместн* сотрудничеств*','взаимн* сотрудничеств*','внезапно вдруг','краткое резюме','основной лейтмотив','первый дебют','стеклянн* остеклени*')},
49
+ 'pleonasm-more':{...each('$2','наглядн* пример*','наиболее оптимальн*','самый оптимальн*'),'в удобное для вас время':'в удобное время','в любое удобное время':'в удобное время','провайдер услуг':'$1'},
50
+ 'date-range-period':{'в период с':'с'},
51
+ 'modality-softener':{'очень вероятно':'$2'},
52
+ 'modality-subjunctive':{'я бы хотел':'я хочу','я бы хотела':'я хочу','мы бы хотели':'мы хотим','вы бы хотели':'вы хотите','мне бы хотелось':'мне хочется','нам бы хотелось':'нам хочется','хотелось бы':'хочется','я бы предпочел':'я предпочитаю','я бы предпочла':'я предпочитаю','мы бы предпочли':'мы предпочитаем'},
53
+ 'pronoun-dannyy':{'данный':'этот','данная':'эта','данное':'это','данную':'эту','данной':'этой','данного':'этого','данном':'этом','данному':'этому'},
54
+ 'vague-quantity':{'большое количество':'много','значительное количество':'много','незначительное количество':'немного','небольшое количество':'немного','уйма':'много','подавляющее большинство':'$2'},
55
+ 'bureaucratese-verbs':{'в целях':'для','посредством':'с помощью'},
56
+ 'stamp-newspaper-idioms':{'в лучших традициях':'по традиции'},
57
+ 'preachy-refusal':{'в обязательном порядке':'обязательно'},
58
+ 'stamp-cliche':{'сочета* в себе':'$1','легко и просто':'просто','без промедления':'сразу','прямиком':'прямо','на каждом шагу':'повсюду','в сухом остатке':'в итоге','на высоком уровне':'хорошо','на должном уровне':'хорошо'},
59
+ 'stamp-idioms-more':{'бытует мнение':'считается','в эпоху цифровых технологий':'сейчас','братья наши меньшие':'животные','в сети интернет':'в интернете','от а до я':'полностью'},
60
+ 'stamp-business-claims':{'в кратчайшие сроки':'быстро','кратчайшие сроки':'короткие сроки','точно в срок':'в срок','на самом высоком уровне':'хорошо','на высоком профессиональном уровне':'хорошо'},
61
+ 'stamp-formal-letter':{'приносим свои глубочайшие извинения':'приносим извинения','приносим глубочайшие извинения':'приносим извинения','глубочайшие извинения':'извинения','доброго времени суток':'здравствуйте','с радостью предложим':'$3','именно поэтому мы':'$2 $3'},
62
+ 'adjective-metaphor':{'железная воля':'сильная воля','железной воли':'сильной воли','железной волей':'сильной волей','железную волю':'сильную волю','стальные нервы':'крепкие нервы','стальных нервах':'крепких нервах','стальная хватка':'крепкая хватка','стальной хватки':'крепкой хватки','золотые руки':'умелые руки','золотой стандарт':'эталон','золотого стандарта':'эталона'},
63
+ 'mistake-po-case':{'по приезду':'по приезде','по прибытию':'по прибытии','по окончанию':'по окончании','по завершению':'по завершении','по истечению':'по истечении','по возвращению':'по возвращении'},
64
+ 'mistake-soglasno':{'согласно договора':'согласно договору','согласно приказа':'согласно приказу','согласно графика':'согласно графику','согласно плана':'согласно плану','согласно закона':'согласно закону','согласно решения':'согласно решению','согласно распоряжения':'согласно распоряжению','согласно требований':'согласно требованиям','согласно условий':'согласно условиям','согласно правил':'согласно правилам'},
65
+ // «заплатить за» is right both for «за работу» and «за меня» (the rule's note)
66
+ 'mistake-oplata':{'оплатить за':'заплатить за','оплатил за':'заплатил за','оплатила за':'заплатила за','оплатили за':'заплатили за','оплачу за':'заплачу за','оплатите за':'заплатите за','оплачивать за':'платить за','оплачиваем за':'платим за','оплачивают за':'платят за'},
67
+ 'mistake-oboih':{'обоих сторон':'обеих сторон','обоим сторонам':'обеим сторонам','обоими сторонами':'обеими сторонами','обоих рук':'обеих рук','обоими руками':'обеими руками'},
68
+ // the rule's own fix; «в течении реки» is rare next to «в течении дня»
69
+ 'mistake-context':{'в течении':'в течение','в продолжении':'в продолжение','в следствии':'вследствие','не смотря на':'несмотря на'},
70
+ // the pairs whose right word is a phrase: the wrong word alone is marked («играет значение», «имеет роль»)
71
+ 'paronyms':{'играет':'имеет','играют':'имеют','играть':'иметь','играл':'имел','играла':'имела','играло':'имело','играли':'имели','играя':'имея','имеет':'играет','имеют':'играют','иметь':'играть','имел':'играл','имела':'играла','имело':'играло','имели':'играли','имея':'играя','оказывает':'играет','оказывают':'играют','оказать':'сыграть','оказывал':'играл','оказывала':'играла','оказывали':'играли','оказал':'сыграл','оказала':'сыграла','оказали':'сыграли','оказало':'сыграло','оплатить':'заплатить','оплатил':'заплатил','оплатила':'заплатила','оплатили':'заплатили','оплатит':'заплатит','оплатите':'заплатите','оплатим':'заплатим','оплатят':'заплатят','оплачу':'заплачу','оплачивать':'платить','оплачивает':'платит','оплачиваем':'платим','оплачивают':'платят','оплачивай':'плати','оплачивайте':'платите'},
72
+ };
73
+
74
+ // Where «Убрать» keeps the sentence whole. any: anywhere; mod: the fragment modifies the next word (a word follows,
75
+ // not a conjunction, and no linking verb is before it); paren: a parenthetical (at the start of a sentence or after a
76
+ // comma, a comma or a colon after it, with «что»); that: at the start of a sentence with «, что» or a colon after it,
77
+ // they go with it («Не секрет, что цены» → «Цены»); but: «, но» or «, что» after it go with it («Вы не поверите, но
78
+ // цены» → «Цены»); sent: the whole sentence (with its end mark) or its address before a comma («Здравствуйте,
79
+ // Николай!»); withS: before «с» or «со» of a date range («в течение периода с мая» → «с мая»).
80
+ const anywhere=['any'],never=[],mod=['mod','paren'],paren=['paren'],that=['that'],sent=['sent'];
81
+ // Words that can be the predicate («Это просто.», «Проект успешен.», «Мне плевать.») or that need their «, что»
82
+ // («Не секрет, что») have a condition.
83
+ const REMOVE={
84
+ 'time-parasite-start':{'*':anywhere},
85
+ 'time-parasite-anywhere':{'*':anywhere},
86
+ 'intro-oral':{'*':anywhere,'плевать':never,'супер':mod,'короче':paren},
87
+ 'intro-obviousness':{'*':anywhere,'не секрет':that,'ни для кого не секрет':that,'общеизвестно':that,'всем известно':that,'очевидно':[...paren,...that],'естественно':[...paren,...that]},
88
+ 'filler-simply':{'*':anywhere,'просто':mod},
89
+ 'filler-start':{'*':anywhere},
90
+ 'filler-phrases':{'*':anywhere},
91
+ 'intensifiers':{'*':anywhere},
92
+ 'sincerity':{'*':anywhere,'реальн*':mod},
93
+ 'vague-hedge':{'*':anywhere},
94
+ 'success-word':{'успешн*':mod,'успешност*':never},
95
+ 'vague-soon':{'*':anywhere},
96
+ 'intro-transition':{...each(anywhere,'во-первых','во-вторых','в-третьих','для начала','далее','прежде всего'),'отметим также':that},
97
+ 'intro-structure':{'в завершение':anywhere,'в заключение':anywhere,'подведем итог':[...sent,...that],'подведем итоги':[...sent,...that]},
98
+ 'intro-example':{'к примеру':paren,'скажем':paren},
99
+ 'intro-attention':{'*':paren},
100
+ 'bureaucratese-phrases':{'обращаем ваше внимание':paren,'обращаю ваше внимание':paren,'хочу обратить ваше внимание':paren,'сообщаем вам':that},
101
+ 'self-announce':{'*':that,'имеем честь':never},
102
+ 'ui-reference':each(paren,'как сказано выше','как было сказано выше','как говорилось выше'),
103
+ 'back-reference':each(paren,'как я уже говорил','как я говорил','как я писал','как я уже писал','как мы уже говорили','как мы писали'),
104
+ 'banal-intro':{'все мы знаем':that,'давно известно':that,...each(anywhere,'с давних пор','с незапамятных времен','с древних времен')},
105
+ 'greeting-start':each(sent,'здравствуйте','привет','всем привет','добро пожаловать','дорогие друзья','дорогие читатели','спасибо что читаете'),
106
+ 'sign-off':{'*':sent},
107
+ 'stamp-sales-phrases':{'*':sent},
108
+ 'stamp-call-to-action':{'*':sent},
109
+ 'stamp-clickbait':{'вы удивитесь':['but','sent'],'вы не поверите':['but','sent'],...each(sent,'не переключайтесь','доверьтесь мне','доверьтесь профессионалам')},
110
+ 'stamp-formal-letter':{'*':sent,'вынуждены сообщить':that},
111
+ 'stamp-cliche':{'как показывает практика':paren},
112
+ 'stamp-sales':each(mod,'высококвалифицированн*','клиентоориентированн*','динамично развивающ*'),
113
+ 'slang-register':each([...paren,...sent],'вау','ура-ура'),
114
+ 'proof-claims':{'*':mod,'гарантированно':anywhere,'без обмана':anywhere,'доказано':that,'гарантируем':never,'гарантирую':never},
115
+ 'vague-indefinite':{...each(anywhere,'как-то','где-то','зачем-то','почему-то','как-нибудь'),...each(mod,'какой-то','какая-то','какое-то','какие-то','каких-то','каким-то','какую-то','какого-то','какой-нибудь')},
116
+ 'preachy-tone':{'обязательно':mod,'ни в коем случае':mod,'не забывайте':that,'не забудьте':that,'запомните':that,'важно помнить':that},
117
+ 'date-range-period':{'на протяжении периода':['withS'],'в течение периода':['withS']},
118
+ };
119
+
120
+ // The fragment of «Убрать» by its conditions: [start, end) or wider (its «, что», its end mark), null: an advice.
121
+ function removal(conditions,text,start,end){
122
+ const before=text.slice(0,start),after=text.slice(end),rest=before.replace(/[  ]+$/,'');
123
+ const starts=!rest||OPENING.includes(rest[rest.length-1]);
124
+ const extended=m=>m&&{start,end:end+m[0].length},plain={start,end};
125
+ // «не просто работаем», «не очень хорошо»: without the word the negation moves to the next one
126
+ if(WORD_BEFORE.exec(before)?.[1].toLowerCase()==='не')return null;
127
+ for(const c of conditions){
128
+ if(c==='any')return plain;
129
+ if(c==='that'&&starts){const e=extended(THAT_AFTER.exec(after));if(e)return e;}
130
+ if(c==='but'&&starts){const e=extended(BUT_AFTER.exec(after));if(e)return e;}
131
+ if(c==='paren'&&(starts||rest.endsWith(','))){const e=extended(THAT_AFTER.exec(after));if(e)return e;if(after.replace(/^[  ]+/,'').startsWith(','))return plain;}
132
+ if(c==='sent'&&starts){const e=extended(END_MARKS.exec(after));if(e)return e;const next=after.replace(/^[  ]+/,'');if(!next||next[0]===','||next[0]==='\n')return plain;}
133
+ if(c==='mod'){
134
+ const words=WORD_AFTER.exec(after),next=words?.[1].toLowerCase();
135
+ // «Успешным проект стал»: the predicate put first
136
+ const then=words&&WORD_AFTER.exec(after.slice(words[0].length))?.[1].toLowerCase(),prev=WORD_BEFORE.exec(before)?.[1].toLowerCase();
137
+ if(next&&!CONJUNCTIONS.has(next)&&!PREPOSITIONS.has(next)&&!LINKING.has(prev)&&!LINKING.has(then))return plain;
138
+ }
139
+ if(c==='withS'&&DATE_FROM.test(after))return plain;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ // «Убрать» of [start, end): with the comma that set it off («Кстати, это» → «Это», «Это, безусловно, важно» → «Это
145
+ // важно», «Это важно, кстати.» → «Это важно.») and the spaces that would double; at the start of a sentence the next
146
+ // word gets the fragment's capital (the edit then takes that letter in too).
147
+ const CONTEXT=12;
148
+ function cut(text,start,end){
149
+ const before=text.slice(Math.max(0,start-CONTEXT),start),after=text.slice(end,end+CONTEXT),fragment=text.slice(start,end);
150
+ let spacesAfter=0;while(spacesAfter<after.length&&space(after[spacesAfter]))spacesAfter++;
151
+ let keep=before.length;while(keep>0&&space(before[keep-1]))keep--;
152
+ const spacesBefore=before.length-keep,commaBefore=keep>0&&before[keep-1]===',',next=after[spacesAfter];
153
+ const whole=(keep===0||OPENING.includes(before[keep-1]))&&next!==undefined&&'.!?…'.includes(next);
154
+ let left,right;
155
+ // «Мир. В настоящее время.»: the fragment is the whole sentence, its end mark goes too
156
+ if(whole){let marks=spacesAfter;while(marks<after.length&&'.!?…'.includes(after[marks]))marks++;if(spacesBefore&&keep){left=spacesBefore;right=marks;}else{left=0;right=marks;while(right<after.length&&space(after[right]))right++;}}
157
+ // «Это, безусловно, важно»: both commas go, the space after the second stays
158
+ else if(next===','&&commaBefore){left=spacesBefore+1;right=spacesAfter+1;}
159
+ // «Кстати, это», «Мы кстати, пришли»: the comma and the spaces after it go
160
+ else if(next===','){left=0;right=spacesAfter+1;while(right<after.length&&space(after[right]))right++;}
161
+ // «Это важно, кстати.»: the end of a sentence - the spaces and the comma before go
162
+ else if(next===undefined||CLOSING.includes(next)){left=spacesBefore+(commaBefore?1:0);right=spacesAfter;}
163
+ // the start or the middle: the spaces after go, a space before stays between the words
164
+ else{left=0;right=spacesAfter;}
165
+ const rest=before.slice(0,before.length-left).replace(/[  ]+$/,''),starts=!rest||OPENING.includes(rest[rest.length-1]);
166
+ const letter=after[right],capital=starts&&upper(fragment[0])&&lower(letter);
167
+ return capital?{start:start-left,end:end+right+1,text:letter.toUpperCase()}:{start:start-left,end:end+right,text:''};
168
+ }
169
+
170
+ // «Который…» starting a sentence of its own: joined to the sentence before («…дом. Который» → «…дом, который»)
171
+ function join(text,start,end,fragment){
172
+ let dot=start;while(dot>0&&space(text[dot-1]))dot--;
173
+ if(dot===start||dot<2||text[dot-1]!=='.'||!/[\p{L}\p{Nd}]/u.test(text[dot-2]))return null;
174
+ return {start:dot-1,end,text:', '+fragment[0].toLowerCase()+fragment.slice(1)};
175
+ }
176
+
177
+ // a word of one script with letters of the other that look the same: they become the word's own script
178
+ const LATIN_LOOK='aceopxyACEHKMOPTXB',CYRILLIC_LOOK='асеорхуАСЕНКМОРТХВ';
179
+ function mixed(word){
180
+ const latin=c=>/[a-zA-Z]/.test(c),cyrillic=c=>/[а-яА-ЯёЁ]/.test(c),chars=[...word];
181
+ const toCyrillic=chars.filter(cyrillic).length>=chars.filter(latin).length,from=toCyrillic?LATIN_LOOK:CYRILLIC_LOOK,to=toCyrillic?CYRILLIC_LOOK:LATIN_LOOK;
182
+ if(chars.some(c=>(toCyrillic?latin(c):cyrillic(c))&&!from.includes(c)))return null;
183
+ return chars.map(c=>{const k=from.indexOf(c);return k>=0?to[k]:c;}).join('');
184
+ }
185
+
186
+ function typography(id,text,start,end,f){
187
+ switch(id){
188
+ case 'typo-double-space':return {start,end,text:' '};
189
+ // the spaces go: with the mark after them, or with the bracket before them («( окна» → «(окна»); not after a
190
+ // smiley («:( и»)
191
+ case 'typo-space-before':if(start>0&&'(«'.includes(text[start-1]))return /[:;=]-?\($/.test(text.slice(Math.max(0,start-3),start))?null:{start:start-1,end,text:text[start-1]};
192
+ return end<text.length?{start,end:end+1,text:text[end]}:null;
193
+ case 'typo-space-after':return {start,end,text:f+' '};
194
+ case 'typo-quotes':return f.length>=2?{start,end,text:'«'+f.slice(1,-1)+'»'}:null;
195
+ case 'typo-dash':return {start,end,text:' — '};
196
+ case 'typo-repeat-punct':return {start,end,text:/^\.+$/.test(f)?(f.length>=4?'…':'.'):/^[!?]+$/.test(f)?(f.includes('!')&&f.includes('?')?'?!':f[0]):f[0]};
197
+ // «И.И. Иванов» → «И. И. Иванов», «А.Пушкин» → «А. Пушкин»
198
+ case 'typo-initials':{const spaced=f.replace(/\.(?=\p{L})/gu,'. ');return {start,end,text:end<text.length&&/\p{L}/u.test(text[end])?spaced+' ':spaced};}
199
+ case 'typo-mixed-script':{const w=mixed(f);return w===null?null:{start,end,text:w};}
200
+ }
201
+ return null;
202
+ }
203
+
204
+ // The ready edit of a hint of rule [id] on [start, end) of [text] (the block's text, line breaks as \n):
205
+ // {start, end, text} puts text in place of [start, end) (cased as the fragment), remove: true is «Убрать» (text is
206
+ // empty or the next word's capital). null: no edit, the hint stays an advice.
207
+ export function readyEdit(id,text,start,end){
208
+ if(start<0||end>text.length||start>=end)return null;
209
+ const f=text.slice(start,end),t=typography(id,text,start,end,f);if(t)return t;
210
+ if(id==='syntax-fragment-start')return join(text,start,end,f);
211
+ if(id==='bureaucratese-verbs'&&norm(f)==='с целью')return {start,end,text:INFINITIVE.test(text.slice(end))?'чтобы':'для'};
212
+ const v=REPLACE[id]&&longest(REPLACE[id],f);
213
+ if(v!==undefined&&v!==null){
214
+ // «много» is no noun: not after a preposition or a word that agrees with «количество» («такое», «это»)
215
+ const prev=WORD_BEFORE.exec(text.slice(0,start))?.[1].toLowerCase();
216
+ if(id==='vague-quantity'&&v.endsWith('много')&&prev!==undefined&&(PREPOSITIONS.has(prev)||['это','то','все','всё'].includes(prev)||AGREEING.test(prev)))return null;
217
+ return {start,end,text:matchCase(f,fill(v,f))};
218
+ }
219
+ const conditions=REMOVE[id]&&longest(REMOVE[id],f);if(!conditions)return null;
220
+ const at=removal(conditions,text,start,end);
221
+ return at&&{...cut(text,at.start,at.end),remove:true};
222
+ }
package/src/markdown.js CHANGED
@@ -66,7 +66,9 @@ export function mergeIssues(blocks,{spelling=[],style=[],local=[]}){
66
66
  const overlaps=(x,it)=>x.block===it.block&&x.start<it.end&&it.start<x.end;
67
67
  const kept=local.filter(it=>!(it.id==='nominal-chain'&&style.some(x=>x.id==='genitive-chain'&&overlaps(x,it)))&&!style.some(x=>(x.id==='weak-verb-noun'||x.category===it.category&&it.category==='repetition')&&overlaps(x,it)&&x.start<=it.start&&it.end<=x.end));
68
68
  const found=[...spellingIssues(blocks,spelling),...kept,...style.filter(it=>blocks[it.block]&&it.start<it.end&&it.end<=blocks[it.block].text.length)];
69
- return found.map(it=>{const b=blocks[it.block];return {...it,start:b.map[it.start],end:b.map[it.end-1]+1};}).sort((a,b)=>a.start-b.start||b.end-a.end);
69
+ // edit (a ready edit of the core, see fixes.js) is mapped as the hint itself.
70
+ const at=(b,it)=>({...it,start:b.map[it.start],end:b.map[it.end-1]+1});
71
+ return found.map(it=>{const b=blocks[it.block],out=at(b,it);if(it.edit)out.edit=at(b,it.edit);return out;}).sort((a,b)=>a.start-b.start||b.end-a.end);
70
72
  }
71
73
  // Plain text → paragraphs split by blank lines; every UTF-16 unit keeps its own offset (emoji take two).
72
74
  export function textBlocks(text){
package/src/style.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Style hints by dictionary and simple rules. Runs in the browser and in Node; the rules are our own word lists.
2
2
  import {RULES,CATEGORIES} from './rules.js';
3
+ import {readyEdit} from './fixes.js';
3
4
  const L='A-Za-zА-Яа-яЁё';
4
5
  const norm=s=>s.toLowerCase().replace(/ё/g,'е');
5
6
  const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
@@ -127,6 +128,28 @@ const DOUBLE_OK=new Set(['да','нет','ну','так','вот','ни','ха',
127
128
  const PASSIVE=new RegExp('(?<!['+L+'])(?:был|была|было|были|будет|будут)\\s+['+L+']+(?:ан|ян|ен|ён|т)(?:а|о|ы)?(?!['+L+'])','gi');
128
129
  const NOMINAL=new RegExp('(?:(?<!['+L+'])['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])\\s+){2,}['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])','gi');
129
130
  const REPEAT_SKIP=new Set(['который','которая','которое','которые','которых','которым','потому','только','сейчас','больше','меньше','просто','сделать','делать','чтобы','когда','после','через','between','about']);
131
+ // Blocks map every character to the source; gaps are spaces that stand for code, an image or a line break, not typos.
132
+ // before[k] = gaps before offset k, so "a gap inside s..e" costs the same however many gaps the block has.
133
+ // plain(s, e): s..e maps onto the source one to one, with no removed markup or gap inside; edge(s, e): a gap is right
134
+ // before or after it.
135
+ function spansOf(b){
136
+ const text=b.text,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];
137
+ const gapAt=j=>j>=0&&j<text.length&&before[j+1]>before[j];
138
+ return {plain:(s,e)=>(!b.map||b.map[e-1]-b.map[s]===e-1-s)&&before[e]-before[s]===0,edge:(s,e)=>gapAt(s-1)||gapAt(e)};
139
+ }
140
+ // Hints without the rule's replacement get the ready edit of fixes.js, if any: on the hint's own fragment it goes to
141
+ // replace, «Убрать» and a fragment wider than the hint's go to edit. Only where the edit maps onto the source one to
142
+ // one and does not touch a gap: the rule sees gaps as line breaks, but a gap can be code or an image («вроде `x`»).
143
+ // The fields the hint had stay as they were.
144
+ function addEdits(issues,blocks){
145
+ const seen=new Map(),of=i=>{let v=seen.get(i);if(!v){const b=blocks[i],chars=b.text.split('');for(const j of b.gaps||[])if(j>=0&&j<chars.length)chars[j]='\n';seen.set(i,v={text:chars.join(''),...spansOf(b)});}return v;};
146
+ for(const it of issues){
147
+ if(it.replace||it.variants)continue;
148
+ const v=of(it.block),e=readyEdit(it.id,v.text,it.start,it.end);if(!e||!v.plain(e.start,e.end)||v.edge(e.start,e.end))continue;
149
+ if(!e.remove&&e.start===it.start&&e.end===it.end){it.replace=[e.text];it.exact=true;}else it.edit=e;
150
+ }
151
+ return issues;
152
+ }
130
153
  // blocks: [{text, kind:'p'|'li'|'h', tag?:'H1'|'H2'…}] → issues: [{block,start,end,id,category,label,message,fix}]
131
154
  export function checkStyle(blocks,{maxSentenceWords=25,maxParagraphWords=120}={}){
132
155
  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,...(rule.replace&&{replace:rule.replace}),...(rule.exact&&{exact:true}),...(rule.variants&&{variants:rule.variants})});
@@ -134,10 +157,7 @@ export function checkStyle(blocks,{maxSentenceWords=25,maxParagraphWords=120}={}
134
157
  const text=b.text,low=norm(text),list=sentences(text);
135
158
  // A start-only rule needs nothing but spaces between the sentence start and the match.
136
159
  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;}{const found=text.slice(m.index,m.index+m[0].length);add(i,m.index,m.index+m[0].length,rule.id,offered(swapped(rule,m[0],found),found));}}}
137
- // Blocks map every character to the source; gaps are spaces that stand for code, an image or a line break, not typos.
138
- // before[k] = gaps before offset k, so "a gap inside s..e" costs the same however many gaps the block has.
139
- 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];
140
- 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);
160
+ const {plain}=spansOf(b),tech=technical(text);
141
161
  for(const [id,re,fix] 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,fix?{...BUILTIN[id],replace:[fix(m[0])],exact:true}:BUILTIN[id]);
142
162
  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']);
143
163
  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']);
@@ -163,5 +183,5 @@ export function checkStyle(blocks,{maxSentenceWords=25,maxParagraphWords=120}={}
163
183
  blocks.forEach((b,i)=>{if(b.kind!=='h'||b.tag==='H1')return;let j=i+1,size=0;while(j<blocks.length&&blocks[j].kind!=='h'){size+=blocks[j].text.trim().length;j++;}if(size<150)add(i,0,b.text.length,'thin-section',BUILTIN['thin-section']);});
164
184
  const starts=[];blocks.forEach((b,i)=>{if(b.kind!=='h')for(const s of sentences(b.text)){const r=trimmed(s);starts.push({i,start:r.start,ya:/^Я(?![A-Za-zА-Яа-яЁё])/.test(b.text.slice(r.start))});}});
165
185
  if(starts.length>=10&&starts.filter(s=>s.ya).length/starts.length>0.15)for(const s of starts.filter(s=>s.ya))add(s.i,s.start,s.start+1,'ya-start',BUILTIN['ya-start']);
166
- return issues.sort((a,b)=>a.block-b.block||a.start-b.start);
186
+ return addEdits(issues,blocks).sort((a,b)=>a.block-b.block||a.start-b.start);
167
187
  }