Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@MrLoh
Created January 31, 2018 10:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MrLoh/b6142cde26d49d8f080a37f59fa22b06 to your computer and use it in GitHub Desktop.
Save MrLoh/b6142cde26d49d8f080a37f59fa22b06 to your computer and use it in GitHub Desktop.
React native Markdown
// @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);
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