Просмотр исходного кода

Merge pull request #4228 from weseek/feat/6982-textlint

feat: Add Textlint Support
Yuki Takei 4 лет назад
Родитель
Сommit
4b468d8574
27 измененных файлов с 1584 добавлено и 39 удалено
  1. 1 0
      packages/app/.gitignore
  2. 13 1
      packages/app/bin/cdn/cdn-resources-downloader.ts
  3. 1 0
      packages/app/config/cdn.js
  4. 2 0
      packages/app/docker/Dockerfile
  5. 3 1
      packages/app/package.json
  6. 86 0
      packages/app/resource/cdn-manifests.js
  7. 37 0
      packages/app/resource/locales/en_US/translation.json
  8. 36 0
      packages/app/resource/locales/ja_JP/translation.json
  9. 37 1
      packages/app/resource/locales/zh_CN/translation.json
  10. 23 0
      packages/app/src/client/services/EditorContainer.js
  11. 286 0
      packages/app/src/components/Me/EditorSettings.tsx
  12. 7 0
      packages/app/src/components/Me/PersonalSettings.jsx
  13. 41 4
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  14. 8 1
      packages/app/src/components/PageEditor/Editor.jsx
  15. 41 0
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  16. 41 0
      packages/app/src/server/models/editor-settings.ts
  17. 1 0
      packages/app/src/server/models/user.js
  18. 92 1
      packages/app/src/server/routes/apiv3/personal-setting.js
  19. 4 4
      packages/app/src/services/cdn-resources-service.js
  20. 1 0
      packages/codemirror-textlint/.gitignore
  21. 50 0
      packages/codemirror-textlint/package.json
  22. 151 0
      packages/codemirror-textlint/src/index.ts
  23. 9 0
      packages/codemirror-textlint/src/utils/logger/index.ts
  24. 8 0
      packages/codemirror-textlint/tsconfig.base.json
  25. 17 0
      packages/codemirror-textlint/tsconfig.build.json
  26. 6 0
      packages/codemirror-textlint/tsconfig.json
  27. 582 26
      yarn.lock

+ 1 - 0
packages/app/.gitignore

@@ -6,6 +6,7 @@
 /dist/
 /transpiled/
 /public/static/js
+/public/static/dict
 /public/static/styles
 /public/uploads
 /tmp/

+ 13 - 1
packages/app/bin/cdn/cdn-resources-downloader.ts

@@ -4,7 +4,9 @@ import urljoin from 'url-join';
 import { Transform } from 'stream';
 import replaceStream from 'replacestream';
 
-import { cdnLocalScriptRoot, cdnLocalStyleRoot, cdnLocalStyleWebRoot } from '^/config/cdn';
+import {
+  cdnLocalScriptRoot, cdnLocalDictRoot, cdnLocalStyleRoot, cdnLocalStyleWebRoot,
+} from '^/config/cdn';
 import * as cdnManifests from '^/resource/cdn-manifests';
 
 import { CdnResource, CdnManifest } from '~/interfaces/cdn';
@@ -19,6 +21,15 @@ export default class CdnResourcesDownloader {
     const cdnScriptResources: CdnResource[] = cdnManifests.js.map((manifest: CdnManifest) => {
       return { manifest, outDir: cdnLocalScriptRoot };
     });
+
+    const cdnDictResources: CdnResource[] = cdnManifests.dict.map((manifest: CdnManifest) => {
+      return { manifest, outDir: cdnLocalDictRoot };
+    });
+
+    const dictExtensionOptions = {
+      ext: 'gz',
+    };
+
     const cdnStyleResources: CdnResource[] = cdnManifests.style.map((manifest) => {
       return { manifest, outDir: cdnLocalStyleRoot };
     });
@@ -31,6 +42,7 @@ export default class CdnResourcesDownloader {
 
     return Promise.all([
       this.downloadScripts(cdnScriptResources),
+      this.downloadScripts(cdnDictResources, dictExtensionOptions),
       this.downloadStyles(cdnStyleResources, dlStylesOptions),
     ]);
   }

+ 1 - 0
packages/app/config/cdn.js

@@ -4,5 +4,6 @@ import { projectRoot } from '~/utils/project-dir-utils';
 
 export const cdnLocalScriptRoot = path.join(projectRoot, 'public/static/js/cdn');
 export const cdnLocalScriptWebRoot = '/static/js/cdn';
+export const cdnLocalDictRoot = path.join(projectRoot, 'public/static/dict/cdn');
 export const cdnLocalStyleRoot = path.join(projectRoot, 'public/static/styles/cdn');
 export const cdnLocalStyleWebRoot = '/static/styles/cdn';

+ 2 - 0
packages/app/docker/Dockerfile

@@ -18,6 +18,7 @@ COPY ./yarn.lock .
 COPY ./lerna.json .
 COPY ./packages/app/package.json packages/app/
 COPY ./packages/core/package.json packages/core/
+COPY ./packages/codemirror-textlint/package.json packages/codemirror-textlint/
 COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
 COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
 COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
@@ -96,6 +97,7 @@ COPY ./tsconfig.base.json ./
 # copy all related packages
 COPY packages/app packages/app
 COPY packages/core packages/core
+COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker

+ 3 - 1
packages/app/package.json

@@ -54,6 +54,7 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
+    "@growi/codemirror-textlint": "^4.4.4-RC.0",
     "@growi/plugin-attachment-refs": "^4.4.4-RC.0",
     "@growi/plugin-pukiwiki-like-linker": "^4.4.4-RC.0",
     "@growi/plugin-lsx": "^4.4.4-RC.0",
@@ -96,7 +97,6 @@
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
     "helmet": "^4.6.0",
-    "nocache": "^3.0.1",
     "http-errors": "~1.6.2",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
@@ -114,6 +114,7 @@
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
+    "nocache": "^3.0.1",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "=2.5.0",
@@ -180,6 +181,7 @@
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
+    "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",

+ 86 - 0
packages/app/resource/cdn-manifests.js

@@ -89,6 +89,92 @@ module.exports = {
       },
     },
   ],
