diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index 7351423dd..789baf182 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,81 +1,160 @@ // @flow +// simple-markdown types +type State = {| + key?: string | number | void, + inline?: ?boolean, + [string]: any, +|}; + +type Parser = (source: string, state?: ?State) => Array; + +type Capture = + | (Array & { index: number }) + | (Array & { index?: number }); + +type SingleASTNode = {| + type: string, + [string]: any, +|}; + +type UnTypedASTNode = { + [string]: any, + ..., +}; + const paragraphRegex = /^((?:[^\n]*)(?:\n|$))/; const paragraphStripTrailingNewlineRegex = /^([^\n]*)(?:\n|$)/; const headingRegex = /^ *(#{1,6}) ([^\n]+?)#* *(?![^\n])/; const headingStripFollowingNewlineRegex = /^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/; const fenceRegex = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?\n)\1(?:\n|$)/; const fenceStripTrailingNewlineRegex = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/; const codeBlockRegex = /^(?: {4}[^\n]*\n*?)+(?!\n* {4}[^\n])(?:\n|$)/; const codeBlockStripTrailingNewlineRegex = /^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/; const blockQuoteRegex = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$)/; const blockQuoteStripFollowingNewlineRegex = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/; const urlRegex = /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/i; type JSONCapture = {| +[0]: string, +json: Object, |}; function jsonMatch(source: string): ?JSONCapture { if (!source.startsWith('{')) { return null; } let jsonString = ''; let counter = 0; for (let i = 0; i < source.length; i++) { const char = source[i]; jsonString += char; if (char === '{') { counter++; } else if (char === '}') { counter--; } if (counter === 0) { break; } } if (counter !== 0) { return null; } let json; try { json = JSON.parse(jsonString); } catch { return null; } if (!json || typeof json !== 'object') { return null; } return { [0]: jsonString, json, }; } function jsonPrint(capture: JSONCapture): string { return JSON.stringify(capture.json, null, ' '); } +const listRegex = /^( *)([*+-]|\d+\.) ([\s\S]+?)(?:\n{3}|\s*\n*$)/; +const listItemRegex = /^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm; +const listItemPrefixRegex = /^( *)([*+-]|\d+\.) /; +const listLookBehindRegex = /(?:^|\n)( *)$/; + +function matchList(source: string, state: State) { + if (state.inline) { + return null; + } + const prevCaptureStr = state.prevCapture ? state.prevCapture[0] : ''; + const isStartOfLineCapture = listLookBehindRegex.exec(prevCaptureStr); + if (!isStartOfLineCapture) { + return null; + } + const fullSource = isStartOfLineCapture[1] + source; + return listRegex.exec(fullSource); +} + +// We've defined our own parse function for lists because simple-markdown +// handles newlines differently. Outside of that our implementation is fairly +// similar. For more details about list parsing works, take a look at the +// comments in the simple-markdown package +function parseList( + capture: Capture, + parse: Parser, + state: State, +): UnTypedASTNode { + const bullet = capture[2]; + const ordered = bullet.length > 1; + const start = ordered ? Number(bullet) : undefined; + const items = capture[0].match(listItemRegex); + + let itemContent = null; + if (items) { + itemContent = items.map((item: string) => { + const prefixCapture = listItemPrefixRegex.exec(item); + const space = prefixCapture ? prefixCapture[0].length : 0; + const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm'); + const content: string = item + .replace(spaceRegex, '') + .replace(listItemPrefixRegex, ''); + // We're handling this different than simple-markdown - + // each item is a paragraph + return parse(content, state); + }); + } + + return { + ordered: ordered, + start: start, + items: itemContent, + }; +} + export { paragraphRegex, paragraphStripTrailingNewlineRegex, urlRegex, blockQuoteRegex, blockQuoteStripFollowingNewlineRegex, headingRegex, headingStripFollowingNewlineRegex, codeBlockRegex, codeBlockStripTrailingNewlineRegex, fenceRegex, fenceStripTrailingNewlineRegex, jsonMatch, jsonPrint, + matchList, + parseList, }; diff --git a/web/markdown/markdown.css b/web/markdown/markdown.css index 4bd4f8043..b28ad8b7f 100644 --- a/web/markdown/markdown.css +++ b/web/markdown/markdown.css @@ -1,64 +1,70 @@ div.markdown { display: inline; } div.markdown h1, div.markdown h2, div.markdown h3, div.markdown h4, div.markdown h5, div.markdown h6 { display: inline; } div.markdown blockquote { display: inline-block; padding: .5em 10px; margin: 0 0 6px 0; box-sizing: border-box; width: 100%; margin: 6px 0; } div.darkBackground blockquote { background: #A9A9A9; border-left: 5px solid #808080; } div.lightBackground blockquote { background: #D3D3D3; border-left: 5px solid #C0C0C0; } div.markdown code { padding: 0 4px; margin: 0 2px; border-radius: 3px; } div.lightBackground code { background: #DCDCDC; color: #222222; } div.darkBackground code { background: #222222; color: #F3F3F3; } div.markdown pre { padding: .5em 10px; border-radius: 5px; margin: 6px 0; } div.lightBackground pre { background: #DCDCDC; color: #222222; } div.darkBackground pre { background: #222222; color: #F3F3F3; } div.markdown pre > code { width: 100%; display: inline-block; box-sizing: border-box; tab-size: 2; - overflow-x: scroll; + overflow-x: auto; +} + +div.markdown ol, +div.markdown ul { + padding-left: 1em; + margin-left: 0.5em; } diff --git a/web/markdown/rules.react.js b/web/markdown/rules.react.js index 3b597a2f2..28ac23c62 100644 --- a/web/markdown/rules.react.js +++ b/web/markdown/rules.react.js @@ -1,133 +1,139 @@ // @flow import * as SimpleMarkdown from 'simple-markdown'; import * as React from 'react'; import * as SharedMarkdown from 'lib/shared/markdown'; type MarkdownRuleSpec = {| +simpleMarkdownRules: SimpleMarkdown.Rules, |}; export type MarkdownRules = () => MarkdownRuleSpec; function linkRules(): MarkdownRuleSpec { const simpleMarkdownRules = { // We are using default simple-markdown rules // For more details, look at native/markdown/rules.react link: { ...SimpleMarkdown.defaultRules.link, match: () => null, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, paragraph: { ...SimpleMarkdown.defaultRules.paragraph, match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex), // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, text: SimpleMarkdown.defaultRules.text, url: { ...SimpleMarkdown.defaultRules.url, match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, }; return { simpleMarkdownRules: simpleMarkdownRules, }; } // function will contain additional rules for message formatting function markdownRules(): MarkdownRuleSpec { const linkMarkdownRules = linkRules(); const simpleMarkdownRules = { ...linkMarkdownRules.simpleMarkdownRules, autolink: SimpleMarkdown.defaultRules.autolink, link: { ...linkMarkdownRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SimpleMarkdown.blockRegex(SharedMarkdown.blockQuoteRegex), parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { const content = capture[1].replace(/^ *> ?/gm, ''); return { content: parse(content, state), }; }, }, inlineCode: SimpleMarkdown.defaultRules.inlineCode, em: SimpleMarkdown.defaultRules.em, strong: SimpleMarkdown.defaultRules.strong, del: SimpleMarkdown.defaultRules.del, u: SimpleMarkdown.defaultRules.u, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex(SharedMarkdown.headingRegex), }, mailto: SimpleMarkdown.defaultRules.mailto, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex(SharedMarkdown.codeBlockRegex), parse: (capture: SimpleMarkdown.Capture) => ({ content: capture[0].replace(/^ {4}/gm, ''), }), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex(SharedMarkdown.fenceRegex), parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: SharedMarkdown.jsonPrint(capture), }), }, + list: { + ...SimpleMarkdown.defaultRules.list, + match: SharedMarkdown.matchList, + parse: SharedMarkdown.parseList, + }, + escape: SimpleMarkdown.defaultRules.escape, }; return { ...linkMarkdownRules, simpleMarkdownRules, }; } export { linkRules, markdownRules };