Created
January 31, 2018 10:14
-
-
Save MrLoh/b6142cde26d49d8f080a37f59fa22b06 to your computer and use it in GitHub Desktop.
React native Markdown
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @flow | |
import * as React from 'react'; | |
import styled, { withTheme } from 'styled-components/native'; | |
import { Text, StyleSheet } from 'react-native'; | |
import { pure, compose } from 'recompose'; | |
import { linkHandler, parseMarkdown, log } from '../utils/'; | |
import { WrappedImage } from '../elements'; | |
const Wrapper = styled.View` | |
width: 100%; | |
padding-top: ${p => p.theme.unit * 2}px; | |
align-items: stretch; | |
`; | |
// Markdown styles | |
const Paragraph = styled.View` | |
padding: 0 ${p => p.theme.unit * 2}px; | |
margin-bottom: ${p => p.theme.unit * 2}px; | |
align-items: stretch; | |
`; | |
const Body = styled.Text` | |
font-weight: 400; | |
font-size: ${p => p.theme.fontSize(0)}px; | |
line-height: ${p => p.theme.unit * 3}px; | |
color: ${p => p.theme.colors.main}; | |
text-align: ${p => (p.centered ? 'center' : 'left')}; | |
`; | |
const Heading = styled.Text` | |
font-weight: ${p => (p.level === 1 ? 300 : 700)}; | |
font-size: ${p => p.theme.fontSize(p.level === 1 ? 1.5 : -0.25)}px; | |
letter-spacing: -${p => p.theme.fontSize(p.level === 1 ? 1.5 : 0) / 80}px; | |
line-height: ${p => p.theme.unit * 3}px; | |
color: ${p => p.theme.colors.main}; | |
`; | |
// making an image width: 100% height: auto is super hard, so the height is just fixed here | |
const ImageWrapper = styled.View` | |
width: 100%; | |
height: ${p => p.theme.unit * 30}px; | |
margin-bottom: ${p => p.theme.unit * 2}px; | |
justify-content: flex-end; | |
`; | |
const Image = styled(WrappedImage)` | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
`; | |
const CaptionWrapper = styled.View` | |
padding: ${p => p.theme.unit}px ${p => p.theme.unit * 2}px; | |
background-color: ${p => p.theme.colors.overlayBackground}; | |
`; | |
const Caption = styled.Text` | |
font-size: ${p => p.theme.fontSize(-0.5)}px; | |
line-height: ${p => p.theme.unit * 2}px; | |
color: ${p => p.theme.colors.overlayColor}; | |
`; | |
const Border = styled.View` | |
width: 100%; | |
border-top-width: 1px; | |
border-color: ${p => p.theme.colors.secondary}; | |
margin-bottom: ${p => p.theme.unit}px; | |
`; | |
const List = styled.View`margin-left: ${p => p.theme.unit}px;`; | |
const ListItemWrapper = styled.View` | |
flex-direction: row; | |
align-items: stretch; | |
`; | |
const ListBullet = styled.Text` | |
font-weight: 500; | |
font-size: ${p => p.theme.fontSize(0)}px; | |
line-height: ${p => p.theme.unit * 3}px; | |
color: ${p => p.theme.colors.main}; | |
margin-right: ${p => p.theme.unit}px; | |
`; | |
const BlockquoteWrapper = styled.View` | |
margin: 0 ${p => p.theme.unit * 2}px ${p => p.theme.unit * 2}px; | |
flex-flow: row wrap; | |
justify-content: ${p => (p.centered ? 'center' : 'flex-start')}; | |
align-items: flex-start; | |
border-color: ${p => p.theme.colors.secondary}; | |
border-left-width: ${p => p.theme.unit * 0.5}px; | |
`; | |
const Blockquote = styled.Text` | |
font-weight: 600; | |
font-size: ${p => p.theme.fontSize(0)}px; | |
line-height: ${p => p.theme.unit * 2.5}px; | |
color: ${p => p.theme.colors.strongSecondary}; | |
margin-left: ${p => p.theme.unit}px; | |
`; | |
const renderMarkdownString = (options, string) => { | |
// we can't use styled components because it wraps in Views which breaks nested Text | |
const styles = StyleSheet.create({ | |
bold: { fontWeight: 'bold' }, | |
italic: { fontStyle: 'italic' }, | |
link: { | |
textDecorationLine: 'underline', | |
color: options.theme.strengthenColor(0.25, options.theme.colors.accent), | |
}, | |
}); | |
// recursive rendering function for markdown syntax tree | |
const renderMdNodes = mdNodes => | |
mdNodes.map(({ type, children, ...content }, i) => { | |
switch (type) { | |
case 'text': { | |
const words = content.text.split(' '); | |
return words.map((text, j) => ( | |
<Text key={j}>{text + (words.length === j + 1 ? '' : ' ')}</Text> | |
)); | |
} | |
case 'hr': { | |
return <Border key={i} />; | |
} | |
case 'strong': | |
return ( | |
<Text style={styles.bold} key={i}> | |
{renderMdNodes(children)} | |
</Text> | |
); | |
case 'em': | |
return ( | |
<Text style={styles.italic} key={i}> | |
{renderMdNodes(children)} | |
</Text> | |
); | |
case 'link': | |
return ( | |
<Text style={styles.link} key={i} onPress={linkHandler(content.href)}> | |
{renderMdNodes(children)} | |
</Text> | |
); | |
case 'paragraph': | |
return ( | |
<Paragraph key={i} centered={options.centered}> | |
<Body centered={options.centered}>{renderMdNodes(children)}</Body> | |
</Paragraph> | |
); | |
case 'image_block': | |
return ( | |
<ImageWrapper key={i}> | |
<Image src={content.href} /> | |
{Boolean(content.text) && ( | |
<CaptionWrapper> | |
<Caption>{content.text}</Caption> | |
</CaptionWrapper> | |
)} | |
</ImageWrapper> | |
); | |
case 'heading': | |
// make text uppercase for subheadings | |
if (content.depth === 2 && children) { | |
const uppercaseText = nodes => | |
nodes.map(node => { | |
if (node.type === 'text' && node.text) node.text = node.text.toUpperCase(); | |
if (node.children) node.children = uppercaseText(node.children); | |
return node; | |
}); | |
uppercaseText(children); | |
} | |
return ( | |
<Paragraph key={i} centered={options.centered}> | |
<Heading level={content.depth}>{renderMdNodes(children)}</Heading> | |
</Paragraph> | |
); | |
case 'list': | |
return ( | |
<Paragraph key={i} centered={options.centered}> | |
<List>{renderMdNodes(children)}</List> | |
</Paragraph> | |
); | |
case 'list_item': | |
return ( | |
<ListItemWrapper key={i}> | |
<ListBullet>•</ListBullet> | |
<Body>{renderMdNodes(children)}</Body> | |
</ListItemWrapper> | |
); | |
case 'space': | |
return null; | |
case 'blockquote': | |
return ( | |
<BlockquoteWrapper key={i}> | |
<Blockquote>{renderMdNodes(children)}</Blockquote> | |
</BlockquoteWrapper> | |
); | |
default: | |
return <Text key={i}>{JSON.stringify({ type, children, ...content }, null, 4)}</Text>; | |
} | |
}); | |
const markdownNodes = parseMarkdown(string); | |
log.d('parsed', 'markdown', markdownNodes); | |
return renderMdNodes(markdownNodes); | |
}; | |
const Article = ({ | |
children, | |
text, | |
markdown, | |
centered, | |
theme, | |
}: { | |
children?: string, | |
text?: string, | |
theme: Object, | |
markdown?: boolean, | |
centered?: boolean, | |
}) => | |
children || text ? ( | |
<Wrapper> | |
{log.d('render', 'Article')} | |
{markdown ? ( | |
renderMarkdownString({ centered, theme }, children || text) | |
) : ( | |
<Paragraph> | |
<Body centered={centered}>{children || text}</Body> | |
</Paragraph> | |
)} | |
</Wrapper> | |
) : null; | |
export default compose(withTheme, pure)(Article); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { parse } from 'rn-markdown-parser'; | |
export type HrNode = { type: 'hr' }; | |
export type BrNode = { type: 'br' }; | |
export type TextNode = { type: 'text', text: string }; | |
export type ImageNode = { type: 'image', href: string, title: string, text: string }; | |
export type LinkNode = { type: 'link', href: string, text: string }; | |
export type StrongNode = { type: 'strong', children: MarkdownNode[] }; | |
export type EmNode = { type: 'em', children: MarkdownNode[] }; | |
export type HeadingNode = { type: 'heading', depth: number, children: MarkdownNode[] }; | |
export type ParagraphNode = { type: 'paragraph', children: MarkdownNode[] }; | |
export type ListNode = { type: 'list', ordered: boolean, children: MarkdownNode[] }; | |
export type ListItemNode = { type: 'list_item', children: MarkdownNode[] }; | |
export type BlockquoteNode = { type: 'blockquote', children: MarkdownNode[] }; | |
export type MarkdownNode = | |
| HrNode | |
| BrNode | |
| TextNode | |
| ImageNode | |
| LinkNode | |
| StrongNode | |
| EmNode | |
| HeadingNode | |
| ParagraphNode | |
| ListNode | |
| ListItemNode | |
| BlockquoteNode; | |
type ParserOptions = { | |
gfm?: boolean, | |
tables?: boolean, | |
breaks?: boolean, | |
pedantic?: boolean, | |
sanitize?: boolean, | |
smartLists?: boolean, | |
smartypants?: boolean, | |
}; | |
const DEFAULT_OPTIONS = { | |
gfm: true, | |
breaks: true, | |
smartLists: true, | |
smartypants: true, | |
}; | |
export default function(text: string, options: ParserOptions = {}): MarkdownNode[] { | |
const parsed: { children: MarkdownNode[] } = parse(text, { ...DEFAULT_OPTIONS, ...options }); | |
const postProcessMarkdownSyntaxTree = children => { | |
const cleanedChildren = []; | |
for (let i = 0; i < children.length; i++) { | |
let node = children[i]; | |
const lastNode = i > 0 ? cleanedChildren[i - 1] : null; | |
// if two consecutive text nodes appear, combine them (happened with `!` for some reason) | |
if (lastNode && node.type === 'text' && lastNode.type === 'text') { | |
lastNode.text += node.text; | |
} else { | |
// remove space nodes and white space only text nodes | |
if (node.children) | |
node.children = node.children.reduce((childs, nod) => { | |
if ( | |
(nod.type === 'text' && (!nod.text || nod.text.replace(/\s/g, '').length === 0)) || | |
node.type === 'space' | |
) { | |
return childs; | |
} else { | |
return [...childs, nod]; | |
} | |
}, []); | |
// transform paragraphs with only an image into image_blocks | |
if ( | |
node.type === 'paragraph' && | |
node.children && | |
node.children.length === 1 && | |
node.children[0].type === 'image' | |
) { | |
node = node.children[0]; | |
node.type = 'image_block'; | |
} | |
// remove nested paragraphs in blockquotes | |
if ( | |
node.type === 'blockquote' && | |
node.children && | |
node.children.length === 1 && | |
node.children[0].type === 'paragraph' | |
) { | |
node = node.children[0]; | |
node.type = 'blockquote'; | |
} | |
// remove parent reference, which prevent stringifying because of circularity | |
node.parent && delete node.parent; | |
// recursively apply to all children | |
if (node.children) node.children = postProcessMarkdownSyntaxTree(node.children); | |
cleanedChildren.push(node); | |
} | |
} | |
return cleanedChildren; | |
}; | |
const markdownSyntaxTree = postProcessMarkdownSyntaxTree(parsed.children); | |
return markdownSyntaxTree; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment