diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8babc6 --- /dev/null +++ b/README.md @@ -0,0 +1,424 @@ +

marq logo

+ +

marq

+
a powerful, lightweight, and enhanced markdown to HTML converter
+ +## Welcome to marq +Marq is a powerful, lightweight, and enhanced markdown to HTML converter. It supports a version of [GFM](https://github.github.com/gfm/) with a few additions and changes. + +### Install +Install marq with [yarn](https://yarnpkg.com/) or [npm](https://www.npmjs.com/). +```bash +yarn add @wcarhart/marq +``` +```bash +npm install @wcarhart/marq +``` +To use marq, import it. +```javascript +const { Marq } = require('@wcarhart/marq') +``` + +### Usage +To use marq, first instatiate a new `Marq` object. Then, simply pass in your markdown that you'd like to convert with `convertSync()`. +```javascript +let markdown = '# This is a title\nThis is some paragraph text.\nHere is a list:\n* item 1\n* item 2\n* item 3\n' +let marq = new Marq() +let html = marq.convertSync(markdown) +console.log(html) +``` +``` +

This is a title

This is some paragraph text.

Here is a list:

+``` +Or, use marq asynchronously with `convert()`. +```javascript +const generateMarkdown = async (md) => { + let marq = new Marq() + try { + let html = await marq.convert(md) + } catch (e) { + console.error(e) + } +} +``` +Marq prefixes all built HTML classes with `marq-` so it's easy for you to target in your CSS. Don't like that? Reconfigure it yourself. +```javascript +let marq = new Marq({ + cssPrefix: 'generated-markdown-', + cssSuffix: '-from-marq' +}) +let html = marq.convertSync('# My Cool Title') +console.log(html) +``` +``` +

My Cool Title

+``` +Add separate classes and IDs with ease. +```javascript +let marq = new Marq() +let html = marq.convertSync('# My Cool Title', {cls: ['my-cool-class', 'another-class'], id: 'my-id'}) +console.log(html) +``` +``` +

My Cool Title

+``` +For more intense markdown generation, include a page name for error messages and better traceability. +```javascript +let marq = new Marq() +let html = marq.convertSync('|Column Name|\n|-|\n|data|', {page: 'mypage.html'}) +``` +``` +Error: Invalid markdown: unclosed table in 'mypage.html', did you forget to end the table with an empty newline? +``` +Use marq with files. +```javascript +const fs = require('fs') +fs.writeFileSync('my_webpage.html', marq.convertSync(fs.readFileSync('my_markdown.md').toString())) +``` +And much, much more. + +### Examples +A preliminary CSS and JS files are provided to showcase some styling options, but most of styling is left to the author. For real life examples of marq's capabilities, check out my [personal website](https://willcarh.art), [willcarh.art](https://github.com/wcarhart/willcarh.art), which uses marq for all its markdown generation. + +### Markdown Options and Documentation +Marq supports a wide variety of markdown structures, initially inspired by [GFM](https://github.github.com/gfm/) (_note: most GFM .md files will work with marq_). Keep in mind that these examples are HTML structures only (see the `css/` and `js/` directories for some styling samples). + +#### Titles +Titles `h1` through `h6` start with a `#`. +``` +# This is an h1 +### This is an h3 +###### This is an h6 +``` +```html +

This is an h1

+

This is an h3

+
This is an h6
+``` + +#### Shoutouts +Shoutouts are an addition to [GFM](https://github.github.com/gfm) and are a cool way to call attention to something in an article or blog post. +``` +>> Help! | Can you please help me by subscribing to my new email list? It would be much appreciated! +``` +```html +
+

Help!

+

Can you please help me by subscribing to my new email list? It would be much appreciated!

+
+``` + +#### Paragraph Text +Regular text is rendered as paragraph `

` text. One notable difference is consecutive newlines not separated by a line break are not concatenated to the same line, which is different from [GFM](https://github.github.com/gfm). +``` +Here is some text. +Here is some more text, not on the same line. +``` +```html +

Here is some text.

+

Here is some more text, not on the same line.

+``` + +#### Inline text decoration +Inline text decoration includes `inline code`, **bold text**, _italicized text_, and ~~strikethrough~~. You can combine each of these together on one line, too. +``` +This text is `inline code`. +This text is **bold**. +This text is _italicized_. +This text is ~~struck through~~. +Here is some **bold and _italicized_ text**. +``` +```html +

This text is inline code.

+

This text is bold.

+

This text is italicized.

+

This text is struck through.

+

Here is some bold and italicized text.

+``` + +#### Unordered Lists +Unordered lists start with `* ` and must be followed by an empty new line. _Note: nested lists are not currently supported._ +``` +This week's groceries: +* bananas +* Goldfish crackers +* chicken breasts + +``` +```html +

This week's groceries:

+ +``` + +#### Ordered Lists +Ordered lists start with a number and must be followed by an empty new line. The list can start with any integer. _Note: nested lists are not currently supported._ +``` +Chores, continued from last week: +2. Mow lawn +3. Take out garbage +4. Change car oil + +``` +```html +

Chores, continued from last week:

+
    +
  1. Mow lawn
  2. +
  3. Take out garbage
  4. +
  5. Change car oil
  6. +
+``` + +#### Links +Links consist of a URL and some link text. +``` +Check out my cool website: [willcarh.art](https://willcarh.art) +``` +```html +

Check out my cool website: willcarh.art

+``` + +#### Images +Images are the same as links, except they start with a `!`. They can also contain a caption. +``` +![image alt](https://google.com/image_url) +![my cool summer vacation trip](https://mywebsite.com/pics/vacation_0.jpg) +``` +```html +image alt

+my cool summer vacation trip

Here's a cool picture of me on vacation

+``` + +#### Inline HTML +You can use HTML blocks between `===` to render actual HTML and inline JavaScript instead of markdown-generated HTML. +``` +=== + +=== +This is some static text. +=== +

This is some .

+=== +``` +```html + +

This is some static text.

+

This is some .

+``` + +#### Code Blocks +You can render preformatted code between `` ``` `` blocks. You can specify a language for automatic syntax highlighting in the browser via [Highlight.js](https://github.com/highlightjs/highlight.js/). Marq also specs out a blank image in case you want to add a copy button to the code block later. +```` +```python +def say_hello(): + print('hello') +``` +```` +```html +
+    
+    
+    def say_hello():
+        print("hello")
+    
+
+``` + +#### Blockquotes +Blockquotes are an HTML structure used to indicate that a longer portion of text is quoted from another source. Start a line with `>` to utilize them with marq. +``` +> www.google.com | This is a blockquote with an optional source to cite. +> This is a blockquote without a source. +``` +```html +
This is a blockquote with an optional source to cite.
+
This is a blockquote without a source.
+``` + +#### Comments +Ever wanted to leave comments in your markdown? Now you can, by starting the line with a `?`. +``` +Here is some content. +? Here is a comment +Here is some more content. +``` +```html +

Here is some content.

+

Here is some more content.

+``` + +#### Embedded YouTube Videos +Similar to embedded images, marq supports embedded YouTube videos with minimal YouTube branding. Add a line with `~()` and pass in the YouTube video's 11 character ID. +``` +See our product in action below: +~(dQw4w9WgXcQ) +``` +```html +

See our product in action below:

+ +``` + +#### Centered Text +Ever wanted to center a piece of text in markdown? Now you can. Start a line with `=` for it to be centered (requires additional CSS, see the `css/` directory for samples). +``` +=This text is centered. +``` +```html +

This text is centered.

+``` + +#### Horizontal Rules +Create a horizontal rule with `---` or `___`. +``` +--- +``` +```html +
+``` + +#### Empty Lines +Use empty lines to add whitespace to your HTML. +``` +Here is some top text.\n\n\nHere is some bottom text. +``` +```html +

Here is some top text.

+
+
+

Here is some bottom text.

+``` + +#### Tables +Marq supports complex table structures with nested inline text decoration. Start a table with a `|`. The table format consists of at least three lines: one line for the header, one line for the configuration, and at least one line of data. Tables must be followed by an empty line. In addition, tables do not have a default `marq-table` class like other structures, but can still be given additional classes or an ID as desired. +``` +| Column 1 | Column 2 | Column 3 | +|----------|:--------:|---------:| +|Row can have blank data| | | +|Cells support `inline` **text** _decoration_.| | | +| |This text is centered.| | +| | |This text is right-aligned.| + +``` +```html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Column 1Column 2Column 3
Row can have blank data
Cells support inline text decoration.
This text is centered.
This text is right-aligned.
+``` +![Screen shot of generated table HTML](demos/table_demo.png) + +#### Slideshows +One of marq's most powerful features is its ability to support embedded slideshows (i.e. carousels). You can specify a number of slides, which contain an image and a caption, and marq will build them into a carousel. This feature requires JavaScript, please see the `js/` directory for the included script. + +To create a slideshow, create a slideshow block with `[[[` and `]]]`, similar to a code or HTML block. Then, within the slideshow block, add a number of slides using the format `[caption](image URL)`. The slide caption becomes the image alt (if you leave the caption blank, an alt will be generated automatically). You can pass in your own path to `slideshow.js` when you instatiate `Marq`, the default is the path to the one in this repository on your local machine. +``` +[[[ + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-0.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-1.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-2.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-3.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-4.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-3.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-5.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-6.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-7.png) + [](https://cdn.willcarh.art/img/blog/reducing-aws-s3-storage-costs-with-bubble-trees/aws-diagram-8.png) +]]] +``` +```html + +
+
+ Inline slideshow, slide 1 +
+
+
+ Inline slideshow, slide 2 +
+
+
+ Inline slideshow, slide 3 +
+
+
+ Inline slideshow, slide 4 +
+
+
+ Inline slideshow, slide 5 +
+
+
+ Inline slideshow, slide 6 +
+
+
+ Inline slideshow, slide 7 +
+
+
+ Inline slideshow, slide 8 +
+
+
+ Inline slideshow, slide 9 +
+
+
+ Inline slideshow, slide 10 +
+
+ + +
+
+
+ + + + + + + + + + +
+``` +![Screenshot of slideshow demo](demos/slideshow_demo.png) diff --git a/blank.png b/blank.png new file mode 100644 index 0000000..bba47ae Binary files /dev/null and b/blank.png differ diff --git a/css/markdown.css b/css/markdown.css new file mode 100644 index 0000000..34f3693 --- /dev/null +++ b/css/markdown.css @@ -0,0 +1,223 @@ +@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap'); + +:root { + --background: #ecf0f1; + --detail: #bdc3c7; + --subtitle: #6A6C6E; + --color: #e67e22; + --text: #252323; +} + +.marq-bold-text { + font-family: 'Lato', sans-serif; + font-weight: 700; +} + +.link { + font-weight: 400; +} + +.marq-p { + color: var(--subtitle); + font-size: 1.1rem; + font-family: 'Lato', sans-serif; + font-weight: 300; +} + +.marq-centered-text { + text-align: center; +} + +.marq-inline-code { + color: var(--color); +} + +.marq-block-code { + color: var(--text); + font-family: 'Roboto Mono', monospace; +} + +.marq-code-container { + background-color: var(--detail); + padding: 1rem; + font-family: 'Roboto Mono', monospace; + width: 0; + max-width: 100%; + min-width: 100%; +} + +.marq-shoutout { + border: solid 0.2rem var(--text); + border-radius: 1rem; + padding: 1rem; + margin: 0rem; +} + +.marq-shoutout-title { + color: var(--color); + font-size: 1.3rem; + padding-bottom: 0rem; + margin-bottom: 0rem; + font-family: 'Lato', sans-serif; +} + +.marq-shoutout-text { + color: var(--subtitle); + font-size: 1.2rem; + margin: 0rem; + font-family: 'Lato', sans-serif; + font-weight: 300; +} + +.marq-blockquote { + font-family: 'Lato', sans-serif; + font-style: italic; + color: var(--text); + font-size: 1.5rem; + margin-left: 2rem; + padding-left: 0.5rem; + border-left: 0.1rem solid; + border-left-color: var(--text); +} + +.marq-content-img { + display: block; + margin: 0 auto; + max-width: 100%; + max-height: 30rem; + background-size: contain; + object-fit: contain; + background-repeat: no-repeat; + background-position: 50% 50%; +} + +.img-subtitle { + padding-top: 0.5rem; + text-align: center; + font-size: 0.8rem; + font-style: italic; + color: var(--subtitle); +} + +.table-align-left { + text-align: left +} + +.table-align-center { + text-align: center +} + +.table-align-right { + text-align: right +} + +.marq-youtube-embedded { + display: block; + margin: 0 auto; + max-width: 100%; + height: 30rem; + max-height: 30rem; +} + +th { + padding: 0 0.5rem; +} + +td { + padding: 0 0.5rem; + font-family: 'Lato', sans-serif; + font-weight: 300; +} + +/* slideshow */ +/* slideshow container */ +.marq-markdown-slideshow-container { + box-sizing: border-box; + max-width: 98%; + position: relative; + margin: auto; +} + +/* hide the images by default */ +.marq-markdown-slideshow-slide { + box-sizing: border-box; + display: none; +} + +/* next & previous buttons */ +.marq-markdown-slideshow-prev, .marq-markdown-slideshow-next { + box-sizing: border-box; + cursor: pointer; + position: absolute; + top: 50%; + width: auto; + margin-top: -1.375rem; + padding: 1rem; + color: black; + font-weight: bold; + font-size: 1.125rem; + transition: 0.6s ease; + border-radius: 0 0.188rem 0.188rem 0; + user-select: none; +} + +/* position the next button to the right */ +.marq-markdown-slideshow-next { + box-sizing: border-box; + right: 0; + border-radius: 0.188rem 0 0 0.188rem; +} + +/* on hover, add a black background color with a little bit see-through */ +.marq-markdown-slideshow-prev:hover, .marq-markdown-slideshow-next:hover { + background-color: rgba(0,0,0,0.8); + color: white; + text-decoration: none !important; +} + +/* caption text */ +.marq-markdown-slideshow-slide-caption { + box-sizing: border-box; + color: black; + font-size: 0.938rem; + padding: 0.5rem 0.75rem; + position: absolute; + bottom: 0.5rem; + width: 100%; + text-align: center; +} + +/* the dots indicators (current slide indicator) */ +.marq-markdown-slideshow-dot { + box-sizing: border-box; + cursor: pointer; + height: 0.5rem; + width: 0.5rem; + margin: 0 0.125rem; + background-color: #bbb; + border-radius: 50%; + display: inline-block; + transition: background-color 0.6s ease; +} + +.marq-markdown-slideshow-active-slide, .marq-markdown-slideshow-dot:hover { + background-color: #717171; +} + +/* fading animation */ +.fade { + -webkit-animation-name: fade; + -webkit-animation-duration: 1.5s; + animation-name: fade; + animation-duration: 1.5s; +} + +@-webkit-keyframes fade { + from {opacity: .4} + to {opacity: 1} +} + +@keyframes fade { + from {opacity: .4} + to {opacity: 1} +} \ No newline at end of file diff --git a/demos/slideshow_demo.png b/demos/slideshow_demo.png new file mode 100644 index 0000000..b16536c Binary files /dev/null and b/demos/slideshow_demo.png differ diff --git a/demos/table_demo.png b/demos/table_demo.png new file mode 100644 index 0000000..24668f4 Binary files /dev/null and b/demos/table_demo.png differ diff --git a/index.js b/index.js new file mode 100644 index 0000000..ccb606a --- /dev/null +++ b/index.js @@ -0,0 +1,875 @@ +const fs = require('fs') +const path = require('path') +const util = require('util') + +class Marq { + constructor(options) { + if (options === undefined) { + options = { + mode: 'extended', + cssPrefix: 'marq-', + cssSuffix: '', + snippetDir: 'snippets', + cssDir: 'css', + jsDir: 'js', + placeholder: path.join(__dirname, 'blank.png'), + slideshowScript: path.join(__dirname, 'js', 'slideshow.js') + } + } + this.mode = options.mode === undefined ? 'extended' : options.mode + this.cssPrefix = options.cssPrefix === undefined ? 'marq-' : options.cssPrefix + this.cssSuffix = options.cssSuffix === undefined ? '' : options.cssSuffix + this.snippetDir = options.snippetDir === undefined ? 'snippets' : options.snippetDir + this.cssDir = options.cssDir === undefined ? 'css' : options.cssDir + this.jsDir = options.jsDir === undefined ? 'js' : options.jsDir + this.placeholder = options.placeholder === undefined ? path.join(__dirname, 'blank.png') : options.placeholder + this.slideshowScript = options.slideshowScript === undefined ? path.join(__dirname, this.jsDir, 'slideshow.js') : options.slideshowScript + + this.validate() + } + + // validate Marq object + validate() { + // this.mode must be one of supported modes + const SUPPORTED_MODES = ['gfmd', 'extended'] + if (!SUPPORTED_MODES.includes(this.mode)) { + throw Error(`Invalid marq: unsupported mode, must be one of: ${SUPPORTED_MODES}`) + } + + // this.cssPrefix and this.cssSuffix don't have any validation + + // this.snippetDir must exist + if (!this.snippetDir.startsWith('/')) { + this.snippetDir = path.join(__dirname, this.snippetDir) + } + try { + fs.statSync(this.snippetDir).isDirectory() + } catch (e) { + throw Error(`Invalid marq: no such snippetDir '${this.snippetDir}'`) + } + + // this.cssDir must exist + if (!this.cssDir.startsWith('/')) { + this.cssDir = path.join(__dirname, this.cssDir) + } + try { + fs.statSync(this.cssDir).isDirectory() + } catch (e) { + throw Error(`Invalid marq: no such cssDir '${this.cssDir}'`) + } + + // this.jsDir must exist + if (!this.jsDir.startsWith('/')) { + this.jsDir = path.join(__dirname, this.jsDir) + } + try { + fs.statSync(this.jsDir).isDirectory() + } catch (e) { + throw Error(`Invalid marq: no such jsDir '${this.jsDir}'`) + } + } + + // make sure tables start and end with '|' + validateTableRow(row, lineNumber, page) { + if (row[0] !== '|' || row[row.length-1] !== '|') { + throw new Error(`Invalid table row in '${page}' (line ${lineNumber}): line does not start and end with '|'`) + } + } + + // make sure table configs are valid + validateTableConfigs(configs, lineNumber, page) { + let isConfig = configs.reduce((result, tc) => { return Boolean(/^:?-+:?$/.exec(tc.trim())) && result }, true) + if (isConfig === false) { + throw new Error(`Invalid table row in '${page}' (line ${lineNumber}): invalid table configuration`) + } + } + + // add an slide to a slideshow + buildSlideshowSlide(line, index, page, lineNumber) { + // parse HTML snippets + let slideSnippet = path.join(this.snippetDir, 'slideshow/slide.html') + let dotSnippet = path.join(this.snippetDir, 'slideshow/dot.html') + let snip + try { + for (snip of [slideSnippet, dotSnippet]) { + let stat = fs.statSync(snip) + if (!stat.isFile()) { + throw Error() + } + } + } catch (e) { + throw Error(`No such snippet: ${snip}`) + } + slideSnippet = fs.readFileSync(slideSnippet).toString() + dotSnippet = fs.readFileSync(dotSnippet).toString() + + // slideshow lines should be in the form of links + // for example, [image caption](www.example.com/myimage) + let slide = null, dot = null + line = line.trim() + if (/\[.*?\]\(.+?\)/.exec(line)) { + let match = /\[.*?\]\(.+?\)/.exec(line)[0] + let caption = match.replace(/^\[/, '').replace(/\].*$/, '') + let href = match.replace(/^.*\(/, '').replace(/\)$/, '') + let alt = `Inline slideshow, slide ${index}` + if (caption !== '') { + alt = caption + } + slide = slideSnippet.replace('{{slide-caption}}', caption).replace('{{slide-content}}', href).replace('{{slide-alt}}', alt) + dot = dotSnippet.replace('{{slide-index}}', index) + } else { + throw new Error(`Invalid slideshow slide in '${page}' (line ${lineNumber}): invalid slide format, expecting [caption](image_url)`) + } + + return [slide, dot] + } + + // build table HTML + buildTable(headers, configs, rows, page) { + // parse HTML snippets + let tableSnippet = path.join(this.snippetDir, 'table/table.html') + let theadSnippet = path.join(this.snippetDir, 'table/thead.html') + let trSnippet = path.join(this.snippetDir, 'table/tr.html') + let thSnippet = path.join(this.snippetDir, 'table/th.html') + let tbodySnippet = path.join(this.snippetDir, 'table/tbody.html') + let tdSnippet = path.join(this.snippetDir, 'table/td.html') + let snip + try { + for (snip of [tableSnippet, theadSnippet, trSnippet, thSnippet, tbodySnippet, tdSnippet]) { + let stat = fs.statSync(snip) + if (!stat.isFile()) { + throw Error() + } + } + } catch (e) { + throw Error(`No such snippet: ${snip}`) + } + tableSnippet = fs.readFileSync(tableSnippet).toString() + theadSnippet = fs.readFileSync(theadSnippet).toString() + trSnippet = fs.readFileSync(trSnippet).toString() + thSnippet = fs.readFileSync(thSnippet).toString() + tbodySnippet = fs.readFileSync(tbodySnippet).toString() + tdSnippet = fs.readFileSync(tdSnippet).toString() + + // verify table is sized properly + // - all data rows must have same number of cols + // - configs and all rows must have same number of cols + // - if table has headers, headers, configs, and all rows must have same number of cols + let rowColumnCounts = rows.reduce((result, row) => { return result.includes(row.length) ? result : result.concat(row.length) }, []) + if (rowColumnCounts.length !== 1) { + throw new Error(`Invalid markdown: invalid table in '${page}', unequal columns found in table data`) + } + rowColumnCounts = rowColumnCounts[0] + if (headers.length !== 0) { + if (headers.length !== configs.length && configs.length !== rowColumnCounts) { + throw new Error(`Invalid markdown: invalid table in '${page}', unequal table headers, configurations, and rows`) + } + } else { + if (configs.length !== rowColumnCounts) { + throw new Error(`Invalid markdown: invalid table in '${page}', unequal table configurations and rows`) + } + } + + // build html templates + let table = tableSnippet + let headerRow = '' + let bodyRows = [] + + // determine column alignments + let columnAligns = configs.map(col => { + switch(col) { + case '-': + case ':-': + return 'left' + case ':-:': + return 'center' + case '-:': + return 'right' + } + }) + + // compute colspan for each header + let colspan = 1 + let previous = null + let headerMap = [] + for (let [index, header] of headers.entries()) { + if (previous === null) { + previous = header + continue + } + if (previous === header) { + colspan += 1 + } else { + headerMap.push([previous, colspan]) + previous = header + colspan = 1 + } + if (index === headers.length - 1) { + headerMap.push([previous, colspan]) + } + } + + // build optional header row + headerRow = trSnippet.replace( + '{{row}}', + headerMap.map((elements, i) => { + return thSnippet + .replace('{{header-align}}', columnAligns[i]) + .replace('{{colspan}}', elements[1]) + .replace('{{header}}', elements[0]) + }).join('\n') + ) + + // build body rows + for (let row of rows) { + // append row to body + bodyRows.push(trSnippet.replace( + '{{row}}', + row.map((col, i) => { + return tdSnippet + .replace('{{data-align}}', columnAligns[i]) + .replace('{{data}}', col) + }).join('\n') + )) + } + + // construct table + table = table + .replace( + '{{table-headers}}', + theadSnippet.replace('{{headers}}', headerRow) + ) + .replace( + '{{table-rows}}', + tbodySnippet.replace('{{body}}', bodyRows.join('\n')) + ) + return table + } + + // build markdown subcomponents for each line + buildSubcomponents(text) { + // parse HTML snippets + let inlineCodeSnippet = path.join(this.snippetDir, 'inline-code.html') + try { + let stat = fs.statSync(inlineCodeSnippet) + if (!stat.isFile()) { + throw Error() + } + } catch (e) { + throw Error(`No such snippet: ${inlineCodeSnippet}`) + } + inlineCodeSnippet = fs.readFileSync(inlineCodeSnippet).toString() + + // build components + let subcomponent = text + + // this is a little tricky + // we need to handle plaintext '<', '>', and '$', which can interfere with HTML + // we want to replace '<', '>', and '$', except in HTML chunks, unless the HTML chunk is in an inline code chunk + + // first, segment subcomponent based on inline code chunks + let codeSegments = [] + let startIndex = 0 + for (let match of [...subcomponent.matchAll(/`.+?`/g)]) { + codeSegments.push(text.slice(startIndex, match['index'])) + codeSegments.push(match[0]) + startIndex = match['index'] + match[0].length + } + if (startIndex !== text.length) { + codeSegments.push(text.slice(startIndex)) + } + + // second, segment subcomponent based on HTML chunks + let htmlSegments = [] + for (let s of codeSegments) { + startIndex = 0 + for (let match of [...s.matchAll(/===.+?===/g)]) { + htmlSegments.push(s.slice(startIndex, match['index'])) + htmlSegments.push(match[0]) + startIndex = match['index'] + match[0].length + } + if (startIndex !== s.length) { + htmlSegments.push(s.slice(startIndex)) + } + } + + // then, replace '<' and '>' appropriately + for (let [index, chunk] of htmlSegments.entries()) { + if (!chunk.startsWith('===') || !chunk.endsWith('===')) { + htmlSegments[index] = chunk.replace(//g, '>').replace(/\$/g, '$') + } else { + htmlSegments[index] = chunk.replace(/^===/, '').replace(/===$/, '') + } + } + subcomponent = htmlSegments.join('') + + // handle links: [...](...) + while (/\[.+?\]\(.+?\)/.exec(subcomponent)) { + let match = /\[.+?\]\(.+?\)/.exec(subcomponent)[0] + let anchor = match.replace(/^\[/, '').replace(/\].*$/, '') + let href = match.replace(/^.*\(/, '').replace(/\)$/, '') + let html = '' + if (/\{\{src:.*\}\}/.exec(match) || /\{\{sys:home\}\}/.exec(match)) { + html = `${anchor}` + } else { + html = `${anchor}` + } + subcomponent = subcomponent.replace(match, html) + } + + // handle inline code + while (/`.+?`/.exec(subcomponent)) { + let match = /`.+?`/.exec(subcomponent)[0] + let code = match.replace(/`/g, '') + // we need to replace other special characters so they don't interfere + code = code.replace(/_/g, '_').replace(/\*/g, '*').replace(/~/g, '~') + let html = inlineCodeSnippet.replace('{{code}}', code) + subcomponent = subcomponent.replace(match, html) + } + + // handle italics: _..._ + // middle of string + while (/[^A-Za-z0-9"'`]_.+?_[^A-Za-z0-9"'`]/.exec(subcomponent)) { + let match = /[^A-Za-z0-9"'`]_.+?_[^A-Za-z0-9"'`]/.exec(subcomponent)[0] + let startChar = match[0] + let endChar = match[match.length - 1] + let cleansedMatch = match.substring(1, match.length - 2) + let italics = cleansedMatch.replace(/^_/, '').replace(/_$/, '') + subcomponent = subcomponent.replace(match, `${startChar}${italics}${endChar}`) + } + // start of string + while (/^_.+?_[^A-Za-z0-9"'`]/.exec(subcomponent)) { + let match = /^_.+?_[^A-Za-z0-9"'`]/.exec(subcomponent)[0] + let endChar = match[match.length - 1] + let cleansedMatch = match.substring(0, match.length - 2) + let italics = cleansedMatch.replace(/^_/, '').replace(/_$/, '') + subcomponent = subcomponent.replace(match, `${italics}${endChar}`) + } + // end of string + while (/[^A-Za-z0-9"'`]_.+?_$/.exec(subcomponent)) { + let match = /[^A-Za-z0-9"'`]_.+?_$/.exec(subcomponent)[0] + let startChar = match[0] + let cleansedMatch = match.substring(1, match.length - 1) + let italics = cleansedMatch.replace(/^_/, '').replace(/_$/, '') + subcomponent = subcomponent.replace(match, `${startChar}${italics}`) + } + // whole string + while (/^_.+?_$/.exec(subcomponent)) { + let match = /^_.+?_$/.exec(subcomponent)[0] + let italics = match.replace(/^_/, '').replace(/_$/, '') + subcomponent = subcomponent.replace(match, `${italics}`) + } + + // handle bold: **...** + // middle of string + while (/[^A-Za-z0-9"'`]\*\*.+?\*\*[^A-Za-z0-9"'`]/.exec(subcomponent)) { + let match = /[^A-Za-z0-9"'`]\*\*.+?\*\*[^A-Za-z0-9"'`]/.exec(subcomponent)[0] + let startChar = match[0] + let endChar = match[match.length - 1] + let cleansedMatch = match.substring(1, match.length - 3) + let bold = cleansedMatch.replace(/^\*\*/, '').replace(/\*\*$/, '') + subcomponent = subcomponent.replace(match, `${startChar}${bold}${endChar}`) + } + // start of string + while (/^\*\*.+?\*\*[^A-Za-z0-9"'`]/.exec(subcomponent)) { + let match = /^\*\*.+?\*\*[^A-Za-z0-9"'`]/.exec(subcomponent)[0] + let endChar = match[match.length - 1] + let cleansedMatch = match.substring(0, match.length - 3) + let bold = cleansedMatch.replace(/^\*\*/, '').replace(/\*\*$/, '') + subcomponent = subcomponent.replace(match, `${bold}${endChar}`) + } + // end of string + while (/[^A-Za-z0-9"'`]\*\*.+?\*\*$/.exec(subcomponent)) { + let match = /[^A-Za-z0-9"'`]\*\*.+?\*\*$/.exec(subcomponent)[0] + let startChar = match[0] + let cleansedMatch = match.substring(1, match.length - 2) + let bold = cleansedMatch.replace(/^\*\*/, '').replace(/\*\*$/, '') + subcomponent = subcomponent.replace(match, `${startChar}${bold}`) + } + // whole string + while (/^\*\*.+?\*\*$/.exec(subcomponent)) { + let match = /^\*\*.+?\*\*$/.exec(subcomponent)[0] + let bold = match.replace(/^\*\*/, '').replace(/\*\*$/, '') + subcomponent = subcomponent.replace(match, `${bold}`) + } + + // handle strikethrough: ~~...~~ + // middle of string + while (/[^A-Za-z0-9"'`]~~.+?~~[^A-Za-z0-9"'`]/.exec(subcomponent)) { + let match = /[^A-Za-z0-9"'`]~~.+?~~[^A-Za-z0-9"'`]/.exec(subcomponent)[0] + let startChar = match[0] + let endChar = match[match.length - 1] + let cleansedMatch = match.substring(1, match.length - 3) + let strikethrough = cleansedMatch.replace(/^~~/, '').replace(/~~$/, '') + subcomponent = subcomponent.replace(match, `${startChar}${strikethrough}${endChar}`) + } + // start of string + while (/^~~.+?~~[^A-Za-z0-9"'`]/.exec(subcomponent)) { + let match = /^~~.+?~~[^A-Za-z0-9"'`]/.exec(subcomponent)[0] + let endChar = match[match.length - 1] + let cleansedMatch = match.substring(0, match.length - 3) + let strikethrough = cleansedMatch.replace(/^~~/, '').replace(/~~$/, '') + subcomponent = subcomponent.replace(match, `${strikethrough}${endChar}`) + } + // end of string + while (/[^A-Za-z0-9"'`]~~.+?~~$/.exec(subcomponent)) { + let match = /[^A-Za-z0-9"'`]~~.+?~~$/.exec(subcomponent)[0] + let startChar = match[0] + let cleansedMatch = match.substring(1, match.length - 2) + let strikethrough = cleansedMatch.replace(/^~~/, '').replace(/~~$/, '') + subcomponent = subcomponent.replace(match, `${startChar}${strikethrough}`) + } + // whole string + while (/^~~.+?~~$/.exec(subcomponent)) { + let match = /^~~.+?~~$/.exec(subcomponent)[0] + let strikethrough = match.replace(/^~~/, '').replace(/~~$/, '') + subcomponent = subcomponent.replace(match, `${strikethrough}`) + } + + return subcomponent + } + + // resolve marq prefixes and suffixes, additional classes, and id + resolveAttributes(html, cls, id) { + let classesToAdd = [cls] + if (Array.isArray(cls)) { + classesToAdd = cls + } + classesToAdd = classesToAdd.filter(c => c !== '' && c !== undefined && c !== null) + + html = html.replace(/\{\{marq-prefix\}\}/g, this.cssPrefix) + html = html.replace(/\{\{marq-suffix\}\}/g, this.cssSuffix) + if (classesToAdd.length === 0) { + html = html.replace(/\{\{marq-class\}\}/g, '') + } else { + html = html.replace(/\{\{marq-class\}\}/g, ` ${classesToAdd.join(' ')}`) + } + if (id !== '' && id !== undefined && id !== null) { + html = html.replace(/\{\{marq-id\}\}/g, ` id="${id}"`) + } else { + html = html.replace(/\{\{marq-id\}\}/g, '') + } + + return html + } + + // convert markdown to HTML + convertSync(md, options) { + if (options === undefined) { + options = { + page: '', + cls: '', + id: '' + } + } + let page = options.page === undefined ? '' : options.page + let cls = options.cls === undefined ? '' : options.cls + let id = options.id === undefined ? '' : options.id + if (typeof md !== 'string') { + throw Error('Markdown input must be a string, try converting it with .toString()') + } + + // parse snippets + let centeredTextSnippet = path.join(this.snippetDir, 'centered-text.html') + let blockCodeSnippet = path.join(this.snippetDir, 'block-code.html') + let shoutoutSnippet = path.join(this.snippetDir, 'shoutout.html') + let ulSnippet = path.join(this.snippetDir, 'ul.html') + let olSnippet = path.join(this.snippetDir, 'ol.html') + let liSnippet = path.join(this.snippetDir, 'li.html') + let olliSnippet = path.join(this.snippetDir, 'olli.html') + let imgSnippet = path.join(this.snippetDir, 'img.html') + let imgSubtitleSnippet = path.join(this.snippetDir, 'img-subtitle.html') + let youtubeVideoSnippet = path.join(this.snippetDir, 'youtube.html') + let slideshowSnippet = path.join(this.snippetDir, 'slideshow/slideshow.html') + let h1Snippet = path.join(this.snippetDir, 'headers/h1.html') + let h2Snippet = path.join(this.snippetDir, 'headers/h2.html') + let h3Snippet = path.join(this.snippetDir, 'headers/h3.html') + let h4Snippet = path.join(this.snippetDir, 'headers/h4.html') + let h5Snippet = path.join(this.snippetDir, 'headers/h5.html') + let h6Snippet = path.join(this.snippetDir, 'headers/h6.html') + let pSnippet = path.join(this.snippetDir, 'p.html') + let blockquoteSnippet = path.join(this.snippetDir, 'blockquote.html') + let snip + try { + for (snip of [ + centeredTextSnippet, blockCodeSnippet, shoutoutSnippet, pSnippet, + ulSnippet, olSnippet, liSnippet, olliSnippet, imgSnippet, + imgSubtitleSnippet, youtubeVideoSnippet, slideshowSnippet, + h1Snippet, h2Snippet, h3Snippet, h4Snippet, h5Snippet, h6Snippet, + blockquoteSnippet + ]) { + let stat = fs.statSync(snip) + if (!stat.isFile()) { + throw Error() + } + } + } catch (e) { + throw Error(`No such snippet: ${snip}`) + } + centeredTextSnippet = fs.readFileSync(centeredTextSnippet).toString() + blockCodeSnippet = fs.readFileSync(blockCodeSnippet).toString() + shoutoutSnippet = fs.readFileSync(shoutoutSnippet).toString() + ulSnippet = fs.readFileSync(ulSnippet).toString() + olSnippet = fs.readFileSync(olSnippet).toString() + liSnippet = fs.readFileSync(liSnippet).toString() + olliSnippet = fs.readFileSync(olliSnippet).toString() + imgSnippet = fs.readFileSync(imgSnippet).toString() + imgSubtitleSnippet = fs.readFileSync(imgSubtitleSnippet).toString() + youtubeVideoSnippet = fs.readFileSync(youtubeVideoSnippet).toString() + slideshowSnippet = fs.readFileSync(slideshowSnippet).toString() + h1Snippet = fs.readFileSync(h1Snippet).toString() + h2Snippet = fs.readFileSync(h2Snippet).toString() + h3Snippet = fs.readFileSync(h3Snippet).toString() + h4Snippet = fs.readFileSync(h4Snippet).toString() + h5Snippet = fs.readFileSync(h5Snippet).toString() + h6Snippet = fs.readFileSync(h6Snippet).toString() + pSnippet = fs.readFileSync(pSnippet).toString() + blockquoteSnippet = fs.readFileSync(blockquoteSnippet).toString() + + // convert MD to HTML + let lines = md.split('\n') + let html = '' + + // code block state inforamtion + let inCodeBlock = false + let codeblock = [] + let codeblockLanguage = '' + + // list state information + let inUnorderedList = false + let inOrderedList = false + let unorderedListItems = [] + let orderedListItems = [] + let orderedListStart = 1 + + // table state information + let inTable = false + let tableHeaders = [] + let tableConfigs = [] + let tableRows = [] + + // HTML block state information + let inHtmlBlock = false + let htmlblock = [] + + // slideshow state information + let inSlideshow = false + let slideshow = [] + + for (let [index, line] of lines.entries()) { + // we'll need to keep track of the state of the markdown + // valid states: + // - in code block + // - in HTML block + // - in table + // - in unordered list + // - in ordered list + // - in slideshow + // - normal + + // normal state + if (inCodeBlock === false && inHtmlBlock === false && inTable === false && inUnorderedList === false && inOrderedList === false && inSlideshow === false) { + + // lines that start with '#' are titles + if (line.startsWith('# ')) { + let text = line.replace(/^# /, '') + let subcomponent = this.buildSubcomponents(text) + html += h1Snippet.replace('{{title}}', subcomponent) + } else if (line.startsWith('## ')) { + let text = line.replace(/^## /, '') + let subcomponent = this.buildSubcomponents(text) + html += h2Snippet.replace('{{title}}', subcomponent) + } else if (line.startsWith('### ')) { + let text = line.replace(/^### /, '') + let subcomponent = this.buildSubcomponents(text) + html += h3Snippet.replace('{{title}}', subcomponent) + } else if (line.startsWith('#### ')) { + let text = line.replace(/^#### /, '') + let subcomponent = this.buildSubcomponents(text) + html += h4Snippet.replace('{{title}}', subcomponent) + } else if (line.startsWith('##### ')) { + let text = line.replace(/^##### /, '') + let subcomponent = this.buildSubcomponents(text) + html += h5Snippet.replace('{{title}}', subcomponent) + } else if (line.startsWith('###### ')) { + let text = line.replace(/^###### /, '') + let subcomponent = this.buildSubcomponents(text) + html += h6Snippet.replace('{{title}}', subcomponent) + + // lines that start with '>>' are interpreted to be shoutouts + } else if (line.startsWith('>> ')) { + let shoutout = line.replace(/^>> /, '') + let components = shoutout.split(' | ') + let shoutoutTitle = components.shift() + let shoutoutText = components.join(' | ') + shoutoutTitle = this.buildSubcomponents(shoutoutTitle) + shoutoutText = this.buildSubcomponents(shoutoutText) + html += shoutoutSnippet.replace('{{title}}', shoutoutTitle).replace('{{text}}', shoutoutText) + + // lines that start with '*' are interpreted to be unordered lists + } else if (line.startsWith('* ')) { + inUnorderedList = true + let text = line.replace(/^\* /, '') + unorderedListItems.push(text) + + // lines that start with \d. are interpreted to be ordered lists + } else if (/^\d+\./.exec(line)) { + if (inOrderedList === false) { + orderedListStart = Number(line.replace(/\..*$/, '')) + } + inOrderedList = true + let text = line.replace(/^\d+\.\s*/, '') + orderedListItems.push(text) + + // lines that start with '!' are interpreted to be images + } else if (line.startsWith('![')) { + // we have to be careful here - must be lazy and not greedy - what if <.*> contains ']' or ')'? + let imgAlt = line.replace(/^!\[/, '').replace(/\].*?$/, '') + let imgSrc = line.replace(/^.*?\(/, '').replace(/\).*?$/, '') + let remaining = line.replace(/^!\[.*?\]\(.*?\)/, '') + let subtitleText = '' + if (remaining[0] === '<' && remaining[remaining.length-1] === '>') { + subtitleText = line.replace(/^.*$/, '') + subtitleText = this.buildSubcomponents(subtitleText) + } + let imgSubtitle = imgSubtitleSnippet.replace('{{subtitle}}', subtitleText) + html += imgSnippet.replace('{{alt}}', imgAlt).replace('{{src}}', imgSrc).replace('{{img-subtitle}}', imgSubtitle) + + // lines that start with '~' are interpreted to be YouTube vides + } else if (line.startsWith('~(')) { + let videoId = line.replace(/^~\(/, '').replace(/\)$/, '') + html += youtubeVideoSnippet.replace(/\{\{video-id\}\}/g, videoId) + + // lines that are '===' are interpreted to be the start or end of HTML blocks + } else if (line === '===') { + inHtmlBlock = true + + // lines that start with '=' are interpreted to be centered + } else if (line.startsWith('=')) { + let text = line.replace(/^=/, '') + let subcomponent = this.buildSubcomponents(text) + html += centeredTextSnippet.replace('{{text}}', subcomponent) + + // lines that start with '?' are interpretted to be comments and should not be rendered as HTML + } else if (line.startsWith('?')) { + continue + + // lines that start with '>' are interpretted to be block quotes + } else if (line.startsWith('>')) { + let blockquote = line.replace(/^> /, '') + let quoteText, quoteCite + if (blockquote.includes(' | ')) { + let components = blockquote.split(' | ') + quoteCite = components.shift() + quoteText = this.buildSubcomponents(components.join(' | ')) + } else { + quoteCite = '' + quoteText = this.buildSubcomponents(blockquote) + } + + let quoteHtml + if (quoteCite === '') { + quoteHtml = blockquoteSnippet.replace('{{marq-cite}}', '') + } else { + quoteHtml = blockquoteSnippet.replace('{{marq-cite}}', `cite="${quoteCite}"`) + } + html += quoteHtml.replace('{{blockquote}}', quoteText) + + // lines that start with '|' are interpretted to be table contents + } else if (line.startsWith('|')) { + inTable = true + let tableComponents = line.split('|') + this.validateTableRow(line, index, page) + tableComponents = tableComponents.filter(tc => tc !== '') + let isConfig = tableComponents.reduce((result, tc) => { return Boolean(/^:?-+:?$/.exec(tc.trim())) && result }, true) + if (isConfig === false) { + tableHeaders = tableHeaders.concat(tableComponents.map(tc => tc.trim())) + } else { + tableConfigs = tableConfigs.concat(tableComponents.map(tc => tc.trim().replace(/-+/, '-'))) + this.validateTableConfigs(tableConfigs, index, page) + } + + // lines that are '```' are interpreted to be the start or end of a code block + } else if (line.startsWith('```')) { + inCodeBlock = true + if (line !== '```') { + codeblockLanguage = line.replace(/^```/, '') + } + + // lines that are '[[[' are intepreted to be the start of a slideshow + } else if (line === '[[[') { + inSlideshow = true + + // lines that are '---' or '___' are interpreted to be horizontal rules + } else if (line === '---' || line === '___') { + html += '
' + + // empty lines are interpreted to be line breaks + } else if (line === '') { + if (inUnorderedList === true) { + let listHtml = unorderedListItems.map(li => liSnippet.replace('{{text}}', this.buildSubcomponents(li))) + html += ulSnippet.replace('{{list-items}}', listHtml.join('')) + unorderedListItems = [] + inUnorderedList = false + } else if (inOrderedList === true) { + let listHtml = orderedListItems.map(olli => olliSnippet.replace('{{text}}', this.buildSubcomponents(olli))) + html += olSnippet.replace('{{list-items}}', listHtml.join('')).replace('{{ol-start}}', orderedListStart) + orderedListItems = [] + inOrderedList = false + orderedListStart = 1 + } else { + html += '
' + } + + // all other lines are interpreted to be regular content text + } else { + let subcomponent = this.buildSubcomponents(line) + html += pSnippet.replace('{{text}}', subcomponent) + } + + // in unordered list state + } else if (inUnorderedList === true) { + if (line === '') { + let listHtml = unorderedListItems.map(li => liSnippet.replace('{{text}}', this.buildSubcomponents(li))) + html += ulSnippet.replace('{{list-items}}', listHtml.join('')) + unorderedListItems = [] + inUnorderedList = false + } else if (line.startsWith('* ')) { + let text = line.replace(/^\* /, '') + unorderedListItems.push(text) + } else { + throw new Error(`Invalid markdown: unclosed unordered list in '${page}'`) + } + + // in ordered list state + } else if (inOrderedList === true) { + if (line === '') { + let listHtml = orderedListItems.map(olli => olliSnippet.replace('{{text}}', this.buildSubcomponents(olli))) + html += olSnippet.replace('{{list-items}}', listHtml.join('')).replace('{{ol-start}}', orderedListStart) + orderedListItems = [] + inOrderedList = false + orderedListStart = 1 + } else if (/^\d+\./.exec(line)) { + let text = line.replace(/^\d+\.\s*/, '') + orderedListItems.push(text) + } else { + throw new Error(`Invalid markdown: unclosed ordered list in '${page}'`) + } + + // in table state + } else if (inTable === true) { + // table ends when we encounter a blank line + if (line === '') { + inTable = false + let tableHtml = this.buildTable(tableHeaders, tableConfigs, tableRows, page) + html += tableHtml + tableHeaders = [] + tableConfigs = [] + tableRows = [] + } else { + if (!line.startsWith('|')) { + throw new Error(`Invalid table row (line ${index}): did you forget to end the table with an empty newline?`) + } + // ignore '|' if inline code + let tableComponents = line.match(/(?:[^|`]+|`[^`]*`)+/g) + this.validateTableRow(line, index, page) + tableComponents = tableComponents.filter(tc => tc !== '') + if (tableConfigs.length === 0) { + tableConfigs = tableConfigs.concat(tableComponents.map(tc => tc.trim().replace(/-+/, '-'))) + this.validateTableConfigs(tableConfigs, index, page) + } else { + tableRows.push(tableComponents.map(tc => { return this.buildSubcomponents(tc) })) + } + } + + // in code block state + } else if (inCodeBlock === true) { + // if we encounter another '```', close the code block + if (line === '```') { + inCodeBlock = false + codeblock = codeblock.map(b => b.replace('<', '<').replace('>', '>')) + if (codeblockLanguage === '') { + codeblockLanguage = 'nohighlight' + } else { + codeblockLanguage = `language-${codeblockLanguage}` + } + codeblock = codeblock.join('\n') + html += blockCodeSnippet.replace('{{code}}', codeblock).replace('{{codeblock-language}}', codeblockLanguage).replace('{{marq-blank-img}}', this.placeholder) + codeblock = [] + codeblockLanguage = '' + } else { + // handle plaintext '<', '>', and '$' which can interfere with HTML + line = line.replace(//g, '>') + line = line.replace(/\$/g, '$') + + // append to the code block + codeblock.push(line) + } + + // in HTML block state + } else if (inHtmlBlock === true) { + // if we encounter another '===', close the HTML block + if (line === '===') { + inHtmlBlock = false + html += htmlblock.join('\n') + htmlblock = [] + } else { + // append to the HTML block + htmlblock.push(line) + } + + // in slideshow + } else if (inSlideshow === true) { + // if we encounter ']]]', close the slideshow + if (line === ']]]') { + inSlideshow = false + html += slideshowSnippet.replace('{{slides}}', slideshow.map(s => s[0]).join('\n')).replace('{{dots}}', slideshow.map(s => s[1]).join('\n')).replace('{{marq-slideshow-script}}', this.slideshowScript) + slideshow = [] + } else { + // append to the slideshow + slideshow.push(this.buildSlideshowSlide(line, slideshow.length + 1, page, index)) + } + + // error state + } else { + throw new Error('Ambiguous markdown state: in table, code block, unordered list, ordered list, slideshow, or HTML block at the same time') + } + } + + // handle invalid markdown cases + if (inCodeBlock) { + throw new Error(`Invalid markdown: unclosed code block in '${page}'`) + } + if (inUnorderedList) { + throw new Error(`Invalid markdown: unclosed unordered list in '${page}'`) + } + if (inOrderedList) { + throw new Error(`Invalid markdown: unclosed ordered list in '${page}'`) + } + if (inTable) { + throw new Error(`Invalid markdown: unclosed table in '${page}', did you forget to end the table with an empty newline?`) + } + if (inSlideshow) { + throw new Error(`Invalid markdown: unclosed slideshow in '${page}'`) + } + + html = this.resolveAttributes(html, cls, id) + return html + } + + convert(md, options) { + return new Promise((resolve, reject) => { + try { + let result = this.convertSync(md, options) + resolve(result) + } catch (e) { + reject(e) + } + }) + } +} + +module.exports = { + Marq +} \ No newline at end of file diff --git a/js/slideshow.js b/js/slideshow.js new file mode 100644 index 0000000..f6393a4 --- /dev/null +++ b/js/slideshow.js @@ -0,0 +1,39 @@ +// set up and control markdown slideshow + +// initial slide setup +let slideIndex = 1 +$(document).ready(() => { + showSlides(slideIndex) +}) + +// show selected slide +const showSlides = (n) => { + let i + let slides = document.getElementsByClassName('marq-markdown-slideshow-slide') + let dots = document.getElementsByClassName('marq-markdown-slideshow-dot') + + if (n > slides.length) { + slideIndex = 1 + } + if (n < 1) { + slideIndex = slides.length + } + for (i = 0; i < slides.length; i++) { + slides[i].style.display = 'none' + } + for (i = 0; i < dots.length; i++) { + dots[i].className = dots[i].className.replace(' marq-markdown-slideshow-active-slide', '') + } + slides[slideIndex-1].style.display = 'block' + dots[slideIndex-1].className += ' marq-markdown-slideshow-active-slide' +} + +// next/previous controls +const plusSlides = (n) => { + showSlides(slideIndex += n) +} + +// thumbnail image controls +const currentSlide = (n) => { + showSlides(slideIndex = n) +} diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..2f798be Binary files /dev/null and b/logo.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..30efc91 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "@wcarhart/marq", + "version": "0.0.0", + "main": "index.js", + "repository": "https://github.com/wcarhart/marq", + "author": "Will Carhart ", + "license": "MIT", + "dependencies": {} +} diff --git a/snippets/block-code.html b/snippets/block-code.html new file mode 100644 index 0000000..d5db3b4 --- /dev/null +++ b/snippets/block-code.html @@ -0,0 +1 @@ +
{{code}}
\ No newline at end of file diff --git a/snippets/blockquote.html b/snippets/blockquote.html new file mode 100644 index 0000000..36d55fa --- /dev/null +++ b/snippets/blockquote.html @@ -0,0 +1 @@ +
{{blockquote}}
\ No newline at end of file diff --git a/snippets/centered-text.html b/snippets/centered-text.html new file mode 100644 index 0000000..5b397ff --- /dev/null +++ b/snippets/centered-text.html @@ -0,0 +1 @@ +

{{text}}

\ No newline at end of file diff --git a/snippets/headers/h1.html b/snippets/headers/h1.html new file mode 100644 index 0000000..54f6a55 --- /dev/null +++ b/snippets/headers/h1.html @@ -0,0 +1 @@ +

{{title}}

\ No newline at end of file diff --git a/snippets/headers/h2.html b/snippets/headers/h2.html new file mode 100644 index 0000000..bff5518 --- /dev/null +++ b/snippets/headers/h2.html @@ -0,0 +1 @@ +

{{title}}

\ No newline at end of file diff --git a/snippets/headers/h3.html b/snippets/headers/h3.html new file mode 100644 index 0000000..3ecfe23 --- /dev/null +++ b/snippets/headers/h3.html @@ -0,0 +1 @@ +

{{title}}

\ No newline at end of file diff --git a/snippets/headers/h4.html b/snippets/headers/h4.html new file mode 100644 index 0000000..1c925df --- /dev/null +++ b/snippets/headers/h4.html @@ -0,0 +1 @@ +

{{title}}

\ No newline at end of file diff --git a/snippets/headers/h5.html b/snippets/headers/h5.html new file mode 100644 index 0000000..de1a752 --- /dev/null +++ b/snippets/headers/h5.html @@ -0,0 +1 @@ +
{{title}}
\ No newline at end of file diff --git a/snippets/headers/h6.html b/snippets/headers/h6.html new file mode 100644 index 0000000..fd83c05 --- /dev/null +++ b/snippets/headers/h6.html @@ -0,0 +1 @@ +
{{title}}
\ No newline at end of file diff --git a/snippets/img-subtitle.html b/snippets/img-subtitle.html new file mode 100644 index 0000000..7ea770c --- /dev/null +++ b/snippets/img-subtitle.html @@ -0,0 +1 @@ +

