diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 123ae94..11f8aed 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,9 @@ build/Release # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules + +# Package +package-lock.json + +# logs +.DS_Store diff --git a/README.md b/README.md index 674ef70..b6d05a8 100644 --- a/README.md +++ b/README.md @@ -7,5 +7,8 @@ example usage: var slackify = require('slackify-html'); var text = slackify('this link is important'); -// text variable contains 'this is *important*' +// text variable contains 'this is *important*' ``` + +### How to setup repo and test locally +https://app.getguru.com/card/TqGjya8c/slackifyhtml-basic-setup diff --git a/package.json b/package.json index 7625649..707fe5c 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "slackify-html", - "version": "1.0.0", + "version": "1.3.4", "description": "convert simple html to slack markdown", "main": "slackify-html.js", "scripts": { - "test": "tap tests.js" + "test": "jest test.js" }, "repository": { "type": "git", @@ -25,10 +25,10 @@ }, "homepage": "https://github.com/mrq-cz/slackify-html", "dependencies": { - "html-entities": "^1.1.3", + "html-entities": "^2.3.3", "htmlparser": "^1.7.7" }, "devDependencies": { - "tap": "^1.4.0" + "jest": "^29.3.1" } } diff --git a/slackify-html.js b/slackify-html.js index f317a13..9bc44b9 100644 --- a/slackify-html.js +++ b/slackify-html.js @@ -1,40 +1,410 @@ -var htmlparser = require('htmlparser'), - Entities = require('html-entities').AllHtmlEntities; - -entities = new Entities(); +const htmlparser = require("htmlparser"), + entities = require("html-entities"); module.exports = function slackify(html) { - var handler = new htmlparser.DefaultHandler(function (error, dom) { + const handler = new htmlparser.DefaultHandler(function (error, dom) { // error ignored }); - var parser = new htmlparser.Parser(handler); + const parser = new htmlparser.Parser(handler); parser.parseComplete(html); - var dom = handler.dom; - if (dom) - return entities.decode(walk(dom)); - else - return ''; + const dom = handler.dom; + if (dom) return entities.decode(walk(dom)); + else return ""; +}; + +function walkLink(dom) { + let out=''; + + if (dom) { + dom.forEach(function (el) { + if (el.type === 'text') + out += el.data; + else if (el.type === 'tag' && el.children) + out += walkLink(el.children); + }); + } + return out; } -function walk(dom) { - var out = ''; - if (dom) +function walkList(dom, ordered, nesting, start) { + let out = ""; + if (dom) { + let listItemIndex = start ? start : 1; + dom.forEach(function (el) { + let suffixSpace; + let prefixSpace; + if ("text" === el.type && el.data.trim() !== "") { + out += el.data; + } else if ("tag" === el.type) { + let i; + let content; + let contentArr; + let innerOutput; + + switch (el.name) { + case "li": + // Add indentation based on the nesting level + for (i = 0; i < nesting * 2; i++) { + out += " "; + } + // Add the bullet or number, followed by the text content of the li element + out += + (ordered ? listItemIndex++ + ". " : "• ") + + walkList(el.children, ordered, nesting) + + "\n"; + break; + case "p": + // Add the text content of the p element + out += walkList(el.children, ordered, nesting) + "\n"; + break; + case "a": + if (el.attribs && el.attribs.href) { + out += "<" + el.attribs.href + "|" + walkList(el.children) + "> "; + } else { + out += walkList(el.children); + } + break; + case "code": + out += "`" + walkList(el.children) + "` "; + break; + case "del": + out += "~" + walkList(el.children) + "~ "; + break; + case "strong": + content = walkList(el.children); + contentArr = content.split("\n"); + innerOutput = ""; + for (i = 0; i < contentArr.length; i++) { + content = contentArr[i]; + if (content.trim() !== "") { + prefixSpace = false; + suffixSpace = false; + if (content && content.charAt(0) === " ") { + content = content.substring(1, content.length); + prefixSpace = true; + } + if (content && content.charAt(content.length - 1) === " ") { + content = content.substring(0, content.length - 1); + suffixSpace = true; + } + if (prefixSpace) { + innerOutput += " "; + } + if ( + content.charAt(0) === "*" && + content.charAt(content.length - 1) === "*" + ) { + innerOutput += content; + } else { + innerOutput += "*" + content + "*"; + } + if (suffixSpace) { + innerOutput += " "; + } + } + if (i < contentArr.length - 1) { + innerOutput += "\n"; + } + } + out += innerOutput + " "; + break; + case "em": + content = walkList(el.children); + contentArr = content.split("\n"); + innerOutput = ""; + for (i = 0; i < contentArr.length; i++) { + content = contentArr[i]; + if (content.trim() !== "") { + prefixSpace = false; + suffixSpace = false; + if (content && content.charAt(0) === " ") { + content = content.substring(1, content.length); + prefixSpace = true; + } + if (content && content.charAt(content.length - 1) === " ") { + content = content.substring(0, content.length - 1); + suffixSpace = true; + } + if (prefixSpace) { + innerOutput += " "; + } + innerOutput += "_" + content + "_"; + if (suffixSpace) { + innerOutput += " "; + } + out += innerOutput + " "; + } + if (i < contentArr.length - 1) { + out += "\n"; + } + } + break; + default: + // Recursively process the children of the element + out += walkList(el.children, ordered, nesting + 1); + } + } + }); + } + return out; +} + +function walkPre(dom) { + let out = ""; + if (dom) { + dom.forEach(function (el) { + if ("text" === el.type) { + out += el.data; + } else if ("tag" === el.type) { + out += walkPre(el.children) + "\n"; + } + }); + } + return out; +} + +function walkTable(dom) { + let out = ""; + if (dom) { + dom.forEach(function (el) { + if ("tag" === el.type) { + if ("thead" === el.name) { + out += walkTableHead(el.children); + } else if ("tbody" === el.name) { + out += walkTableBody(el.children); + } + } + }); + } + + return out; +} + +function walkTableHead(dom) { + let out = ""; + if (dom) { + const headers = []; dom.forEach(function (el) { - if ('text' === el.type) { + if ("text" === el.type && el.data.trim() !== "") { out += el.data; + } else if ("tr" === el.name) { + out += walkTableHead(el.children); + } else if ("th" === el.name) { + const header = walkTableHead(el.children); + headers.push(header); + out += "| " + header + " "; } - if ('tag' === el.type) { + }); + if (headers.length > 0) { + out += " |\n"; + headers.forEach(function (item) { + out += "| "; + for (let i = 0; i < item.length; i++) { + out += "-"; + } + out += " "; + }); + out += " |\n"; + } + } + + return out; +} + +function walkTableBody(dom) { + let out = ""; + if (dom) { + dom.forEach(function (el) { + if ("text" === el.type && el.data.trim() !== "") { + out += el.data; + } else if ("td" === el.name) { + out += "| " + walk(el.children, 0, true) + " "; + } else if ("tr" === el.name) { + out += walkTableBody(el.children).replace(/\n/gi, " ") + "|\n"; + } + }); + } + return out; +} + +function walk(dom, nesting) { + if (!nesting) { + nesting = 0; + } + let out = ""; + if (dom) + dom.forEach(function (el) { + let suffixSpace; + let prefixSpace; + if ("text" === el.type) { + out += el.data; + } else if ("tag" === el.type) { + let i; + let content; + let contentArr; + let innerOutput; + switch (el.name) { - case 'a': - out += '<' + el.attribs.href + '|' + walk(el.children) + '>'; + case "summary": + out += `\n*${walk(el.children).trim()}*\n` + break; + + + case "a": + if (el.attribs && el.attribs.href) { + out += "<" + el.attribs.href + "|" + walkLink(el.children) + ">"; + } else { + out += walk(el.children); + } + break; + case "h1": + case "h2": + case "h3": + case "h4": + case "strong": + case "b": + content = walk(el.children); + contentArr = content.split("\n"); + innerOutput = ""; + for (i = 0; i < contentArr.length; i++) { + content = contentArr[i]; + if (content.trim() !== "") { + prefixSpace = false; + suffixSpace = false; + if (content && content.charAt(0) === " ") { + content = content.substring(1, content.length); + prefixSpace = true; + } + if (content && content.charAt(content.length - 1) === " ") { + content = content.substring(0, content.length - 1); + suffixSpace = true; + } + if (prefixSpace) { + innerOutput += " "; + } + if ( + el.name === "h1" || + el.name === "h2" || + el.name === "h3" || + el.name === "h4" + ) { + content = content.replace(/\*/g, ""); + innerOutput += "*" + content + "*"; + } else if ( + content.charAt(0) === "*" && + content.charAt(content.length - 1) === "*" + ) { + innerOutput += content; + } else { + innerOutput += "*" + content + "*"; + } + if (suffixSpace) { + innerOutput += " "; + } + } + if (i < contentArr.length - 1) { + innerOutput += "\n"; + } + } + out += innerOutput; + + switch (el.name) { + case "h1": + case "h2": + case "h3": + case "h4": + out += "\n"; + break; + } + break; + case "i": + case "em": + content = walk(el.children); + contentArr = content.split("\n"); + innerOutput = ""; + for (i = 0; i < contentArr.length; i++) { + content = contentArr[i]; + if (content.trim() !== "") { + prefixSpace = false; + suffixSpace = false; + if (content && content.charAt(0) === " ") { + content = content.substring(1, content.length); + prefixSpace = true; + } + if (content && content.charAt(content.length - 1) === " ") { + content = content.substring(0, content.length - 1); + suffixSpace = true; + } + if (prefixSpace) { + innerOutput += " "; + } + innerOutput += "_" + content + "_"; + if (suffixSpace) { + innerOutput += " "; + } + out += innerOutput; + } + if (i < contentArr.length - 1) { + out += "\n"; + } + } + break; + case "div": + out += walk(el.children); + if ( + el.attribs && + el.attribs.class === "ghq-card-content__paragraph" + ) { + out += "\n"; + } + break; + case "p": + out += walk(el.children) + "\n"; + break; + case "br": + out += walk(el.children) + "\n"; + break; + case "ol": + case "ul": + const startIndex = el.attribs ? el.attribs.start : false; + out += walkList(el.children, "ol" === el.name, nesting, startIndex); + break; + case "code": + out += "`" + walk(el.children) + "`"; + break; + case "pre": + out += "```\n" + walkPre(el.children) + "```\n"; + break; + case "table": + out += walkTable(el.children); + break; + case "img": + const alt = el.attribs.alt; + out += + ""; break; - case 'strong': - case 'b': - out += '*' + walk(el.children) + '*'; + case "blockquote": + content = walk(el.children); + innerOutput = ""; + contentArr = content.split("\n"); + contentArr.forEach((item) => { + if (el.name === "br" || el.name === "p") { + innerOutput += ">" + item; + } else { + innerOutput += ">" + item + "\n"; + } + }); + if (innerOutput.endsWith("\n>\n")) { + innerOutput = innerOutput.substring(0, innerOutput.length - 2); + } + out += innerOutput + "\n"; break; - case 'i': - case 'em': - out += '_' + walk(el.children) + '_'; + case "del": + out += "~" + walkList(el.children) + "~"; break; default: out += walk(el.children); diff --git a/test.js b/test.js new file mode 100644 index 0000000..12b04c8 --- /dev/null +++ b/test.js @@ -0,0 +1,311 @@ +slackify = require("./slackify-html"); + +describe("Slackify HTML", () => { + it("simple", () => { + expect(slackify("test")).toBe("test"); + expect(slackify("test 1")).toBe("test 1"); + }); + + it("tags", () => { + expect(slackify("test bold")).toBe("test *bold*"); + expect(slackify('test example link')).toBe( + "test " + ); + }); + + it("malformed html", () => { + expect(slackify("test asd")).toBe("test *asd*"); + expect(slackify("sab tag")).toBe("sab tag"); + expect(slackify(" { + expect(slackify("")).toBe(""); + }); + + it("vcheck example", () => { + expect( + slackify( + '2.4-SNAPSHOT • revision a245dc9 • build 2015-09-07 14:06 • wbl 1.3.33 • details »' + ) + ).toBe( + "*2.4-SNAPSHOT* • revision • build 2015-09-07 14:06 • wbl 1.3.33 • " + ); + }); + + it("test bold text", () => { + expect(slackify("totally bold")).toBe("*totally bold*"); + expect(slackify("

