Repository: speee/jsx-slack (ts)

Issue:
Over-escape for URL and time format is triggered by styling and <Escape>
`<Escape>` and format with corresponded special characters will break URL and time format by over-escaping.

```jsx
<Blocks>
  <Section>
    <p><a href="https://example.com/a_b_c">link</a></p>
    <p><time datetime={1234567890} fallback="fail">{date} {time_secs}</time></p>
  </Section>
  <Section>
    <Escape>
      <p><a href="https://example.com/a_b_c">link</a></p>
      <p><time datetime={1234567890} fallback="fail">{date} {time_secs}</time></p>
    </Escape>
  </Section>
  <Section>
    <i>
      <p><a href="https://example.com/a_b_c">link</a></p>
      <p><time datetime={1234567890} fallback="fail">{date} {time_secs}</time></p>
    </i>
  </Section>
</Blocks>
```

```json
{
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "<https://example.com/a_b_c|link>\n\n<!date^1234567890^{date} {time_secs}|fail>",
        "verbatim": true
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "<https://example.com/aˍbˍc|link>\n\n<!date^1234567890^{date} {timeˍsecs}|fail>",
        "verbatim": true
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "_<https://example.com/aˍbˍc|link>_\n\n_<!date^1234567890^{date} {timeˍsecs}|fail>_",
        "verbatim": true
      }
    }
  ]
}
```

URL in `<a>` link is broken by escaping, and `<time>` tag shows as fallback text.

`<a>` tag has a workaround: Move `<Escape>`/ styling element into the inside of `<a>` tag.

Pull request description:
Prevent over-escaping for link and time formatting
Resolves #118.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4da5118..7ffcc22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### Fixed
+
+- Prevent over-escaping for link and time formatting ([#118](https://github.com/speee/jsx-slack/issues/118), [#120](https://github.com/speee/jsx-slack/pull/120))
+
 ## v1.3.1 - 2020-02-14
 
 ### Fixed
diff --git a/src/html.tsx b/src/html.tsx
index 3cd5815..fe6c5ea 100644
--- a/src/html.tsx
+++ b/src/html.tsx
@@ -2,7 +2,8 @@
 import { JSXSlack } from './jsx'
 import { Html } from './utils'
 
-const emojiShorthandRegex = /(:[-a-z0-9ÀÁÂÃÄÇÈÉÊËÍÎÏÑÓÔÕÖŒœÙÚÛÜŸßàáâãäçèéêëíîïñóôõöùúûüÿ_＿+＋'\u2e80-\u2fd5\u3005\u3041-\u3096\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcb\uff10-\uff19\uff41-\uff5a\uff61-\uff9f]+:)/
+// An internal HTML tag and emoji shorthand should not escape
+const preventEscapeRegex = /(<.*?>|:[-a-z0-9ÀÁÂÃÄÇÈÉÊËÍÎÏÑÓÔÕÖŒœÙÚÛÜŸßàáâãäçèéêëíîïñóôõöùúûüÿ_＿+＋'\u2e80-\u2fd5\u3005\u3041-\u3096\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcb\uff10-\uff19\uff41-\uff5a\uff61-\uff9f]+:)/
 
 export const escapeReplacers = {
   blockquote: (partial: string) =>
@@ -23,9 +24,9 @@ export const escapeChars = (
   replacer: (partial: string) => string = escapeCharsDefaultReplacer
 ) =>
   mrkdwn
-    .split(emojiShorthandRegex)
+    .split(preventEscapeRegex)
     .reduce(
-      (acc, str, i) => [...acc, i % 2 === 1 ? str : replacer(str)],
+      (acc, str, i) => [...acc, i % 2 ? str : replacer(str)],
       [] as string[]
     )
     .join('')
diff --git a/src/mrkdwn/jsx.ts b/src/mrkdwn/jsx.ts
index 2a08193..dec7634 100644
--- a/src/mrkdwn/jsx.ts
+++ b/src/mrkdwn/jsx.ts
@@ -13,6 +13,13 @@ const replaceUnmatchedString = (
     .reduce((acc, s, i) => acc.concat(i % 2 ? s : replacer(s)), [] as string[])
     .join('')
 
+const escapeEverythingContents = (str: string) =>
+  replaceUnmatchedString(str, /(<[\s\S]*?>)/, s =>
+    replaceUnmatchedString(s, /(&\w+;)/, ss =>
+      [...ss].map(x => `&#${x.codePointAt(0)};`)
+    )
+  )
+
 const jsxToHtml = (
   name: string,
   props: Record<string, any>,
@@ -61,13 +68,8 @@ const jsxToHtml = (
       if (isDescendant('ul', 'ol', 'time')) return text()
 
       // Encode everything to preserve whitespaces (except tags such as <a> and <time>)
-      return `<pre>${replaceUnmatchedString(
-        text().replace(/`{3}/g, '``\u02cb'),
-        /(<[\s\S]*?>)/,
-        s =>
-          replaceUnmatchedString(s, /(&\w+;)/, ss =>
-            [...ss].map(x => `&#${x.codePointAt(0)};`)
-          )
+      return `<pre>${escapeEverythingContents(
+        text().replace(/`{3}/g, '``\u02cb')
       )}</pre>`
     }
     case 'a': {
@@ -96,7 +98,10 @@ const jsxToHtml = (
           })
         : buildAttr({ 'data-fallback': formatDate(date, format) }, false)
 
-      return `<time${datetimeAttr}${fallbackAttr}>${format}</time>`
+      // Encode everything of the format text to preserve from unexpected escape
+      return `<time${datetimeAttr}${fallbackAttr}>${escapeEverythingContents(
+        format
+      )}</time>`
     }
     case 'small':
     case 'span':


Test patch:
diff --git a/test/block-kit/builtin-components.tsx b/test/block-kit/builtin-components.tsx
index 866eaa9..5387ab7 100644
--- a/test/block-kit/builtin-components.tsx
+++ b/test/block-kit/builtin-components.tsx
@@ -59,6 +59,48 @@ describe('Built-in components', () => {
           }),
         }),
       ]))
+
+    it('ignores escaping underscore in valid link', () => {
+      expect(
+        JSXSlack(
+          <Blocks>
+            <Section>
+              <Escape>
+                <a href="https://example.com/a_b_c">_link_</a>
+              </Escape>
+            </Section>
+          </Blocks>
+        )
+      ).toStrictEqual([
+        expect.objectContaining({
+          text: expect.objectContaining({
+            text: '<https://example.com/a_b_c|\u02cdlink\u02cd>',
+          }),
+        }),
+      ])
+    })
+
+    it('ignores escaping underscore in valid time formatting', () => {
+      expect(
+        JSXSlack(
+          <Blocks>
+            <Section>
+              <Escape>
+                <time datetime={1234567890} fallback="fall_back">
+                  {'{date_num} {time_secs}'}
+                </time>
+              </Escape>
+            </Section>
+          </Blocks>
+        )
+      ).toStrictEqual([
+        expect.objectContaining({
+          text: expect.objectContaining({
+            text: '<!date^1234567890^{date_num} {time_secs}|fall_back>',
+          }),
+        }),
+      ])
+    })
   })
 
   describe('<Fragment> component', () => {
diff --git a/test/html.tsx b/test/html.tsx
index 19b155b..3f33f39 100644
--- a/test/html.tsx
+++ b/test/html.tsx
@@ -91,6 +91,29 @@ describe('HTML parser for mrkdwn', () => {
       expect(html(<i>:絵＿文字:</i>)).toBe('_:絵＿文字:_')
     })
 
+    it('does not escape underscore contained in valid link', () => {
+      expect(
+        html(
+          <i>
+            <a href="https://example.com/a_b_c">_link_</a>
+          </i>
+        )
+      ).toBe('_<https://example.com/a_b_c|\u02cdlink\u02cd>_')
+    })
+
+    it('does not escape underscore contained in valid time formatting', () => {
+      // NOTE: Fallback text will render as plain text even if containing character for formatting
+      expect(
+        html(
+          <i>
+            <time datetime={1234567890} fallback="fall_back">
+              {'{date_num} {time_secs}'}
+            </time>
+          </i>
+        )
+      ).toBe('_<!date^1234567890^{date_num} {time_secs}|fall_back>_')
+    })
+
     it('applies markup per each lines when text has multiline', () => {
       expect(
         html(