diff --git a/.changeset/fast-bobcats-battle.md b/.changeset/fast-bobcats-battle.md new file mode 100644 index 0000000..261c903 --- /dev/null +++ b/.changeset/fast-bobcats-battle.md @@ -0,0 +1,13 @@ +--- +"md-to-react-email": major +--- + +### Changes + +- Added [`Marked`](https://marked.js.org/) for markdown transformations +- Removed `ParseMarkdownToReactEmail` function + +### Fixes + +- Fixed issue with [list parsing](https://github.com/codeskills-dev/md-to-react-email/issues/11) +- Fixed `parseCssInJsToInlineCss` issue with numerical values diff --git a/README.md b/README.md index 29a9861..b5ff42b 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ Read the documentation [here](https://md2re.codeskills.dev/) md-to-react-email is a lightweight utility for converting [Markdown](https://www.markdownguide.org/) into valid [React-email](https://react.email) templates. This tool simplifies the process of creating responsive and customizable email templates by leveraging the power of React and Markdown. +**Note**: Starting from `version 4`, `md-to-react-email` uses [`Marked`](https://marked.js.org/) for markdown transformation. see all changes [here](/CHANGELOG.md) + ### Support The following markdown flavors are supported - Offical markdown flavour -- Github flavoured markdown ## Installation @@ -35,7 +36,6 @@ npm install md-to-react-email - `camelToKebabCase`: converts strings from camelcase ['thisIsCamelCase'] to kebab case ['this-is-kebab-case'] - `parseCssInJsToInlineCss`: converts css styles from css-in-js to inline css e.g fontSize: "18px" => font-size: 18px; -- `parseMarkdownToReactEmail`: parses markdown to a valid react-email string that can be copied and pasted directly into your codebase - `parseMarkdownToReactEmailJSX`: parses markdown to valid react-email JSX for the client (i.e the browser) ### Components: @@ -75,18 +75,6 @@ npm install md-to-react-email ``` -- For code generation (copy and paste) - - ``` - import {parseMarkdownToReactEmail} from "md-to-react-email" - - const markdown = `# Hello World` - const parsedReactMail = parseMarkdownToReactEmail(markdown) - - console.log(parsedReactMail) // `` - - ``` - ## Components md-to-react-email contains pre-defined react-email and html components for the email template structure and styling. You can modify these components to customize the look and feel of your email template. @@ -98,7 +86,7 @@ The following components are available for customization: - Text: paragraphs, bold and italic text - Links - Code: Code blocks and inline code -- Lists: ul, li +- Lists: ul, ol, li - Image - Line-breaks (br) - Horizontal-rule (hr) diff --git a/__tests__/camelToKebabCase.test.ts b/__tests__/camelToKebabCase.test.ts index c1d0f02..83e46e6 100644 --- a/__tests__/camelToKebabCase.test.ts +++ b/__tests__/camelToKebabCase.test.ts @@ -1,4 +1,4 @@ -import { camelToKebabCase } from "../src"; +import { camelToKebabCase } from "../src/utils"; describe("camelToKebabCase", () => { it("should convert camel case to kebab case", () => { diff --git a/__tests__/parseCssInJsToInlineCss.test.ts b/__tests__/parseCssInJsToInlineCss.test.ts index 08e3cf0..ce8447e 100644 --- a/__tests__/parseCssInJsToInlineCss.test.ts +++ b/__tests__/parseCssInJsToInlineCss.test.ts @@ -1,4 +1,4 @@ -import { parseCssInJsToInlineCss } from "../src"; +import { parseCssInJsToInlineCss } from "../src/utils"; describe("parseCssInJsToInlineCss", () => { test("should return an empty string for undefined CSS properties", () => { diff --git a/__tests__/parseMarkdownToReactEmail.test.ts b/__tests__/parseMarkdownToReactEmail.test.ts deleted file mode 100644 index 6d50594..0000000 --- a/__tests__/parseMarkdownToReactEmail.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { parseMarkdownToReactEmail } from "../src"; - -describe("Markdown to React Mail Parser", () => { - it("converts headers correctly", () => { - const markdown = "# Hello, World!"; - const expected = `
Hello, World!
`; - - const rendered = parseMarkdownToReactEmail(markdown); - expect(rendered).toBe(expected); - }); - - it("converts paragraphs, headers, lists, and tables correctly", () => { - const markdown = `# Header 1 - -This is a paragraph with some text. You can have multiple paragraphs separated by empty lines. - -## Header 2 - -Here is an unordered list: -- Item 1 -- Item 2 -- Item 3 - -### Header 3 - -Here is an ordered list: -1. First -2. Second -3. Third - -#### Header 4 - -A table example: - -| Column 1 | Column 2 | -|----------|----------| -| Cell 1 | Cell 2 | -| Cell 3 | Cell 4 | -`; - - const expected = `
Header 1 - -This is a paragraph with some text. You can have multiple paragraphs separated by empty lines. - -Header 2 - -Here is an unordered list: - - -Header 3 - -Here is an ordered list: -
  1. First
  2. -
  3. Second
  4. -
  5. Third
  6. -
-Header 4 - -A table example: -
Column 1Column 2
Cell 1Cell 2
Cell 3Cell 4
`; - - const rendered = parseMarkdownToReactEmail(markdown); - expect(rendered).toBe(expected); - }); - - it("converts images, codeblocks, blockquotes and nested blockquotes correctly", () => { - const markdown = `##### Header 5 - -An image example: -![Alt Text](https://example.com/image.jpg) - -###### Header 6 - -Some **bold text** and *italic text* _italic text_. - -Code block example: -\`\`\` -javascript -function greet() { - console.log('Hello!'); -} -\`\`\` - -Here is a block quote example: - -> This is a block quote. -> It can span multiple lines. -> > This is a nested quote -> > With multiple -> > Lines of code - -> Block quotes are often used to highlight important information or provide references.`; - - const expected = `
Header 5 - -An image example: -\"Alt - -Header 6 - -Some bold text and italic text italic text. - -Code block example: -
-  javascript
-  function greet() {
-    console.log('Hello!');
-  }
-
- -Here is a block quote example: - - -This is a block quote. -It can span multiple lines. - -This is a nested quote -With multiple -Lines of code - - - - -Block quotes are often used to highlight important information or provide references. -
`; - - const rendered = parseMarkdownToReactEmail(markdown); - expect(rendered).toBe(expected); - }); -}); diff --git a/__tests__/parseMarkdownToReactEmailJSX.test.ts b/__tests__/parseMarkdownToReactEmailJSX.test.ts index 4f9a6c2..ae443e7 100644 --- a/__tests__/parseMarkdownToReactEmailJSX.test.ts +++ b/__tests__/parseMarkdownToReactEmailJSX.test.ts @@ -1,6 +1,88 @@ import { parseMarkdownToReactEmailJSX } from "../src"; describe("Markdown to React Mail JSX Parser", () => { + it("handles markdown correctly", () => { + const markdown = `# Markdown Test Document + +This is a **test document** to check the capabilities of a Markdown parser. + +## Headings + +### Third-Level Heading + +#### Fourth-Level Heading + +##### Fifth-Level Heading + +###### Sixth-Level Heading + +## Text Formatting + +This is some **bold text** and this is some *italic text*. You can also use ~~strikethrough~~ and \`inline code\`. + +## Lists + +1. Ordered List Item 1 +2. Ordered List Item 2 +3. Ordered List Item 3 + +- Unordered List Item 1 +- Unordered List Item 2 +- Unordered List Item 3 + +## Links + +[Markdown Guide](https://www.markdownguide.org) + +## Images + +![Markdown Logo](https://markdown-here.com/img/icon256.png) + +## Blockquotes + +> This is a blockquote. +> - Author + +## Code Blocks + +\`\`\`javascript +function greet(name) { +console.log(\`Hello, \$\{name\}!\`); +} +\`\`\``; + + const expected = `

Markdown Test Document

This is a test document to check the capabilities of a Markdown parser.

+

Headings

Third-Level Heading

Fourth-Level Heading

Fifth-Level Heading
Sixth-Level Heading

Text Formatting

This is some bold text and this is some italic text. You can also use strikethrough and inline code.

+

Lists

    +
  1. Ordered List Item 1
  2. +
  3. Ordered List Item 2
  4. +
  5. Ordered List Item 3
  6. +
+ +

Links

Markdown Guide

+

Images

\"Markdown

+

Blockquotes

+

This is a blockquote.

+ +
+

Code Blocks

