├── .gitignore ├── mod.ts ├── LICENSE.txt ├── marky.ts ├── marky_test.ts ├── marky.esm.js ├── README.md └── parsers.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | playground.ts 3 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import * as defaultParsers from "./parsers.ts"; 2 | 3 | export { marky } from "./marky.ts"; 4 | export { defaultParsers }; 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Asko Nõmm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /marky.ts: -------------------------------------------------------------------------------- 1 | import defaultParsers, { Parser } from "./parsers.ts"; 2 | 3 | /** 4 | * Scans given `blocks` for any partial code blocks which 5 | * it then stitches together, returning only whole blocks. 6 | */ 7 | function stitchCodeBlocks(blocks: string[]): string[] { 8 | const capturedBlocks: string[] = []; 9 | const codeBlockIndexes: number[] = []; 10 | 11 | blocks.forEach((block, index) => { 12 | // If the block starts as a code block, but doesn't end as 13 | // one, that means the code block spans multiple blocks and 14 | // we need to stitch them together until we find an end. 15 | if (block.trim().startsWith("```") && !block.trim().endsWith("```")) { 16 | let capturingBlock = block; 17 | let nextIndex = index + 1; 18 | 19 | // Saving indexes of blocks that are code blocks to be able 20 | // to distinguish between code blocks and other blocks. 21 | codeBlockIndexes.push(...[index, nextIndex]); 22 | 23 | // This will run and stitch together blocks until it finds 24 | // that the next block is the end of the code block. 25 | while ( 26 | typeof blocks[nextIndex] !== "undefined" && 27 | !blocks[nextIndex].trim().endsWith("```") 28 | ) { 29 | if (!codeBlockIndexes.length) { 30 | capturingBlock += blocks[nextIndex]; 31 | } else { 32 | capturingBlock += "\n\n" + blocks[nextIndex]; 33 | } 34 | 35 | nextIndex += 1; 36 | codeBlockIndexes.push(nextIndex); 37 | } 38 | 39 | // Now that we know that the next block is the last one, 40 | // we can stitch that as well. 41 | capturingBlock += "\n\n" + blocks[nextIndex]; 42 | 43 | // One block done :) 44 | capturedBlocks.push(capturingBlock); 45 | } // The following will be any other block, which we'll 46 | // keep as-is. 47 | else if (!codeBlockIndexes.includes(index)) { 48 | capturedBlocks.push(block); 49 | } 50 | }); 51 | 52 | return capturedBlocks; 53 | } 54 | 55 | /** 56 | * Parses given `content` for double line breaks which it then 57 | * turns into blocks. 58 | */ 59 | function createBlocks(content: string, parsers: Parser[]): string { 60 | let blocks: string[] = content.split(/\n\n/); 61 | 62 | // Stitch code blocks 63 | blocks = stitchCodeBlocks(blocks); 64 | 65 | // Return parsed blocks 66 | return blocks.map((block) => { 67 | const match = parsers.find((parser) => 68 | parser.matcher && parser.matcher(block) 69 | ); 70 | 71 | // If a match was found, we want a specific parser to deal with this block 72 | if (match) { 73 | for (const renderer of match.renderers) { 74 | block = renderer(block); 75 | } 76 | 77 | return block; 78 | } 79 | 80 | // If no match was found, we let everything without a matcher deal with this block 81 | const parsersWithoutMatcher = parsers.filter((parser) => !parser.matcher); 82 | 83 | for (const parser of parsersWithoutMatcher) { 84 | for (const renderer of parser.renderers) { 85 | block = renderer(block); 86 | } 87 | } 88 | 89 | return block; 90 | }).join(""); 91 | } 92 | 93 | /** 94 | * Takes in raw Markdown as `content`, sends it to the heavens 95 | * where it will be polished into fine bits of HTML and handed down 96 | * to you with great glory. 97 | */ 98 | export function marky( 99 | content: string, 100 | parsers: Parser[] = defaultParsers, 101 | ): string { 102 | return createBlocks(content, parsers); 103 | } 104 | -------------------------------------------------------------------------------- /marky_test.ts: -------------------------------------------------------------------------------- 1 | import { marky } from "./marky.ts"; 2 | import { assertEquals } from "https://deno.land/std@0.113.0/testing/asserts.ts"; 3 | 4 | // Test bold text parsing and conversion 5 | Deno.test("bold text parsing and conversion", () => { 6 | const testString = `Hello **Mr. Bond**.`; 7 | const expectedResult = `

Hello Mr. Bond.

`; 8 | assertEquals(marky(testString), expectedResult); 9 | }); 10 | 11 | // Test italic text parsing and conversion 12 | Deno.test("italic text parsing and conversion", () => { 13 | const testString = `Hello _Mr. Bond_.`; 14 | const expectedResult = `

Hello Mr. Bond.

`; 15 | assertEquals(marky(testString), expectedResult); 16 | }); 17 | 18 | // Test inline code text parsing and conversion 19 | Deno.test("inline code text parsing and conversion", () => { 20 | const testString = `Hello \`Mr. Bond\`.`; 21 | const expectedResult = `

Hello Mr. Bond.

`; 22 | assertEquals(marky(testString), expectedResult); 23 | }); 24 | 25 | // Test strikethrough text parsing and conversion 26 | Deno.test("strikethrough text parsing and conversion", () => { 27 | const testString = `Hello ~~Mr. Bond\~~.`; 28 | const expectedResult = `

Hello Mr. Bond.

`; 29 | assertEquals(marky(testString), expectedResult); 30 | }); 31 | 32 | // Links and images text parsing and conversion 33 | Deno.test("links and images parsing and conversion", () => { 34 | const testString = 35 | `Hello [Mr. Bond](https://google.com). Here's a ![cat](catpic.jpg)`; 36 | const expectedResult = 37 | `

Hello Mr. Bond. Here's a cat

`; 38 | assertEquals(marky(testString), expectedResult); 39 | }); 40 | 41 | // Test code blocks 42 | Deno.test("code blocks", () => { 43 | const testString = ` 44 | Hi there. 45 | 46 | \`\`\` 47 | code goes Header 48 | \`\`\` 49 | 50 | \`\`\`javascript 51 | asdasdjs goes Header 52 | 53 | and what if this also has \`\`\`ticks\`\`\` 54 | \`\`\` 55 | 56 | \`\`\`html 57 |
this is html
58 | \`\`\` 59 | 60 | And regular text ensues. 61 | 62 | And more regular text. 63 | `; 64 | const expectedResult = 65 | `

Hi there.

\ncode goes Header\n
asdasdjs goes Header

and what if this also has \`\`\`ticks\`\`\`
<div>this is html</div>

And regular text ensues.

And more regular text.

`; 66 | assertEquals(marky(testString), expectedResult); 67 | }); 68 | 69 | // Test quote blocks 70 | Deno.test("quote blocks", () => { 71 | const testString = ` 72 | Hi there. 73 | 74 | > quote block _with italic text_ 75 | > hola 76 | > and hola dos 77 | > 78 | > new paragraph! 79 | > 80 | > > nested blockquote 81 | > > continues here 82 | 83 | :)`; 84 | const expectedResult = 85 | `

Hi there.

quote block with italic text\n hola\n and hola dos

new paragraph!

nested blockquote\n continues here

:)

`; 86 | assertEquals(marky(testString), expectedResult); 87 | }); 88 | 89 | // Test list blocks 90 | Deno.test("list block parsing and conversion", () => { 91 | const testString = ` 92 | Paragraph that has a number in it 123. 93 | 94 | Paragraph that has an asterisk in it * hello. 95 | 96 | * and a new list! 97 | * woohoo 98 | - * Nested list 99 | - * Yes? 100 | - - 1. And another 101 | - - 2. More nest 102 | * **thing!** 103 | 104 | Another paragraph.`; 105 | const expectedResult = 106 | `

Paragraph that has a number in it 123.

Paragraph that has an asterisk in it * hello.

Another paragraph.

`; 107 | assertEquals(marky(testString), expectedResult); 108 | }); 109 | 110 | // Test block parsing and conversion 111 | Deno.test("block parsing and conversion", () => { 112 | const testString = ` 113 | # The Villain 114 | 115 | Hello Mr. Bond. 116 | 117 | I've been _expecting_ you. 118 | 119 | ## The Bond 120 | 121 | Why, me too! 122 | `; 123 | const expectedResult = 124 | `

The Villain

Hello Mr. Bond.

I've been expecting you.

The Bond

Why, me too!

`; 125 | assertEquals(marky(testString), expectedResult); 126 | }); 127 | -------------------------------------------------------------------------------- /marky.esm.js: -------------------------------------------------------------------------------- 1 | function stitchCodeBlocks(blocks) { 2 | const capturedBlocks = []; 3 | const codeBlockIndexes = []; 4 | blocks.forEach((block, index) => { 5 | if (block.trim().startsWith("```") && !block.trim().endsWith("```")) { 6 | let capturingBlock = block; 7 | let nextIndex = index + 1; 8 | codeBlockIndexes.push(...[ 9 | index, 10 | nextIndex, 11 | ]); 12 | while ( 13 | typeof blocks[nextIndex] !== "undefined" && 14 | !blocks[nextIndex].trim().endsWith("```") 15 | ) { 16 | if (!codeBlockIndexes.length) { 17 | capturingBlock += blocks[nextIndex]; 18 | } else { 19 | capturingBlock += "\n\n" + blocks[nextIndex]; 20 | } 21 | nextIndex += 1; 22 | codeBlockIndexes.push(nextIndex); 23 | } 24 | capturingBlock += "\n\n" + blocks[nextIndex]; 25 | capturedBlocks.push(capturingBlock); 26 | } else if (!codeBlockIndexes.includes(index)) { 27 | capturedBlocks.push(block); 28 | } 29 | }); 30 | return capturedBlocks; 31 | } 32 | function bold(block) { 33 | const matches = block.match(/\*\*.*?\*\*/g); 34 | if (matches) { 35 | for (const match of matches) { 36 | const value = match.substring(2, match.length - 2); 37 | const replacement = `${value}`; 38 | block = block.replace(match, replacement); 39 | } 40 | } 41 | return block; 42 | } 43 | function createBlocks(content, parsers) { 44 | let blocks = content.split(/\n\n/); 45 | blocks = stitchCodeBlocks(blocks); 46 | return blocks.map((block) => { 47 | const match = parsers.find((parser) => 48 | parser.matcher && parser.matcher(block) 49 | ); 50 | if (match) { 51 | for (const renderer of match.renderers) { 52 | block = renderer(block); 53 | } 54 | return block; 55 | } 56 | const parsersWithoutMatcher = parsers.filter((parser) => !parser.matcher); 57 | for (const parser of parsersWithoutMatcher) { 58 | for (const renderer of parser.renderers) { 59 | block = renderer(block); 60 | } 61 | } 62 | return block; 63 | }).join(""); 64 | } 65 | function marky1(content, parsers = defaultParsers) { 66 | return createBlocks(content, parsers); 67 | } 68 | function italic(block) { 69 | const matches = block.match(/_.*?_/g); 70 | if (matches) { 71 | for (const match of matches) { 72 | const value = match.substring(1, match.length - 1); 73 | const replacement = `${value}`; 74 | block = block.replace(match, replacement); 75 | } 76 | } 77 | return block; 78 | } 79 | function inlineCode(block) { 80 | const matches = block.match(/\`.*?\`/g); 81 | if (matches) { 82 | for (const match of matches) { 83 | let value = match.substring(1, match.length - 1); 84 | value = value.replace(/&/g, "&"); 85 | value = value.replace(//g, ">"); 87 | const replacement = `${value}`; 88 | block = block.replace(match, replacement); 89 | } 90 | } 91 | return block; 92 | } 93 | function strikethrough(block) { 94 | const matches = block.match(/~~.*?~~/g); 95 | if (matches) { 96 | for (const match of matches) { 97 | const value = match.substring(2, match.length - 2); 98 | const replacement = `${value}`; 99 | block = block.replace(match, replacement); 100 | } 101 | } 102 | return block; 103 | } 104 | function linkAndImage(block) { 105 | const matches = block.match(/\[(.*?)\]\((.*?)\)/g); 106 | if (matches) { 107 | for (const match of matches) { 108 | const isImage = block[block.indexOf(match) - 1] === "!"; 109 | const label = match.substring(match.indexOf("[") + 1, match.indexOf("]")); 110 | const href = match.substring(match.indexOf("(") + 1, match.indexOf(")")); 111 | if (isImage) { 112 | block = block.replace( 113 | "!" + match, 114 | `${label}`, 115 | ); 116 | } else { 117 | block = block.replace(match, `${label}`); 118 | } 119 | } 120 | } 121 | return block; 122 | } 123 | function isEmptyBlock(block) { 124 | return block.trim() === ""; 125 | } 126 | function emptyBlock(_block) { 127 | return ""; 128 | } 129 | function isHeadingBlock(block) { 130 | return block.replaceAll("\n", "").trim().startsWith("#"); 131 | } 132 | function headingBlock(block) { 133 | const singleLineBlock = block.replaceAll("\n", "").trim(); 134 | const sizeIndicators = singleLineBlock.split(" ")[0].trim(); 135 | const size = sizeIndicators.length; 136 | const value = singleLineBlock.split(" ").slice(1).join(" ").trim(); 137 | return `${value}`; 138 | } 139 | function isCodeBlock(block) { 140 | const singleLineBlock = block.replaceAll("\n", "").trim(); 141 | return singleLineBlock.startsWith("```") && singleLineBlock.endsWith("```"); 142 | } 143 | function codeBlock(block) { 144 | const languageMatch = block.match(/\`\`\`\w+/); 145 | const language = languageMatch 146 | ? languageMatch[0].replace("```", "").trim() 147 | : false; 148 | let value = ""; 149 | if (language) { 150 | value = block.replace(/\`\`\`\w+/, "").replace(/\n\`\`\`/, ""); 151 | if (value.split("\n")[0].trim() === "") { 152 | value = value.replace("\n", ""); 153 | } 154 | value = value.replace(/&/g, "&"); 155 | value = value.replace(//g, ">"); 157 | value = value.replaceAll("\n", "
"); 158 | return `
${value}
`; 159 | } 160 | return `
${block.substring(3, block.length - 3)}
`; 161 | } 162 | function isHorizontalLineBlock(block) { 163 | return block.replaceAll("\n", "").trim() === "***"; 164 | } 165 | function horizontalLineBlock() { 166 | return `
`; 167 | } 168 | function isQuoteBlock(block) { 169 | return block.replaceAll("\n", "").trim().startsWith(">"); 170 | } 171 | function quoteBlock(block) { 172 | const matches = block.match(/>.*/g); 173 | if (matches) { 174 | return `
${ 175 | marky1( 176 | matches.map((match) => { 177 | return match.substring(1); 178 | }).join("\n"), 179 | ) 180 | }
`; 181 | } 182 | return block; 183 | } 184 | function isListBlock(block) { 185 | return !!block.match(/\n-?\s?-?\s?(\*\s.*|\d\.\s.*)[^\*]/g); 186 | } 187 | function listBlock(block) { 188 | const matches = block.match(/-?\s?-?\s?(\*\s.*|\d\.\s.*)[^\*]/g); 189 | const isOrderedList = matches && !matches[0].startsWith("*"); 190 | const skipIndexes = []; 191 | let result = ""; 192 | if (matches) { 193 | result += isOrderedList ? `
    ` : `
` : ``; 218 | return result; 219 | } 220 | return block; 221 | } 222 | function paragraphBlock(block) { 223 | return `

${block.trim()}

`; 224 | } 225 | const defaultParsers = [ 226 | { 227 | matcher: isEmptyBlock, 228 | renderers: [ 229 | emptyBlock, 230 | ], 231 | }, 232 | { 233 | matcher: isHeadingBlock, 234 | renderers: [ 235 | bold, 236 | italic, 237 | inlineCode, 238 | strikethrough, 239 | linkAndImage, 240 | headingBlock, 241 | ], 242 | }, 243 | { 244 | matcher: isCodeBlock, 245 | renderers: [ 246 | codeBlock, 247 | ], 248 | }, 249 | { 250 | matcher: isHorizontalLineBlock, 251 | renderers: [ 252 | horizontalLineBlock, 253 | ], 254 | }, 255 | { 256 | matcher: isQuoteBlock, 257 | renderers: [ 258 | quoteBlock, 259 | ], 260 | }, 261 | { 262 | matcher: isListBlock, 263 | renderers: [ 264 | bold, 265 | italic, 266 | inlineCode, 267 | strikethrough, 268 | linkAndImage, 269 | listBlock, 270 | ], 271 | }, 272 | { 273 | renderers: [ 274 | bold, 275 | italic, 276 | inlineCode, 277 | strikethrough, 278 | linkAndImage, 279 | paragraphBlock, 280 | ], 281 | }, 282 | ]; 283 | export { marky1 as marky }; 284 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Marky 2 | 3 | A modular and extensible Markdown parser written in TypeScript that spits out 4 | HTML available as a Deno third party module and as a ES module. 5 | 6 | ## Usage 7 | 8 | ### Deno 9 | 10 | ```typescript 11 | import { marky } from "https://deno.land/x/marky@v1.1.7/mod.ts"; 12 | 13 | const html = marky("**hi there**"); // =>

hi there

14 | ``` 15 | 16 | ### ESM 17 | 18 | You can also use Marky anywhere where ES modules are supported by downloading 19 | and using the `marky.esm.js` file, and then importing it as follows: 20 | 21 | ```javascript 22 | import { marky } from "./marky.esm.js"; 23 | 24 | const html = marky("**hi there**"); // =>

hi there

25 | ``` 26 | 27 | Or if you want to use Marky in the browser and don't want to bother downloading 28 | and hosting Marky yourself then you can import it conveniently via JSDelivr like 29 | this: 30 | 31 | ```html 32 | 37 | ``` 38 | 39 | ## Parsers 40 | 41 | By default Marky has the feature-set described here in the 42 | [default spec](#default-spec) section, but you can change that! You can add, 43 | remove and even change existing features however you see fit. You see, Marky is 44 | made out of a bunch of parsers as the basic building blocks of the whole thing. 45 | 46 | If you wish to overwrite existing parsers, simply provide an array of `Parser`'s 47 | as the second argument to `marky`, like this: 48 | 49 | ```typescript 50 | function isHorizontalLineBlock(block: string): boolean { 51 | return block.replaceAll("\n", "").trim() === "***"; 52 | } 53 | 54 | function horizontalLineBlock(): string { 55 | return `
`; 56 | } 57 | 58 | const parsers = [{ 59 | matcher: isHorizontalLineBlock, 60 | renderers: [horizontalLineBlock], 61 | }]; 62 | 63 | const html = marky("***", parsers); 64 | ``` 65 | 66 | As you can see in the above example, we're replacing the default parsers with a 67 | parser for just horizontal line blocks which will detect that a block has three 68 | asterisk characters and will then render a `
` instead. 69 | 70 | Each parser has an optional matcher which takes in a block as a string, and must 71 | return a boolean, in which case that parser' renderers will be used to render 72 | the block. A parser also has an array of renderers that are used to create the 73 | final output. Each renderer takes in a block as a string, but unlike a matcher, 74 | must also return a string. You can have as many as you'd like, of course, for 75 | example: 76 | 77 | ```typescript 78 | const parsers = [{ 79 | matcher: isHeadingBlock, 80 | renderers: [ 81 | bold, 82 | italic, 83 | inlineCode, 84 | strikethrough, 85 | linkAndImage, 86 | headingBlock, 87 | ], 88 | }]; 89 | ``` 90 | 91 | This parser will detect if we're dealing with a heading block, and will then run 92 | the block through several parsers. 93 | 94 | **Note:** You do not have to provide a matcher for a parser, but then the parser 95 | and its renderers will only be used when no other parser was matched. 96 | 97 | ### Extending default parsers 98 | 99 | If you wish to add to the default parsers array, simply extend `defaultParsers`, 100 | like so: 101 | 102 | ```typescript 103 | import { defaultParsers, marky } from "./marky.esm.js"; 104 | 105 | const parsers = [{ 106 | matcher: isMyNewBlockMatcher, 107 | renderers: [myNewRenderer], 108 | }]; 109 | 110 | marky("***", defaultParsers.concat(parsers)); 111 | ``` 112 | 113 | ### Selecting default parsers 114 | 115 | You can also selectively create a list of default parsers, like so: 116 | 117 | ```typescript 118 | import { 119 | bold, 120 | headingBlock, 121 | isHeadingBlock, 122 | italic, 123 | marky, 124 | } from "./marky.esm.js"; 125 | 126 | marky("***", [{ 127 | matcher: isHeadingBlock, 128 | renderers: [bold, italic, headingBlock], 129 | }]); 130 | ``` 131 | 132 | Available list of renderers you can import: 133 | 134 | - `bold` 135 | - `italic` 136 | - `inlineCode` 137 | - `strikethrough` 138 | - `linkAndImage` 139 | - `emptyBlock` 140 | - `headingBlock` 141 | - `codeBlock` 142 | - `horizontalLineBlock` 143 | - `quoteBlock` 144 | - `listBlock` 145 | - `paragraphBlock` 146 | 147 | Available list of matchers you can import: 148 | 149 | - `isEmptyBlock` 150 | - `isHeadingBlock` 151 | - `isCodeBlock` 152 | - `isHorizontalLineBlock` 153 | - `isQuoteBlock` 154 | - `isListBlock` 155 | 156 | ## Default spec 157 | 158 | ### Bold text 159 | 160 | Bold text is created by wrapping selected text with two asterisk characters. 161 | 162 | ```markdown 163 | There's **nothing** quite like a cold beverage on a hot summer night. 164 | ``` 165 | 166 | ### Italic text 167 | 168 | Italic text is created by wrapping selected text with one underscore character. 169 | 170 | ```markdown 171 | There's _nothing_ quite like a cold beverage on a hot summer night. 172 | ``` 173 | 174 | ### Links 175 | 176 | Links can be created by wrapping the label of a link in two square brackets, 177 | followed by the link being wrapped in two parentheses. 178 | 179 | ```markdown 180 | You should totally [visit my site](https://bien.ee). 181 | ``` 182 | 183 | ### Images 184 | 185 | Images can be created just like links, where you wrap the label (well, alt title 186 | in this case) in two square brackets which is followed by the image aadress 187 | being wrapped in two parentheses. Except, add a exclamation mark in front, which 188 | will signify that we're dealing with an image and not with a link. 189 | 190 | ```markdown 191 | Here's a photo ![profile photo](https://somewhere.com/photo.jpg) 192 | ``` 193 | 194 | ### Inline code 195 | 196 | Inline code text is created by wrapping selected text with one backtick 197 | character. 198 | 199 | ```markdown 200 | There's `nothing` quite like a cold beverage on a hot summer night. 201 | ``` 202 | 203 | ### Striked out text 204 | 205 | Striked out text is created by wrapping selected text with two tilde characters. 206 | 207 | ```markdown 208 | There's ~~nothing~~ quite like a cold beverage on a hot summer night. 209 | ``` 210 | 211 | ### Horizontal line separator block 212 | 213 | Horizontal line separator is created by having a block separated by a empty line 214 | break (just like paragraphs or code blocks) and writing three concecutive 215 | asterisk characters. 216 | 217 | ```markdown 218 | This is a paragraph. 219 | 220 | *** 221 | 222 | And this is another paragraph separated by a horizontal line. 223 | ``` 224 | 225 | ### Paragraph blocks 226 | 227 | Paragraphs are created by simply leaving one empty line break between text, 228 | which, technically means having two line breaks, but remember it as just one 229 | empty line between text. 230 | 231 | ### Heading blocks 232 | 233 | Headings are created by adding a octothorp (hashtag) character in front of a 234 | block of text that is separated from others by one empty line. 235 | 236 | ```markdown 237 | # This is a big title 238 | 239 | And some paragraph goes here. 240 | 241 | ## A little smaller title 242 | 243 | And another paragraph goes here. 244 | ``` 245 | 246 | As you can see, the smaller the amount of octothorp characters the bigger the 247 | title will be. You can use as many octothorps as you wish, but browsers can only 248 | recognize up to 6 of them. 249 | 250 | ### Code blocks 251 | 252 | Code blocks are created by wrapping your code with three backtick characters. 253 | 254 | ````markdown 255 | ``` 256 | code goes here 257 | ``` 258 | ```` 259 | 260 | If you want to also make sure that the HTML output would have a class associated 261 | with the programming language used in the code block, make sure to append the 262 | language name to the first occurence of backticks, like so: 263 | 264 | ````markdown 265 | ```javascript 266 | code goes here 267 | ``` 268 | ```` 269 | 270 | ### Quote blocks 271 | 272 | Quote blocks are created by prepending an arrow and space to the left of the 273 | text you want to quote. 274 | 275 | ```markdown 276 | This is a paragraph of text. 277 | 278 | > This is a paragraph of text in a quote 279 | ``` 280 | 281 | Quote blocks behave like any other block, in that if you separate quote blocks 282 | by one item where there is no text (only arrow), you create new paragraphs. You 283 | can also nest quote blocks by appeding more arrows. 284 | 285 | ```markdown 286 | This is a paragraph of text. 287 | 288 | > This is a paragraph of text in a quote 289 | > 290 | > This is another paragraph of text in a quote. 291 | > 292 | > > This is a paragraph of text in a nested quote. 293 | ``` 294 | 295 | ### List blocks 296 | 297 | List blocks are created by prepending an asterisk character for unordered lists 298 | and a number with a dot suffix to ordered lists. 299 | 300 | ```markdown 301 | This is a paragraph. 302 | 303 | * This is an unordered list item. 304 | * And this is another one. 305 | 306 | 1. This is an ordered list item. 307 | 2. And this is another one. 308 | ``` 309 | 310 | Nested lists are also supported, which can be created with a dash character 311 | prepended to list items, like so: 312 | 313 | ```markdown 314 | * This is a list item. 315 | - * This is a nested list item 316 | - - 1. This is yet another level of nesting. 317 | - * And so on. 318 | * And so on. 319 | ``` 320 | -------------------------------------------------------------------------------- /parsers.ts: -------------------------------------------------------------------------------- 1 | import { marky } from "./marky.ts"; 2 | 3 | /** 4 | * Parses given `content` for any bold text which sits between 5 | * the double asterisk characters like `**text**` and then compiles 6 | * that into HTML like `text`. 7 | */ 8 | export function bold(block: string): string { 9 | const matches = block.match(/\*\*.*?\*\*/g); 10 | 11 | if (matches) { 12 | for (const match of matches) { 13 | const value = match.substring(2, match.length - 2); 14 | const replacement = `${value}`; 15 | 16 | block = block.replace(match, replacement); 17 | } 18 | } 19 | 20 | return block; 21 | } 22 | 23 | /** 24 | * Parses given `block` for any italic text which sits between 25 | * the underscore characters like `_text_` and then compiles that 26 | * into HTML like `text`. 27 | */ 28 | export function italic(block: string): string { 29 | const matches = block.match(/_.*?_/g); 30 | 31 | if (matches) { 32 | for (const match of matches) { 33 | const value = match.substring(1, match.length - 1); 34 | const replacement = `${value}`; 35 | 36 | block = block.replace(match, replacement); 37 | } 38 | } 39 | 40 | return block; 41 | } 42 | 43 | /** 44 | * Parses given `block` for any inline code text which sits between 45 | * the tick characters like ``text`` and then compiles that into 46 | * HTML like `text`. 47 | */ 48 | export function inlineCode(block: string): string { 49 | const matches = block.match(/\`.*?\`/g); 50 | 51 | if (matches) { 52 | for (const match of matches) { 53 | let value = match.substring(1, match.length - 1); 54 | 55 | // Encode 56 | value = value.replace(/&/g, "&"); 57 | value = value.replace(//g, ">"); 59 | 60 | const replacement = `${value}`; 61 | 62 | block = block.replace(match, replacement); 63 | } 64 | } 65 | 66 | return block; 67 | } 68 | 69 | /** 70 | * Parses given `block` for any inline code text which sits between 71 | * the tilde characters like `~~text~~` and then compiles that into 72 | * HTML like `text`. 73 | */ 74 | export function strikethrough(block: string): string { 75 | const matches = block.match(/~~.*?~~/g); 76 | 77 | if (matches) { 78 | for (const match of matches) { 79 | const value = match.substring(2, match.length - 2); 80 | const replacement = `${value}`; 81 | 82 | block = block.replace(match, replacement); 83 | } 84 | } 85 | 86 | return block; 87 | } 88 | 89 | /** 90 | * Parses given `block` for any links which look like `[label](url)` 91 | * or images which look like `![alt-title](url)` and then compiles that 92 | * into HTML like `label` or `label`. 93 | */ 94 | export function linkAndImage(block: string): string { 95 | const matches = block.match(/\[(.*?)\]\((.*?)\)/g); 96 | 97 | if (matches) { 98 | for (const match of matches) { 99 | const isImage = block[block.indexOf(match) - 1] === "!"; 100 | const label = match.substring(match.indexOf("[") + 1, match.indexOf("]")); 101 | const href = match.substring(match.indexOf("(") + 1, match.indexOf(")")); 102 | 103 | if (isImage) { 104 | block = block.replace( 105 | "!" + match, 106 | `${label}`, 107 | ); 108 | } else { 109 | block = block.replace(match, `${label}`); 110 | } 111 | } 112 | } 113 | 114 | return block; 115 | } 116 | 117 | /** 118 | * Checks whether the given `block` is a empty block. 119 | */ 120 | export function isEmptyBlock(block: string): boolean { 121 | return block.trim() === ""; 122 | } 123 | 124 | /** 125 | * Returns an empty string. 126 | */ 127 | export function emptyBlock(_block: string): string { 128 | return ""; 129 | } 130 | 131 | /** 132 | * Checks whether the given `block` is a heading block. 133 | */ 134 | export function isHeadingBlock(block: string): boolean { 135 | return block.replaceAll("\n", "").trim().startsWith("#"); 136 | } 137 | 138 | /** 139 | * Parses the given `block` for heading sizes and compiles 140 | * that into HTML like `

text

`. 141 | */ 142 | export function headingBlock(block: string): string { 143 | const singleLineBlock = block.replaceAll("\n", "").trim(); 144 | const sizeIndicators = singleLineBlock.split(" ")[0].trim(); 145 | const size = sizeIndicators.length; 146 | const value = singleLineBlock.split(" ").slice(1).join(" ").trim(); 147 | 148 | return `${value}`; 149 | } 150 | 151 | /** 152 | * Checks whether the given `block` is a code block. 153 | */ 154 | export function isCodeBlock(block: string): boolean { 155 | const singleLineBlock = block.replaceAll("\n", "").trim(); 156 | 157 | return ( 158 | singleLineBlock.startsWith("```") && 159 | singleLineBlock.endsWith("```") 160 | ); 161 | } 162 | 163 | /** 164 | * Parses the given `block` for a code block and compiles 165 | * that into HTML like `
text
`. 166 | */ 167 | export function codeBlock(block: string): string { 168 | const languageMatch = block.match(/\`\`\`\w+/); 169 | const language = languageMatch 170 | ? languageMatch[0].replace("```", "").trim() 171 | : false; 172 | let value = ""; 173 | 174 | if (language) { 175 | value = block.replace(/\`\`\`\w+/, "").replace(/\n\`\`\`/, ""); 176 | 177 | // Remove first \n if the first line is empty 178 | if (value.split("\n")[0].trim() === "") { 179 | value = value.replace("\n", ""); 180 | } 181 | 182 | // Encode 183 | value = value.replace(/&/g, "&"); 184 | value = value.replace(//g, ">"); 186 | 187 | // Replace all line breaks with a `
` because otherwise 188 | // `
` thinks that lines following a \n should have a tab, which is dumb.
189 |     value = value.replaceAll("\n", "
"); 190 | 191 | return `
${value}
`; 192 | } 193 | 194 | return `
${block.substring(3, block.length - 3)}
`; 195 | } 196 | 197 | /** 198 | * Checks whether the given `block` is a horizontal line block. 199 | */ 200 | export function isHorizontalLineBlock(block: string): boolean { 201 | return block.replaceAll("\n", "").trim() === "***"; 202 | } 203 | 204 | /** 205 | * Compiles HTML that creates a horizontal line, e.g `
`. 206 | */ 207 | export function horizontalLineBlock(): string { 208 | return `
`; 209 | } 210 | 211 | /** 212 | * Checks whether the given `block` is a quote block. 213 | */ 214 | export function isQuoteBlock(block: string): boolean { 215 | return block.replaceAll("\n", "").trim().startsWith(">"); 216 | } 217 | 218 | /** 219 | * Parses the given `block` and compiles HTML that creates 220 | * a blockquote like `
text
`. It's a 221 | * recursive action, in that each blockquote will also be ran 222 | * through Marky itself, again and again, until all nested 223 | * blockquotes are also parsed just like any other blocks. 224 | */ 225 | export function quoteBlock(block: string): string { 226 | const matches = block.match(/>.*/g); 227 | 228 | if (matches) { 229 | return `
${ 230 | marky( 231 | matches.map((match) => { 232 | return match.substring(1); 233 | }).join("\n"), 234 | ) 235 | }
`; 236 | } 237 | 238 | return block; 239 | } 240 | 241 | /** 242 | * Checks whether the given `block` is a unordered list block. 243 | */ 244 | export function isListBlock(block: string): boolean { 245 | return !!block.match(/\n-?\s?-?\s?(\*\s.*|\d\.\s.*)[^\*]/g); 246 | } 247 | 248 | /** 249 | * Parses the given `block` and compiles HTML that creates lists. 250 | * Both ordered and unordered lists, as well as nested lists, due to 251 | * its recursive nature. The output is a mixture of `
    ` and ``; 297 | 298 | return result; 299 | } 300 | 301 | return block; 302 | } 303 | 304 | /** 305 | * Parses the given block for a paragraph and compiles 306 | * that into HTML like `

    text

    `. 307 | */ 308 | export function paragraphBlock(block: string): string { 309 | return `

    ${block.trim()}

    `; 310 | } 311 | 312 | export type Parser = { 313 | matcher?: (block: string) => boolean; 314 | renderers: ((block: string) => string)[]; 315 | }; 316 | 317 | /** 318 | * Default building blocks that Marky is made out of. 319 | */ 320 | const defaultParsers: Parser[] = [{ 321 | matcher: isEmptyBlock, 322 | renderers: [emptyBlock], 323 | }, { 324 | matcher: isHeadingBlock, 325 | renderers: [ 326 | bold, 327 | italic, 328 | inlineCode, 329 | strikethrough, 330 | linkAndImage, 331 | headingBlock, 332 | ], 333 | }, { 334 | matcher: isCodeBlock, 335 | renderers: [codeBlock], 336 | }, { 337 | matcher: isHorizontalLineBlock, 338 | renderers: [horizontalLineBlock], 339 | }, { 340 | matcher: isQuoteBlock, 341 | renderers: [quoteBlock], 342 | }, { 343 | matcher: isListBlock, 344 | renderers: [bold, italic, inlineCode, strikethrough, linkAndImage, listBlock], 345 | }, { 346 | renderers: [ 347 | bold, 348 | italic, 349 | inlineCode, 350 | strikethrough, 351 | linkAndImage, 352 | paragraphBlock, 353 | ], 354 | }]; 355 | 356 | export default defaultParsers; 357 | --------------------------------------------------------------------------------