├── .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.
Hello Mr. Bond.
Hello Mr. Bond. Here's a 
Hi there.
\ncode goes Header\nasdasdjs 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 | `Hello Mr. Bond.
I've been expecting you.
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}`;
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 `${ 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 ? `
${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 `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`.
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 `` 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 ``
252 | * HTML with list items.
253 | *
254 | * As opposed to using two spaces to create a nested list, Marky uses
255 | * a dash character `-` to signify that the given list should be nested.
256 | * While the function itself has no limit to nesting, the current regex
257 | * pattern supports only up to two levels deep nesting.
258 | */
259 | export function listBlock(block: string): string {
260 | const matches = block.match(/-?\s?-?\s?(\*\s.*|\d\.\s.*)[^\*]/g);
261 | const isOrderedList = matches && !matches[0].startsWith("*");
262 | const skipIndexes: number[] = [];
263 | let result = "";
264 |
265 | if (matches) {
266 | result += isOrderedList ? `` : ``;
267 |
268 | matches.forEach((match, index) => {
269 | if (skipIndexes.includes(index)) {
270 | return;
271 | }
272 |
273 | // If the match starts with `-`, it means we're dealing
274 | // with nested lists and thus need to stitch those matches
275 | // together and pass them back to `marky`.
276 | if (match.startsWith("-")) {
277 | let captured = match.substring(2);
278 | let nextIndex = index + 1;
279 | skipIndexes.push(...[index, nextIndex]);
280 |
281 | while (
282 | typeof matches[nextIndex] !== "undefined" &&
283 | matches[nextIndex].startsWith("-")
284 | ) {
285 | captured += matches[nextIndex].substring(2);
286 | nextIndex += 1;
287 | }
288 |
289 | result += marky(captured);
290 | } // Otherwise we continue as-is.
291 | else {
292 | result += `- ${match.substring(2).trim()}
`;
293 | }
294 | });
295 |
296 | result += isOrderedList ? `
` : `
`;
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 |
--------------------------------------------------------------------------------