function greet(name) {
+console.log(\`Hello, $\{name\}!\`);
+}
+
+`; + + const rendered = parseMarkdownToReactEmailJSX({ + markdown, + }); + expect(rendered).toBe(expected); + }); + it("handles empty string correctly", () => { const markdown = ""; const expected = ``; @@ -19,13 +101,7 @@ describe("Markdown to React Mail JSX Parser", () => { ##### Header 5 ###### Header 6 `; - const expected = `

Header 1

-

Header 2

-

Header 3

-

Header 4

-
Header 5
-
Header 6
-`; + const expected = `

Header 1

Header 2

Header 3

Header 4

Header 5
Header 6
`; const rendered = parseMarkdownToReactEmailJSX({ markdown, @@ -39,9 +115,7 @@ This is one This is two `; - const expected = `

The paragraphs

-

This is one

- + const expected = `

The paragraphs

This is one

This is two

`; @@ -53,13 +127,12 @@ This is two it("handles bold, italic and strikethrough texts correctly", () => { const markdown = `# The text formats -This is **one** bold and _italic_ text +This is **one** bold and *italic* text This is ~~striked~~ text and \`inline code\``; - const expected = `

The text formats

-

This is one bold and italic text

- -

This is striked text and inline code

`; + const expected = `

The text formats

This is one bold and italic text

+

This is striked text and inline code

+`; const rendered = parseMarkdownToReactEmailJSX({ markdown, @@ -79,17 +152,19 @@ Here is an ordered list: 2. Second 3. Third `; - const expected = `

The lists

-

Here is an unordered list:

-

Here is an ordered list:

-
  1. First
  2. +
      +
    1. First
    2. Second
    3. Third
    4. -
    `; +
+`; const rendered = parseMarkdownToReactEmailJSX({ markdown, @@ -106,7 +181,23 @@ Here is an ordered list: | Cell 3 | Cell 4 | `; const expected = `

A table example:

-
Column 1Column 2
Cell 1Cell 2
Cell 3Cell 4
`; + + + + + + + + + + + + + + + +
Column 1Column 2
Cell 1Cell 2
Cell 3Cell 4
+`; const rendered = parseMarkdownToReactEmailJSX({ markdown, @@ -125,11 +216,13 @@ greet("World") \`\`\``; const expected = `

A example:

-
  python
-  def greet(name):
-  print(f"Hello, {name}!")
-
greet("World") -
`; +
python
+def greet(name):
+print(f"Hello, {name}!")
+
+greet("World")
+
+`; const rendered = parseMarkdownToReactEmailJSX({ markdown, @@ -144,9 +237,10 @@ greet("World") const expected = `

A example:

-

Here's a block quote:

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

-
`; +

Here's a block quote: +Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+ +`; const rendered = parseMarkdownToReactEmailJSX({ markdown, @@ -158,8 +252,9 @@ greet("World") const markdown = `A example: ![Image description](https://example.com/image.jpg)`; - const expected = `

A example:

-Image description`; + const expected = `

A example: +Image description

+`; const rendered = parseMarkdownToReactEmailJSX({ markdown, @@ -171,8 +266,9 @@ greet("World") const markdown = `A link example: Here's a link to [OpenAI's website](https://openai.com/).`; - const expected = `

A link example:

-

Here's a link to OpenAI's website.

`; + const expected = `

A link example: +Here's a link to OpenAI's website.

+`; const rendered = parseMarkdownToReactEmailJSX({ markdown, diff --git a/__tests__/reactEmailMarkdown.test.tsx b/__tests__/reactEmailMarkdown.test.tsx index 3f13eff..e701a5f 100644 --- a/__tests__/reactEmailMarkdown.test.tsx +++ b/__tests__/reactEmailMarkdown.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render } from "@react-email/render"; -import { ReactEmailMarkdown } from "../src/components"; +import { ReactEmailMarkdown } from "../src"; describe("ReactEmailMarkdown component renders correctly", () => { it("renders the markdown in the correct format for browsers", () => { @@ -8,7 +8,7 @@ describe("ReactEmailMarkdown component renders correctly", () => { ); expect(actualOutput).toMatchInlineSnapshot( - `"

Hello, World!

"` + `"

Hello, World!

"` ); }); }); diff --git a/package.json b/package.json index 34e5bf1..fb19b21 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ ], "scripts": { "build": "tsup", - "demo": "npx tsc src/demo && node src/demo", "lint": "tsc", "release": "pnpm run build && changeset publish", "test": "jest --coverage", @@ -34,6 +33,7 @@ }, "license": "MIT", "devDependencies": { + "@changesets/cli": "^2.26.2", "@react-email/render": "0.0.7", "@types/jest": "29.5.2", "@types/react": "18.2.8", @@ -43,7 +43,6 @@ "ts-jest": "29.1.0", "ts-node": "10.9.1", "tsup": "6.7.0", - "@changesets/cli": "^2.26.2", "typescript": "5.1.3" }, "peerDependencies": { @@ -51,7 +50,8 @@ "react-email": ">1.9.3" }, "dependencies": { - "isomorphic-dompurify": "1.7.0" + "isomorphic-dompurify": "1.7.0", + "marked": "7.0.4" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dbacd1..8d52b74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ dependencies: isomorphic-dompurify: specifier: 1.7.0 version: 1.7.0 + marked: + specifier: 7.0.4 + version: 7.0.4 devDependencies: '@changesets/cli': @@ -3992,6 +3995,12 @@ packages: engines: {node: '>=8'} dev: true + /marked@7.0.4: + resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==} + engines: {node: '>= 16'} + hasBin: true + dev: false + /meow@6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} engines: {node: '>=8'} diff --git a/pre-planning/BASE-STYLES.md b/pre-planning/BASE-STYLES.md deleted file mode 100644 index 7a41987..0000000 --- a/pre-planning/BASE-STYLES.md +++ /dev/null @@ -1,68 +0,0 @@ -h1, h2, h3, h4, h5, h6 { -font-weight: 500; -padding-top: 20px; -} - -h1 { -font-size: 2.5rem; -} - -h2 { -font-size: 2rem; -} - -h3 { -font-size: 1.75rem; -} - -.h4, h4 { -font-size: 1.5rem; -} - -.h5, h5 { -font-size: 1.25rem; -} - -.h6, h6 { -font-size: 1rem; -} - -bold{ -font-weight: bold; -} - -italic { -font-style: italic; -} - -blockquote { -background: #f9f9f9; -border-left: 10px solid #ccc; -margin: 1.5em 10px; -padding: 1em 10px 0.1em 10px; -quotes: "\201C""\201D""\2018""\2019"; -} - -code { -color: #212529; -font-size: 87.5%; -word-wrap: break-word; -display: inline; -background: #f8f8f8; -font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; -} - -code-block { -padding-top: 10px; -padding-right: 10px; -padding-left: 10px; -padding-bottom: 1px; -margin-bottom: 20px; -background: #f8f8f8; -} - -link { -color: #007bff; -text-decoration: underline; -background-color: transparent; -} diff --git a/pre-planning/IDEAS.md b/pre-planning/IDEAS.md deleted file mode 100644 index 687c57e..0000000 --- a/pre-planning/IDEAS.md +++ /dev/null @@ -1,5 +0,0 @@ -- export package for use on web ui -- export directly as reactmail component ✅ -- pass custom components for different md tags -- pass custom styles for different md tags ✅ -- custom matchers for md tags for different flavors diff --git a/pre-planning/MATCHER-CASES.md b/pre-planning/MATCHER-CASES.md deleted file mode 100644 index 537cc31..0000000 --- a/pre-planning/MATCHER-CASES.md +++ /dev/null @@ -1,50 +0,0 @@ -// Handle headings (e.g., # Heading) -(/^#\s+(.+)$/gm, "

$1

"); - (/^##\s+(.+)$/gm, "

$1

"); - (/^###\s+(.+)$/gm, "

$1

"); - (/^####\s+(.+)$/gm, "

$1

"); - (/^#####\s+(.+)$/gm, "
$1
"); - (/^######\s+(.+)$/gm, "
$1
"); - -// Handle paragraphs -( -/((\n|^)(?!\n)((?!<\/?(h|ul|ol|p|pre|div|blockquote)[>\s]).)+(\n|$)+)+/gm, - "

$&

" -); - -// Handle bold text (e.g., **bold**) -(/\*\*(.+?)\*\*/g, "$1"); - -// Handle italic text (e.g., _italic_) -(/\*(.+?)\*/g, "$1"); - -// Handle unordered lists -(/^\s*-\s+(.*)$/gm, "
  • $1
  • "); - (/<\/li>\n
  • /g, "
  • "); -``; - -// Handle ordered lists -(/^\s*\d+\.\s+(.*)$/gm, "
  • $1
  • "); - (/<\/li>\n
  • /g, "
  • "); -`
      ${markdown}
    `; - -// Handle links (e.g., [link text](url)) -(/\[(.+?)\]\((.\*?)\)/g, '$1'); - -// Handle images (e.g., ![alt text](url)) -( -/!\[(._?)\]\((._?)\)/g, -'$1' -); - -// Handle code blocks (e.g., `code`) -(/`(.+?)`/gs, "
    $1
    "); - -// Handle inline code (e.g., `code`) -(/`(.+?)`/g, "$1"); - -// Handle line breaks (e.g.,
    ) -(/ \n/g, "
    "); - -// Handle horizontal rules (e.g., ---) -(/^-{3,}$/gm, "
    "); diff --git a/pre-planning/PROJECT-BREAKDOWN.md b/pre-planning/PROJECT-BREAKDOWN.md deleted file mode 100644 index 0da0b1a..0000000 --- a/pre-planning/PROJECT-BREAKDOWN.md +++ /dev/null @@ -1,55 +0,0 @@ -# Project breakdown - -## Functions: - -- `camelToKebabCase`: converts strings from camelcase ['thisIsCamelCase'] to kebab case ['this-is-kebab-case'] -- `parseCssInJsToInlineCss`: converts css styles from css-in-js to inline css e.g fontSize: "18px" => font-size: 18px; -- `parseMarkdownToReactEmail`: parses markdown to a valid react-email string that can be copied and pasted directly into your codebase -- `parseMarkdownToReactEmailJSX`: parses markdown to valid react-email JSX for the client (i.e the browser) - -## Components: - -- `ReactEmailMarkdown`: a react-email component that takes in markdown input and parses it directly in your code base - -## Usage: - -- Directly as `React-email` component - -``` -import {ReactEmailMarkdown} from "md-to-react-email" - -export default function EmailTemplate() { - return ( - - -
    - -
    -
    - ) - } -``` - -- Directly into react-email template - -``` -import {parseMarkdownToReactEmailJSX} from "md-to-react-email" - -const markdown = `# Hello World` -const parsedReactMail = parseMarkdownToReactEmail(markdown) - -console.log(parsedReactMail) // `` - -``` - -- For code generation (copy and paste) - -``` -import {parseMarkdownToReactEmail} from "md-to-react-email" - -const markdown = `# Hello World` -const parsedReactMail = parseMarkdownToReactEmail(markdown) - -console.log(parsedReactMail) // `` - -``` diff --git a/pre-planning/PROJECT-OUTLINE.md b/pre-planning/PROJECT-OUTLINE.md deleted file mode 100644 index 76964ac..0000000 --- a/pre-planning/PROJECT-OUTLINE.md +++ /dev/null @@ -1,35 +0,0 @@ -# Project breakdown - -### Note: This is a non opinionated project. - -- Create some basic styles for all our components - - - Headers h1-h6 - - Paragraphs - - Quotes - - Lists - ordered and unordered - - Code Blocks - - Images - - Horizontal Rules - - Line breaks - - Links - -- ReactMail (rm) -- Input markdown (md) -- Function mdToRm -- Output valid RM e.g (# A boy => A boy) - -# General file structure - -- Styles file(css-in-js): contains all the basic styles - - > CSS
    - > .header{
    - > font-size: 16px
    - > }
    >
    - > CSS-in-JS
    - > const header = {
    - > fontSize: 16
    - > }
    - -- Index.js: one function that takes in md and spits out rm with styles already in place diff --git a/pre-planning/REPLACEMENT-CASES.md b/pre-planning/REPLACEMENT-CASES.md deleted file mode 100644 index bd8082d..0000000 --- a/pre-planning/REPLACEMENT-CASES.md +++ /dev/null @@ -1,43 +0,0 @@ -``` - - - - - - - -
    - - - - - - -
    - - -``` - -- Headers h1-h6 - -- Paragraphs - -- Quotes -
    - -- Lists - ordered and unordered -- Inline code - -- Code Blocks -
    - -- Images - -- Horizontal Rules -
    -- Line breaks -
    -- Links - diff --git a/pre-planning/TODOS.md b/pre-planning/TODOS.md deleted file mode 100644 index 3795581..0000000 --- a/pre-planning/TODOS.md +++ /dev/null @@ -1,3 +0,0 @@ -- Extensive testing of packages and components -- Create NPM package -- Export component directly into RM teplate✅ diff --git a/src/.DS_Store b/src/.DS_Store index d17bb8d..18d0551 100644 Binary files a/src/.DS_Store and b/src/.DS_Store differ diff --git a/src/components/reactEmailMarkdown.tsx b/src/components/reactEmailMarkdown.tsx index 21417e6..8fc5634 100644 --- a/src/components/reactEmailMarkdown.tsx +++ b/src/components/reactEmailMarkdown.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { StylesType } from "../types"; -import { parseMarkdownToReactEmailJSX } from "../utils"; +import { parseMarkdownToReactEmailJSX } from "../parseMarkdownToReactEmailJSX"; interface ReactEmailMarkdownProps { markdown: string; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..155388d --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,17 @@ +import DOMPurify from "isomorphic-dompurify"; + +DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if ("target" in node) { + node.setAttribute("target", "_blank"); + } +}); + +function preprocess(markdown: string) { + return markdown; +} + +function postprocess(html: string): string { + return DOMPurify.sanitize(html); +} + +export const hooks = { preprocess, postprocess }; diff --git a/src/index.ts b/src/index.ts index 55d88fc..cec4b0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,29 +1,19 @@ -/** - * Default Styles - */ -export { styles } from "./styles"; - -/** - * Default Matchers - */ -export { patterns } from "./patterns"; - /** * Types */ -export { Patterns, StylesType } from "./types"; +export { StylesType, ParseMarkdownToReactEmailJSXProps } from "./types"; /** - * Functions + * Utility Functions */ -export { - camelToKebabCase, - parseCssInJsToInlineCss, - parseMarkdownToReactEmail, - parseMarkdownToReactEmailJSX, -} from "./utils"; +export { parseMarkdownToReactEmailJSX } from "./parseMarkdownToReactEmailJSX"; /** * Components */ export { ReactEmailMarkdown } from "./components"; + +/** + * String Utils + */ +export { camelToKebabCase, parseCssInJsToInlineCss } from "./utils"; diff --git a/src/parseMarkdownToReactEmailJSX.ts b/src/parseMarkdownToReactEmailJSX.ts new file mode 100644 index 0000000..266be00 --- /dev/null +++ b/src/parseMarkdownToReactEmailJSX.ts @@ -0,0 +1,11 @@ +import { MarkdownParser } from "./parser"; +import { ParseMarkdownToReactEmailJSXProps } from "./types"; + +export const parseMarkdownToReactEmailJSX = ({ + markdown, + customStyles, + withDataAttr, +}: ParseMarkdownToReactEmailJSXProps) => { + const parser = new MarkdownParser({ customStyles, withDataAttr }); + return parser.parse(markdown); +}; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..e91c420 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,27 @@ +import { marked, RendererObject } from "marked"; +import { StylesType } from "./types"; +import { initRenderer } from "./utils"; +import { hooks } from "./hooks"; + +export class MarkdownParser { + private readonly renderer: RendererObject; + + constructor({ + customStyles, + withDataAttr, + }: { + customStyles?: StylesType; + withDataAttr?: boolean; + }) { + this.renderer = initRenderer({ customStyles, withDataAttr }); + } + + parse(markdown: string) { + marked.use({ + renderer: this.renderer, + hooks, + }); + + return marked.parse(markdown); + } +} diff --git a/src/patterns.ts b/src/patterns.ts deleted file mode 100644 index 77a4bca..0000000 --- a/src/patterns.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const patterns = { - h1: /^#\s+(.+)$/gm, - h2: /^##\s+(.+)$/gm, - h3: /^###\s+(.+)$/gm, - h4: /^####\s+(.+)$/gm, - h5: /^#####\s+(.+)$/gm, - h6: /^######\s+(.+)$/gm, - p: /^(?!#{1,6}\s)((?!<(pre|blockquote|Text)\b[^>]*>)(?!.*<\/(pre|blockquote|Text)>$)((?!(?:[-*+\s]+|\d+\.\s+|#\s+|.*?\|.*?\||!\[.*?\]\(.*?\)|```\s*\n(?:.|\n)+?```| {4}(?:.|\n)+?))(?:.|\n)+?))(?=\n{2,}|$)/gm, - bold: /\*\*(.+?)\*\*/g, - italic: /([*_])(.*?)\1/g, - li: /^\s*[-|\*]\s+(.*)$/gm, - ul: /()(?![\s\S]*<\/ul>)/gs, - ol: /^(?:\d+\.\s+.+$(?:\n(?!$).+)*(?:\n|$))+/gm, - image: /!\[(.*?)\]\((.*?)\)/g, - link: /\[(.+?)\]\((.*?)\)/g, - blockQuote: /^>(?: .+?(?:\n|$))+/gm, - nestedBlockQuote: /^>( .+?(?:\n|$))+/gm, - codeBlocks: /```(?:[\s\S]*?\n)?([\s\S]*?)\n```/g, - codeInline: /(? { - if ("target" in node) { - node.setAttribute("target", "_blank"); - } -}); +import { StylesType, initRendererProps } from "./types"; +import { RendererObject } from "marked"; +import { styles } from "./styles"; export function camelToKebabCase(str: string): string { return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); @@ -20,625 +12,246 @@ export function parseCssInJsToInlineCss( ): string { if (!cssProperties) return ""; + const numericalCssProperties = [ + "width", + "height", + "margin", + "marginTop", + "marginRight", + "marginBottom", + "marginLeft", + "padding", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "borderWidth", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "outlineWidth", + "top", + "right", + "bottom", + "left", + "fontSize", + "lineHeight", + "letterSpacing", + "wordSpacing", + "maxWidth", + "minWidth", + "maxHeight", + "minHeight", + "borderRadius", + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomLeftRadius", + "borderBottomRightRadius", + "textIndent", + "gridColumnGap", + "gridRowGap", + "gridGap", + "translateX", + "translateY", + ]; + return Object.entries(cssProperties) - .map(([property, value]) => `${camelToKebabCase(property)}:${value}`) + .map(([property, value]) => { + if ( + typeof value === "number" && + numericalCssProperties.includes(property) + ) { + return `${camelToKebabCase(property)}:${value}px`; + } else { + return `${camelToKebabCase(property)}:${value}`; + } + }) .join(";"); } -export function parseMarkdownToReactEmail( - markdown: string, - customStyles?: StylesType -): string { +export const initRenderer = ({ + customStyles, + withDataAttr = false, +}: initRendererProps): RendererObject => { const finalStyles = { ...styles, ...customStyles }; - let reactMailTemplate = ""; - // Handle inline code (e.g., `code`) - reactMailTemplate = markdown.replace( - patterns.codeInline, - `$2` - ); - - // Handle code blocks (e.g., ```code```) - reactMailTemplate = reactMailTemplate.replace( - patterns.codeBlocks, - function (_, codeContent: string) { - const indentedCodeContent = codeContent - .split("\n") - .map((line) => ` ${line}`) - .join("\n"); - return `\n${indentedCodeContent}\n`; - } - ); + }>\n${quote}\n`; + }, - // Handle blockquotes - function parseMarkdownWithBlockQuotes(markdown: string): string { - const blockquoteRegex = /^(>\s*((?:.+\n?)+))(?!\n(?=>\s))/gm; - - function parseBlockQuote(match: string) { - const nestedContent = match.replace(/^>\s*/gm, ""); - const nestedHTML = parseMarkdownWithBlockQuotes(nestedContent); - return `\n${nestedHTML}\n`; - } - - return markdown.replace(blockquoteRegex, parseBlockQuote); - } - - reactMailTemplate = parseMarkdownWithBlockQuotes(reactMailTemplate); - - // Handle paragraphs - reactMailTemplate = reactMailTemplate.replace( - patterns.p, - `$1` - ); - - // Handle headings (e.g., # Heading) - reactMailTemplate = reactMailTemplate.replace( - patterns.h1, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h2, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h3, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h4, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h5, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h6, - `$1` - ); - - // Handle Tables from GFM - reactMailTemplate = reactMailTemplate.replace( - patterns.table, - (match: string) => { - const rows = match.trim().split("\n"); - const headers = rows[0] - .split("|") - .slice(1, -1) - .map((cell) => cell.trim()); - const alignments = rows[1] - .split("|") - .slice(1, -1) - .map((cell) => { - const align = cell.trim().toLowerCase(); - return align === ":--" - ? "left" - : align === "--:" - ? "right" - : "center"; - }); - const body = rows - .slice(2) - .map((row) => { - const cells = row - .split("|") - .slice(1, -1) - .map((cell) => cell.trim()); - return `${cells - .map( - (cell, index) => - `${cell}` - ) - .join("")}`; - }) - .join(""); - - const table = `${headers - .map( - (header, index) => - `${header}` - ) - .join("")}${body}`; - return table; - } - ); - - // Handle strikethrough - reactMailTemplate = reactMailTemplate.replace( - patterns.strikethrough, - `$1` - ); - - // Handle bold text (e.g., **bold**) - reactMailTemplate = reactMailTemplate.replace( - patterns.bold, - `$1` - ); - - // Handle italic text (e.g., *italic*) - reactMailTemplate = reactMailTemplate.replace( - patterns.italic, - `$2` - ); - - // Handle lists (unordered) - reactMailTemplate = reactMailTemplate.replace( - patterns.li, - `$1
  • ` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.ul, - `$&` - ); - - // Handle lists (ordered) - reactMailTemplate = reactMailTemplate.replace(patterns.ol, function (match) { - const listItems = match - .split("\n") - .map((line) => { - const listItemContent = line.replace(/^\d+\.\s+/, ""); - return listItemContent - ? `${listItemContent}` - : ""; - }) - .join("\n"); - return `${listItems}`; - }); - - // Handle images (e.g., ![alt text](url)) - reactMailTemplate = reactMailTemplate.replace( - patterns.image, - `$1` - ); - - // Handle links (e.g., [link text](url)) - reactMailTemplate = reactMailTemplate.replace( - patterns.link, - `$1` - ); - - // Handle line breaks (e.g.,
    ) - reactMailTemplate = reactMailTemplate.replace( - patterns.br, - `` - ); - - // Handle horizontal rules (e.g., ---) - reactMailTemplate = reactMailTemplate.replace( - patterns.hr, - `` - ); - - reactMailTemplate = `
    ${reactMailTemplate}
    `; - - return reactMailTemplate; -} - -interface ParseMarkdownToReactEmailJSXProps { - markdown: string; - customStyles?: StylesType; - withDataAttr?: boolean; -} + } />`; + }, -export function parseMarkdownToReactEmailJSX({ - markdown, - customStyles, - withDataAttr = false, -}: ParseMarkdownToReactEmailJSXProps): string { - if ( - markdown === undefined || - markdown === null || - markdown === "" || - typeof markdown !== "string" - ) { - return ""; - } + code(code) { + code = code.replace(/\n$/, "") + "\n"; - const finalStyles = { ...styles, ...customStyles }; - let reactMailTemplate = ""; - - // Handle inline code (e.g., `code`) - reactMailTemplate = markdown.replace( - patterns.codeInline, - `$2` - ); - - // Handle code blocks (e.g., ```code```) - reactMailTemplate = reactMailTemplate.replace( - patterns.codeBlocks, - function (_, codeContent: string) { - const indentedCodeContent = codeContent - .split("\n") - .map((line) => ` ${line}`) - .join("\n"); return `\n${indentedCodeContent}\n`; - } - ); + }>${code}\n`; + }, - // Handle blockquotes - function parseMarkdownWithBlockQuotes(markdown: string): string { - const blockquoteRegex = /^(>\s*((?:.+\n?)+))(?!\n(?=>\s))/gm; + codespan(text) { + return `${text}`; + }, - function parseBlockQuote(match: string) { - const nestedContent = match.replace(/^>\s*/gm, ""); - const nestedHTML = parseMarkdownWithBlockQuotes(nestedContent); - return `\n${nestedHTML}\n`; - } + }>${text}`; + }, - return markdown.replace(blockquoteRegex, parseBlockQuote); - } + em(text) { + return `${text}`; + }, + + heading(text, level) { + return `${text}`; + }, + + hr() { + return `\n`; + }, - reactMailTemplate = parseMarkdownWithBlockQuotes(reactMailTemplate); + image(href, _, text) { + let out = `${text}`; + return out; + }, + + link(href, _, text) { + let out = `${text}`; + return out; + }, + + list(body, ordered, start) { + const type = ordered ? "ol" : "ul"; + const startatt = ordered && start !== 1 ? ' start="' + start + '"' : ""; + return ( + "<" + + type + + startatt + + `${ + parseCssInJsToInlineCss(finalStyles.ol) !== "" + ? ` style="${parseCssInJsToInlineCss( + finalStyles[ordered ? "ol" : "ul"] + )}"` + : "" + } >\n` + + body + + "\n" + ); + }, + + listitem(text) { + return `${text}\n`; + }, - // Handle paragraphs - reactMailTemplate = reactMailTemplate.replace( - patterns.p, - `$1

    ` - ); + paragraph(text) { + return `${text}

    \n`; + }, - // Handle headings (e.g., # Heading) - reactMailTemplate = reactMailTemplate.replace( - patterns.h1, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h2, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h3, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h4, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h5, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.h6, - `$1` - ); + strong(text) { + return `${text}`; + }, - // Handle Tables from GFM - reactMailTemplate = reactMailTemplate.replace( - patterns.table, - (match: string) => { - const rows = match.trim().split("\n"); - const headers = rows[0] - .split("|") - .slice(1, -1) - .map((cell) => cell.trim()); - const alignments = rows[1] - .split("|") - .slice(1, -1) - .map((cell) => { - const align = cell.trim().toLowerCase(); - return align === ":--" - ? "left" - : align === "--:" - ? "right" - : "center"; - }); - const body = rows - .slice(2) - .map((row) => { - const cells = row - .split("|") - .slice(1, -1) - .map((cell) => cell.trim()); - return `${cells - .map( - (cell, index) => - `${cell}` - ) - .join("")}`; - }) - .join(""); + table(header, body) { + if (body) body = `${body}`; - const table = `\n\n${header}\n${body}\n`; + }, + + tablecell(content, flags) { + const type = flags.header ? "th" : "td"; + const tag = flags.align + ? `<${type} align="${flags.align}"${ + parseCssInJsToInlineCss(finalStyles.td) !== "" + ? ` style="${parseCssInJsToInlineCss(finalStyles.td)}"` + : "" + }>` + : `<${type}${ + parseCssInJsToInlineCss(finalStyles.td) !== "" + ? ` style="${parseCssInJsToInlineCss(finalStyles.td)}"` + : "" + }>`; + return tag + content + `\n`; + }, + + tablerow(content) { + return `${headers - .map( - (header, index) => - `${header}` - ) - .join("")}${body}`; - return table; - } - ); - - // Handle strikethrough - reactMailTemplate = reactMailTemplate.replace( - patterns.strikethrough, - `$1` - ); - - // Handle bold text (e.g., **bold**) - reactMailTemplate = reactMailTemplate.replace( - patterns.bold, - `$1` - ); + }>\n${content}\n`; + }, + }; - // Handle italic text (e.g., *italic*) - reactMailTemplate = reactMailTemplate.replace( - patterns.italic, - `$2` - ); - - // Handle lists (unordered) - reactMailTemplate = reactMailTemplate.replace( - patterns.li, - `$1` - ); - reactMailTemplate = reactMailTemplate.replace( - patterns.ul, - `$&` - ); - - // Handle lists (ordered) - reactMailTemplate = reactMailTemplate.replace(patterns.ol, function (match) { - const listItems = match - .split("\n") - .map((line) => { - const listItemContent = line.replace(/^\d+\.\s+/, ""); - return listItemContent - ? `${listItemContent}` - : ""; - }) - .join("\n"); - return `${listItems}`; - }); - - // Handle images (e.g., ![alt text](url)) - reactMailTemplate = reactMailTemplate.replace( - patterns.image, - `$1` - ); - - // Handle links (e.g., [link text](url)) - reactMailTemplate = reactMailTemplate.replace( - patterns.link, - `$1` - ); - - // Handle line breaks (e.g.,
    ) - reactMailTemplate = reactMailTemplate.replace( - patterns.br, - `` - ); - - // Handle horizontal rules (e.g., ---) - reactMailTemplate = reactMailTemplate.replace( - patterns.hr, - `` - ); - - return DOMPurify.sanitize(reactMailTemplate, { - USE_PROFILES: { html: true }, - }); -} + return customRenderer; +};