Repository: nhn/tui.editor
Pull request #1720 (2021-08-02 00:58:21)

fix: cannot import i18n bundle
<!-- EDIT TITLE PLEASE -->
<!-- It should be one of them
  <ISSUE TYPE>: Short Description (<CLOSING TYPE> #<ISSUE NUMBERS>)
  ex)
  feat: add new feature (close #111)
  fix: wrong behavior (fix #111)
  chore: change build tool (ref #111)
-->

<!-- SPECIFY A ISSUE TYPE AT HEAD
  feat: A new feature
  fix: A bug fix
  docs: Documentation only changes
  style: Changes that do not affect the meaning of the code (white-space, formatting etc)
  refactor: A code change that neither fixes a bug or adds a feature
  perf: A code change that improves performance
  test: Adding missing tests
  chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
-->

<!-- ADD CLOSING TYPE AND ISSUE NUMBER AT TAIL
  (<CLOSING TYPE> #<ISSUE NUMBERS>)
  close: resolve not a bug(feature, docs, etc) completely
  fix: resolve a bug completely
  ref: not fully resolved or related to
-->

### Please check if the PR fulfills these requirements
- [x] It's the right issue type on the title
- [ ] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number)
- [x] The commit message follows our guidelines
- [ ] Tests for the changes have been added (for bug fixes/features)
- [ ] Docs have been added/updated (for bug fixes/features)
- [ ] It does not introduce a breaking change or has a description of the breaking change

### Description
* fixed that users cannot import i18n bundle
  * added i18n esm bundle to **exports** field in `package.json`
  * related issue
    * #1677
    * #1684
    * #1708


---
Thank you for your contribution to TOAST UI product. 🎉 😘 ✨


js87zz: @adhrinae 
> 리뷰 완료합니다.
> 요게 v3에서 개발환경 및 번들 환경 갈아엎으면서 i18n 관련 번들링이 빠져서 생긴건가요?
esm 모듈을 지원하기 위해 package.json의 `module` 필드에서 `exports` 필드까지 범용적인 옵션이나 설정을 위해 확장되었는데요. 이때 `exports` 필드가 있는 경우, 웹팩과 롤업, 스노우팩 등 여러 빌드 도구에서 `exports`를 우선적으로 바라봅니다. 그런데 `i18n`의 경우 `exports` 필드에서 매핑된 경로를 찾을 수 없어 발생하는 문제입니다~! 이게 아마 또 레거시 버전의 번들러들은 잘될거에요(그때는 이런걸 전혀 고려안했으니). 그래서 이번에 `exports` 필드에도 `i18n` esm 전용 번들을 생성해서 매핑하였습니다.


Patch:
diff --git a/apps/editor/package.json b/apps/editor/package.json
index c58f33b8..a923ac18 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -24,6 +24,10 @@
       "import": "./dist/esm/indexViewer.js",
       "require": "./dist/toastui-editor-viewer.js"
     },
+    "./dist/i18n/*": {
+      "import": "./dist/esm/i18n/*.js",
+      "require": "./dist/i18n/*.js"
+    },
     "./dist/toastui-editor-viewer": "./dist/toastui-editor-viewer.js",
     "./dist/toastui-editor.css": "./dist/toastui-editor.css",
     "./dist/toastui-editor-viewer.css": "./dist/toastui-editor-viewer.css",
diff --git a/apps/editor/rollup.config.js b/apps/editor/rollup.config.js
index e5a8cb22..61d5c3f1 100644
--- a/apps/editor/rollup.config.js
+++ b/apps/editor/rollup.config.js
@@ -1,8 +1,21 @@
 import typescript from '@rollup/plugin-typescript';
 import commonjs from '@rollup/plugin-commonjs';
 import { nodeResolve } from '@rollup/plugin-node-resolve';
+import fs from 'fs';
+
+function i18nEditorImportPath() {
+  return {
+    name: 'i18nEditorImportPath',
+    transform(code) {
+      return code.replace('../editorCore', '@toast-ui/editor');
+    },
+  };
+}
+
+const fileNames = fs.readdirSync('./src/i18n');
 
 export default [
+  // editor
   {
     input: 'src/esm/index.ts',
     output: {
@@ -13,6 +26,7 @@ export default [
     plugins: [typescript(), commonjs(), nodeResolve()],
     external: [/^prosemirror/],
   },
+  // viewer
   {
     input: 'src/esm/indexViewer.ts',
     output: {
@@ -23,4 +37,15 @@ export default [
     plugins: [typescript(), commonjs(), nodeResolve()],
     external: [/^prosemirror/],
   },
+  // i18n
+  {
+    input: fileNames.map((fileName) => `src/i18n/${fileName}`),
+    output: {
+      dir: 'dist/esm/i18n',
+      format: 'es',
+      sourcemap: false,
+    },
+    external: ['@toast-ui/editor'],
+    plugins: [typescript(), commonjs(), nodeResolve(), i18nEditorImportPath()],
+  },
 ];
diff --git a/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts b/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts
index 23dbbe9e..544a186f 100644
--- a/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts
+++ b/apps/editor/src/convertors/toMarkdown/toMdConvertorState.ts
@@ -120,14 +120,12 @@ export default class ToMdConvertorState {
     this.closed = node;
   }
 
-  text(text: string, escaped?: boolean) {
+  text(text: string, escaped = true) {
     const lines = text.split('\n');
 
     for (let i = 0; i < lines.length; i += 1) {
-      const startOfLine = this.isInBlank() || !!this.closed;
-
       this.write();
-      this.result += escaped !== false ? escape(lines[i], startOfLine) : lines[i];
+      this.result += escaped ? escape(lines[i]) : lines[i];
 
       if (i !== lines.length - 1) {
         this.result += '\n';
diff --git a/apps/editor/src/ui/toolbarItemFactory.ts b/apps/editor/src/ui/toolbarItemFactory.ts
index ef30c871..dd48e8b3 100644
--- a/apps/editor/src/ui/toolbarItemFactory.ts
+++ b/apps/editor/src/ui/toolbarItemFactory.ts
@@ -229,7 +229,6 @@ interface Payload {
   initialValues?: PopupInitialValues;
 }
 
-// eslint-disable-next-line consistent-return
 export function createPopupInfo(type: string, payload: Payload): PopupInfo | null {
   const { el, pos, popup, initialValues } = payload;
 
diff --git a/apps/editor/src/utils/common.ts b/apps/editor/src/utils/common.ts
index 1022463f..010e3934 100644
--- a/apps/editor/src/utils/common.ts
+++ b/apps/editor/src/utils/common.ts
@@ -1,10 +1,16 @@
 import isUndefined from 'tui-code-snippet/type/isUndefined';
 import isNull from 'tui-code-snippet/type/isNull';
 import sendHostname from 'tui-code-snippet/request/sendHostname';
+import forEachOwnProperties from 'tui-code-snippet/collection/forEachOwnProperties';
 
 import { LinkAttributeNames, LinkAttributes } from '@t/editor';
 
 export const isMac = /Mac/.test(navigator.platform);
+const reSpaceMoreThanOne = /[\u0020]+/g;
+const reEscapeChars = /[>(){}[\]+-.!#|]/g;
+const reEscapeHTML = /<([a-zA-Z_][a-zA-Z0-9\-._]*)(\s|[^\\/>])*\/?>|<(\/)([a-zA-Z_][a-zA-Z0-9\-._]*)\s*\/?>|<!--[^-]+-->|<([a-zA-Z_][a-zA-Z0-9\-.:/]*)>/g;
+const reEscapeBackSlash = /\\[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~\\]/g;
+const reEscapePairedChars = /[*_~`]/g;
 
 export function sendHostName() {
   sendHostname('editor', 'UA-129966929-1');
@@ -15,6 +21,19 @@ export function includes<T>(arr: T[], targetItem: T) {
 }
 
 const availableLinkAttributes: LinkAttributeNames[] = ['rel', 'target', 'hreflang', 'type'];
+const reMarkdownTextToEscapeMap = {
+  codeblock: /(^ {4}[^\n]+\n*)+/,
+  thematicBreak: /^ *((\* *){3,}|(- *){3,} *|(_ *){3,}) */,
+  atxHeading: /^(#{1,6}) +[\s\S]+/,
+  seTextheading: /^([^\n]+)\n *(=|-){2,} */,
+  blockquote: /^( *>[^\n]+.*)+/,
+  list: /^ *(\*+|-+|\d+\.) [\s\S]+/,
+  def: /^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +["(]([^\n]+)[")])? */,
+  link: /!?\[.*\]\(.*\)/,
+  reflink: /!?\[.*\]\s*\[([^\]]*)\]/,
+  verticalBar: /\u007C/,
+  fencedCodeblock: /^((`|~){3,})/,
+};
 
 export function sanitizeLinkAttribute(attribute: LinkAttributes) {
   if (!attribute) {
@@ -42,14 +61,39 @@ export function repeat(text: string, count: number) {
   return result;
 }
 
-export function escape(text: string, startOfLine?: boolean) {
-  const result = text.replace(/[`*\\~[\]]/g, '\\$&');
+function isNeedEscapeText(text: string) {
+  let needEscape = false;
 
-  if (startOfLine) {
-    return result.replace(/^[:#\-*+]/, '\\$&').replace(/^(\d+)\./, '$1\\.');
+  forEachOwnProperties(reMarkdownTextToEscapeMap, (reMarkdownTextToEscape) => {
+    if (reMarkdownTextToEscape.test(text)) {
+      needEscape = true;
+    }
+    return !needEscape;
+  });
+
+  return needEscape;
+}
+
+export function escape(text: string) {
+  const replacer = (matched: string) => `\\${matched}`;
+
+  let escapedText = text.replace(reSpaceMoreThanOne, ' ');
+
+  if (reEscapeBackSlash.test(escapedText)) {
+    escapedText = escapedText.replace(reEscapeBackSlash, replacer);
   }
 
-  return result;
+  escapedText = escapedText.replace(reEscapePairedChars, replacer);
+
+  if (reEscapeHTML.test(escapedText)) {
+    escapedText = escapedText.replace(reEscapeHTML, replacer);
+  }
+
+  if (isNeedEscapeText(escapedText)) {
+    escapedText = escapedText.replace(reEscapeChars, replacer);
+  }
+
+  return escapedText;
 }
 
 export function quote(text: string) {
diff --git a/docs/v3.0-migration-guide.md b/docs/v3.0-migration-guide.md
index f6147192..9b39b24b 100644
--- a/docs/v3.0-migration-guide.md
+++ b/docs/v3.0-migration-guide.md
@@ -1,8 +1,8 @@
 ## Introduction
 
-This migration guide includes all information regarding changes users must be aware of whern updating from TOAST UI Editor 2.x to TOAST UI Editor 3.0. 
+This migration guide includes all information regarding changes users must be aware of when updating from TOAST UI Editor 2.x to TOAST UI Editor 3.0. 
 
-TOAST UI Editor (hereafter referred to as the 'Editor') has removed the original [CodeMirror](https://codemirror.net/), squire, and to-mark dependencies and has modified the editor to use abstract models through Prosemirror. Since the core module API and plugin usages were changed, it is advised that users consult the migration guide carefully. The table of contents is as follows and refer to the 'Changes' in the order enumerated when updating. 
+TOAST UI Editor (hereafter referred to as the 'Editor') has removed the original [CodeMirror](https://codemirror.net/), squire, and to-mark dependencies and has modified the editor to use abstract models through Prosemirror. Since the core module API and plugin usages were changed, it is advised that users consult the migration guide carefully. The table of contents is as follows and refers to the 'Changes' in the order enumerated when updating. 
 
 ## Table of Contents
 
@@ -21,7 +21,7 @@ TOAST UI Editor (hereafter referred to as the 'Editor') has removed the original
 
 ### 1. Installation and Usages
 
-In order to use the Editor, use the [scoped package](https://docs.npmjs.com/using-npm/scope.html) to install the `@toast-ui/editor` package like you did for the previous v2.x. The following is an example of using the npm command to install the Editor. 
+To use the Editor, use the [scoped package](https://docs.npmjs.com/using-npm/scope.html) to install the `@toast-ui/editor` package as you did for the previous v2.x. The following is an example of using the npm command to install the Editor. 
 
 ```sh
 $ npm install @toast-ui/editor
@@ -503,4 +503,4 @@ Lastly, the following is a list of APIs removed from the v3.0.
 | `getCodeMirror` | `{function}` |
 | `getSquire` | `{function}` |
 | `getCurrentModeEditor` | `{function}` |
-| `getUI` | `{function}` |
\ No newline at end of file
+| `getUI` | `{function}` |


Test patch:
diff --git a/apps/editor/src/__test__/unit/convertor.spec.ts b/apps/editor/src/__test__/unit/convertor.spec.ts
index 471ed38a..50fb0318 100644
--- a/apps/editor/src/__test__/unit/convertor.spec.ts
+++ b/apps/editor/src/__test__/unit/convertor.spec.ts
@@ -890,4 +890,26 @@ describe('Convertor', () => {
       assertConverting(markdown, markdown);
     });
   });
+
+  describe('should escape markdown text in wysiwyg', () => {
+    it('with markdown text', () => {
+      const markdown = source`
+        \\# heading
+        \\> blockquote
+        \\*test\\*
+        \\* list
+      `;
+
+      assertConverting(markdown, markdown);
+    });
+
+    it('with html text', () => {
+      const markdown = source`
+        \\<div>block\\</div>
+        \\<strong>bold\\</strong>
+      `;
+
+      assertConverting(markdown, markdown);
+    });
+  });
 });