+  dict: [
+    {
+      name: 'base.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/base.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'cc.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/cc.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'check.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/check.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'tid_map.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid_map.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'tid_pos.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid_pos.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'tid.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_char.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_char.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_compat.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_compat.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_invoke.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_invoke.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_map.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_map.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_pos.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_pos.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+  ],
   style: [
     {
       name: 'lato',

+ 37 - 0
packages/app/resource/locales/en_US/translation.json

@@ -255,6 +255,43 @@
       "This tree": "Only children of this tree"
     }
   },
+  "editor_settings": {
+    "editor_settings": "Editor Settings",
+    "common_settings": {
+      "common_settings": "Common Settings",
+      "common_misspellings": "Textlint rules to find common misspellings from Wikipedia.",
+      "max_comma": "Textlint rule is that limit maximum ten(,) count of sentence. Default: 4",
+      "sentence_length": "Textlint rules that limit Maximum Length of Sentence. Default: 100",
+      "en_capitalization": "Textlint rule that check capitalization in english text.",
+      "no_unmatched_pair": "Textlint rule that check unmatched pairs like ( and ]",
+      "date_weekday_mismatch": "Textlint rule that found mismatch between date and weekday.",
+      "no_kangxi_radicals": "Textlint rule to prevent using kangxi radicals.",
+      "no_surrogate_pair": "Detects surrogate pairs (D800-DBFF and DC00-DFFF) in sentences.",
+      "no_zero_width_spaces": "Textlint rule that disallow zero width spaces.",
+      "period_in_list_item": "Textlint rule that check with or without period in list item.",
+      "use_si_units": "Use of units other than SI unit units is prohibited."
+
+      },
+    "japanese_settings": {
+      "japanese_settings": "Japanese Settings",
+      "ja_no_abusage": "Textlint rules to check for common misuse.",
+      "ja_hiragana_keishikimeishi": "Textlint rules to check easy-to-read Keishikimeishi(pronouns) written in Hiragana than Kanji.",
+      "ja_no_inappropriate_words": "Textlint rules to check for inappropriate expressions",
+      "ja_no_mixed_period": "Textlint rules to check that a paragraph always has a punctuation mark at the end.",
+      "ja_no_redundant_expression": "Textlint rules that prohibits redundant expressions. Redundant expressions are expressions that make sense even if they are omitted from the sentence.",
+      "max_kanji_continuous_len": "Textlint rules that limits the maximum number of consecutive Kanji. Default: 5",
+      "max_ten": "Textlint rule is that limit maximum ten(、) count of sentence.",
+      "no_double_negative_ja": "Textlint rules that detects double negation.",
+      "no_doubled_conjunction": "Textlint rules to check duplicated same conjunctions.",
+      "no_doubled_joshi": "Textlint rules that checks that the same particle appears consecutively in one sentence.",
+      "no_dropping_the_ra": "Textlint rules that detects the word dropping the ra.",
+      "no_hankaku_kana": "Textlint rules that disallow to use Half-width kana.",
+      "prefer_tari_tari": "Textlint rules that checks tari tari.",
+      "ja_unnatural_alphabet": "Detects unnatural alphabets.",
+      "no_mixed_zenkaku_and_hankaku_alphabet": "Check for mixed full-width and half-width alphabets.",
+      "no_nfd": "textlint rule that disallow to use NFD like UTF8-MAC Sonant mark."
+    }
+  },
   "copy_to_clipboard": {
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",

+ 36 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -258,6 +258,42 @@
       "This tree": "この階層下の子ページのみ"
     }
   },
+  "editor_settings": {
+    "editor_settings": "エディター設定",
+    "common_settings": {
+      "common_settings": "共通設定",
+      "common_misspellings": "ウィキペディアから一般的なスペルミスを見つけます。",
+      "max_comma": "文の読点(,)を最大10個に制限します。初期値: 4。",
+      "sentence_length": "文の最大文字数を制限します。初期値: 100。",
+      "en_capitalization": "英文の大文字化をチェックします",
+      "no_unmatched_pair": "( と ] のような一致しないペアをチェックします",
+      "date_weekday_mismatch": "日付と平日の不一致を検出します。",
+      "no_kangxi_radicals": "康熙帝の部首の使用を防ぎます。",
+      "no_surrogate_pair": "文中のサロゲートペア(D800-DBFFおよびDC00-DFFF)を検出します。",
+      "no_zero_width_spaces": "ゼロ幅スペースを許可しません。",
+      "period_in_list_item": "リストアイテムのピリオドの有無をチェックします。",
+      "use_si_units": "SI単位系以外の使用を禁止します。"
+      },
+    "japanese_settings": {
+      "japanese_settings": "日本語設定",
+      "ja_hiragana_keishikimeishi": "漢字よりひらがなで書かれた読みやすい形式名詞をチェックします。",
+      "ja_no_abusage": "よくある誤用をチェックします。",
+      "ja_no_inappropriate_words": "不適切表現をチェックします。",
+      "ja_no_mixed_period": "パラグラフの末尾に必ず句点記号を付けていることをチェックします。",
+      "ja_no_redundant_expression": "冗長な表現を禁止します。冗長な表現とは、その文から省いても意味が通じるような表現を示しています。",
+      "max_kanji_continuous_len": "漢字が連続する最大文字数を制限します。初期値: 5。",
+      "max_ten": "一文に利用できる、の数を制限します。一文の読点の数が多いと冗長で読みにくい文章となるため、読点の数を一定数以下にするルールです。 読点の数を減らすためには、句点(。)で文を区切る必要があります。",
+      "no_double_negative_ja": "二重否定を検出します。",
+      "no_doubled_conjunction": "同じ接続詞が連続して出現していないかどうかをチェックします。",
+      "no_doubled_joshi": "1つの文中に同じ助詞が連続して出てくるのをチェックします。",
+      "no_dropping_the_ra": "ら抜き言葉を検出します。",
+      "no_hankaku_kana": "半角カナの利用を禁止します。",
+      "prefer_tari_tari": "「〜たり〜たりする」をチェックします。",
+      "ja_unnatural_alphabet": "不自然なアルファベットを検知します。",
+      "no_mixed_zenkaku_and_hankaku_alphabet": "全角と半角アルファベットを混在をチェックします。",
+      "no_nfd": "UTF8-MAC濁点のようなNFDの使用を禁止します。"
+    }
+  },
   "copy_to_clipboard": {
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",

+ 37 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -236,7 +236,43 @@
 			"All pages": "所有页面",
 			"This tree": "当前分支以下内容"
 		}
-	},
+  },
+  "editor_settings": {
+    "editor_settings": "编辑器设置",
+    "common_settings": {
+      "common_settings": "常用设置",
+      "common_misspellings": "从 Wikipedia 中查找常见拼写错误的 Textlint。",
+      "max_comma": "Textlint 规则是限制句子的最大十(,)个计数。默认:4。",
+      "sentence_length": "限制最大句子长度的 Textlint 默认: 100。",
+      "en_capitalization": "检查英文文本大小写的 Textlint 规则。",
+      "no_unmatched_pair": "检查不匹配对的 Textlint 规则,如 ( 和 ]",
+      "date_weekday_mismatch": "发现日期和工作日之间不匹配的 Textlint 规则。",
+      "no_kangxi_radicals": "防止使用康熙部首的 Textlint 规则。",
+      "no_surrogate_pair": "检测句子中的代理对(D800-DBFF 和 DC00-DFFF)。",
+      "no_zero_width_spaces": "不允许零宽度空格的 Textlint 规则。",
+      "period_in_list_item": "在列表项中检查是否有句点的 Textlint 规则。",
+      "use_si_units": "禁止使用 SI 单位以外的单位。"
+      },
+    "japanese_settings": {
+      "japanese_settings": "日语设置",
+      "ja_no_abusage": "用于检查常见误用的 Textlint 规则。",
+      "ja_hiragana_keishikimeishi": "Textlint 规则检查易于阅读的 Keishikimeishi(代词)用平假名而不是汉字编写。",
+      "ja_no_inappropriate_words": "Textlint 规则来检查不适当的表达",
+      "ja_no_mixed_period": "Textlint 规则用于检查段落末尾是否总是有标点符号。",
+      "ja_no_redundant_expression": "禁止冗余表达式的 Textlint 规则。冗余表达式是即使从句子中省略也有意义的表达式。",
+      "max_kanji_continuous_len": "限制连续汉字的最大数量的 Textlint 规则。默认:5。",
+      "max_ten": "Textlint 规则是限制句子的最大十(、)个计数。",
+      "no_double_negative_ja": "检测双重否定的 Textlint 规则。",
+      "no_doubled_conjunction": "Textlint 规则来检查重复的相同连词。",
+      "no_doubled_joshi": "Textlint 规则,用于检查同一个粒子是否连续出现在一个句子中。",
+      "no_dropping_the_ra": "检测丢弃 ra 的单词的 Textlint 规则。",
+      "no_hankaku_kana": "不允许使用半角假名的 Textlint 规则。",
+      "prefer_tari_tari": "检查 tari tari 的 Textlint 规则。",
+      "ja_unnatural_alphabet": "检测不自然的字母。",
+      "no_mixed_zenkaku_and_hankaku_alphabet": "检查混合的全角和半角字母。",
+      "no_nfd": "禁止使用 UTF8-MAC 浊音等 NFD。"
+    }
+  },
 	"copy_to_clipboard": {
 		"Copy to clipboard": "复制到剪贴板",
 		"Page path": "页面路径",

+ 23 - 0
packages/app/src/client/services/EditorContainer.js

@@ -15,6 +15,7 @@ export default class EditorContainer extends Container {
 
     this.appContainer = appContainer;
     this.appContainer.registerContainer(this);
+    this.retrieveEditorSettings = this.retrieveEditorSettings.bind(this);
 
     const mainContent = document.querySelector('#content-main');
 
@@ -35,6 +36,9 @@ export default class EditorContainer extends Container {
 
       editorOptions: {},
       previewOptions: {},
+      isTextlintEnabled: false,
+      textlintRules: [],
+
       indentSize: this.appContainer.config.adminPreferredIndentSize || 4,
     };
 
@@ -195,4 +199,23 @@ export default class EditorContainer extends Container {
     return null;
   }
 
+
+  /**
+   * Retrieve Editor Settings
+   */
+  async retrieveEditorSettings() {
+    const { data } = await this.appContainer.apiv3Get('/personal-setting/editor-settings');
+
+    if (data?.textlintSettings == null) {
+      return;
+    }
+
+    const { isTextlintEnabled = false, textlintRules = [] } = data.textlintSettings;
+
+    this.setState({
+      isTextlintEnabled,
+      textlintRules,
+    });
+  }
+
 }

+ 286 - 0
packages/app/src/components/Me/EditorSettings.tsx

@@ -0,0 +1,286 @@
+import React, {
+  Dispatch,
+  FC, SetStateAction, useCallback, useEffect, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+type EditorSettingsBodyProps = {
+  appContainer: AppContainer
+}
+
+type RuleListGroupProps = {
+  title: string;
+  ruleList: RulesMenuItem[]
+  textlintRules: LintRule[]
+  setTextlintRules: Dispatch<SetStateAction<LintRule[]>>
+}
+
+type LintRule = {
+  name: string
+  options?: unknown
+  isEnabled?: boolean
+}
+
+type RulesMenuItem = {
+  name: string
+  description: string
+}
+
+
+const commonRulesMenuItems = [
+  {
+    name: 'common-misspellings',
+    description: 'editor_settings.common_settings.common_misspellings',
+  },
+  {
+    name: 'max-comma',
+    description: 'editor_settings.common_settings.max_comma',
+  },
+  {
+    name: 'sentence-length',
+    description: 'editor_settings.common_settings.sentence_length',
+  },
+  {
+    name: 'en-capitalization',
+    description: 'editor_settings.common_settings.en_capitalization',
+  },
+  {
+    name: 'no-unmatched-pair',
+    description: 'editor_settings.common_settings.no_unmatched_pair',
+  },
+  {
+    name: 'date-weekday-mismatch',
+    description: 'editor_settings.common_settings.date_weekday_mismatch',
+  },
+  {
+    name: 'no-kangxi-radicals',
+    description: 'editor_settings.common_settings.no_kangxi_radicals',
+  },
+  {
+    name: 'no-surrogate-pair',
+    description: 'editor_settings.common_settings.no_surrogate_pair',
+  },
+  {
+    name: 'no-zero-width-spaces',
+    description: 'editor_settings.common_settings.no_zero_width_spaces',
+  },
+  {
+    name: 'period-in-list-item',
+    description: 'editor_settings.common_settings.period_in_list_item',
+  },
+  {
+    name: 'use-si-units',
+    description: 'editor_settings.common_settings.use_si_units',
+  },
+];
+
+const japaneseRulesMenuItems = [
+  {
+    name: 'ja-hiragana-keishikimeishi',
+    description: 'editor_settings.japanese_settings.ja_hiragana_keishikimeishi',
+  },
+  {
+    name: 'ja-no-abusage',
+    description: 'editor_settings.japanese_settings.ja_no_abusage',
+  },
+  {
+    name: 'ja-no-inappropriate-words',
+    description: 'editor_settings.japanese_settings.ja_no_inappropriate_words',
+  },
+  {
+    name: 'ja-no-mixed-period',
+    description: 'editor_settings.japanese_settings.ja_no_mixed_period',
+  },
+  {
+    name: 'ja-no-redundant-expression',
+    description: 'editor_settings.japanese_settings.ja_no_redundant_expression',
+  },
+  {
+    name: 'max-kanji-continuous-len',
+    description: 'editor_settings.japanese_settings.max_kanji_continuous_len',
+  },
+  {
+    name: 'max-ten',
+    description: 'editor_settings.japanese_settings.max_ten',
+  },
+  {
+    name: 'no-double-negative-ja',
+    description: 'editor_settings.japanese_settings.no_double_negative_ja',
+  },
+  {
+    name: 'no-doubled-conjunction',
+    description: 'editor_settings.japanese_settings.no_doubled_conjunction',
+  },
+  {
+    name: 'no-doubled-joshi',
+    description: 'editor_settings.japanese_settings.no_doubled_joshi',
+  },
+  {
+    name: 'no-dropping-the-ra',
+    description: 'editor_settings.japanese_settings.no_dropping_the_ra',
+  },
+  {
+    name: 'no-hankaku-kana',
+    description: 'editor_settings.japanese_settings.no_hankaku_kana',
+  },
+  {
+    name: 'prefer-tari-tari',
+    description: 'editor_settings.japanese_settings.prefer_tari_tari',
+  },
+  {
+    name: 'ja-unnatural-alphabet',
+    description: 'editor_settings.japanese_settings.ja_unnatural_alphabet',
+  },
+  {
+    name: 'no-mixed-zenkaku-and-hankaku-alphabet',
+    description: 'editor_settings.japanese_settings.no_mixed_zenkaku_and_hankaku_alphabet',
+  },
+  {
+    name: 'no-nfd',
+    description: 'editor_settings.japanese_settings.no_nfd',
+  },
+
+];
+
+
+const RuleListGroup: FC<RuleListGroupProps> = ({
+  title, ruleList, textlintRules, setTextlintRules,
+}) => {
+  const { t } = useTranslation();
+
+  const isCheckedRule = (ruleName: string) => (
+    textlintRules.find(stateRule => (
+      stateRule.name === ruleName
+    ))?.isEnabled || false
+  );
+
+  const ruleCheckboxHandler = (isChecked: boolean, ruleName: string) => {
+    setTextlintRules(prevState => (
+      prevState.filter(rule => rule.name !== ruleName).concat({ name: ruleName, isEnabled: isChecked })
+    ));
+  };
+
+  return (
+    <>
+      <h2 className="border-bottom my-4">{t(title)}</h2>
+      <div className="form-group row">
+        <div className="offset-md-3 col-md-6 text-left">
+          {ruleList.map(rule => (
+            <div
+              key={rule.name}
+              className="custom-control custom-switch custom-checkbox-success"
+            >
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id={rule.name}
+                checked={isCheckedRule(rule.name)}
+                onChange={e => ruleCheckboxHandler(e.target.checked, rule.name)}
+              />
+              <label className="custom-control-label" htmlFor={rule.name}>
+                <strong>{rule.name}</strong>
+              </label>
+              <p className="form-text text-muted small">
+                {t(rule.description)}
+              </p>
+            </div>
+          ))}
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+RuleListGroup.propTypes = {
+  title: PropTypes.string.isRequired,
+  ruleList: PropTypes.array.isRequired,
+  textlintRules: PropTypes.array.isRequired,
+  setTextlintRules: PropTypes.func.isRequired,
+};
+
+
+const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
+  const { t } = useTranslation();
+  const { appContainer } = props;
+  const [textlintRules, setTextlintRules] = useState<LintRule[]>([]);
+
+  const initializeEditorSettings = useCallback(async() => {
+    const { data } = await appContainer.apiv3Get('/personal-setting/editor-settings');
+    const retrievedRules: LintRule[] = data?.textlintSettings?.textlintRules;
+
+    // If database is empty, add default rules to state
+    if (retrievedRules != null && retrievedRules.length > 0) {
+      setTextlintRules(retrievedRules);
+    }
+    else {
+      const createRulesFromDefaultList = (rule: { name: string }) => (
+        {
+          name: rule.name,
+          isEnabled: true,
+        }
+      );
+
+      const defaultCommonRules = commonRulesMenuItems.map(rule => createRulesFromDefaultList(rule));
+      const defaultJapaneseRules = japaneseRulesMenuItems.map(rule => createRulesFromDefaultList(rule));
+      setTextlintRules([...defaultCommonRules, ...defaultJapaneseRules]);
+    }
+
+  }, [appContainer]);
+
+  useEffect(() => {
+    initializeEditorSettings();
+  }, [initializeEditorSettings]);
+
+  const updateRulesHandler = async() => {
+    try {
+      const { data } = await appContainer.apiv3Put('/personal-setting/editor-settings', { textlintSettings: { textlintRules: [...textlintRules] } });
+      setTextlintRules(data.textlintSettings.textlintRules);
+      toastSuccess(t('toaster.update_successed', { target: 'Updated Textlint Settings' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <>
+      <RuleListGroup
+        title="editor_settings.common_settings.common_settings"
+        ruleList={commonRulesMenuItems}
+        textlintRules={textlintRules}
+        setTextlintRules={setTextlintRules}
+      />
+      <RuleListGroup
+        title="editor_settings.japanese_settings.japanese_settings"
+        ruleList={japaneseRulesMenuItems}
+        textlintRules={textlintRules}
+        setTextlintRules={setTextlintRules}
+      />
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            type="button"
+            className="btn btn-primary"
+            onClick={updateRulesHandler}
+          >
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export const EditorSettings = withUnstatedContainers(EditorSettingsBody, [AppContainer]);
+
+EditorSettingsBody.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};

+ 7 - 0
packages/app/src/components/Me/PersonalSettings.jsx

@@ -8,6 +8,7 @@ import UserSettings from './UserSettings';
 import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import ApiSettings from './ApiSettings';
+import { EditorSettings } from './EditorSettings';
 
 const PersonalSettings = (props) => {
 
@@ -39,6 +40,12 @@ const PersonalSettings = (props) => {
         i18n: t('API Settings'),
         index: 3,
       },
+      editor_settings: {
+        Icon: () => <i className="icon-fw icon-pencil"></i>,
+        Content: EditorSettings,
+        i18n: t('editor_settings.editor_settings'),
+        index: 4,
+      },
     };
   }, [t]);
 

+ 41 - 4
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -7,9 +7,12 @@ import * as codemirror from 'codemirror';
 import { Button } from 'reactstrap';
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
+import { JSHINT } from 'jshint';
+
 import * as loadScript from 'simple-load-script';
 import * as loadCssSync from 'load-css-file';
 
+import { createValidator } from '@growi/codemirror-textlint';
 import InterceptorManager from '~/services/interceptor-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -30,6 +33,9 @@ import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
 
+
+window.JSHINT = JSHINT;
+
 // set save handler
 codemirror.commands.save = (instance) => {
   if (instance.codeMirrorEditor != null) {
@@ -56,6 +62,8 @@ require('codemirror/addon/fold/foldgutter.css');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/addon/display/placeholder');
+require('codemirror/addon/lint/lint');
+require('codemirror/addon/lint/lint.css');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/gfm-growi.mode');
 // import modes to highlight
@@ -144,8 +152,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   init() {
     this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0';
-    this.cmNoCdnScriptRoot = '/js/cdn';
-    this.cmNoCdnStyleRoot = '/styles/cdn';
+    this.cmNoCdnScriptRoot = '/static/js/cdn';
+    this.cmNoCdnStyleRoot = '/static/styles/cdn';
+    window.kuromojin = this.props.noCdn
+      ? { dicPath: '/static/dict/cdn' }
+      : { dicPath: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict' };
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([
@@ -162,6 +173,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
       this.emojiAutoCompleteHelper = new EmojiAutoCompleteHelper(this.props.emojiStrategy);
       this.setState({ isEnabledEmojiAutoComplete: true });
     }
+
+    this.initializeTextlint();
   }
 
   componentDidMount() {
@@ -187,6 +200,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.setKeymapMode(keymapMode);
   }
 
+  async initializeTextlint() {
+    if (this.props.onInitializeTextlint != null) {
+      await this.props.onInitializeTextlint();
+      // If database has empty array, pass null instead to enable all default rules
+      const rulesForValidator = this.props.textlintRules?.length !== 0 ? this.props.textlintRules : null;
+      this.textlintValidator = createValidator(rulesForValidator);
+      this.codemirrorLintConfig = { getAnnotations: this.textlintValidator, async: true };
+    }
+  }
+
   getCodeMirror() {
     return this.cm.editor;
   }
@@ -860,9 +883,17 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   render() {
     const mode = this.state.isGfmMode ? 'gfm-growi' : undefined;
+    const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
+    const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
 
-    const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
+    const gutters = [];
+    if (this.props.lineNumbers != null) {
+      gutters.push('CodeMirror-linenumbers', 'CodeMirror-foldgutter');
+    }
+    if (this.props.isTextlintEnabled === true) {
+      gutters.push('CodeMirror-lint-markers');
+    }
 
     return (
       <React.Fragment>
@@ -893,7 +924,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
             matchTags: { bothTags: true },
             // folding
             foldGutter: this.props.lineNumbers,
-            gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
+            gutters,
             // match-highlighter, matchesonscrollbar, annotatescrollbar options
             highlightSelectionMatches: { annotateScrollbar: true },
             // continuelist, indentlist
@@ -905,6 +936,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
               'Shift-Tab': 'indentLess',
               'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
             },
+            lint,
           }}
           onCursor={this.cursorHandler}
           onScroll={(editor, data) => {
@@ -953,11 +985,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 CodeMirrorEditor.propTypes = Object.assign({
   editorOptions: PropTypes.object.isRequired,
+  isTextlintEnabled: PropTypes.bool,
+  textlintRules: PropTypes.array,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
   onMarkdownHelpButtonClicked: PropTypes.func,
   onAddAttachmentButtonClicked: PropTypes.func,
+  onInitializeTextlint: PropTypes.func,
 }, AbstractEditor.propTypes);
+
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,
+  isTextlintEnabled: false,
 };

+ 8 - 1
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,7 @@ import {
 import Dropzone from 'react-dropzone';
 
 import EditorContainer from '~/client/services/EditorContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 import Cheatsheet from './Cheatsheet';
 import AbstractEditor from './AbstractEditor';
@@ -18,7 +19,7 @@ import TextAreaEditor from './TextAreaEditor';
 
 import pasteHelper from './PasteHelper';
 
-export default class Editor extends AbstractEditor {
+class Editor extends AbstractEditor {
 
   constructor(props) {
     super(props);
@@ -316,6 +317,9 @@ export default class Editor extends AbstractEditor {
                         ref={(c) => { this.cmEditor = c }}
                         indentSize={editorContainer.state.indentSize}
                         editorOptions={editorContainer.state.editorOptions}
+                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                        textlintRules={editorContainer.state.textlintRules}
+                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}
                         onMarkdownHelpButtonClicked={this.showMarkdownHelp}
@@ -377,4 +381,7 @@ Editor.propTypes = Object.assign({
   emojiStrategy: PropTypes.object,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 }, AbstractEditor.propTypes);
+
+export default withUnstatedContainers(Editor, [EditorContainer]);

+ 41 - 0
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -10,6 +10,7 @@ import {
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import { toastError } from '~/client/util/apiNotification';
 
 
 export const defaultEditorOptions = {
@@ -51,6 +52,8 @@ class OptionsSelector extends React.Component {
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
+    this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
+    this.updateIsTextlintEnabledToDB = this.updateIsTextlintEnabledToDB.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
     this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
   }
@@ -111,6 +114,23 @@ class OptionsSelector extends React.Component {
     editorContainer.saveOptsToLocalStorage();
   }
 
+  async updateIsTextlintEnabledToDB(newVal) {
+    const { appContainer } = this.props;
+    try {
+      await appContainer.apiv3Put('/personal-setting/editor-settings', { textlintSettings: { isTextlintEnabled: newVal } });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  async switchTextlintEnabledHandler() {
+    const { editorContainer } = this.props;
+    const newVal = !editorContainer.state.isTextlintEnabled;
+    editorContainer.setState({ isTextlintEnabled: newVal });
+    this.updateIsTextlintEnabledToDB(newVal);
+  }
+
   onToggleConfigurationDropdown(newValue) {
     this.setState({ isCddMenuOpened: !this.state.isCddMenuOpened });
   }
@@ -207,6 +227,7 @@ class OptionsSelector extends React.Component {
             {this.renderActiveLineMenuItem()}
             {this.renderRealtimeMathJaxMenuItem()}
             {this.renderMarkdownTableAutoFormattingMenuItem()}
+            {this.renderIsTextlintEnabledMenuItem()}
             {/* <DropdownItem divider /> */}
           </DropdownMenu>
 
@@ -286,6 +307,26 @@ class OptionsSelector extends React.Component {
     );
   }
 
+  renderIsTextlintEnabledMenuItem() {
+    const isActive = this.props.editorContainer.state.isTextlintEnabled;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={this.switchTextlintEnabledHandler}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">Textlint</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }
+
   renderIndentSizeSelector() {
     const { appContainer, editorContainer } = this.props;
     const menuItems = this.typicalIndentSizes.map((indent) => {

+ 41 - 0
packages/app/src/server/models/editor-settings.ts

@@ -0,0 +1,41 @@
+import {
+  Schema, Model, Document,
+} from 'mongoose';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+
+export interface ILintRule {
+  name: string;
+  options?: unknown;
+  isEnabled?: boolean;
+}
+
+export interface ITextlintSettings {
+  isTexlintEnabled: string;
+  textlintRules: ILintRule[];
+}
+
+export interface IEditorSettings {
+  userId: Schema.Types.ObjectId;
+  textlintSettings: ITextlintSettings;
+}
+
+export interface EditorSettingsDocument extends IEditorSettings, Document {}
+export type EditorSettingsModel = Model<EditorSettingsDocument>
+
+const textlintSettingsSchema = new Schema<ITextlintSettings>({
+  isTextlintEnabled: { type: Boolean, default: false },
+  textlintRules: {
+    type: [
+      { name: { type: String }, options: { type: Object }, isEnabled: { type: Boolean } },
+    ],
+  },
+});
+
+const editorSettingsSchema = new Schema<IEditorSettings>({
+  userId: { type: String },
+  textlintSettings: textlintSettingsSchema,
+});
+
+
+export default getOrCreateModel<EditorSettingsDocument, EditorSettingsModel>('EditorSettings', editorSettingsSchema);

+ 1 - 0
packages/app/src/server/models/user.js

@@ -35,6 +35,7 @@ module.exports = function(crowi) {
     userEvent.on('activated', userEvent.onActivated);
   }
 
+
   const userSchema = new mongoose.Schema({
     userId: String,
     image: String,

+ 92 - 1
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -4,6 +4,8 @@ import loggerFactory from '~/utils/logger';
 
 import { listLocaleIds } from '~/utils/locale-utils';
 
+import EditorSettings from '../../models/editor-settings';
+
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 
 const express = require('express');
@@ -15,7 +17,7 @@ const router = express.Router();
 /**
  * @swagger
  *  tags:
- *    name: PsersonalSetting
+ *    name: PersonalSetting
  */
 
 /**
@@ -98,6 +100,13 @@ module.exports = (crowi) => {
       body('providerType').isString().not().isEmpty(),
       body('accountId').isString().not().isEmpty(),
     ],
+    editorSettings: [
+      body('textlintSettings.isTextlintEnabled').optional().isBoolean(),
+      body('textlintSettings.textlintRules.*.name').optional().isString(),
+      body('textlintSettings.textlintRules.*.options').optional(),
+      body('textlintSettings.textlintRules.*.isEnabled').optional().isBoolean(),
+
+    ],
   };
 
   /**
@@ -459,5 +468,87 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+   * @swagger
+   *
+   *    /personal-setting/editor-settings:
+   *      put:
+   *        tags: [EditorSetting]
+   *        operationId: putEditorSettings
+   *        summary: /editor-setting
+   *        description: Put editor preferences
+   *        responses:
+   *          200:
+   *            description: params of editor settings
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: editor settings
+   */
+  router.put('/editor-settings', accessTokenParser, loginRequiredStrictly, csrf, validator.editorSettings, apiV3FormValidator, async(req, res) => {
+    const query = { userId: req.user.id };
+    const textlintSettings = req.body.textlintSettings;
+    const document = {};
+
+    if (textlintSettings == null) {
+      return res.apiv3Err('no-settings-found');
+    }
+
+    if (textlintSettings.isTextlintEnabled != null) {
+      Object.assign(document, { 'textlintSettings.isTextlintEnabled': textlintSettings.isTextlintEnabled });
+    }
+    if (textlintSettings.textlintRules != null) {
+      Object.assign(document, { 'textlintSettings.textlintRules': textlintSettings.textlintRules });
+    }
+
+    // Insert if document does not exist, and return new values
+    // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
+    const options = { upsert: true, new: true };
+    try {
+      const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
+      return res.apiv3(response);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('updating-editor-settings-failed');
+    }
+  });
+
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/editor-settings:
+   *      get:
+   *        tags: [EditorSetting]
+   *        operationId: getEditorSettings
+   *        summary: /editor-setting
+   *        description: Get editor preferences
+   *        responses:
+   *          200:
+   *            description: params of editor settings
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: editor settings
+   */
+  router.get('/editor-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    try {
+      const query = { userId: req.user.id };
+      const response = await EditorSettings.findOne(query);
+      return res.apiv3(response);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('getting-editor-settings-failed');
+    }
+  });
+
   return router;
 };

+ 4 - 4
packages/app/src/services/cdn-resources-service.js

@@ -6,10 +6,10 @@ const urljoin = require('url-join');
 
 const { envUtils } = require('growi-commons');
 
-const cdnLocalScriptRoot = 'public/js/cdn';
-const cdnLocalScriptWebRoot = '/js/cdn';
-const cdnLocalStyleRoot = 'public/styles/cdn';
-const cdnLocalStyleWebRoot = '/styles/cdn';
+const cdnLocalScriptRoot = 'public/static/js/cdn';
+const cdnLocalScriptWebRoot = '/static/js/cdn';
+const cdnLocalStyleRoot = 'public/static/styles/cdn';
+const cdnLocalStyleWebRoot = '/static/styles/cdn';
 
 
 class CdnResourcesService {

+ 1 - 0
packages/codemirror-textlint/.gitignore

@@ -0,0 +1 @@
+/dist

+ 50 - 0
packages/codemirror-textlint/package.json

@@ -0,0 +1,50 @@
+{
+  "name": "@growi/codemirror-textlint",
+  "version": "4.4.4-RC.0",
+  "license": "MIT",
+  "main": "dist/index.js",
+  "scripts": {
+    "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
+    "tsc": "tsc -p tsconfig.build.json",
+    "tsc:w": "yarn tsc -w",
+    "lint": "eslint src --ext .ts",
+    "lint:fix": "eslint src --ext .ts --fix"
+  },
+  "dependencies": {},
+  "devDependencies": {
+    "@textlint-rule/textlint-rule-no-unmatched-pair": "^1.0.8",
+    "@textlint/kernel": "^12.0.2",
+    "@types/codemirror": "^5.60.2",
+    "textlint-message-to-codemirror": "^1.0.0",
+    "textlint-plugin-markdown": "^4.0.6",
+    "textlint-rule-common-misspellings": "^1.0.1",
+    "textlint-rule-date-weekday-mismatch": "^1.0.6",
+    "textlint-rule-en-capitalization": "^2.0.3",
+    "textlint-rule-ja-hiragana-keishikimeishi": "^1.1.0",
+    "textlint-rule-ja-no-abusage": "^3.0.0",
+    "textlint-rule-ja-no-inappropriate-words": "^2.0.0",
+    "textlint-rule-ja-no-mixed-period": "^2.1.1",
+    "textlint-rule-ja-no-redundant-expression": "^4.0.0",
+    "textlint-rule-ja-unnatural-alphabet": "^2.0.1",
+    "textlint-rule-max-comma": "^2.0.2",
+    "textlint-rule-max-kanji-continuous-len": "^1.1.1",
+    "textlint-rule-max-ten": "^4.0.2",
+    "textlint-rule-no-double-negative-ja": "^2.0.0",
+    "textlint-rule-no-doubled-conjunction": "^2.0.2",
+    "textlint-rule-no-doubled-joshi": "^4.0.0",
+    "textlint-rule-no-dropping-the-ra": "^3.0.0",
+    "textlint-rule-no-hankaku-kana": "^1.0.2",
+    "textlint-rule-no-kangxi-radicals": "^0.2.0",
+    "textlint-rule-no-mixed-zenkaku-and-hankaku-alphabet": "^1.0.1",
+    "textlint-rule-no-nfd": "^1.0.2",
+    "textlint-rule-no-surrogate-pair": "^1.0.1",
+    "textlint-rule-no-zero-width-spaces": "^1.0.1",
+    "textlint-rule-period-in-list-item": "^0.3.2",
+    "textlint-rule-prefer-tari-tari": "^1.0.3",
+    "textlint-rule-sentence-length": "^3.0.0",
+    "textlint-rule-use-si-units": "^1.0.2"
+  },
+  "peerDependencies": {
+    "codemirror": "^5.62.3"
+  }
+}

+ 151 - 0
packages/codemirror-textlint/src/index.ts

@@ -0,0 +1,151 @@
+import { TextlintKernel, TextlintKernelRule, TextlintRuleOptions } from '@textlint/kernel';
+import textlintToCodeMirror from 'textlint-message-to-codemirror';
+import textlintRuleNoUnmatchedPair from '@textlint-rule/textlint-rule-no-unmatched-pair';
+import textlintRuleCommonMisspellings from 'textlint-rule-common-misspellings';
+import textlintRuleDateWeekdayMismatch from 'textlint-rule-date-weekday-mismatch';
+import textlintRuleEnCapitalization from 'textlint-rule-en-capitalization';
+import textlintRuleJaHiraganaKeishikimeishi from 'textlint-rule-ja-hiragana-keishikimeishi';
+import textlintRuleJaNoAbusage from 'textlint-rule-ja-no-abusage';
+import textlintRuleJaNoInappropriateWords from 'textlint-rule-ja-no-inappropriate-words';
+import textlintRuleJaNoMixedPeriod from 'textlint-rule-ja-no-mixed-period';
+import textlintRuleJaNoRedundantExpression from 'textlint-rule-ja-no-redundant-expression';
+import textlintRuleJaUnnaturalAlphabet from 'textlint-rule-ja-unnatural-alphabet';
+import textlintRuleMaxComma from 'textlint-rule-max-comma';
+import textlintRuleMaxKanjiContinuousLen from 'textlint-rule-max-kanji-continuous-len';
+import textlintRuleMaxTen from 'textlint-rule-max-ten';
+import textlintRuleNoDoubleNegativeJa from 'textlint-rule-no-double-negative-ja';
+import textlintRuleNoDoubledConjunction from 'textlint-rule-no-doubled-conjunction';
+import textlintRuleNoDoubledJoshi from 'textlint-rule-no-doubled-joshi';
+import textlintRuleNoDroppingTheRa from 'textlint-rule-no-dropping-the-ra';
+import textlintRuleNoHankakuKana from 'textlint-rule-no-hankaku-kana';
+import textlintRuleNoKangxiRadicals from 'textlint-rule-no-kangxi-radicals';
+import textlintRuleNoMixedZenkakuAndHankakuAlphabet from 'textlint-rule-no-mixed-zenkaku-and-hankaku-alphabet';
+import textlintRuleNoNfd from 'textlint-rule-no-nfd';
+import textlintRuleNoSurrogatePair from 'textlint-rule-no-surrogate-pair';
+import textlintRuleNoZeroWidthSpaces from 'textlint-rule-no-zero-width-spaces';
+import textlintRulePeriodInListItem from 'textlint-rule-period-in-list-item';
+import textlintRulePreferTariTari from 'textlint-rule-prefer-tari-tari';
+import textlintRuleSentenceLength from 'textlint-rule-sentence-length';
+import textlintRuleUseSiUnits from 'textlint-rule-use-si-units';
+
+import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
+import { loggerFactory } from './utils/logger';
+
+type RulesConfigObj = {
+  name: string,
+  options?: unknown,
+  isEnabled?: boolean,
+}
+
+type RuleExtension = {
+  ext: string
+}
+
+const ruleModulesList = {
+  'no-unmatched-pair': textlintRuleNoUnmatchedPair,
+  'common-misspellings': textlintRuleCommonMisspellings,
+  'date-weekday-mismatch': textlintRuleDateWeekdayMismatch,
+  'en-capitalization': textlintRuleEnCapitalization,
+  'ja-hiragana-keishikimeishi': textlintRuleJaHiraganaKeishikimeishi,
+  'ja-no-abusage': textlintRuleJaNoAbusage,
+  'ja-no-inappropriate-words': textlintRuleJaNoInappropriateWords,
+  'ja-no-mixed-period': textlintRuleJaNoMixedPeriod,
+  'ja-no-redundant-expression': textlintRuleJaNoRedundantExpression,
+  'ja-unnatural-alphabet': textlintRuleJaUnnaturalAlphabet,
+  'max-comma': textlintRuleMaxComma,
+  'max-kanji-continuous-len': textlintRuleMaxKanjiContinuousLen,
+  'max-ten': textlintRuleMaxTen,
+  'no-double-negative-ja': textlintRuleNoDoubleNegativeJa,
+  'no-doubled-conjunction': textlintRuleNoDoubledConjunction,
+  'no-doubled-joshi': textlintRuleNoDoubledJoshi,
+  'no-dropping-the-ra': textlintRuleNoDroppingTheRa,
+  'no-hankaku-kana': textlintRuleNoHankakuKana,
+  'no-kangxi-radicals': textlintRuleNoKangxiRadicals,
+  'no-mixed-zenkaku-and-hankaku-alphabet': textlintRuleNoMixedZenkakuAndHankakuAlphabet,
+  'no-nfd': textlintRuleNoNfd,
+  'no-surrogate-pair': textlintRuleNoSurrogatePair,
+  'no-zero-width-spaces': textlintRuleNoZeroWidthSpaces,
+  'period-in-list-item': textlintRulePeriodInListItem,
+  'prefer-tari-tari': textlintRulePreferTariTari,
+  'sentence-length': textlintRuleSentenceLength,
+  'use-si-units': textlintRuleUseSiUnits,
+};
+
+const logger = loggerFactory('growi:codemirror:codemirror-textlint');
+const kernel = new TextlintKernel();
+const textlintOption: TextlintRuleOptions<RuleExtension> = {
+  ext: '.md',
+  plugins: [
+    {
+      pluginId: 'markdown',
+      plugin: require('textlint-plugin-markdown'),
+    },
+  ],
+};
+
+const createSetupRules = (rules, ruleOptions): TextlintKernelRule[] => (
+  Object.keys(rules).map(ruleName => (
+    {
+      ruleId: ruleName,
+      rule: rules[ruleName],
+      options: ruleOptions[ruleName],
+    }
+  ))
+);
+
+
+export const createValidator = (rulesConfigArray: RulesConfigObj[] | null): AsyncLinter<RulesConfigObj[] | null> => {
+  if (rulesConfigArray != null) {
+    const filteredConfigArray = rulesConfigArray
+      .filter((rule) => {
+        if (ruleModulesList[rule.name] == null) {
+          logger.error(`Textlint rule ${rule.name} is not installed`);
+        }
+        return (ruleModulesList[rule.name] != null && rule.isEnabled !== false);
+      });
+
+    const rules = filteredConfigArray
+      .reduce((rules, rule) => {
+        rules[rule.name] = ruleModulesList[rule.name];
+        return rules;
+      }, {});
+
+    const rulesOption = filteredConfigArray
+      .reduce((rules, rule) => {
+        rules[rule.name] = rule.options || {};
+        return rules;
+      }, {});
+
+    Object.assign(
+      textlintOption,
+      { rules: createSetupRules(rules, rulesOption) },
+    );
+  }
+
+  const defaultSetupRules: TextlintKernelRule[] = Object.entries(ruleModulesList)
+    .map(ruleName => ({
+      ruleId: ruleName[0],
+      rule: ruleName[1],
+    }));
+
+  if (rulesConfigArray == null) {
+    Object.assign(
+      textlintOption,
+      { rules: defaultSetupRules },
+    );
+  }
+
+  return (text, callback) => {
+    if (!text) {
+      callback([]);
+      return;
+    }
+    kernel
+      .lintText(text, textlintOption)
+      .then((result) => {
+        const lintMessages = result.messages;
+        const lintErrors: Annotation[] = lintMessages.map(textlintToCodeMirror);
+        callback(lintErrors);
+      });
+  };
+};

+ 9 - 0
packages/codemirror-textlint/src/utils/logger/index.ts

@@ -0,0 +1,9 @@
+import Logger from 'bunyan';
+import { createLogger } from 'universal-bunyan';
+
+export const loggerFactory = function(name: string): Logger {
+  return createLogger({
+    name,
+    config: { default: 'info' },
+  });
+};

+ 8 - 0
packages/codemirror-textlint/tsconfig.base.json

@@ -0,0 +1,8 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+  },
+  "include": [
+    "src"
+  ]
+}

+ 17 - 0
packages/codemirror-textlint/tsconfig.build.json

@@ -0,0 +1,17 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "./src",
+    "outDir": "dist",
+    "declaration": true,
+    "noResolve": false,
+    "preserveConstEnums": true,
+    "sourceMap": true,
+    "noEmit": false,
+    "inlineSources": true,
+
+    "baseUrl": ".",
+    "paths": {
+    }
+  }
+}

+ 6 - 0
packages/codemirror-textlint/tsconfig.json

@@ -0,0 +1,6 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": "."
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 582 - 26
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов