UNPKG

markshell

Version:

markshell allows you to output any markdown file formatted and style to the console

821 lines (573 loc) 21 kB
'use strict'; const fs = require('fs'); const path = require('path'); const chalk = require('chalk'); const syntaxHighlighter = require('./syntaxhighlighter'); const admonitions = require('./admonitions'); const EOL = require('os').EOL; /** * Escape special regex characters to prevent ReDoS attacks * @param {string} str - String to escape * @returns {string} - Escaped string safe for use in RegExp */ const escapeRegex = (str) => { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; const theme = require('./syntaxhighlighter/themes/okaidia.theme'); let _theme; /** * Default indent definitons * @property {number} blockquote Indent of blockquote * @property {number} definition list Indent of blockquote */ const defIndents = { blockquote: 3, definitionList: 3 } /** * @property {boolean} enabled true = enabled, false = disabled * @property {boolean} useSafeColors true = enbaled, false = disabled * @property {number} beforeIndent before title style * @property {number} afterIndent after title style */ const admonitionSettings = { enabled: true, useSafeColors: false, getStyles: admonitions.getStyles, setStyles: admonitions.setStyles } /** * @property {chalk} headline Headline format * @property {chalk} bold Bold string format * @property {chalk} italic Italic format * @property {chalk} strikethrough Strike-throught format * @property {chalk} code Code format * @property {chalk} inlineCode Inline markdown code format * @property {chalk} blockQuote Blockquote format * @property {syntaxHighlighter} sourceCodeTheme Syntax hightlighter theme * @property {number} indents Define how many spaces get added to blockquotes and definition lists * @property {boolean} useAdmonitions Define if markdown should use Admonitions plugin * @property {admonitionSettings} Addmonition settings * @property {string} includePath Include path for external files */ const defTheme = { headline: chalk.bold.keyword('yellow'), bold: chalk.bold.keyword('white'), italic: chalk.italic.keyword('white'), strikethrough: chalk.strikethrough, /** @deprecated Used before syntax highlighter */ code: chalk.bgGray.white.bold, inlineCode: chalk.keyword('orange'), blockQuote: chalk.italic.bgMagentaBright.white.bold, sourceCodeTheme: syntaxHighlighter.themes.OKAIDIA, availableSourceThemes: syntaxHighlighter.availableThemes, indents: defIndents, admonitions: admonitionSettings, includePath: null } const _allThemes = () => { return syntaxHighlighter.availableThemes; } /** * Replace common inline MarkDown tokens by using regular expressions * @param {string} content of MarkDown file * @param {RegExp} regexMatch Regular expretion of MarkDown format * @param {chalk} colorFunction Chalk color definition * @param {number} removeChars Removes x characters from the beginning nad end of the word. */ const _highlightText = (content, regexMatch, colorFunction, removeChars = null) => { let match; // Define new content for replacement let newContent = content; let i = 0; while ((match = regexMatch.exec(content)) !== null) { i+=1; // This is necessary to avoid infinite loops with zero-width matches if (regexMatch.lastIndex === undefined && match.index === regexMatch.lastIndex) { regexMatch.lastIndex++; } // get identifier (first group) and content in between (group) if (match.length >= 2 && match[0] !== null && match[1] !== null) { if (removeChars === null) { let findAllRegex = new RegExp(escapeRegex(match[0]), "ig"); newContent = newContent.replace(findAllRegex, colorFunction(match[1])); } else { newContent = newContent.replace( match[0], colorFunction( match[1].substr( removeChars, match[0].length - 1 - removeChars) ) ); } } } return newContent; } /** * Replace common inline MarkDown tokens by using regular expressions * @param {string} content of MarkDown file * @param {RegExp} regexMatch Regular expretion of MarkDown format */ const _removeImages = (content, regexMatch) => { let match; // Define new content for replacement let newContent = content; while ((match = regexMatch.exec(content)) !== null) { // This is necessary to avoid infinite loops with zero-width matches if (regexMatch.lastIndex === undefined && match.index === regexMatch.lastIndex) { regexMatch.lastIndex++; } // get identifier (first group) and content in between (group) if (match.length >= 2 && match[0] !== null && match[1] !== null) { newContent = newContent.replace( match[0], '' ); } } return newContent; } /** * Format all headlines * @param {string} content of MarkDown file */ const _headlines = (content) => { let newContent = content.split(EOL); newContent.forEach((line, index) => { if (line.startsWith('#')) { var headLine = line.replace(/\#/ig, ''); newContent[index] = this._theme.headline(headLine.trim()); } }); return newContent.join(EOL); } /** * Formats source code blocks without Prismjs * @param {string} content of MarkDown file */ const _codeBlock = (content) => { let codeRegex = new RegExp(/(\`\`\`)(.*?)(\`\`\`)/igs); let newContent = content.match(codeRegex); newContent.forEach((element) => { let langRegex = new RegExp(/(\`\`\`)(.*?)(\r?\n)/igs); let langIdentifiere = element.match(langRegex); if (langIdentifiere.length === 1) { let lang = langIdentifiere[0].replace(/```/, '').replace(/\n/, ''); //Replace language let source = element.replace(`\`\`\`${lang}\n`, ''); //Replace ``` source = source.replace('```', ''); try { let hlSource = syntaxHighlighter.highlight(source, lang.trim(), this._theme.sourceCodeTheme); content.replace(element, hlSource) } catch (e) { throw e; } } }) return content; } /** * Formats source code blocks using PrismJS * @param {string} content of MarkDown file */ // Module-level storage for code block placeholders let _codeBlockPlaceholders = []; const _highlightedCodeBlock = (content) => { let codeRegex = new RegExp(/(\`\`\`)(.*?)(\`\`\`)/igs); let newContent = content.match(codeRegex); if (newContent === null) { return content; } // Reset and store highlighted code blocks with placeholders to protect from inline code processing _codeBlockPlaceholders = []; let placeholderIndex = 0; newContent.forEach((element) => { let langRegex = new RegExp(/(\`\`\`)(.*?)(\r?\n)/igs); let langIdentifiere = element.match(langRegex); if (langIdentifiere.length === 1) { let lang = langIdentifiere[0].replace(/\`\`\`/, '').replace(/\n/, '').trim(); //Replace language let source = element.replace(`\`\`\`${lang}\n`, ''); //Replace ``` source = source.replace('\`\`\`', ''); try { let hlSource = syntaxHighlighter.highlight(source, lang, this._theme.sourceCodeTheme); this._theme.sourceCodeTheme; // Use a placeholder that won't be matched by inline code regex const placeholder = `__CODEBLOCK_${placeholderIndex}__`; _codeBlockPlaceholders.push({ placeholder, code: hlSource }); placeholderIndex++; content = content.replace(element, placeholder) } catch (e) { throw e; } } }) return content; } const _restoreCodeBlocks = (content) => { // Restore code blocks from placeholders _codeBlockPlaceholders.forEach(({ placeholder, code }) => { content = content.replace(placeholder, code); }); return content; } /** * Formats Blockquote * @param {string} content of markdown file * @param {number} [indentLeft=3] default indent on left side * @param {number} [indentRight=3] default indent on right side */ const _addBlockQuote = (content, indentLeft = 3, indentRight = 3) => { let newContent = content.split('\n'); var columns = process.stdout.columns - 4; let maxWordLength = columns - indentLeft - indentRight; newContent.forEach((line, index) => { if (line.startsWith('>')) { var quote = line.replace(/>/ig, ''); var words = quote.split(' '); var newLine = ''; var curLine = 0; words.forEach(element => { var calcLine = Math.floor( (newLine.length + element.length + 2) / maxWordLength ); if (curLine !== calcLine) { curLine = calcLine; newLine += '\n' + element; } else { newLine += element + ' '; } }); var blockQuoteLines = newLine.split(EOL); newContent[index] = ""; blockQuoteLines.forEach(line => { let fillUpRight = maxWordLength - line.trim().length; let fillUpString = fillUpRight > 0 ? " ".repeat(fillUpRight) : ""; newContent[index] += " ".repeat(indentLeft) + this._theme.blockQuote( line.trim() + fillUpString ) + '\n'; }) } }) return newContent.join('\n'); } /** * supports markdown-include * @param {string} content * @param {Array} externalFound * @param {string} baseDir */ const _includeExternals = (content, externalFound, baseDir, regexDelimiter) => { let replacements = []; externalFound.forEach(element => { let filename = regexDelimiter.exec(element); if (filename[1] !== undefined) { let locFileName = filename[1].trim(); if (locFileName.startsWith("'")) { locFileName = locFileName.replace(/'/ig, ''); } if (locFileName.startsWith('"')) { locFileName = locFileName.replace(/"/ig, ''); } let partialPath = path.join(baseDir, locFileName); if (fs.existsSync(partialPath)) { let partialContent = fs.readFileSync(partialPath); replacements[element] = partialContent; } } }); let keys = Object.keys(replacements); let newContent = content; for (let i = 0; i < keys.length; i++) { var regExp = new RegExp(escapeRegex(keys[i]), 'ig'); newContent = newContent.replace(regExp, replacements[keys[i]]); } return newContent; } /** * Include support for * MDInclude https://github.com/cmacmackin/markdown-include * @param {string} content * @param {string} filepath */ const _extMDInclude = (content, filepath) => { let extFileRegexp = new RegExp(/({!)(.*?)(!})/igs); let externalFound = content.match(extFileRegexp); let baseDir = path.dirname(filepath); // just in case no external could be found if (externalFound === null) { return content; } return _includeExternals(content, externalFound, baseDir, /\{\!(.*?)\!\}/); } /** * Include support for * PyMdown https://facelessuser.github.io/pymdown-extensions/extensions/snippets/ * @param {string} content */ const _extPyMDown = (content) => { if (this._theme.includePath === null) { return content; } let extFileRegexp = new RegExp(/^-{2,}8<-{2,}.*$/gim); let externalFound = content.match(extFileRegexp); let baseDir = this._theme.includePath; if (externalFound === null) { return content; } return _includeExternals(content, externalFound, baseDir, /^-{2,}8<-{2,}(.*)/i); } /** * Formats bold elements in MarkDown * @param {string} content of markdown file */ const _addBold = (content) => { return _highlightText(content, /\*\*(.*?)\*\*/ig, this._theme.bold); } /** * Formats italic elements in MarkDown * Supports both underscore (_text_) and asterisk (*text*) emphasis markers * @param {string} content of markdown file */ const _addItalic = (content) => { // First pass: Handle underscore emphasis // Pattern: _text_ where underscores are not adjacent to word characters let underscoreRegex = new RegExp(/(?<!\w)_([^_\n]+?)_(?!\w)/g); content = _highlightText(content, underscoreRegex, this._theme.italic); // Second pass: Handle asterisk emphasis (but not bold **) // Pattern: *text* where asterisks are not adjacent to other asterisks (to avoid matching **bold**) // Also not adjacent to word characters on the outside to avoid matching mid-word asterisks let asteriskRegex = new RegExp(/(?<!\*)(?<!\w)\*([^*\n]+?)\*(?!\*)(?!\w)/g); content = _highlightText(content, asteriskRegex, this._theme.italic); return content; } /** * Formats strikethrough elements in MarkDown * @param {string} content */ const _addStrikeThrough = (content) => { return _highlightText(content, /\~\~(.*?)\~\~/ig, this._theme.strikethrough); } /** * Formats code blocks * @param {string} content of markdown file */ const _addCode = (content) => { return _codeBlock(content); } /** * Format `inline` source code in markdown files * @param {string} content of markdown file */ const _addInlineCode = (content) => { // Use negative lookbehind/lookahead to avoid matching backticks that are part of triple-backtick code blocks // (?<!`) = not preceded by backtick, (?!`) = not followed by backtick // This ensures we only match single backticks for inline code, not triple backticks for code blocks return _highlightText(content, /(?<!`)`([^`]+)`(?!`)/g, this._theme.inlineCode); } const _addAdmonitions = (content, beforeIndent, afterIndent, titleIndent, useSafeColors) => { return admonitions.add(content, beforeIndent, afterIndent, titleIndent, useSafeColors); } /** * Format `inline` source code in markdown files * @param {string} content of markdown file */ const _addHyperlinks = (content) => { let regExp = new RegExp(/\[(.*?)\]\((.*?)\)/ig); let elements = content.match(regExp); if (elements === null) { return content; } elements.forEach(element => { let linkMatch = new RegExp(/\[(?<linktext>.*?)\]\((?<link>.*?)\)/ig); let href = linkMatch.exec(element); let newHyperlink; // link and text are the same. if (href.groups.link === href.groups.linktext) { newHyperlink = href.groups.link; } else // link is internal if (href.groups.link.startsWith('.')) { newHyperlink = href.groups.linktext; } // now everything else else { newHyperlink = `${href.groups.linktext} (${href.groups.link})`; } content = content.replace(element, newHyperlink); }); return content; } /** * Outputs formated string * @param {string} filepath to markdown file */ const _toConsole = (filepath) => { if (!filepath.toLowerCase().endsWith('.md')) { throw "File needs to be a markdown file ending with '.md'"; } let content = _toRawContent(filepath); console.log(content); } /** * Format definiton list * @param {string} content of Markdown file */ const _addDefinitionList = (content, leftIndent = 3) => { let contentBlocks = content.split('\n'); contentBlocks.forEach((element, index) => { if (element.startsWith(':')) { let newDefList = element.substr(2, element.length - 2).replace(EOL, ''); let maxLineLength = process.stdout.columns - leftIndent; // - EOL.length; let indent = " ".repeat(leftIndent); let fill2end = process.stdout.columns - newDefList.length; let lineCount = Math.ceil(newDefList.length / process.stdout.columns); let words = newDefList.replace(EOL, " ").split(" "); let formattedLines = [indent]; let lineIndex = 0; words.forEach(word => { if ((formattedLines[lineIndex] + word + " ").length <= maxLineLength) { formattedLines[lineIndex] += word + " "; } else { lineIndex += 1; formattedLines[lineIndex] = indent + word + " "; } }); contentBlocks[index] = formattedLines.join('\n'); } }); // console.log(contentBlocks); return contentBlocks.join('\n'); } /** * Returns the raw formatted string * @param {string} filepath to markdown file */ const _toRawContent = (filepath) => { if (!fs.existsSync(filepath)) { throw `File ${filepath} path do no exist` } if (!filepath.toLowerCase().endsWith('.md')) { throw "File needs to be a markdown file ending with '.md'"; } let content; try { content = fs.readFileSync(filepath).toString(); } catch (e) { throw e; } if (typeof this._theme === 'undefined') { _setTheme(defTheme); } content = content.replace(/\r\n/ig, '\n') try { // console.log('Add definiton List'); content = _extMDInclude(content, filepath); content = _extPyMDown(content); } catch (e) { throw `Error in external file: ${e}`; } try { content = _removeImages(content, /!\[([^\]]*)\]\(([^)"'\s]+)(?:\s+["']([^"']*)["'])?\)/g); } catch (e) { throw `Error in remove images: ${e}`; } try { // console.log('Add hyperlink'); content = _addHyperlinks(content); } catch (e) { throw `Error in addHyperlinks: ${e}`; } try { // console.log('Add definiton List'); content = _addDefinitionList(content, this._theme.indents.definitionList); } catch (e) { throw `Error in addDefinitionList: ${e}`; } try { // Process code blocks FIRST - replaces them with placeholders // This prevents inline code regex from matching backticks inside code blocks content = _highlightedCodeBlock(content); } catch (e) { throw `Error in Highlighted Code: ${e}`; } try { // Process inline code AFTER code blocks are replaced with placeholders // At this point, code blocks are hidden as __CODEBLOCK_N__ so inline code won't match them content = _addInlineCode(content); } catch (e) { throw `Error in addInlineCode: ${e}`; } try { // Restore code blocks from placeholders content = _restoreCodeBlocks(content); } catch (e) { throw `Error in restoring code blocks: ${e}`; } try { // console.log('Add Bold'); content = _addBold(content); } catch (e) { throw `Error in addBold Code: ${e}`; } try { // console.log('Add Italic'); content = _addItalic(content); } catch (e) { throw `Error in addItalic: ${e}`; } try { // console.log('Add strike throught'); content = _addStrikeThrough(content); } catch (e) { throw `Error in addStrikeThrough: ${e}`; } try { // console.log('Add Headlines'); content = _headlines(content); } catch (e) { throw `Error in headlines: ${e}`; } try { // console.log('Add block quote'); content = _addBlockQuote(content, this._theme.indents.blockQuote); } catch (e) { throw `Error in headlines: ${e}`; } if (this._theme.admonitions.enabled !== undefined && this._theme.admonitions.enabled === true) { try { content = _addAdmonitions(content, this._theme.admonitions.beforeIndent, this._theme.admonitions.afterIndent, this._theme.admonitions.titleIndent, this._theme.admonitions.useSafeColors); } catch (e) { throw `Error in Admonitions: ${e}`; } } return content.replace(/\n/ig, EOL); } /** * Define a custom theme for the output * @param {*} customTheme */ const _setTheme = (customTheme) => { this._theme = customTheme; } /** * Returns the default theme */ const _getTheme = () => { return defTheme; } module.exports = { getTheme: _getTheme, setTheme: _setTheme, toConsole: _toConsole, toRawContent: _toRawContent, sourceTheme: syntaxHighlighter.themes }