{{subtitle}}

\ No newline at end of file diff --git a/snippets/img.html b/snippets/img.html new file mode 100644 index 0000000..6dc2275 --- /dev/null +++ b/snippets/img.html @@ -0,0 +1 @@ +{{alt}}{{img-subtitle}} \ No newline at end of file diff --git a/snippets/inline-code.html b/snippets/inline-code.html new file mode 100644 index 0000000..87e8ab3 --- /dev/null +++ b/snippets/inline-code.html @@ -0,0 +1 @@ +{{code}} \ No newline at end of file diff --git a/snippets/li.html b/snippets/li.html new file mode 100644 index 0000000..cba7494 --- /dev/null +++ b/snippets/li.html @@ -0,0 +1 @@ +
  • {{text}}
  • \ No newline at end of file diff --git a/snippets/ol.html b/snippets/ol.html new file mode 100644 index 0000000..6f0c305 --- /dev/null +++ b/snippets/ol.html @@ -0,0 +1 @@ +
      {{list-items}}
    \ No newline at end of file diff --git a/snippets/olli.html b/snippets/olli.html new file mode 100644 index 0000000..ba21119 --- /dev/null +++ b/snippets/olli.html @@ -0,0 +1 @@ +
  • {{text}}
  • \ No newline at end of file diff --git a/snippets/p.html b/snippets/p.html new file mode 100644 index 0000000..fa63a06 --- /dev/null +++ b/snippets/p.html @@ -0,0 +1 @@ +

    {{text}}

    \ No newline at end of file diff --git a/snippets/shoutout.html b/snippets/shoutout.html new file mode 100644 index 0000000..5e15cd0 --- /dev/null +++ b/snippets/shoutout.html @@ -0,0 +1,4 @@ +
    +

    {{title}}

    +

    {{text}}

    +
    \ No newline at end of file diff --git a/snippets/slideshow/dot.html b/snippets/slideshow/dot.html new file mode 100644 index 0000000..0358711 --- /dev/null +++ b/snippets/slideshow/dot.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/snippets/slideshow/slide.html b/snippets/slideshow/slide.html new file mode 100644 index 0000000..491a838 --- /dev/null +++ b/snippets/slideshow/slide.html @@ -0,0 +1,4 @@ +
    + {{slide-alt}} +
    {{slide-caption}}
    +
    \ No newline at end of file diff --git a/snippets/slideshow/slideshow.html b/snippets/slideshow/slideshow.html new file mode 100644 index 0000000..eda8e1c --- /dev/null +++ b/snippets/slideshow/slideshow.html @@ -0,0 +1,10 @@ + +
    + {{slides}} + + +
    +
    +
    + {{dots}} +
    \ No newline at end of file diff --git a/snippets/table/table.html b/snippets/table/table.html new file mode 100644 index 0000000..a08f827 --- /dev/null +++ b/snippets/table/table.html @@ -0,0 +1 @@ +{{table-headers}}{{table-rows}}
    \ No newline at end of file diff --git a/snippets/table/tbody.html b/snippets/table/tbody.html new file mode 100644 index 0000000..0d15e0d --- /dev/null +++ b/snippets/table/tbody.html @@ -0,0 +1 @@ +{{body}} \ No newline at end of file diff --git a/snippets/table/td.html b/snippets/table/td.html new file mode 100644 index 0000000..8ccded5 --- /dev/null +++ b/snippets/table/td.html @@ -0,0 +1 @@ +{{data}} \ No newline at end of file diff --git a/snippets/table/th.html b/snippets/table/th.html new file mode 100644 index 0000000..d9769d9 --- /dev/null +++ b/snippets/table/th.html @@ -0,0 +1 @@ +{{header}} \ No newline at end of file diff --git a/snippets/table/thead.html b/snippets/table/thead.html new file mode 100644 index 0000000..ec815a0 --- /dev/null +++ b/snippets/table/thead.html @@ -0,0 +1 @@ +{{headers}} \ No newline at end of file diff --git a/snippets/table/tr.html b/snippets/table/tr.html new file mode 100644 index 0000000..56d3d41 --- /dev/null +++ b/snippets/table/tr.html @@ -0,0 +1 @@ +{{row}} \ No newline at end of file diff --git a/snippets/ul.html b/snippets/ul.html new file mode 100644 index 0000000..c0c0e4b --- /dev/null +++ b/snippets/ul.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/snippets/youtube.html b/snippets/youtube.html new file mode 100644 index 0000000..251fc9b --- /dev/null +++ b/snippets/youtube.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..fb57ccd --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +