reedach 0.3.2 → 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 +26 -1
- package/package.json +1 -1
- package/src/fixes.js +222 -0
- package/src/markdown.js +3 -1
- package/src/rules.js +13 -0
- package/src/style.js +26 -6
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
|
+
"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
|
-
|
|
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/rules.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
// Our own style rules, written from public advice on information style.
|
|
2
2
|
export const CATEGORIES={paronym:'Пароним',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
|
+
// pattern: a regular expression over the lower-cased text (ё as е) for mistakes a word list cannot pin down.
|
|
4
|
+
const W='[а-яa-z]',NW='(?!'+W+')',BW='(?<!'+W+')';
|
|
5
|
+
// «что бы» is the conjunction before an infinitive or after «для того», but not after «не знаю, посоветуйте…»
|
|
6
|
+
// («не знаю, что бы почитать») and not in a question («что бы выбрать?»).
|
|
7
|
+
const ASK='(?:зна|дума|задума|подума|придума|реша|реши|выбира|выбра|выбер|спраш|спрос|посовет|совету|подскаж|подсказ|предлага|предлож|интересн|ищ|иска|гада|прикидыва|вспомина|вспомн|сообража|сообрази|понима|поня|угада|подбира|подобра|подбер)';
|
|
8
|
+
const NOT_INF='(?:опять|ведь|мать|дочь|путь|суть|часть|сеть|власть|смерть|память|пять|шесть|девять|десять|треть|кровать|тетрадь|площадь|лошадь|печать|зять|кисть|страсть|честь|месть|жесть|нить|рать)';
|
|
9
|
+
const INF=W+'*(?:ть|ться|чь|чься|(?:йти|идти|нести|вести|расти|спасти|цвести|плести|трясти|грести|пасти|везти|ползти)(?:сь)?)';
|
|
10
|
+
const CHTOBY='(?<='+BW+'для\\s+того,?\\s+)что\\s+бы'+NW+'|(?<!'+BW+ASK+W+'*,?\\s+)'+BW+'что\\s+бы(?=\\s+(?!'+W+'*ость'+NW+')(?!'+NOT_INF+NW+')'+INF+NW+')(?![^.!?…]*\\?)';
|
|
11
|
+
// «по-настоящему», «по-новому»: an adverb, unless a noun in the dative follows («по новому адресу», «по моему мнению»).
|
|
12
|
+
const PO='(?<!'+W+')по\\s+(?:настоящ|нов|стар|друг|разн|прежн|хорош|плох|честн|человеческ|дружеск|детск|взросл|домашн|летн|зимн|весенн|осенн|особ|всяк|ин|делов|прост|сво|мо|тво|наш|ваш)(?:ому|ему)(?!\\s+(?:же|ли|бы|шоссе|кафе|метро|радио|кино|такси|меню|депо|ателье|казино|фойе|бюро|пальто)'+NW+')(?=\\s*$|\\s*[^\\sа-яa-z0-9]|\\s+'+W+'*[а-зй-тф-эяa-z]'+NW+')';
|
|
3
13
|
export const RULES=[
|
|
4
14
|
// Time parasites
|
|
5
15
|
{id:'time-parasite-start',category:'time',start:true,message:'Вступление о нынешнем времени ничего не добавляет к предложению.',fix:'Убрать его или назвать конкретную дату или факт.',words:['в настоящее время','в настоящий момент','в наши дни','в наше время','в последнее время','в последние годы','нынче','до недавнего времени','в современном мире','в современных условиях']},
|
|
@@ -97,5 +107,8 @@ export const RULES=[
|
|
|
97
107
|
{id:'mistake-oplata',category:'mistake',message:'Оплачивают что-то, а платят за что-то: оплатить за услугу смешивает два оборота.',fix:'Писать оплатить работу или заплатить за работу. Оплатить за меня или за месяц вперёд — верно.',words:['оплатить за','оплатил за','оплатила за','оплатили за','оплачу за','оплатите за','оплачивать за','оплачиваем за','оплачивают за']},
|
|
98
108
|
{id:'mistake-oboih',category:'mistake',message:'Со словами женского рода пишут обеих, обеим, обеими.',fix:'Писать обеих сторон, обеим сторонам, обеими руками.',words:['обоих сторон','обоим сторонам','обоими сторонами','обоих рук','обоими руками']},
|
|
99
109
|
{id:'mistake-context',category:'mistake',message:'Если речь о времени или причине, пишется иначе: в течение, в продолжение, вследствие, несмотря на.',fix:'Оставить как есть, только если речь о течении реки, продолжении книги, следствии по делу или о том, кто куда смотрит.',words:['в течении','в продолжении','в следствии','не смотря на']},
|
|
110
|
+
{id:'mistake-chtoby',category:'mistake',message:'Союз чтобы пишется слитно. Раздельно что бы пишут, когда это вопрос о предмете: что бы ни случилось, что бы почитать.',fix:'Писать слитно, если подходит для того чтобы: чтобы рассказать, для того чтобы мы творили.',exact:true,swap:{'что бы':'чтобы'},pattern:CHTOBY},
|
|
111
|
+
{id:'mistake-takzhe',category:'mistake',message:'В значении и ещё пишется слитно: а также. Раздельно так же пишут при сравнении: так же, как раньше.',fix:'Писать а также, если можно заменить на и.',exact:true,swap:{'а так же':'а также'},pattern:BW+'а\\s+так\\s+же'+NW+'(?!\\s*,)(?![^.!?…]*'+BW+'как'+NW+')(?![^.!?…]*\\?)'},
|
|
112
|
+
{id:'mistake-po-hyphen',category:'mistake',message:'Наречие с по- и окончанием -ому или -ему пишется через дефис.',fix:'Писать по-настоящему, по-новому, по-моему. Раздельно — если дальше существительное: по новому адресу.',exact:true,swap:{'по *':'$1-$2'},pattern:PO},
|
|
100
113
|
{id:'colloquial-forms',category:'colloquial',message:'Просторечная форма: в письменном тексте выглядит как ошибка.',fix:'Литературно: их, класть или положить, поезжай, ездят, хотят, ляг, сейчас, его.',exact:true,swap:{'ихн':'их','ложить':'класть','ложит':'кладёт','ложишь':'кладёшь','ложат':'кладут','ложим':'кладём','ложите':'кладите, кладёте','ложил':'клал','ложила':'клала','ложили':'клали','ехай':'поезжай, езжай','ехайте':'поезжайте','едь':'поезжай','едьте':'поезжайте','ездию':'езжу','ездиют':'ездят','хочут':'хотят','ляжь':'ляг','ляжьте':'лягте','победю':'выиграю, одержу победу','щас':'сейчас','евонн':'его','евоный':'его','евоная':'его'},words:['ихн*','ложить','ложит','ложишь','ложат','ложим','ложите','ложил','ложила','ложили','ехай','ехайте','едь','едьте','ездию','ездиют','хочут','ляжь','ляжьте','победю','щас','евонн*','евоный','евоная']},
|
|
101
114
|
];
|
package/src/style.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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,'\\$&');
|
|
6
7
|
// "осуществл*" matches any ending; spaces between words may be any whitespace.
|
|
7
8
|
const tokenSource=t=>t.endsWith('*')?esc(norm(t.slice(0,-1)))+'['+L+']*':esc(norm(t));
|
|
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
|
+
function compile(rule){if(rule.pattern)return new RegExp(rule.pattern,'g');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
10
|
// Paronyms: [forms, neighbours, where the neighbour stands (после, до, любой), right word, note]. Only the wrong
|
|
10
11
|
// word is marked, and only next to a neighbour that makes the mistake certain: «одеть пальто», not «одеть ребёнка».
|
|
11
12
|
const alt=list=>'(?:'+list.split('|').map(w=>w.trim().split(/\s+/).map(tokenSource).join('\\s+')).join('|')+')';
|
|
@@ -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
|
-
|
|
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
|
}
|