reedach 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reedach",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Лёгкая проверка русского текста на канцелярит, штампы и воду: словарь правил, работает в браузере и в Node без зависимостей",
5
5
  "license": "MIT",
6
6
  "author": "Dima Faller <dima@dimafaller.ru>",
package/src/markdown.js CHANGED
@@ -2,16 +2,33 @@
2
2
  // Markdown source → plain blocks for the checkers, and all hints merged back onto source offsets.
3
3
  const L='A-Za-zА-Яа-яЁё';
4
4
 
5
+ // Link or image target "(…)" starting at p → index of its closing ")", or -1; one level of inner
6
+ // parentheses is allowed: "(a_(b))". A scan gives the same answer to every position it passed outside
7
+ // inner parentheses, so the answers are kept and a line full of unclosed "](" stays linear.
8
+ function closing(line,p,memo){
9
+ if(line[p]!=='(')return -1;
10
+ const passed=[];let depth=0,end=-1;
11
+ for(let k=p+1;k<line.length;k++){
12
+ if(!depth){if(memo.has(k)){end=memo.get(k);break;}passed.push(k);}
13
+ const c=line[k];
14
+ if(c==='('){if(depth)break;depth=1;}
15
+ else if(c===')'){if(!depth){end=k;break;}depth=0;}
16
+ }
17
+ for(const k of passed)memo.set(k,end);
18
+ return end;
19
+ }
5
20
  // One line of Markdown → text the checkers see, with every character mapped back to its offset in the source.
21
+ // The next ">" and "]" are looked up once and reused: unclosed "<a" or "![" must not rescan the rest of the line.
6
22
  function inline(line,base,from,kind){
7
- let text='';const map=[],gaps=[],put=(c,at)=>{text+=c;map.push(at);};
23
+ let text='',gt=-1,bracket=-1,end;const map=[],gaps=[],memo=new Map(),put=(c,at)=>{text+=c;map.push(at);};
24
+ const next=(ch,i,last)=>{if(last>=i)return last;const k=line.indexOf(ch,i);return k<0?Infinity:k;};
8
25
  for(let i=from;i<line.length;i++){
9
- const c=line[i],rest=line.slice(i);let m;
26
+ const c=line[i];
10
27
  if(c==='\\'&&i+1<line.length){i++;put(line[i],base+i);continue;}
11
28
  if(c==='`'&&line.indexOf('`',i+1)>i){gaps.push(text.length);put(' ',base+i);i=line.indexOf('`',i+1);continue;}
12
- if(c==='!'&&(m=/^!\[[^\]]*\]\((?:[^()]|\([^()]*\))*\)/.exec(rest))){gaps.push(text.length);put(' ',base+i);i+=m[0].length-1;continue;}
13
- if(c===']'&&(m=/^\]\((?:[^()]|\([^()]*\))*\)/.exec(rest))){i+=m[0].length-1;continue;}
14
- if(c==='<'&&(m=/^<\/?[a-z][^>]*>/i.exec(rest))){i+=m[0].length-1;continue;}
29
+ if(c==='!'&&line[i+1]==='['&&(bracket=next(']',i+2,bracket))<Infinity&&(end=closing(line,bracket+1,memo))>=0){gaps.push(text.length);put(' ',base+i);i=end;continue;}
30
+ if(c===']'&&(end=closing(line,i+1,memo))>=0){i=end;continue;}
31
+ if(c==='<'&&/^<\/?[a-z]/i.test(line.slice(i,i+3))&&(gt=next('>',i,gt))<Infinity){i=gt;continue;}
15
32
  if(c==='['||c==='*'||c==='_'&&!(/[\p{L}\d]/u.test(line[i-1]||'')&&/[\p{L}\d]/u.test(line[i+1]||'')))continue;
16
33
  put(c,base+i);
17
34
  }