totally bold in paragraph

")).toBe( + "*totally bold in paragraph*\n" + ); + expect(slackify("*already slackified bold*")).toBe( + "*already slackified bold*" + ); + expect(slackify("*bold inside asterisks*")).toBe( + "*bold *inside* asterisks*" + ); + expect(slackify("*asterisks *inside* asterisks*")).toBe( + "*asterisks *inside* asterisks*" + ); + expect( + slackify("

A sentence with bold text in between.

") + ).toBe("A sentence with *bold text* in between.\n"); + }); + + it("test bold text with headers", () => { + expect(slackify("

a completely bold title

")).toBe( + "*a completely bold title*\n" + ); + expect(slackify("

a completely bold title

")).toBe( + "*a completely bold title*\n" + ); + expect(slackify("

*asterisk title*

")).toBe("*asterisk title*\n"); + expect(slackify("

*asterisk title with *bold**

")).toBe( + "*asterisk title with bold*\n" + ); + expect( + slackify("

alternating bold header content

") + ).toBe("*alternating bold header content* \n"); + expect(slackify("

too many *asterisks* bold text

")).toBe( + "*too many asterisks bold text*\n" + ); + expect( + slackify("

header3 bold tag continues

outside") + ).toBe("*header3 bold tag continues* \n outside"); + expect( + slackify( + "

h1 with bold text

h2 with bold text

h3 with bold text

h4 with bold text

" + ) + ).toBe( + "*h1 with bold text*\n*h2 with bold text*\n*h3 with bold text*\n*h4 with bold text*\n" + ); + }); + + it("test code block", () => { + const input = + '
{  "name": "slackify-html",  "version": "1.0.0",  "description": "convert simple html to slack markdown",  "main": "slackify-html.js",  "scripts": {    "test": "tap tests.js"  }}
'; + const expected = + '```\n{\n "name": "slackify-html",\n "version": "1.0.0",\n "description": "convert simple html to slack markdown",\n "main": "slackify-html.js",\n "scripts": {\n "test": "tap tests.js"\n }\n}\n```\n'; + const output = slackify(input); + expect(output).toBe(expected); + }); + + it("test code block text only", () => { + const input = + '
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
'; + const expected = + "```\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n```\n"; + const output = slackify(input); + expect(output).toBe(expected); + }); + + it("test blockquote with line breaks/new lines", () => { + expect( + slackify("
block quote with
line
breaks
") + ).toBe(">block quote with \n>line\n>breaks\n\n"); + expect( + slackify( + "
block quote with embedded\n\n newlines
" + ) + ).toBe(">block quote with embedded\n>\n> newlines\n\n"); + expect( + slackify( + "
block quote with trailing newlines\n\n
" + ) + ).toBe(">block quote with trailing newlines\n>\n\n"); + }); + + it("test blockquote with paragraphs and line breaks/new lines", () => { + expect( + slackify("

