reedach 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +33 -0
- package/README.md +28 -2
- package/package.json +28 -4
- package/src/index.js +4 -0
- package/src/markdown.js +58 -0
- package/src/rules.js +91 -0
- package/src/style.js +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dima Faller
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Источники и сторонние лицензии
|
|
2
|
+
|
|
3
|
+
## Словарь правил
|
|
4
|
+
|
|
5
|
+
Правила и формулировки подсказок в `packages/core/src/rules.js` написаны для проекта
|
|
6
|
+
самостоятельно. Идеи взяты из открытых статей об информационном стиле:
|
|
7
|
+
[Максим Ильяхов](https://maximilyahov.ru), [Бюро Горбунова](https://bureau.ru) и другие авторы.
|
|
8
|
+
Тексты статей в проект не копировались.
|
|
9
|
+
|
|
10
|
+
Проверка не заменяет профессиональную редактуру.
|
|
11
|
+
|
|
12
|
+
## Сторонние данные и программы
|
|
13
|
+
|
|
14
|
+
Ядро (`packages/core`) не содержит сторонних данных и зависимостей.
|
|
15
|
+
|
|
16
|
+
Сервер (`packages/server`) использует их как зависимости, не включая в этот репозиторий:
|
|
17
|
+
|
|
18
|
+
| Компонент | Лицензия | Источник |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Az.js — морфологический анализ | MIT | https://github.com/deNULL/Az.js |
|
|
21
|
+
| Словари Az.js — данные OpenCorpora, сборка pymorphy2 | CC BY-SA | https://opencorpora.org |
|
|
22
|
+
| dictionary-ru — словарь hunspell, © 1997–2008 Alexander I. Lebedev | BSD-3-Clause | https://github.com/wooorm/dictionaries |
|
|
23
|
+
| dictionary-en — словарь hunspell на основе SCOWL | MIT AND BSD | https://github.com/wooorm/dictionaries |
|
|
24
|
+
| hunspell — проверка орфографии, запускается как отдельная программа | GPL-2.0 / LGPL-2.1 / MPL-1.1 на выбор | https://github.com/hunspell/hunspell |
|
|
25
|
+
|
|
26
|
+
Hunspell не входит в код Reedach и не распространяется вместе с ним: сервер запускает
|
|
27
|
+
установленную в системе программу `hunspell` отдельным процессом и передаёт ей слова. В
|
|
28
|
+
Docker-образ она попадает из пакета Alpine `hunspell` со своей лицензией. Словари hunspell
|
|
29
|
+
ставятся из npm вместе с `reedach-server` (пакеты `dictionary-ru` и `dictionary-en`) и
|
|
30
|
+
используются без изменений.
|
|
31
|
+
|
|
32
|
+
Словари OpenCorpora используются без изменений. Изменённые словари, если появятся,
|
|
33
|
+
будут распространяться под той же лицензией CC BY-SA с указанием источника.
|
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# reedach
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ядро проекта Reedach: лёгкая проверка русского текста на канцелярит, штампы, воду,
|
|
4
|
+
неконкретные и оценочные слова, длинные предложения и абзацы. Без зависимостей,
|
|
5
|
+
работает в браузере и в Node. Текст никуда не отправляется.
|
|
6
|
+
|
|
7
|
+
Это простые подсказки по словарю и правилам: проверка иногда ошибается
|
|
8
|
+
и не заменяет редактора.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm i reedach
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import {checkStyle, sourceBlocks, mergeIssues} from 'reedach';
|
|
16
|
+
|
|
17
|
+
const markdown = 'В настоящее время мы осуществляем доставку хлеба.';
|
|
18
|
+
const blocks = sourceBlocks(markdown);
|
|
19
|
+
const issues = mergeIssues(blocks, {local: checkStyle(blocks)});
|
|
20
|
+
// [{start, end, id, category, label, message, fix}, …] — start и end указывают в исходный текст
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Без Markdown: `checkStyle([{text: '…', kind: 'p'}])`, где `kind` — `p` (абзац), `li` (пункт
|
|
24
|
+
списка) или `h` (заголовок). Настройки длины: `checkStyle(blocks, {maxSentenceWords: 25,
|
|
25
|
+
maxParagraphWords: 120})`.
|
|
26
|
+
|
|
27
|
+
Словарь правил, источники и лицензии — в README и NOTICE.md [репозитория проекта](https://github.com/dimafaller-en/reedach).
|
|
28
|
+
|
|
29
|
+
MIT, © 2026 Дима Фаллер.
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reedach",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Лёгкая проверка русского текста на канцелярит, штампы и воду: словарь правил, работает в браузере и в Node без зависимостей",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dima Faller <dima@dimafaller.ru>",
|
|
7
|
+
"homepage": "https://github.com/dimafaller-en/reedach#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/dimafaller-en/reedach.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/dimafaller-en/reedach/issues",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.js",
|
|
18
|
+
"./style": "./src/style.js",
|
|
19
|
+
"./rules": "./src/rules.js",
|
|
20
|
+
"./markdown": "./src/markdown.js"
|
|
21
|
+
},
|
|
22
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE.md"],
|
|
23
|
+
"keywords": ["russian", "русский", "style", "linter", "infostyle", "информационный стиль", "канцелярит", "proofreading"],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/*.test.mjs"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Reedach core: dictionary style hints for Russian text and Markdown blocks mapped back to the source.
|
|
2
|
+
export {checkStyle,sentences,BUILTIN} from './style.js';
|
|
3
|
+
export {RULES,CATEGORIES} from './rules.js';
|
|
4
|
+
export {sourceBlocks,textBlocks,mergeIssues} from './markdown.js';
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Markdown helpers for editors and the command line:
|
|
2
|
+
// Markdown source → plain blocks for the checkers, and all hints merged back onto source offsets.
|
|
3
|
+
const L='A-Za-zА-Яа-яЁё';
|
|
4
|
+
|
|
5
|
+
// One line of Markdown → text the checkers see, with every character mapped back to its offset in the source.
|
|
6
|
+
function inline(line,base,from,kind){
|
|
7
|
+
let text='';const map=[],put=(c,at)=>{text+=c;map.push(at);};
|
|
8
|
+
for(let i=from;i<line.length;i++){
|
|
9
|
+
const c=line[i],rest=line.slice(i);let m;
|
|
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;}
|
|
14
|
+
if(c==='<'&&(m=/^<\/?[a-z][^>]*>/i.exec(rest))){i+=m[0].length-1;continue;}
|
|
15
|
+
if(c==='['||c==='*'||c==='_'&&!(/[\p{L}\d]/u.test(line[i-1]||'')&&/[\p{L}\d]/u.test(line[i+1]||'')))continue;
|
|
16
|
+
put(c,base+i);
|
|
17
|
+
}
|
|
18
|
+
return {text,map,kind};
|
|
19
|
+
}
|
|
20
|
+
// Markdown source → paragraphs, list items and headings; code, images, galleries and tables are left alone.
|
|
21
|
+
export function sourceBlocks(src){
|
|
22
|
+
const blocks=[];let cur=null,fence=false,pos=0;
|
|
23
|
+
const flush=()=>{if(cur&&cur.text.trim())blocks.push(cur);cur=null;};
|
|
24
|
+
for(const line of src.split('\n')){
|
|
25
|
+
const start=pos;pos+=line.length+1;let m;
|
|
26
|
+
if(/^\s*(?:```|~~~)/.test(line)){flush();fence=!fence;continue;}
|
|
27
|
+
if(fence)continue;
|
|
28
|
+
if(!line.trim()||/^\s*(?::::|\|)/.test(line)||/^\s*!\[[^\]]*\]\([^)]*\)\s*$/.test(line)||/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)){flush();continue;}
|
|
29
|
+
const quote=/^\s{0,3}(?:>\s?)+/.exec(line)?.[0].length||0;
|
|
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
|
+
if((m=/^\s*(?:[-*+]|\d+[.)])\s+/.exec(line.slice(quote)))){flush();cur=inline(line,start,quote+m[0].length,'li');continue;}
|
|
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;
|
|
34
|
+
}
|
|
35
|
+
flush();return blocks;
|
|
36
|
+
}
|
|
37
|
+
function spellingIssues(blocks,spelling){
|
|
38
|
+
const issues=[];
|
|
39
|
+
for(const item of spelling){const re=new RegExp('(?<!['+L+'])'+item.word.replace(/[-]/g,'\\-')+'(?!['+L+'])','g');
|
|
40
|
+
blocks.forEach((b,i)=>{for(const m of b.text.matchAll(re))issues.push({block:i,start:m.index,end:m.index+m[0].length,id:'spelling',category:'spelling',label:'Орфография',message:'Возможна опечатка: «'+item.word+'».',fix:item.suggestions?.length?'Варианты: '+item.suggestions.join(', ')+'.':'Проверьте написание или добавьте слово в словарь.',word:item.word,suggestions:item.suggestions??null});});}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
// spelling: hunspell misses from the server; style: server (morphology) hints by block; local: dictionary hints by block.
|
|
44
|
+
// Returns hints with start/end in the Markdown source, sorted by position.
|
|
45
|
+
export function mergeIssues(blocks,{spelling=[],style=[],local=[]}){
|
|
46
|
+
// A server genitive chain replaces the rough suffix-based one, a weak verb + noun hint replaces the dictionary hit
|
|
47
|
+
// on the same verb, a server repeat replaces a local one on the same word.
|
|
48
|
+
const overlaps=(x,it)=>x.block===it.block&&x.start<it.end&&it.start<x.end;
|
|
49
|
+
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));
|
|
50
|
+
const found=[...spellingIssues(blocks,spelling),...kept,...style.filter(it=>blocks[it.block]&&it.start<it.end&&it.end<=blocks[it.block].text.length)];
|
|
51
|
+
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);
|
|
52
|
+
}
|
|
53
|
+
// Plain text → paragraphs split by blank lines; every UTF-16 unit keeps its own offset (emoji take two).
|
|
54
|
+
export function textBlocks(text){
|
|
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)});}
|
|
57
|
+
return blocks;
|
|
58
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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:'Структура'};
|
|
3
|
+
export const RULES=[
|
|
4
|
+
// Time parasites
|
|
5
|
+
{id:'time-parasite-start',category:'time',start:true,message:'Вступление о нынешнем времени ничего не добавляет к предложению.',fix:'Убрать его или назвать конкретную дату или факт.',words:['в настоящее время','в настоящий момент','в наши дни','в наше время','в последнее время','в последние годы','нынче','до недавнего времени','в современном мире','в современных условиях']},
|
|
6
|
+
{id:'time-parasite-anywhere',category:'time',message:'Оборот про нынешний момент почти всегда лишний.',fix:'Убрать его или указать точную дату.',words:['на сегодняшний день','на сегодняшний момент','на текущий момент','в текущий момент','на настоящий момент']},
|
|
7
|
+
// Modality
|
|
8
|
+
{id:'modality-need',category:'modality',message:'Слова нужно, надо и следует делают фразу уклончивой: не видно, кто и что делает.',fix:'Написать прямо, кто что делает, или назвать причину обязательности.',words:['нужно','необходимо','надо','следует']},
|
|
9
|
+
{id:'modality-must',category:'modality',message:'Должен и обязан звучат как требование без основания.',fix:'Назвать основание требования или перейти на прямое действие.',words:['должен','должна','должно','должны','обязан','обязана','обязано','обязаны']},
|
|
10
|
+
{id:'modality-you-can',category:'modality',message:'Возможность подана через модальный оборот, хотя можно сказать, что делает человек или продукт.',fix:'Описать действие глаголом в настоящем времени.',words:['вы можете','вы сможете','вам можно','вам придется','вам понадобится','пользователи могут','пользователь может','клиенты могут']},
|
|
11
|
+
{id:'modality-intent',category:'modality',message:'Намерение и планы не сообщают о факте.',fix:'Назвать срок или то, что уже сделано.',words:['собираюсь','собираемся','планирую','планируем','намерен','намерены','намереваюсь','намереваемся']},
|
|
12
|
+
{id:'modality-subjunctive',category:'modality',message:'Частица бы смягчает просьбу или оценку и делает автора неуверенным.',fix:'Написать прямо: просьбу командой, оценку в изъявительном наклонении.',words:['я бы хотел','я бы хотела','мы бы хотели','мне бы хотелось','нам бы хотелось','хотелось бы','вы бы хотели','я бы предпочел','я бы предпочла','мы бы предпочли','не могли бы вы','было бы здорово','было бы хорошо','было бы неплохо','было бы круто','было бы классно']},
|
|
13
|
+
// Introductory and structure words
|
|
14
|
+
{id:'intro-transition',category:'intro',start:true,message:'Переходная фраза занимает место: смена темы и так видна читателю.',fix:'Убрать и начать с сути.',words:['во-первых','во-вторых','в-третьих','для начала','далее','прежде всего','отметим также','перейдем к','пару слов о','следует остановиться']},
|
|
15
|
+
{id:'intro-structure',category:'intro',start:true,message:'Фраза об устройстве текста обещает то, что читатель и так увидит.',fix:'Убрать и сразу дать итог или сам материал.',words:['рассмотрим','подведем итог','подведем итоги','в завершение','в заключение','какие выводы']},
|
|
16
|
+
{id:'intro-example',category:'intro',start:true,message:'Вводное слово перед примером можно заменить двоеточием.',fix:'Поставить двоеточие или вынести пример отдельно.',words:['к примеру','допустим','предположим','представьте','представим','скажем']},
|
|
17
|
+
{id:'intro-oral',category:'intro',message:'Слова из устной речи делают письменный текст разговорным.',fix:'Убрать или заменить словом по делу.',words:['вроде','супер','плевать','короче','так сказать','типа','в общем-то','вообще-то','собственно','ну','эх','ох','ах','ой','хм','эм']},
|
|
18
|
+
{id:'intro-self-correction',category:'intro',message:'Маркер вроде то есть показывает, что фраза сначала написана неточно.',fix:'Сразу написать точную формулировку.',words:['то есть','иными словами','другими словами','проще говоря','грубо говоря']},
|
|
19
|
+
{id:'intro-attention',category:'intro',message:'Просьба обратить внимание ничего не сообщает: внимание привлекает оформление.',fix:'Написать сам факт и выделить его оформлением.',words:['обращаем внимание','обращаю внимание','обращайте внимание','обратите внимание','хочу обратить внимание','доводим до вашего сведения','важно отметить','следует отметить','необходимо отметить','стоит отметить','хочу отметить']},
|
|
20
|
+
{id:'intro-obviousness',category:'intro',message:'Слова об очевидности ослабляют мысль: если это ясно, их не нужно.',fix:'Убрать и сразу сказать суть.',words:['как известно','ни для кого не секрет','не секрет','общеизвестно','всем известно','безусловно','несомненно','разумеется','очевидно','естественно']},
|
|
21
|
+
// Fillers and evaluation
|
|
22
|
+
{id:'filler-simply',category:'vague',message:'Слово просто звучит как оправдание или обесценивает сказанное.',fix:'Убрать или изложить факт без оправдания.',words:['просто','попросту','всего лишь','всего-навсего']},
|
|
23
|
+
{id:'intensifiers',category:'evaluation',message:'Усилитель подчёркивает оценку, но не добавляет факта.',fix:'Убрать усилитель или заменить его цифрой.',words:['очень','максимально','абсолютно','предельно','крайне','чрезвычайно','безумно','невероятно','буквально','наиболее','самый','самая','самое','самые']},
|
|
24
|
+
{id:'sincerity',category:'evaluation',message:'Слова искренности убеждают вместо фактов и выдают сомнение автора.',fix:'Убрать и привести аргумент или цифру.',words:['реально','реальный','реальная','реальное','реальные','реального','реальной','реальных','реальным','реальную','действительно','на самом деле','поверьте','честно говоря','если честно','по правде говоря','откровенно говоря','скажу честно']},
|
|
25
|
+
{id:'adjective-marketing',category:'adjective',message:'Оценочное прилагательное заменяет факты и не даёт проверить утверждение.',fix:'Назвать конкретный признак или цифру вместо оценки.',words:['безупречн*','высококачественн*','высококлассн*','роскошн*','революционн*','комфортабельн*','идеальн*','превосходн*','великолепн*','потрясающ*','шикарн*','замечательн*','прекрасн*','красив*','уютн*','изящн*','стильн*','качественн*','надежн*','удобн*','оптимальн*','инновационн*','уникальн*','эксклюзивн*','престижн*']},
|
|
26
|
+
{id:'evaluation-superlatives',category:'evaluation',message:'Превосходная степень или похвала без доказательства читается как преувеличение.',fix:'Назвать сравнение или цифру, на которой основан вывод.',words:['лучший','лучшая','лучшее','лучшие','лучших','лучшего','лучшей','лучшим','единственный','единственная','единственное','единственные','мастерски','виртуозно','блестяще','филигранно','выдающийся','поистине']},
|
|
27
|
+
{id:'success-word',category:'evaluation',message:'Слово успешно дублирует смысл результата и ничего не добавляет.',fix:'Убрать слово или заменить показателями: сроки, цифры, итог.',words:['успешн*']},
|
|
28
|
+
{id:'active-word',category:'evaluation',message:'Слово активный описывает впечатление, а не то, что делают люди.',fix:'Назвать конкретное действие: что делают, сколько и как часто.',words:['активн*']},
|
|
29
|
+
{id:'proof-claims',category:'evaluation',message:'Заявление о проверенности или гарантии без факта требует веры на слово.',fix:'Привести факт, цифру или отзыв, либо убрать заявление.',words:['проверенный','проверенная','проверенное','проверенные','проверенных','проверенным','проверенного','проверенной','проверено','доказано','доказанный','доказанная','доказанные','гарантированно','гарантированный','гарантированная','гарантированное','гарантированные','гарантируем','гарантирую','без обмана','стопроцентн*']},
|
|
30
|
+
// Vague
|
|
31
|
+
{id:'vague-indefinite',category:'vague',message:'Неопределённое слово прячет конкретный предмет или причину.',fix:'Назвать предмет или причину, либо убрать слово.',words:['что-то','как-то','какой-то','какая-то','какое-то','какие-то','каких-то','каким-то','какую-то','какого-то','кто-то','кого-то','кому-то','чего-то','чем-то','где-то','куда-то','зачем-то','почему-то','что-нибудь','кто-нибудь','как-нибудь','какой-нибудь']},
|
|
32
|
+
{id:'vague-approximate',category:'vague',message:'Приблизительное значение без числа читается как неуверенность автора.',fix:'Дать точное число или диапазон.',words:['порядка','примерно','около','приблизительно','ориентировочно','плюс-минус']},
|
|
33
|
+
{id:'vague-hedge',category:'vague',message:'Оговорка размывает мысль.',fix:'Убрать или сказать точно, в чём именно дело.',words:['вообще','в принципе','своего рода','в некотором роде','в некотором смысле','как бы','более-менее','так или иначе','что называется']},
|
|
34
|
+
// Pronoun
|
|
35
|
+
{id:'pronoun-dannyy',category:'pronoun',message:'Слово данный звучит канцелярски и ничего не уточняет.',fix:'Заменить на слово этот или назвать предмет прямо.',words:['данный','данная','данное','данную','данной','данного','данном','данному']},
|
|
36
|
+
// Bureaucratese and nominalization
|
|
37
|
+
{id:'bureaucratese-verbs',category:'bureaucratese',message:'Канцелярский оборот звучит как отписка и скрывает суть.',fix:'Заменить простым глаголом или словом: делать, для, чтобы, это.',words:['осуществл*','имеет место','имеют место','имел место','имело место','в рамках','в целях','с целью','на предмет','посредством','является','являются','являться','вышеуказанный','вышеупомянутый','нижеследующий']},
|
|
38
|
+
{id:'bureaucratese-latinisms',category:'bureaucratese',message:'Иностранное слово там, где есть простое русское, затрудняет чтение.',fix:'Заменить обычным словом: общаться, умение, убрать, уточнить.',words:['коммуникац*','компетенц*','минимизир*','минимизац*','конкретизир*','релевантн*','нерелевантн*','конкурентоспособн*','стагнац*','конъюнктур*','деградац*','имплементир*','имплементац*','консолидир*','консолидац*','синерги*','функционал']},
|
|
39
|
+
{id:'nominalization-support-verbs',category:'nominalization',message:'Глагол плюс существительное длиннее и тяжелее одного глагола.',fix:'Заменить одним глаголом: монтировать вместо производить монтаж.',words:['принимать участие','принять участие','принял участие','приняли участие','принимаю участие','принимаем участие','принимает участие','принимают участие','оказать помощь','оказывать помощь','оказали помощь','оказываем помощь','оказание помощи','оказать услуги','оказывать услуги','оказали услуги','оказываем услуги','оказание услуг','производить монтаж','произвести монтаж','произвели монтаж','производим монтаж','производить замену','произвести замену','произвели замену','производим замену','производить ремонт','произвести ремонт','произвели ремонт','производим ремонт','проводить работы','проводим работы','проводят работы','провести работы','провели работы']},
|
|
40
|
+
// Stamps
|
|
41
|
+
{id:'stamp-cliche',category:'stamp',message:'Оборот затёрт: читатель видит штамп раньше смысла.',fix:'Написать конкретный факт или действие без оборота.',words:['сочета* в себе','на каждом шагу','в сухом остатке','на помощь приходят','на помощь приходит','легко и просто','без промедления','прямиком','расширить географию','расширили географию','играет важную роль','играют важную роль','играет ключевую роль','играют ключевую роль','играет большую роль','играют большую роль','как показывает практика','на высоком уровне','на должном уровне','на новый уровень','время покажет']},
|
|
42
|
+
{id:'stamp-newspaper-idioms',category:'stamp',message:'Газетный оборот давно затёрт и подменяет смысл образом.',fix:'Сказать то же простыми словами: быстро, в конце, правильно.',words:['сломя голову','семимильн* шаг*','аж пятки сверкали','финальн* аккорд*','в нужном русле','сомнительн* удовольствие*','ярк* штрих*','в лучших традициях','испокон веков','достойн* лучшего применения','по большому счету','на всех парах','в двух словах','тернист* пут*']},
|
|
43
|
+
{id:'stamp-sales',category:'stamp',message:'Рекламная формула звучит одинаково у всех и ничего не доказывает.',fix:'Показать, чем это выражается: срок, цифра, пример из работы.',words:['индивидуальн* подход*','широк* спектр*','широк* ассортимент*','команда профессионалов','команды профессионалов','высококвалифицированн*','богат* опыт*','многолетн* опыт*','современн* технолог*','лидер рынка','лидеры рынка','клиентоориентированн*','комплексн* подход*','комплексн* решени*','динамично развивающ*']},
|
|
44
|
+
// Pleonasms
|
|
45
|
+
{id:'pleonasm-pairs',category:'pleonasm',message:'Одно слово повторяет смысл другого.',fix:'Оставить одно слово, которое несёт смысл.',words:['повтор* снова','повтор* еще раз','предварительн* предоплат*','подн* вверх','спуск* вниз','спуст* вниз','вернуть* назад','вернул* назад','возвраща* назад','главн* приоритет*','свободн* ваканси*','сво* собственн*','совместн* сотрудничеств*','взаимн* сотрудничеств*','внезапно вдруг','вдруг внезапно','каждый день ежедневно','ежедневно каждый день','краткое резюме','основной лейтмотив','первый дебют','стеклянн* остеклени*']},
|
|
46
|
+
{id:'pleonasm-dependent',category:'pleonasm',message:'Зависимое слово повторяет то, что уже сказано главным существительным.',fix:'Оставить только главное существительное.',words:['ареал* обитания','коллег* по работе','счет* на оплату','прейскурант цен','прейскуранта цен','период* времени','промежутк* времени','январь месяц','февраль месяц','март месяц','апрель месяц','май месяц','июнь месяц','июль месяц','август месяц','сентябрь месяц','октябрь месяц','ноябрь месяц','декабрь месяц']},
|
|
47
|
+
{id:'date-range-period',category:'pleonasm',message:'Слово период лишнее: диапазон дат сам задаёт период.',fix:'Писать просто: с … по ….',words:['в период с','на протяжении периода','в течение периода']},
|
|
48
|
+
// Structure of the page
|
|
49
|
+
{id:'ui-reference',category:'structure',message:'Указание места на странице быстро устаревает и заставляет искать.',fix:'Назвать действие или объект прямо: выберите тариф, откройте раздел.',words:['ниже по тексту','выше по тексту','как сказано выше','как было сказано выше','как говорилось выше','кликните здесь','кликните тут','нажмите здесь','нажмите на кнопку','по ссылке ниже','в меню ниже','на кнопку ниже','в конце этой страницы','на этой странице','на странице ниже']},
|
|
50
|
+
{id:'greeting-start',category:'structure',start:true,message:'Приветствие в начале занимает место и не даёт читателю пользы.',fix:'Начать сразу с сути.',words:['добро пожаловать','уважаемые','уважаемый','уважаемая','всем привет','дорогие друзья','дорогие читатели','здравствуйте','привет','благодарим','спасибо что читаете']},
|
|
51
|
+
{id:'adjective-metaphor',category:'adjective',message:'Образ с материалом в прилагательном стандартный и ничего конкретного не передаёт.',fix:'Описать действие или признак прямо.',words:['железная воля','железной воли','железной волей','железную волю','стальные нервы','стальных нервах','стальная хватка','стальной хватки','золотые руки','золотой стандарт','золотого стандарта']},
|
|
52
|
+
// Added from sections B and C: sentence starts, claims and tone
|
|
53
|
+
{id:'passive-reflexive-impersonal',category:'passive',message:'Безличный оборот на -ся прячет того, кто делает работу.',fix:'Назвать исполнителя подлежащим: подрядчик проведёт, а не будет проводиться.',words:['будут проводиться','будет проводиться','будет производиться','будут производиться','будет выполняться','будут выполняться']},
|
|
54
|
+
{id:'syntax-fragment-start',category:'syntax',start:true,message:'Предложение начинается со слова, которое тянет его к предыдущему, и выглядит как обрыв.',fix:'Присоединить к предыдущему предложению или добавить главное слово.',words:['плюс','который','которая','которое','которые','которых','которого','которому','которой','которым','которыми']},
|
|
55
|
+
{id:'connective-start',category:'structure',start:true,message:'Связка в начале предложения заставляет читателя держать в голове предыдущее.',fix:'Убрать связку или назвать причину прямо в первых словах.',words:['поэтому','однако','таким образом','следовательно','итак','значит','а значит','кроме того','тем не менее','напротив']},
|
|
56
|
+
{id:'generic-start',category:'vague',start:true,message:'Обобщающее слово в начале даёт общее правило вместо конкретики.',fix:'Назвать конкретный случай или цифру.',words:['обычно','как правило','бывает','часто']},
|
|
57
|
+
{id:'banal-intro',category:'stamp',start:true,message:'Начало похоже на общую истину, которую читатель уже знает.',fix:'Начать с конкретного факта, события или конфликта из текста.',words:['все мы знаем','давно известно','многие из нас','каждый из нас','кто из нас','с давних пор','с незапамятных времен','с древних времен','мир не стоит на месте','мы живем в эпоху','мы живем в мире','мы живем в век','вряд ли найдется']},
|
|
58
|
+
{id:'back-reference',category:'structure',message:'Ссылка на прошлый текст требует, чтобы читатель помнил его или искал.',fix:'Повторить нужный факт прямо в этом месте.',words:['как я уже говорил','как я говорил','как я писал','как я уже писал','как мы уже говорили','как мы писали','в предыдущей статье','в прошлой статье','в предыдущем посте','в прошлом посте','в прошлый раз']},
|
|
59
|
+
{id:'self-announce',category:'stamp',message:'Новость о себе мало интересна читателю, пока не сказано, что она ему даёт.',fix:'Сказать, что это даёт читателю, и привести факт.',words:['рады сообщить','рады объявить','рад сообщить','рада сообщить','с гордостью сообщаем','спешим сообщить','сообщаем','информируем','имеем честь']},
|
|
60
|
+
{id:'preachy-tone',category:'modality',message:'Назидательное слово звучит как мораль, а не как наблюдение.',fix:'Заменить правило примером или историей.',words:['не забывайте','не забудьте','запомните','важно помнить','ни в коем случае','обязательно']},
|
|
61
|
+
{id:'razobralsya',category:'vague',message:'Слово разобрался обещает больше, чем показывает текст.',fix:'Показать, что именно понято или сделано.',words:['разобрался','разобралась','разобрались']},
|
|
62
|
+
{id:'sign-off',category:'structure',message:'Прощальная формула не добавляет вывода.',fix:'Закончить выводом, который полезен сам по себе.',words:['подписывайтесь','подписывайся','спасибо за внимание','до новых встреч','до скорой встречи','на этом все','на этом у меня все','пишите в комментариях','делитесь в комментариях','ставьте лайки']},
|
|
63
|
+
{id:'better-than-others',category:'evaluation',message:'Сравнение без измерения ничего не доказывает.',fix:'Показать конкретную разницу: цифру, срок, пример.',words:['лучше других','лучше аналогов','лучше конкурентов','лучше всех','лучше остальных','нет аналогов','не имеет аналогов','не имеющий аналогов','вне конкуренции']},
|
|
64
|
+
{id:'advantage-word',category:'evaluation',message:'Слово преимущество без сравнения ни о чём не говорит.',fix:'Назвать конкретную пользу или с чем именно сравнивается.',words:['преимуществ*']},
|
|
65
|
+
{id:'stamp-sales-phrases',category:'stamp',message:'Готовая фраза продающего текста заменяет факт.',fix:'Заменить фактом: сколько осталось, до какого числа.',words:['количество ограничено','количество мест ограничено','предложение ограничено','только сегодня','успейте','успей','не упустите','торопитесь','спешите']},
|
|
66
|
+
{id:'scientific-terms',category:'bureaucratese',message:'Научный термин в бытовом тексте звучит тяжело и отдаляет читателя.',fix:'Заменить простым словом или описать, что произошло.',words:['депресс*','апатия','апатии','апатию','мотивац*','рефлекси*','катарсис*','парадигм*','дискурс*','когнитивн*','подсознан*','подсознательн*','фоби*']},
|
|
67
|
+
{id:'definition-patterns',category:'syntax',message:'Определение встроено в фразу и тяжело читается.',fix:'Вынести определение в отдельное предложение: термин — это …',words:['понимается','понимаются','понимать под']},
|
|
68
|
+
// Added from the second pass over public advice: borrowings, register, stamps, claims, letter formulas
|
|
69
|
+
{id:'borrowing-business',category:'borrowing',message:'Заимствование из деловой болтовни там, где есть обычное русское слово.',fix:'Назвать по-русски: отзыв, пример, история, сайт, помощь.',words:['сторителлинг','стаф','суппорт','support','фидбек','фидбэк','кейс*','визуал','гивэвэй','ингейджмент','контент-план','личный бренд','веб-сайт','интернет-сайт','онлайн-платформ*']},
|
|
70
|
+
{id:'slang-register',category:'slang',message:'Жаргон звучит как переписка в чате и подходит не каждому читателю.',fix:'Сказать обычным словом: плохо, деньги, хвастаться, ерунда.',words:['бро','чел','йоу','кринж','флексить','байтить','тупо','тупить','бабло','понты','понтоваться','кидалово','подстава','ща','ежели','окей','вау','ура-ура','бухать','жрать','херачить','фигня','фигни','фигню']},
|
|
71
|
+
{id:'stamp-clickbait',category:'stamp',message:'Интрига вместо содержания: читателя заманивают, а не называют тему.',fix:'Назвать результат или факт прямо в первых словах.',words:['вы удивитесь','вы не поверите','прочитайте эту статью','узнайте как','узнай как','вот что мы узнали','не переключайтесь','секрет успеха','секреты успеха','незаметно убива*','доверьтесь мне','доверьтесь профессионалам']},
|
|
72
|
+
{id:'stamp-call-to-action',category:'stamp',message:'Призыв действовать прямо сейчас давит на читателя и ничего не доказывает.',fix:'Назвать причину или выгоду, а решение оставить читателю.',words:['оставьте заявку','звоните прямо сейчас','бронируйте сегодня','бронируйте сейчас','купи скорее','ставь лайк','подпишись','подпишитесь сейчас','перешлите друзьям','предупредите всех','обращайтесь']},
|
|
73
|
+
{id:'stamp-idioms-more',category:'stamp',message:'Готовый образ из газет и рекламы: читатель пролистывает его, не вдумываясь.',fix:'Сказать то же прямо, своими словами и с конкретным фактом.',words:['бытует мнение','в эпоху цифровых технологий','братья наши меньшие','столбик термометра','просторы интернета','глобальная сеть','всемирная паутина','в сети интернет','мировые сети','оружие массового поражения','делом всей жизни','нож в спину','сломано немало копий','на войне как на войне','как грибы после дождя','британские ученые','верхушка айсберга','конкуренты не дремлют','метать бисер','рано или поздно','от а до я','залог успеха','залог успешных','стремительно набира* популярность','повальное увлечение','яркий тренд','незаменимый помощник','изо всех сил','идеальное сочетание','мурашки по коже','до мурашек','покрывается мурашками','нотки','нотка','нотками','насыщенный букет','богатая палитра','бомба замедленного действия','ворваться в чарты','искорки счастья','искорки шампанского','искорки волшебства','волшебное время','зимняя сказка','новогодняя сказка','время волшебства и чудес','играл* ключев* рол*','сыграл* ключев* рол*','играл* важн* рол*','сыграл* важн* рол*']},
|
|
74
|
+
{id:'stamp-business-claims',category:'stamp',message:'Фраза из коммерческих текстов: звучит одинаково у всех и ничего не доказывает.',fix:'Заменить фактом: сколько, за какой срок, для кого, с какой гарантией.',words:['полный спектр услуг','полный цикл услуг','полный комплекс услуг','решать задачи бизнеса','решаем задачи бизнеса','задачи бизнеса','в кратчайшие сроки','кратчайшие сроки','точно в срок','высочайшего качества','высокое качество','наилучшее качество','высокий профессионализм','высочайший профессионализм','профессионалы своего дела','профессионал своего дела','обратитесь к профессионалам','квалифицированный персонал','квалифицированные специалисты','опытные специалисты','один из лидеров','один из ведущих','одна из ведущих','один из крупнейших','одна из крупнейших','признанный лидер','ведущий специалист','ведущие специалисты','лучший на рынке','работает на рынке','работаем на рынке','особое внимание','высокие темпы роста','передовые технологии','прорывные технологии','инновационные решения','конкурентные преимущества','быстро и качественно','лучшие цены','самые низкие цены','самые выгодные условия','выгодные условия','на самом высоком уровне','на высоком профессиональном уровне','на мощной технологической базе','завоевали доверие','честная конкуренция','корпоративная философия']},
|
|
75
|
+
{id:'stamp-formal-letter',category:'stamp',message:'Формула из официальных писем: вежливость по шаблону, а не живая речь.',fix:'Написать по-человечески и коротко или убрать формулу.',words:['ваш звонок очень важен для нас','приносим свои глубочайшие извинения','приносим глубочайшие извинения','глубочайшие извинения','искренне ваш','заранее спасибо','всего наилучшего','спасибо за понимание','спасибо за внимательное отношение','буду рад ответить на ваши вопросы','буду рад сотрудничеству','с радостью предложим','именно поэтому мы','доброго времени суток','с наступающим','желаю профессиональных успехов','здоровья и успехов','здоровья и процветания','вынуждены сообщить']},
|
|
76
|
+
{id:'bureaucratese-phrases',category:'bureaucratese',message:'Канцелярская формула: звучит как документ, а не как человек.',fix:'Сказать проще: деньги, программы, если есть, поэтому.',words:['денежные средства','денежных средств','материальная помощь','материальной помощи','программное обеспечение','программного обеспечения','товары и услуги','товары или услуги','товаров и услуг','техническая возможность','технической возможности','при наличии','в этой связи','обращаем ваше внимание','обращаю ваше внимание','хочу обратить ваше внимание','сообщаем вам','множество нюансов','выработаем стратегию','выработали стратегию']},
|
|
77
|
+
{id:'pleonasm-more',category:'pleonasm',message:'Слово дублирует смысл соседнего или тянет текст.',fix:'Оставить одно слово, которое несёт смысл.',words:['наглядные примеры','наглядный пример','наиболее оптимальн*','самый оптимальн*','в удобное для вас время','в любое удобное время','провайдер услуг']},
|
|
78
|
+
{id:'generalization-any',category:'generalization',message:'Слова все и любой обещают больше, чем можно проверить.',fix:'Назвать конкретные случаи или количество.',words:['в любой ситуации','для любой ситуации','в любой точке','на любую тему','любую вещь','всех типов','все виды','всегда есть выбор','каждый сам решает','предусмотрим все']},
|
|
79
|
+
{id:'vague-science-appeal',category:'vague',message:'Ссылка на безымянные исследования не даёт проверить сказанное.',fix:'Назвать источник, год и цифру или написать, что наблюдал сам.',words:['ученые доказали','исследования показали','согласно недавним исследованиям','согласно исследованиям','по последним данным','по данным исследований','как показывают исследования','как показывает статистика']},
|
|
80
|
+
{id:'vague-quantity',category:'vague',message:'Слово обозначает количество, но не называет его.',fix:'Дать число или назвать, сколько именно.',words:['большое количество','значительное количество','незначительное количество','небольшое количество','уйма','уже много лет','все больше','подавляющее большинство']},
|
|
81
|
+
{id:'vague-soon',category:'vague',message:'Срок скоро ничего не обещает.',fix:'Назвать дату или срок.',words:['в ближайшее время','как можно скорее']},
|
|
82
|
+
{id:'adjective-hype',category:'adjective',message:'Громкое прилагательное хвалит предмет, но не говорит, чем он хорош.',fix:'Показать признак: размер, срок, цену, пример использования.',words:['впечатляющ*','удивительн*','фантастическ*','магическ*','волшебн*','незабываем*','неповторим*','бесподобн*','обворожительн*','эксклюзив','новейш*','высочайш*','наилучш*','всесторонн*','эффективн*','отличн*','классн*','круто','передов*']},
|
|
83
|
+
{id:'modality-softener',category:'modality',message:'Смягчающее слово делает мысль размытой: автор будто извиняется.',fix:'Убрать слово или назвать точную меру.',words:['немножко','чуть-чуть','чуточку','не совсем','может показаться','очень вероятно']},
|
|
84
|
+
{id:'modality-promise',category:'modality',message:'Обещание читателю в форме вы станете звучит как реклама курса.',fix:'Описать, что есть в тексте, а выводы оставить читателю.',words:['вы научитесь','вы узнаете','вы станете','вы получите','вы найдете','вам потребуется']},
|
|
85
|
+
{id:'filler-start',category:'filler',start:true,message:'Слово-связка в начале даёт паузу вместо мысли.',fix:'Начать сразу с мысли.',words:['кстати','в общем','по сути','да и вообще','сразу скажу']},
|
|
86
|
+
{id:'filler-phrases',category:'filler',message:'Пустая фраза занимает место и ничего не сообщает.',fix:'Убрать или заменить одним конкретным примером.',words:['бла-бла-бла','пятое-десятое','и далее в таком духе','без преувеличения','поверьте мне','правда-правда']},
|
|
87
|
+
{id:'pronoun-ours',category:'pronoun',message:'Слово наш в рекламном смысле выдаёт самохвальство и сужает текст.',fix:'Назвать предмет прямо: услуги, клиенты, сайт.',words:['наши услуги','наши клиенты','наши офисы','о нашей компании','на нашем интернет-сайте','на нашем сайте']},
|
|
88
|
+
{id:'euphemism-soft',category:'euphemism',message:'Мягкое слово прячет суть дела.',fix:'Назвать, что именно случилось и по чьей вине.',words:['форс-мажорн*','форс-мажор','политес']},
|
|
89
|
+
{id:'preachy-refusal',category:'preachy',message:'Список тех, с кем автор не работает, звучит как ультиматум.',fix:'Написать, с кем работаешь и чем это удобно обеим сторонам.',words:['не работаю с','не работаем с','в обязательном порядке']},
|
|
90
|
+
{id:'self-praise',category:'evaluation',message:'Похвала себе без доказательства читается как самореклама.',fix:'Показать результат: проект, срок, отзыв.',words:['мы не новички','нам можно доверить','мы серьезная фирма','никогда не срываю сроки','мы делаем лучше всех']},
|
|
91
|
+
];
|
package/src/style.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Style hints by dictionary and simple rules. Runs in the browser and in Node; the rules are our own word lists.
|
|
2
|
+
import {RULES,CATEGORIES} from './rules.js';
|
|
3
|
+
const L='A-Za-zА-Яа-яЁё';
|
|
4
|
+
const norm=s=>s.toLowerCase().replace(/ё/g,'е');
|
|
5
|
+
const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
6
|
+
// "осуществл*" matches any ending; spaces between words may be any whitespace.
|
|
7
|
+
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
|
+
const compiled=RULES.map(rule=>({rule,re:compile(rule)}));
|
|
10
|
+
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
|
+
const wordCount=s=>(s.match(new RegExp('['+L+'0-9]+(?:-['+L+']+)*','g'))||[]).length;
|
|
12
|
+
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};};
|
|
13
|
+
export const BUILTIN={
|
|
14
|
+
'long-sentence':{category:'length',message:'Длинное предложение: в нём легко потеряться.',fix:'Разделите на два-три предложения, по одной мысли в каждом.'},
|
|
15
|
+
'long-paragraph':{category:'length',message:'Длинный абзац тяжело читать, особенно с телефона.',fix:'Разбейте абзац по мыслям: одна мысль — один абзац.'},
|
|
16
|
+
'kotory-twice':{category:'kotory',message:'Несколько «который» в одном предложении запутывают.',fix:'Вынесите одну из частей в отдельное предложение или замените причастием.'},
|
|
17
|
+
'passive-was':{category:'passive',message:'Страдательный залог прячет того, кто действует.',fix:'Назовите действующее лицо: не «было принято решение», а «мы решили».'},
|
|
18
|
+
'nominal-chain':{category:'nominalization',message:'Цепочка отглагольных существительных звучит казённо.',fix:'Превратите одно из слов в глагол: не «проведение проверки», а «проверили».'},
|
|
19
|
+
'word-repeat':{category:'repetition',message:'Одно и то же слово повторяется в абзаце.',fix:'Уберите лишние повторы или замените местоимением.'},
|
|
20
|
+
'title-too-long':{category:'structure',message:'Длинный заголовок трудно охватить взглядом.',fix:'Сократите до одной мысли, примерно до 70 знаков.'},
|
|
21
|
+
'first-paragraph-long':{category:'structure',message:'Длинный первый абзац: читатель может уйти, не дойдя до главного.',fix:'Начните с главной мысли в двух-трёх предложениях, подробности перенесите ниже.'},
|
|
22
|
+
'no-headings':{category:'structure',message:'Длинный текст без подзаголовков: в нём трудно ориентироваться.',fix:'Разбейте текст на части с подзаголовками по смыслу.'},
|
|
23
|
+
'thin-section':{category:'structure',message:'Под подзаголовком почти нет текста.',fix:'Допишите раздел или объедините его с соседним.'},
|
|
24
|
+
'ya-start':{category:'structure',message:'Много предложений начинаются с «Я»: текст звучит как рассказ о себе.',fix:'Начните часть предложений с того, что важно читателю: с дела, факта, результата.'},
|
|
25
|
+
'question-run':{category:'syntax',message:'Вопросы подряд выглядят как приём, а не как мысль.',fix:'Оставьте один вопрос или замените вопросы утверждением.'},
|
|
26
|
+
};
|
|
27
|
+
const PASSIVE=new RegExp('(?<!['+L+'])(?:был|была|было|были|будет|будут)\\s+['+L+']+(?:ан|ян|ен|ён|т)(?:а|о|ы)?(?!['+L+'])','gi');
|
|
28
|
+
const NOMINAL=new RegExp('(?:(?<!['+L+'])['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])\\s+){2,}['+L+']+(?:ние|ния|нию|нием|нии|ость|ости|остью|ция|ции|цию|цией)(?!['+L+'])','gi');
|
|
29
|
+
const REPEAT_SKIP=new Set(['который','которая','которое','которые','которых','которым','потому','только','сейчас','больше','меньше','просто','сделать','делать','чтобы','когда','после','через','between','about']);
|
|
30
|
+
// blocks: [{text, kind:'p'|'li'|'h', tag?:'H1'|'H2'…}] → issues: [{block,start,end,id,category,label,message,fix}]
|
|
31
|
+
export function checkStyle(blocks,{maxSentenceWords=25,maxParagraphWords=120}={}){
|
|
32
|
+
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
|
+
blocks.forEach((b,i)=>{
|
|
34
|
+
const text=b.text,low=norm(text),list=sentences(text);
|
|
35
|
+
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);}}
|
|
36
|
+
if(b.kind==='h')return;
|
|
37
|
+
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
|
+
for(const s of list){
|
|
39
|
+
if(wordCount(s.text)>maxSentenceWords){const r=trimmed(s);add(i,r.start,r.end,'long-sentence',BUILTIN['long-sentence']);}
|
|
40
|
+
const k=[...norm(s.text).matchAll(new RegExp('(?<!['+L+'])котор['+L+']*','g'))];if(k.length>=2)add(i,s.start+k[1].index,s.start+k[1].index+k[1][0].length,'kotory-twice',BUILTIN['kotory-twice']);
|
|
41
|
+
}
|
|
42
|
+
for(const m of text.matchAll(PASSIVE))add(i,m.index,m.index+m[0].length,'passive-was',BUILTIN['passive-was']);
|
|
43
|
+
for(const m of text.matchAll(NOMINAL))add(i,m.index,m.index+m[0].trimEnd().length,'nominal-chain',BUILTIN['nominal-chain']);
|
|
44
|
+
if(b.kind==='p'&&wordCount(text)>maxParagraphWords)add(i,0,Math.min(text.length,60),'long-paragraph',BUILTIN['long-paragraph']);
|
|
45
|
+
if(b.kind==='p'){const seen=new Map();for(const m of low.matchAll(new RegExp('['+L+']{7,}','g'))){if(REPEAT_SKIP.has(m[0]))continue;const stem=m[0].slice(0,5),list2=seen.get(stem)||[];list2.push(m);seen.set(stem,list2);}for(const ms of seen.values())if(ms.length>=3)for(const m of ms.slice(2))add(i,m.index,m.index+m[0].length,'word-repeat',BUILTIN['word-repeat']);}
|
|
46
|
+
});
|
|
47
|
+
// Whole-text checks: title, first paragraph, headings, sentences that start with «Я».
|
|
48
|
+
blocks.forEach((b,i)=>{if(b.tag==='H1'&&b.text.trim().length>70)add(i,0,b.text.length,'title-too-long',BUILTIN['title-too-long']);});
|
|
49
|
+
const paras=blocks.map((b,i)=>({b,i})).filter(x=>x.b.kind==='p'),first=paras[0];
|
|
50
|
+
if(first&&first.b.text.length>500)add(first.i,0,Math.min(first.b.text.length,60),'first-paragraph-long',BUILTIN['first-paragraph-long']);
|
|
51
|
+
const total=blocks.reduce((n,b)=>n+(b.kind==='h'?0:wordCount(b.text)),0);
|
|
52
|
+
if(first&&total>1500&&!blocks.some(b=>b.tag==='H2'||b.tag==='H3'))add(first.i,0,Math.min(first.b.text.length,60),'no-headings',BUILTIN['no-headings']);
|
|
53
|
+
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']);});
|
|
54
|
+
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))});}});
|
|
55
|
+
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']);
|
|
56
|
+
return issues.sort((a,b)=>a.block-b.block||a.start-b.start);
|
|
57
|
+
}
|