@@ -30,7 +47,8 @@ export function sourceBlocks(src){
30
47
  if((m=/^\s{0,3}#{1,6}\s+/.exec(line))){flush();const b=inline(line,start,m[0].length,'h');if(b.text.trim())blocks.push(b);continue;}
31
48
  if((m=/^\s*(?:[-*+]|\d+[.)])\s+/.exec(line.slice(quote)))){flush();cur=inline(line,start,quote+m[0].length,'li');continue;}
32
49
  const part=inline(line,start,quote,'p');
33
- if(cur){cur.gaps.push(cur.text.length,...part.gaps.map(g=>g+cur.text.length+1));cur.text+=' '+part.text;cur.map.push(start-1,...part.map);}else cur=part;
50
+ // No push(...list): a line of 100k+ characters would overflow the call stack.
51
+ if(cur){const at=cur.text.length+1;cur.gaps.push(at-1);for(const g of part.gaps)cur.gaps.push(g+at);cur.text+=' '+part.text;cur.map.push(start-1);for(const k of part.map)cur.map.push(k);}else cur=part;
34
52
  }
35
53
  flush();return blocks;
36
54
  }
package/src/rules.js CHANGED
@@ -35,7 +35,7 @@ export const RULES=[
35
35
  {id:'pronoun-dannyy',category:'pronoun',message:'Слово данный звучит канцелярски и ничего не уточняет.',fix:'Заменить на слово этот или назвать предмет прямо.',words:['данный','данная','данное','данную','данной','данного','данном','данному']},
36
36
  // Bureaucratese and nominalization
37
37
  {id:'bureaucratese-verbs',category:'bureaucratese',message:'Канцелярский оборот звучит как отписка и скрывает суть.',fix:'Заменить простым глаголом или словом: делать, для, чтобы, это.',words:['осуществл*','имеет место','имеют место','имел место','имело место','в рамках','в целях','с целью','на предмет','посредством','является','являются','являться','вышеуказанный','вышеупомянутый','нижеследующий']},
38
- {id:'bureaucratese-latinisms',category:'bureaucratese',message:'Иностранное слово там, где есть простое русское, затрудняет чтение.',fix:'Заменить обычным словом: общаться, умение, убрать, уточнить.',words:['коммуникац*','компетенц*','минимизир*','минимизац*','конкретизир*','релевантн*','нерелевантн*','конкурентоспособн*','стагнац*','конъюнктур*','деградац*','имплементир*','имплементац*','консолидир*','консолидац*','синерги*','функционал']},
38
+ {id:'bureaucratese-latinisms',category:'bureaucratese',message:'Иностранное слово там, где есть простое русское, затрудняет чтение.',fix:'Заменить обычным русским словом.',swap:{'коммуникац':'общение, связь','компетенц':'умение, знания, круг обязанностей','минимизир':'сократить, уменьшить','минимизац':'сокращение, уменьшение','конкретизир':'уточнить','релевантн':'подходящий, нужный, уместный','нерелевантн':'неподходящий, лишний','конкурентоспособн':'не уступает другим, выгоднее','стагнац':'застой','конъюнктур':'положение на рынке, спрос','деградац':'упадок, ухудшение','имплементир':'внедрить, ввести','имплементац':'внедрение','консолидир':'объединить, собрать','консолидац':'объединение','синерги':'совместная польза, выгода от объединения','функционал':'возможности, функции'},words:['коммуникац*','компетенц*','минимизир*','минимизац*','конкретизир*','релевантн*','нерелевантн*','конкурентоспособн*','стагнац*','конъюнктур*','деградац*','имплементир*','имплементац*','консолидир*','консолидац*','синерги*','функционал']},
39
39
  {id:'nominalization-support-verbs',category:'nominalization',message:'Глагол плюс существительное длиннее и тяжелее одного глагола.',fix:'Заменить одним глаголом: монтировать вместо производить монтаж.',words:['принимать участие','принять участие','принял участие','приняли участие','принимаю участие','принимаем участие','принимает участие','принимают участие','оказать помощь','оказывать помощь','оказали помощь','оказываем помощь','оказание помощи','оказать услуги','оказывать услуги','оказали услуги','оказываем услуги','оказание услуг','производить монтаж','произвести монтаж','произвели монтаж','производим монтаж','производить замену','произвести замену','произвели замену','производим замену','производить ремонт','произвести ремонт','произвели ремонт','производим ремонт','проводить работы','проводим работы','проводят работы','провести работы','провели работы']},
40
40
  // Stamps
41
41
  {id:'stamp-cliche',category:'stamp',message:'Оборот затёрт: читатель видит штамп раньше смысла.',fix:'Написать конкретный факт или действие без оборота.',words:['сочета* в себе','на каждом шагу','в сухом остатке','на помощь приходят','на помощь приходит','легко и просто','без промедления','прямиком','расширить географию','расширили географию','играет важную роль','играют важную роль','играет ключевую роль','играют ключевую роль','играет большую роль','играют большую роль','как показывает практика','на высоком уровне','на должном уровне','на новый уровень','время покажет']},
package/src/style.js CHANGED
@@ -7,6 +7,8 @@ const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
7
7
  const tokenSource=t=>t.endsWith('*')?esc(norm(t.slice(0,-1)))+'['+L+']*':esc(norm(t));
8
8
  function compile(rule){const alts=(rule.words||[]).map(w=>w.trim().split(/\s+/).map(tokenSource).join('\\s+'));return alts.length?new RegExp('(?<!['+L+'])(?:'+alts.join('|')+')(?!['+L+'])','g'):null;}
9
9
  const compiled=RULES.map(rule=>({rule,re:compile(rule)}));
10
+ // A rule with swap gives the replacement for the exact word found; the longest matching stem wins.
11
+ const swapped=(rule,word)=>{if(!rule.swap)return rule;const k=Object.keys(rule.swap).filter(k=>word.startsWith(k)).sort((a,b)=>b.length-a.length)[0];return k?{...rule,fix:'Заменить обычным словом: '+rule.swap[k]+'.'}:rule;};
10
12
  export const sentences=text=>[...text.matchAll(/[^.!?…]+(?:[.!?…]+|$)/g)].map(m=>({start:m.index,end:m.index+m[0].length,text:m[0]})).filter(s=>s.text.trim());
11
13
  const wordCount=s=>(s.match(new RegExp('['+L+'0-9]+(?:-['+L+']+)*','g'))||[]).length;
12
14
  const trimmed=(s,offset=0)=>{const lead=s.text.length-s.text.trimStart().length;return {start:s.start+offset+lead,end:s.start+offset+s.text.trimEnd().length};};
@@ -43,10 +45,14 @@ const TYPO=[
43
45
  ['typo-repeat-punct',/[!?]{3,}|!!|\?\?|,{2,}|;{2,}|(?<![.?!])\.\.(?!\.)|\.{4,}/g],
44
46
  // «И.И. Иванов», «Иванов И.И.», «А.Пушкин»; «Ф.И.О.» and «до Н.Э.» are abbreviations, not initials.
45
47
  ['typo-initials',/(?<!\p{L})[А-ЯЁ]\.[А-ЯЁ]\.(?=\s?[А-ЯЁ][а-яё])|(?<=[А-ЯЁ][а-яё]+\s)[А-ЯЁ]\.[А-ЯЁ]\.(?!\p{L})|(?<![\p{L}.])[А-ЯЁ]\.(?=[А-ЯЁ][а-яё]{2,})/gu],
46
- ['typo-mixed-script',/\p{L}*(?:\p{Script=Cyrillic}\p{Script=Latin}|\p{Script=Latin}\p{Script=Cyrillic})\p{L}*/gu],
47
48
  ];
49
+ // Mixed scripts are checked word by word: one pattern over the whole text backtracked for seconds on a long word.
50
+ const MIXED=/\p{Script=Cyrillic}\p{Script=Latin}|\p{Script=Latin}\p{Script=Cyrillic}/u;
48
51
  // Addresses, e-mails and HTML entities are not prose: no typography hints inside them.
49
- const technical=(text,s)=>{const a=text.lastIndexOf(' ',s)+1,e=text.indexOf(' ',s);return /[/@&=]/.test(text.slice(a,e<0?text.length:e));};
52
+ // Marked once per block (a looked-up word per hint made a long text with many hints quadratic).
53
+ const technical=text=>{const marks=new Uint8Array(text.length);for(const m of text.matchAll(/[^ ]+/g))if(/[/@&=]/.test(m[0]))marks.fill(1,m.index,m.index+m[0].length);return s=>marks[s]===1;};
54
+ // The last sentence starting at or before `at` (sentences are in order), by binary search.
55
+ const sentenceAt=(list,at)=>{let lo=0,hi=list.length-1,hit=null;while(lo<=hi){const mid=(lo+hi)>>1;if(list[mid].start<=at){hit=list[mid];lo=mid+1;}else hi=mid-1;}return hit;};
50
56
  // Brackets and guillemets without a pair. Not brackets: «а)», «1)», «п. 2)», smileys «:)», «:-(», «:))»;
51
57
  // a lone «»» or ««» between spaces is a navigation arrow.
52
58
  function unpaired(text){
@@ -72,10 +78,14 @@ export function checkStyle(blocks,{maxSentenceWords=25,maxParagraphWords=120}={}
72
78
  const issues=[],add=(block,start,end,id,rule)=>issues.push({block,start,end,id,category:rule.category,label:CATEGORIES[rule.category]||rule.category,message:rule.message,fix:rule.fix});
73
79
  blocks.forEach((b,i)=>{
74
80
  const text=b.text,low=norm(text),list=sentences(text);
75
- for(const {rule,re} of compiled){if(!re)continue;re.lastIndex=0;for(const m of low.matchAll(re)){if(rule.start&&!list.some(s=>s.start<=m.index&&low.slice(s.start,m.index).trim()===''))continue;add(i,m.index,m.index+m[0].length,rule.id,rule);}}
81
+ // A start-only rule needs nothing but spaces between the sentence start and the match.
82
+ for(const {rule,re} of compiled){if(!re)continue;re.lastIndex=0;for(const m of low.matchAll(re)){if(rule.start){const s=sentenceAt(list,m.index);if(!s||low.slice(s.start,m.index).trim()!=='')continue;}add(i,m.index,m.index+m[0].length,rule.id,swapped(rule,m[0]));}}
76
83
  // Blocks map every character to the source; gaps are spaces that stand for code, an image or a line break, not typos.
77
- const gap=(s,e)=>(b.gaps||[]).some(j=>j>=s&&j<e),plain=(s,e)=>(!b.map||b.map[e-1]-b.map[s]===e-1-s)&&!gap(s,e);
78
- for(const [id,re] of TYPO)for(const m of text.matchAll(re))if(plain(m.index,m.index+m[0].length)&&!technical(text,m.index))add(i,m.index,m.index+m[0].length,id,BUILTIN[id]);
84
+ // before[k] = gaps before offset k, so "a gap inside s..e" costs the same however many gaps the block has.
85
+ const before=new Uint32Array(text.length+1);for(const j of b.gaps||[])if(j>=0&&j<text.length)before[j+1]++;for(let k=1;k<=text.length;k++)before[k]+=before[k-1];
86
+ const gap=(s,e)=>before[e]-before[s]>0,plain=(s,e)=>(!b.map||b.map[e-1]-b.map[s]===e-1-s)&&!gap(s,e),tech=technical(text);
87
+ for(const [id,re] of TYPO)for(const m of text.matchAll(re))if(plain(m.index,m.index+m[0].length)&&!tech(m.index))add(i,m.index,m.index+m[0].length,id,BUILTIN[id]);
88
+ for(const m of text.matchAll(/\p{L}+/gu))if(MIXED.test(m[0])&&plain(m.index,m.index+m[0].length)&&!tech(m.index))add(i,m.index,m.index+m[0].length,'typo-mixed-script',BUILTIN['typo-mixed-script']);
79
89
  for(const m of text.matchAll(/"[^"\n]*"/g))if(/[А-Яа-яЁё]/.test(m[0]))add(i,m.index,m.index+m[0].length,'typo-quotes',BUILTIN['typo-quotes']);
80
90
  for(const k of unpaired(text))add(i,k,k+1,'typo-unpaired',BUILTIN['typo-unpaired']);
81
91
  for(const m of low.matchAll(new RegExp('(?<!['+L+'])(['+L+']+)\\s+\\1(?![-'+L+'])','g')))if(!DOUBLE_OK.has(m[1])&&plain(m.index,m.index+m[0].length))add(i,m.index,m.index+m[0].length,'word-double',BUILTIN['word-double']);