paragraph in blockquote

") + ).toBe(">paragraph in blockquote\n\n"); + expect( + slackify( + "

paragraph
with
line break blockquote

" + ) + ).toBe(">paragraph\n>with\n>line break blockquote\n\n"); + expect( + slackify( + "

paragraph block quote with embedded\n\n newlines

" + ) + ).toBe(">paragraph block quote with embedded\n>\n> newlines\n\n"); + expect( + slackify( + "

paragraph block quote with trailing newlines\n\n

" + ) + ).toBe(">paragraph block quote with trailing newlines\n>\n>\n\n"); + }); + + it("test guru blockquote", () => { + expect( + slackify( + '
block quote text
' + ) + ).toBe(">block quote text\n\n"); + expect( + slackify( + '
block quote bold text
' + ) + ).toBe(">block quote *bold* text\n\n"); + expect( + slackify( + '
block quote italic text
' + ) + ).toBe(">block quote _italic_ text\n\n"); + expect( + slackify( + '
block quote underline text
' + ) + ).toBe(">block quote underline text\n\n"); + expect( + slackify( + '
block quote strikethrough text
' + ) + ).toBe(">block quote ~strikethrough~ text\n\n"); + expect( + slackify( + '
block quote highlight text
' + ) + ).toBe(">block quote highlight text\n\n"); + expect( + slackify( + '
block quote color text
' + ) + ).toBe(">block quote color text\n\n"); + expect( + slackify( + '
block quote link
' + ) + ).toBe(">block quote \n\n"); + expect( + slackify( + '
block quote file
' + ) + ).toBe(">block quote file\n\n"); + expect( + slackify( + '
block quote guru code snippet
' + ) + ).toBe(">`block quote guru code snippet`\n\n"); + }); + + it("full example", () => { + const input = `

