import { decode } from "html-entities";
import type { Token, Tokens } from "marked";
import type { ReactNode } from "react";
import type { ImageStyle, TextStyle, ViewStyle } from "react-native";
import type { MarkedStyles } from "../theme/types";
import { getTableColAlignmentStyle } from "./../utils/table";
import { getValidURL } from "./../utils/url";
import type { ParserOptions, RendererInterface } from "./types";
class Parser {
private renderer: RendererInterface;
private styles: MarkedStyles;
private headingStylesMap: Record;
private baseUrl: string;
constructor(options: ParserOptions) {
this.styles = { ...options.styles };
this.baseUrl = options.baseUrl ?? "";
this.renderer = options.renderer;
this.headingStylesMap = {
1: this.styles.h1,
2: this.styles.h2,
3: this.styles.h3,
4: this.styles.h4,
5: this.styles.h5,
6: this.styles.h6,
};
}
parse(tokens?: Token[]) {
return this._parse(tokens);
}
private _parse(
tokens?: Token[],
styles?: ViewStyle | TextStyle | ImageStyle,
): ReactNode[] {
if (!tokens) return [];
const elements: ReactNode[] = tokens.map((token) => {
return this._parseToken(token, styles);
});
return elements.filter((element) => element !== null);
}
private _parseToken(
token: Token,
styles?: ViewStyle | TextStyle | ImageStyle,
): ReactNode {
switch (token.type) {
case "paragraph": {
const children = this.getNormalizedSiblingNodesForBlockAndInlineTokens(
token.tokens ?? [],
this.styles.text,
);
return this.renderer.paragraph(children, this.styles.paragraph);
}
case "blockquote": {
const children = this.parse(token.tokens);
return this.renderer.blockquote(children, this.styles.blockquote);
}
case "heading": {
const styles = this.headingStylesMap[token.depth];
if (this.hasDuplicateTextChildToken(token)) {
return this.renderer.heading(token.text, styles, token.depth);
}
const children = this._parse(token.tokens, styles);
return this.renderer.heading(children, styles, token.depth);
}
case "code": {
return this.renderer.code(
token.text,
token.lang,
this.styles.code,
this.styles.em,
);
}
case "hr": {
return this.renderer.hr(this.styles.hr);
}
case "list": {
let startIndex = Number.parseInt(token.start.toString(), 10);
if (Number.isNaN(startIndex)) {
startIndex = 1;
}
const li = (token as Tokens.List).items.map((item) => {
const children = item.tokens.flatMap((cItem) => {
if (cItem.type === "text") {
/* getViewNode since tokens could contain a block like elements (i.e. img) */
const childTokens = (cItem as Tokens.Text).tokens || [];
const listChildren =
this.getNormalizedSiblingNodesForBlockAndInlineTokens(
childTokens,
this.styles.li,
);
// return this.renderer.listItem(listChildren, this.styles.li);
return listChildren;
}
/* Parse the nested token */
return this._parseToken(cItem);
});
return this.renderer.listItem(children, this.styles.li);
});
return this.renderer.list(
token.ordered,
li,
this.styles.list,
this.styles.li,
startIndex,
);
}
case "escape": {
return this.renderer.escape(token.text, {
...this.styles.text,
...styles,
});
}
case "link": {
// Don't render anchors without text and children
if (token.text.trim().length < 1 || !token.tokens) {
return null;
}
// Note: Linking Images (https://www.markdownguide.org/basic-syntax/#linking-images) are wrapped
// in paragraph token, so will be handled via `getNormalizedSiblingNodesForBlockAndInlineTokens`
const linkStyle = {
...this.styles.link,
...styles,
// To override color and fontStyle properties
color: this.styles.link?.color,
fontStyle: this.styles.link?.fontStyle,
};
const href = getValidURL(this.baseUrl, token.href);
if (this.hasDuplicateTextChildToken(token)) {
return this.renderer.link(token.text, href, linkStyle, token.title);
}
const children = this._parse(token.tokens, linkStyle);
return this.renderer.link(children, href, linkStyle, token.title);
}
case "image": {
return this.renderer.image(
token.href,
token.text,
this.styles.image,
token.title,
);
}
case "strong": {
const boldStyle = {
...this.styles.strong,
...styles,
};
if (this.hasDuplicateTextChildToken(token)) {
return this.renderer.strong(token.text, boldStyle);
}
const children = this._parse(token.tokens, boldStyle);
return this.renderer.strong(children, boldStyle);
}
case "em": {
const italicStyle = {
...this.styles.em,
...styles,
};
if (this.hasDuplicateTextChildToken(token)) {
return this.renderer.em(token.text, italicStyle);
}
const children = this._parse(token.tokens, italicStyle);
return this.renderer.em(children, italicStyle);
}
case "codespan": {
return this.renderer.codespan(decode(token.text), {
...this.styles.codespan,
...styles,
});
}
case "br": {
return this.renderer.br();
}
case "del": {
const strikethroughStyle = {
...this.styles.strikethrough,
...styles,
};
if (this.hasDuplicateTextChildToken(token)) {
return this.renderer.del(token.text, strikethroughStyle);
}
const children = this._parse(token.tokens, strikethroughStyle);
return this.renderer.del(children, strikethroughStyle);
}
case "text":
return this.renderer.text(token.raw, {
...this.styles.text,
...styles,
});
case "html": {
return this.renderer.html(token.raw, {
...this.styles.text,
...styles,
});
}
case "table": {
const header = (token as Tokens.Table).header.map((row, i) =>
this._parse(row.tokens, {
...getTableColAlignmentStyle(token.align[i]),
}),
);
const rows = (token as Tokens.Table).rows.map((cols) =>
cols.map((col, i) =>
this._parse(col.tokens, {
...getTableColAlignmentStyle(token.align[i]),
}),
),
);
return this.renderer.table(
header,
rows,
this.styles.table,
this.styles.tableRow,
this.styles.tableCell,
);
}
default: {
return null;
}
}
}
private getNormalizedSiblingNodesForBlockAndInlineTokens(
tokens: Token[],
textStyle?: TextStyle,
): ReactNode[] {
let tokenRenderQueue: Token[] = [];
const siblingNodes: ReactNode[] = [];
for (const t of tokens) {
/**
* To avoid inlining images
* Currently supports images, link images
* Note: to be extend for other token types
*/
if (
t.type === "image" ||
(t.type === "link" &&
t.tokens &&
t.tokens[0] &&
t.tokens[0].type === "image")
) {
// Render existing inline tokens in the queue
const parsed = this._parse(tokenRenderQueue);
if (parsed.length > 0) {
siblingNodes.push(this.renderer.text(parsed, textStyle));
}
// Render the current block token
if (t.type === "image") {
siblingNodes.push(this._parseToken(t));
} else if (t.type === "link" && t.tokens && t.tokens[0]) {
const imageToken = t.tokens[0] as Tokens.Image;
const href = getValidURL(this.baseUrl, t.href);
siblingNodes.push(
this.renderer.linkImage(
href,
imageToken.href,
imageToken.text ?? imageToken.title ?? "",
this.styles.image,
imageToken.title,
),
);
}
tokenRenderQueue = [];
continue;
}
tokenRenderQueue = [...tokenRenderQueue, t];
}
/* Remaining temp tokens if any */
if (tokenRenderQueue.length > 0) {
siblingNodes.push(this.renderer.text(this.parse(tokenRenderQueue), {}));
}
return siblingNodes;
}
// To avoid duplicate text node nesting when there are no child tokens with text emphasis (i.e., italic)
// ref: https://github.com/gmsgowtham/react-native-marked/issues/522
private hasDuplicateTextChildToken(token: Token): boolean {
if (!("tokens" in token)) {
return false;
}
if (
token.tokens &&
token.tokens.length === 1 &&
token.tokens[0]?.type === "text"
) {
return true;
}
return false;
}
}
export default Parser;