Security Overview Header

We take the security of your data very seriously!

In order to instill the necessary confidence, we wanted to provide full transparency on why, who, where, when and how we protect your data.

Given the sensitive nature of your content and need to maintain your privacy being a priority for us, we wanted to share the practices and policies we have put into place.

Privacy Policy

Here's a test blockquote with bolded information

Remember this list

  1. foo

  2. bar

  3. buz

and this list too..

  • abc

    • sub1

    • sub2

  • def

  • xyz

and this

blah
Column 1Column 2Column 3
FooBarBaz
abcdefghi
`; + const expected = + "*Security Overview Header*\n*We take the security of your data very seriously!*\nIn order to instill the necessary confidence, we wanted to provide full transparency on _why_, _who_, _where_, _when_ and _how_ we protect your data.\nGiven the sensitive nature of your content and need to maintain your privacy being a priority for us, we wanted to share the practices and policies we have put into place.\n\n>Here's a test blockquote *with bolded* information\n\nRemember this list\n1. foo\n\n2. bar\n\n3. buz\n\nand this list too..\n• _abc_ \n • sub1\n\n • sub2\n\n\n• *def* \n\n• xyz\n\n`and this`\n```\nblah\n```\n| Column 1 | Column 2 | Column 3 |\n| Foo | Bar | Baz |\n| abc | def | ghi |\n"; + const output = slackify(input); + expect(output).toBe(expected); + }); + + it("tables with paragraph", () => { + const input = + '

one

dwqtwo

three

dwqdwq

dwqdwqdwq

dwqdwqdwqdwq

'; + const expected = `| one | dwqtwo | three | +| dwqdwq | dwqdwqdwq | dwqdwqdwqdwq |\n`; + + const output = slackify(input); + expect(output).toBe(expected); + }); + + it("handles strikethroughs", () => { + const input = + 'strikethrough'; + const expected = "~strikethrough~"; + + const output = slackify(input); + expect(output).toBe(expected); + }); + + it("handles bold & link", () => { + const input = + 'Work Intake (Request Forms)

https://wrike.wistia.com/medias/icpvdlvxa5'; + const expected = "*Work Intake (Request Forms)*\n\n"; + + const output = slackify(input); + expect(output).toBe(expected); + }); + + it("handle links with hidden br & strip", () => { + const input = + 'Work Intake (ad-hoc)
https://wrike.wistia.com/medias/q97lz2gze7
'; + const expected = "*Work Intake (ad-hoc)*"; + + const output = slackify(input); + expect(output).toBe(expected); + }); + + describe("UL and OL", () => { + describe("Old editor HTML", () => { + it("should handle non-nested
    tags appropriately", () => { + const input = + '
    • 1

    • 2

    • 3

    • 4

    '; + + const expected = `• 1\n\n• 2\n\n• 3\n\n• 4\n\n`; + expect(slackify(input)).toBe(expected); + }); + it("should handle nested
      tags appropriately", () => { + const input = + '
      • 1st level

        • 2nd level

          • 3rd level

            • 4th level

          • 3rd level back

        • 2nd level back

        • 2nd level back again

      • 1st level back

      '; + + const expected = `• 1st level\n • 2nd level\n • 3rd level\n • 4th level\n\n\n • 3rd level back\n\n\n • 2nd level back\n\n • 2nd level back again\n\n\n• 1st level back\n\n`; + expect(slackify(input)).toBe(expected); + }); + it("should handle non-nested
        tags appropriately", () => { + const input = + '
        1. test 1
        2. test 2
        3. test 3

        '; + const expected = "1. test 1\n2. test 2\n3. test 3\n\n"; + expect(slackify(input)).toBe(expected); + }); + }); + + describe("New editor HTML", () => { + it("should handle non-nested
          tags appropriately", () => { + const input = + '
          • 1 bullet
          • 2 bullet
          • 3 bullet
          • 4 bullet

          '; + const expected = "• 1 bullet\n• 2 bullet\n• 3 bullet\n• 4 bullet\n\n"; + + expect(slackify(input)).toBe(expected); + }); + it("should handle nested
            tags appropriately", () => { + const input = + '
            • 1st level
              • 2nd level
                • 3rd level
                  • 4th level
                • 3rd level back
              • 2nd level back
              • 2nd level back again
            • 1st level back

            '; + + const expected = `• 1st level\n • 2nd level\n • 3rd level\n • 4th level\n • 3rd level back\n • 2nd level back\n • 2nd level back again\n• 1st level back\n\n`; + + expect(slackify(input)).toBe(expected); + }); + it("should handle non-nested
              tags appropriately", () => { + const input = + '
              1. 1st item
              2. 2nd item
              3. 3rd item
              4. 4th item

              '; + + const expected = + "1. 1st item\n2. 2nd item\n3. 3rd item\n4. 4th item\n\n"; + + expect(slackify(input)).toBe(expected); + }); + it("should handle various different types of markdown formatting within the list content", () => { + const input = + '
              • regular codeblock bold italicized hyperlink strikethrough
              • strikethrough
              • ITALICIZED TEXT
              • BOLDED TEXT

              '; + + const expected = + "• regular `codeblock` *bold* _italicized_ ~strikethrough~ \n• ~strikethrough~ \n• _ITALICIZED TEXT_ \n• *BOLDED TEXT* \n• *_`EVERYTHING`_ * \n• \n\n"; + + expect(slackify(input)).toBe(expected); + }); + }); + + it('should handle collapsible elements', () => { + + const input = `

              Collapsible Element with nested one

              Below is nested

              Summary

              Content

              ` + + const expected = ` +*Collapsible Element with nested one* +Below is nested + +*Summary* +Content +` + expect(slackify(input)).toBe(expected); + }) + }); +}); diff --git a/tests.js b/tests.js deleted file mode 100644 index 708da2b..0000000 --- a/tests.js +++ /dev/null @@ -1,32 +0,0 @@ -var tap = require('tap'), - slackify = require('./slackify-html'); - -tap.test('simple', function simple(t) { - t.equals(slackify('test'), 'test'); - t.equals(slackify('test 1'), 'test 1'); - t.end(); -}); - -tap.test('tags', function tags(t) { - t.equals(slackify('test bold'), 'test *bold*'); - t.equals(slackify('test example link
              '), 'test '); - t.end(); -}); - -tap.test('malformed html', function invalid(t) { - t.equals(slackify('test asd'), 'test *asd*'); - t.equals(slackify('sab tag'), 'sab tag'); - t.equals(slackify('2.4-SNAPSHOT • revision a245dc9 • build 2015-09-07 14:06 • wbl 1.3.33 • details »'), - '*2.4-SNAPSHOT* • revision • build 2015-09-07 14:06 • wbl 1.3.33 • '); - t.end(); -});