feat: support markdown

This commit is contained in:
guanghechen 2024-07-10 16:17:23 +08:00
parent bd230ddd4f
commit 85d144a1e9
34 changed files with 2350 additions and 4 deletions

View file

@ -0,0 +1,42 @@
import { css, cx } from "@emotion/css";
import type { Blockquote } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render `blockquote`.
*
* @see https://www.npmjs.com/package/@yozora/ast#blockquote
* @see https://www.npmjs.com/package/@yozora/tokenizer-blockquote
*/
export class BlockquoteRenderer extends React.Component<Blockquote> {
public override shouldComponentUpdate(nextProps: Readonly<Blockquote>): boolean {
const props = this.props;
return props.children !== nextProps.children;
}
public override render(): React.ReactElement {
const childNodes = this.props.children;
return (
<blockquote className={cls}>
<NodesRenderer nodes={childNodes} />
</blockquote>
);
}
}
const cls = cx(
astClasses.blockquote,
css({
boxSizing: "border-box",
padding: "0.625em 1em",
borderLeft: "0.25em solid var(--colorBorderBlockquote)",
margin: "0px 0px 1.25em 0px",
background: "var(--colorBgBlockquote)",
boxShadow: "0 1px 2px 0 hsla(0deg, 0%, 0%, 0.1)",
"> :last-child": {
marginBottom: 0,
},
}),
);

View file

@ -0,0 +1,27 @@
import { css, cx } from "@emotion/css";
import type { Break } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
/**
* Render `break`.
*
* @see https://www.npmjs.com/package/@yozora/ast#break
* @see https://www.npmjs.com/package/@yozora/tokenizer-break
*/
export class BreakRenderer extends React.Component<Break> {
public override shouldComponentUpdate(): boolean {
return false;
}
public override render(): React.ReactElement {
return <br className={cls} />;
}
}
const cls = cx(
astClasses.break,
css({
boxSizing: "border-box",
}),
);

View file

@ -0,0 +1,32 @@
import { useStateValue } from "@guanghechen/react-viewmodel";
import type { Code } from "@yozora/ast";
import { useNodeRendererContext, type INodeRenderer, IReactMarkdownThemeScheme } from "../context";
import { CodeRendererInner } from "./inner/CodeRendererInner";
/**
* Render `code`
*
* @see https://www.npmjs.com/package/@yozora/ast#code
* @see https://www.npmjs.com/package/@yozora/tokenizer-indented-code
* @see https://www.npmjs.com/package/@yozora/tokenizer-fenced-code
*/
export const CodeRenderer: INodeRenderer<Code> = props => {
const { lang } = props;
const value: string = props.value.replace(/[\r\n]+$/, ""); // Remove trailing line endings.
const { viewmodel } = useNodeRendererContext();
const preferCodeWrap: boolean = useStateValue(viewmodel.preferCodeWrap$);
const showCodeLineno: boolean = useStateValue(viewmodel.showCodeLineno$);
const themeScheme: IReactMarkdownThemeScheme = useStateValue(viewmodel.themeScheme$);
const darken: boolean = themeScheme === "darken";
return (
<CodeRendererInner
darken={darken}
lang={lang ?? "text"}
value={value}
preferCodeWrap={preferCodeWrap}
showCodeLineno={showCodeLineno}
/>
);
};

View file

@ -0,0 +1,37 @@
import { css, cx } from "@emotion/css";
import type { Delete, Node } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render `delete`.
*
* @see https://www.npmjs.com/package/@yozora/ast#delete
* @see https://www.npmjs.com/package/@yozora/tokenizer-delete
*/
export class DeleteRenderer extends React.Component<Delete> {
public override shouldComponentUpdate(nextProps: Readonly<Delete>): boolean {
const props = this.props;
return props.children !== nextProps.children;
}
public override render(): React.ReactElement {
const childNodes: Node[] = this.props.children;
return (
<del className={cls}>
<NodesRenderer nodes={childNodes} />
</del>
);
}
}
const cls = cx(
astClasses.delete,
css({
marginRight: "4px",
color: "var(--colorDelete)",
fontStyle: "italic",
textDecoration: "line-through",
}),
);

View file

@ -0,0 +1,35 @@
import { css, cx } from "@emotion/css";
import type { Emphasis, Node } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render `emphasis`.
*
* @see https://www.npmjs.com/package/@yozora/ast#emphasis
* @see https://www.npmjs.com/package/@yozora/tokenizer-emphasis
*/
export class EmphasisRenderer extends React.Component<Emphasis> {
public override shouldComponentUpdate(nextProps: Readonly<Emphasis>): boolean {
const props = this.props;
return props.children !== nextProps.children;
}
public override render(): React.ReactElement {
const childNodes: Node[] = this.props.children;
return (
<em className={cls}>
<NodesRenderer nodes={childNodes} />
</em>
);
}
}
const cls = cx(
astClasses.emphasis,
css({
fontStyle: "italic",
margin: "0 6px 0 2px",
}),
);

View file

@ -0,0 +1,135 @@
import { css, cx } from "@emotion/css";
import type { Heading } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
type IHeading = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
interface IProps extends Heading {
linkIcon?: React.ReactNode;
}
/**
* Render `heading` content.
*
* @see https://www.npmjs.com/package/@yozora/ast#heading
* @see https://www.npmjs.com/package/@yozora/tokenizer-heading
*/
export class HeadingRenderer extends React.Component<IProps> {
public override shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
const props = this.props;
return (
props.depth !== nextProps.depth ||
props.identifier !== nextProps.identifier ||
props.children !== nextProps.children ||
props.linkIcon !== nextProps.linkIcon
);
}
public override render(): React.ReactElement {
const { depth, identifier, children, linkIcon = "¶" } = this.props;
const id = identifier == null ? undefined : encodeURIComponent(identifier);
const h: IHeading = ("h" + depth) as IHeading;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const H: any = h as keyof JSX.IntrinsicElements;
const cls = cx(astClasses.heading, classes.heading, classes[h]);
return (
<H id={id} className={cls}>
<p className={classes.content}>
<NodesRenderer nodes={children} />
</p>
{identifier && (
<a className={classes.anchor} href={"#" + id}>
{linkIcon}
</a>
)}
</H>
);
}
}
const anchorCls = css({
flex: "0 0 3rem",
paddingLeft: "0.5rem",
color: "var(--colorLink)",
opacity: 0,
transition: "color 0.2s ease-in-out, opacity 0.2s ease-in-out",
userSelect: "none",
textDecoration: "none",
"> svg": {
overflow: "hidden",
display: "inline-block",
verticalAlign: "middle",
fill: "currentColor",
},
});
const classes = {
heading: css({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
padding: "0px",
margin: "0px 0px 1.25em 0px",
marginBottom: "1em",
lineHeight: "1.25",
fontFamily: "var(--fontFamilyHeading)",
color: "var(--colorHeading)",
[`&:active .${anchorCls}`]: {
opacity: 0.8,
color: "var(--colorLinkActive)",
},
[`&&:hover .${anchorCls}`]: {
opacity: 0.8,
color: "var(--colorLinkHover)",
},
}),
anchor: anchorCls,
content: css({
flex: "0 1 auto",
minWidth: 0,
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "pre-wrap",
lineHeight: "1.7",
}),
h1: css({
padding: "0.3rem 0",
borderBottom: "1px solid var(--colorBorderHeading)",
fontSize: "2rem",
fontStyle: "normal",
fontWeight: 500,
}),
h2: css({
padding: "0.3rem 0",
borderBottom: "1px solid var(--colorBorderHeading)",
fontSize: "1.5rem",
fontStyle: "normal",
fontWeight: 500,
marginBottom: "0.875rem",
}),
h3: css({
fontSize: "1.25rem",
fontStyle: "normal",
fontWeight: 500,
}),
h4: css({
fontSize: "1rem",
fontStyle: "normal",
fontWeight: 500,
}),
h5: css({
fontSize: "0.875rem",
fontStyle: "normal",
fontWeight: 500,
}),
h6: css({
fontSize: "0.85rem",
fontStyle: "normal",
fontWeight: 500,
}),
};

View file

@ -0,0 +1,34 @@
import type { Image } from "@yozora/ast";
import React from "react";
import type { INodeRenderer } from "../context";
import { astClasses } from "../context";
import { ImageRendererInner } from "./inner/ImageRendererInner";
/**
* Render `image`.
*
* @see https://www.npmjs.com/package/@yozora/ast#image
* @see https://www.npmjs.com/package/@yozora/tokenizer-image
*/
export const ImageRenderer: INodeRenderer<Image> = props => {
const {
url: src,
alt,
title,
srcSet,
sizes,
loading,
} = props as Image & React.ImgHTMLAttributes<HTMLElement>;
return (
<ImageRendererInner
alt={alt}
src={src}
title={title}
srcSet={srcSet}
sizes={sizes}
loading={loading}
className={astClasses.image}
/>
);
};

View file

@ -0,0 +1,36 @@
import { useStateValue } from "@guanghechen/react-viewmodel";
import type { Definition, ImageReference } from "@yozora/ast";
import React from "react";
import { useNodeRendererContext, type INodeRenderer, astClasses } from "../context";
import { ImageRendererInner } from "./inner/ImageRendererInner";
/**
* Render `imageReference`.
*
* @see https://www.npmjs.com/package/@yozora/ast#imageReference
* @see https://www.npmjs.com/package/@yozora/tokenizer-image-reference
*/
export const ImageReferenceRenderer: INodeRenderer<ImageReference> = props => {
const { viewmodel } = useNodeRendererContext();
const definitionMap: Readonly<Record<string, Definition>> = useStateValue(
viewmodel.definitionMap$,
);
const { alt, srcSet, sizes, loading } = props as ImageReference &
React.ImgHTMLAttributes<HTMLElement>;
const definition = definitionMap[props.identifier];
const src: string = definition?.url ?? "";
const title: string | undefined = definition?.title;
return (
<ImageRendererInner
alt={alt}
src={src}
title={title}
srcSet={srcSet}
sizes={sizes}
loading={loading}
className={astClasses.imageReference}
/>
);
};

View file

@ -0,0 +1,87 @@
import {
BlockquoteType,
BreakType,
CodeType,
DefinitionType,
DeleteType,
EmphasisType,
HeadingType,
HtmlType,
ImageReferenceType,
ImageType,
InlineCodeType,
LinkReferenceType,
LinkType,
ListItemType,
ListType,
ParagraphType,
StrongType,
TableType,
TextType,
ThematicBreakType,
} from "@yozora/ast";
import { INodeRendererMap } from "../context";
import { BlockquoteRenderer } from "./blockquote";
import { BreakRenderer } from "./break";
import { CodeRenderer } from "./code";
import { DeleteRenderer } from "./delete";
import { EmphasisRenderer } from "./emphasis";
import { HeadingRenderer } from "./heading";
import { ImageRenderer } from "./image";
import { ImageReferenceRenderer } from "./imageReference";
import { InlineCodeRenderer } from "./inlineCode";
import { LinkRenderer } from "./link";
import { LinkReferenceRenderer } from "./linkReference";
import { ListRenderer } from "./list";
import { ListItemRenderer } from "./listItem";
import { ParagraphRenderer } from "./paragraph";
import { StrongRenderer } from "./strong";
import { TableRenderer } from "./table";
import { TextRenderer } from "./text";
import { ThematicBreakRenderer } from "./thematicBreak";
export function buildNodeRendererMap(
customizedRendererMap?: Readonly<Partial<INodeRendererMap>>,
): Readonly<INodeRendererMap> {
if (customizedRendererMap == null) return defaultNodeRendererMap;
let hasChanged = false;
const result: INodeRendererMap = {} as unknown as INodeRendererMap;
for (const [key, val] of Object.entries(customizedRendererMap)) {
if (val && val !== defaultNodeRendererMap[key]) {
hasChanged = true;
result[key] = val;
}
}
return hasChanged ? { ...defaultNodeRendererMap, ...result } : defaultNodeRendererMap;
}
// Default ast renderer map.
export const defaultNodeRendererMap: Readonly<INodeRendererMap> = {
[BlockquoteType]: BlockquoteRenderer,
[BreakType]: BreakRenderer,
[CodeType]: CodeRenderer,
[DefinitionType]: () => null,
[DeleteType]: DeleteRenderer,
[EmphasisType]: EmphasisRenderer,
[HeadingType]: HeadingRenderer,
[HtmlType]: () => null,
[ImageType]: ImageRenderer,
[ImageReferenceType]: ImageReferenceRenderer,
[InlineCodeType]: InlineCodeRenderer,
[LinkType]: LinkRenderer,
[LinkReferenceType]: LinkReferenceRenderer,
[ListType]: ListRenderer,
[ListItemType]: ListItemRenderer,
[ParagraphType]: ParagraphRenderer,
[StrongType]: StrongRenderer,
[TableType]: TableRenderer,
[TextType]: TextRenderer,
[ThematicBreakType]: ThematicBreakRenderer,
_fallback: function ReactMarkdownNodeFallback(node, key) {
// eslint-disable-next-line no-console
console.warn(`Cannot find render for \`${node.type}\` type node with key \`${key}\`:`, node);
return null;
},
};

View file

@ -0,0 +1,36 @@
import { css, cx } from "@emotion/css";
import type { InlineCode } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
/**
* Render `inline-code`.
*
* @see https://www.npmjs.com/package/@yozora/ast#inlinecode
* @see https://www.npmjs.com/package/@yozora/tokenizer-inline-code
*/
export class InlineCodeRenderer extends React.Component<InlineCode> {
public override shouldComponentUpdate(nextProps: Readonly<InlineCode>): boolean {
const props = this.props;
return props.value !== nextProps.value;
}
public override render(): React.ReactElement {
return <code className={cls}>{this.props.value}</code>;
}
}
const cls = cx(
astClasses.inlineCode,
css({
padding: "1px 4px",
borderRadius: "4px",
margin: 0,
background: "hsla(210deg, 15%, 60%, 0.15)",
lineHeight: "1.375",
color: "var(--colorInlineCode)",
fontFamily: "var(--fontFamilyCode)",
fontSize: "min(1rem, 18px)",
fontWeight: 500,
}),
);

View file

@ -0,0 +1,69 @@
import { css, cx } from "@emotion/css";
import CodeHighlighter from "@yozora/react-code-highlighter";
import React from "react";
import { astClasses } from "../../context";
import { CopyButton } from "./CopyButton";
interface IProps {
darken: boolean;
lang: string;
value: string;
preferCodeWrap: boolean;
showCodeLineno: boolean;
pendingText?: string;
copyingText?: string;
copiedText?: string;
failedText?: string;
}
export class CodeRendererInner extends React.PureComponent<IProps> {
public override render(): React.ReactElement {
const { calcContentForCopy } = this;
const { darken, lang, value, preferCodeWrap, showCodeLineno } = this.props;
return (
<code className={codeCls} data-wrap={preferCodeWrap}>
<CodeHighlighter
lang={lang}
value={value}
collapsed={false}
showLineNo={showCodeLineno && !preferCodeWrap}
darken={darken}
/>
<div className={copyBtnCls}>
<CopyButton calcContentForCopy={calcContentForCopy} />
</div>
</code>
);
}
protected calcContentForCopy = (): string => {
return this.props.value;
};
}
const copyBtnCls = css({
position: "absolute",
right: "4px",
top: "4px",
display: "none",
});
const codeCls = cx(
astClasses.code,
css({
position: "relative",
display: "block",
boxSizing: "border-box",
borderRadius: "4px",
margin: "0px 0px 1.25em 0px",
backgroundColor: "var(--colorBgCode)",
[`&:hover > .${copyBtnCls}`]: {
display: "inline-block",
},
[`&&[data-wrap="true"] > div`]: {
whiteSpace: "pre-wrap",
wordBreak: "keep-all",
},
}),
);

View file

@ -0,0 +1,69 @@
import { css, cx } from "@emotion/css";
import { Copy as CopyIcon, ClipboardPaste as CopiedIcon } from "lucide-react";
import copy from "copy-to-clipboard";
import React from "react";
export enum CopyStatus {
PENDING = 0,
COPYING = 1,
COPIED = 2,
FAILED = 3,
}
export interface ICopyButtonProps {
delay?: number;
className?: string;
calcContentForCopy: () => string;
}
export const CopyButton: React.FC<ICopyButtonProps> = props => {
const { className, delay = 1500, calcContentForCopy } = props;
const [status, setStatus] = React.useState<CopyStatus>(CopyStatus.PENDING);
const disabled: boolean = status !== CopyStatus.PENDING;
const onCopy = () => {
if (status === CopyStatus.PENDING) {
setStatus(CopyStatus.COPYING);
try {
const contentForCopy: string = calcContentForCopy();
copy(contentForCopy);
setStatus(CopyStatus.COPIED);
} catch (_error) {
setStatus(CopyStatus.FAILED);
}
}
};
React.useEffect((): (() => void) | undefined => {
if (status === CopyStatus.COPIED || status === CopyStatus.FAILED) {
const timer = setTimeout(() => setStatus(CopyStatus.PENDING), delay);
return () => {
if (timer) {
clearTimeout(timer);
}
};
}
return undefined;
}, [status, delay]);
return (
<button
className={cx(
classes.copyButton,
className,
"bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2",
)}
disabled={disabled}
onClick={onCopy}
>
status === CopyStatus.PENDING ? <CopyIcon size={24} /> : <CopiedIcon size={24} />
</button>
);
};
const classes = {
copyButton: css({
cursor: "pointer",
}),
};

View file

@ -0,0 +1,59 @@
import { css } from "@emotion/css";
import React from "react";
interface IProps {
src: string;
alt: string;
title: string | undefined;
srcSet: string | undefined;
sizes: string | undefined;
loading: "eager" | "lazy" | undefined;
className: string;
}
export class ImageRendererInner extends React.Component<IProps> {
public override shouldComponentUpdate(nextProps: IProps): boolean {
const props = this.props;
return (
props.src !== nextProps.src ||
props.alt !== nextProps.alt ||
props.title !== nextProps.title ||
props.srcSet !== nextProps.srcSet ||
props.sizes !== nextProps.sizes ||
props.loading !== nextProps.loading ||
props.className !== nextProps.className
);
}
public override render(): React.ReactElement {
const { src, alt, title, srcSet, sizes, loading, className } = this.props;
return (
<figure className={`${className} ${cls}`}>
<img alt={alt} src={src} title={title} srcSet={srcSet} sizes={sizes} loading={loading} />
{title && <figcaption>{title}</figcaption>}
</figure>
);
}
}
const cls = css({
boxSizing: "border-box",
maxWidth: "80%", // Prevent images from overflowing the container.
display: "flex",
flexDirection: "column",
alignItems: "center",
margin: 0,
"> img": {
flex: "1 0 auto",
boxSizing: "border-box",
maxWidth: "100%",
border: "1px solid var(--colorBorderImage)",
boxShadow: "0 0 20px 1px rgba(126, 125, 150, 0.6)",
},
"> figcaption": {
textAlign: "center",
fontStyle: "italic",
fontSize: "1em",
color: "var(--colorImageTitle)",
},
});

View file

@ -0,0 +1,48 @@
import { css, cx } from "@emotion/css";
import type { Node } from "@yozora/ast";
import React from "react";
import { NodesRenderer } from "../../NodesRenderer";
interface IProps {
url: string;
title: string | undefined;
childNodes: Node[] | undefined;
className: string;
}
export class LinkRendererInner extends React.Component<IProps> {
public override shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
const props = this.props;
return (
props.url !== nextProps.url ||
props.title !== nextProps.title ||
props.childNodes !== nextProps.childNodes ||
props.className !== nextProps.className
);
}
public override render(): React.ReactElement {
const { url, title, childNodes, className } = this.props;
return (
<a className={cx(cls, className)} href={url} title={title} rel="noopener, noreferrer" target="_blank">
<NodesRenderer nodes={childNodes} />
</a>
);
}
}
const cls = css({
padding: "0.2rem 0",
color: "var(--colorLink)",
textDecoration: "none",
"&:active": {
color: "var(--colorLinkActive)",
},
"&&:hover": {
color: "var(--colorLinkHover)",
textDecoration: "underline",
},
"&:visited": {
color: "var(--colorLinkVisited)",
},
});

View file

@ -0,0 +1,23 @@
import type { Link } from "@yozora/ast";
import { astClasses, type INodeRenderer } from "../context";
import { LinkRendererInner } from "./inner/LinkRendererInner";
/**
* Render `link`.
*
* @see https://www.npmjs.com/package/@yozora/ast#link
* @see https://www.npmjs.com/package/@yozora/tokenizer-link
* @see https://www.npmjs.com/package/@yozora/tokenizer-autolink
* @see https://www.npmjs.com/package/@yozora/tokenizer-autolink-extension
*/
export const LinkRenderer: INodeRenderer<Link> = props => {
const { url, title, children: childNodes } = props;
return (
<LinkRendererInner
url={url}
title={title}
childNodes={childNodes}
className={astClasses.link}
/>
);
};

View file

@ -0,0 +1,28 @@
import { useStateValue } from "@guanghechen/react-viewmodel";
import type { Definition, LinkReference } from "@yozora/ast";
import { useNodeRendererContext, type INodeRenderer, astClasses } from "../context";
import { LinkRendererInner } from "./inner/LinkRendererInner";
/**
* Render `link-reference`.
*
* @see https://www.npmjs.com/package/@yozora/ast#linkReference
* @see https://www.npmjs.com/package/@yozora/tokenizer-link-reference
*/
export const LinkReferenceRenderer: INodeRenderer<LinkReference> = props => {
const { viewmodel } = useNodeRendererContext();
const definitionMap: Readonly<Record<string, Definition>> = useStateValue(
viewmodel.definitionMap$,
);
const definition = definitionMap[props.identifier];
const url: string = definition?.url ?? "";
const title: string | undefined = definition?.title;
return (
<LinkRendererInner
url={url}
title={title}
childNodes={props.children}
className={astClasses.linkReference}
/>
);
};

View file

@ -0,0 +1,53 @@
import { css, cx } from "@emotion/css";
import type { List } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render `list`.
*
* @see https://www.npmjs.com/package/@yozora/ast#list
* @see https://www.npmjs.com/package/@yozora/tokenizer-list
*/
export class ListRenderer extends React.Component<List> {
public override shouldComponentUpdate(nextProps: Readonly<List>): boolean {
const props = this.props;
return (
props.ordered !== nextProps.ordered ||
props.orderType !== nextProps.orderType ||
props.start !== nextProps.start ||
props.children !== nextProps.children
);
}
public override render(): React.ReactElement {
const { ordered, orderType, start, children } = this.props;
if (ordered) {
return (
<ol className={cls} type={orderType} start={start}>
<NodesRenderer nodes={children} />
</ol>
);
}
return (
<ul className={cls}>
<NodesRenderer nodes={children} />
</ul>
);
}
}
const cls = cx(
astClasses.list,
css({
padding: "0px",
margin: "0 0 1em 2em",
lineHeight: "2",
"> :last-child": {
marginBottom: "0px",
},
}),
);

View file

@ -0,0 +1,39 @@
import { css, cx } from "@emotion/css";
import type { ListItem, Node } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render `listItem`.
*
* @see https://www.npmjs.com/package/@yozora/ast#listitem
* @see https://www.npmjs.com/package/@yozora/tokenizer-list-item
*/
export class ListItemRenderer extends React.Component<ListItem> {
public override shouldComponentUpdate(nextProps: Readonly<ListItem>): boolean {
const props = this.props;
return props.children !== nextProps.children;
}
public override render(): React.ReactElement {
const childNodes: Node[] = this.props.children;
return (
<li className={cls}>
<NodesRenderer nodes={childNodes} />
</li>
);
}
}
const cls = cx(
astClasses.listItem,
css({
position: "relative",
padding: 0,
margin: 0,
"> :last-child": {
marginBottom: 0,
},
}),
);

View file

@ -0,0 +1,72 @@
import { css, cx } from "@emotion/css";
import type { Node, Paragraph } from "@yozora/ast";
import { ImageReferenceType, ImageType } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render `paragraph`.
*
* @see https://www.npmjs.com/package/@yozora/ast#paragraph
* @see https://www.npmjs.com/package/@yozora/tokenizer-paragraph
*/
export class ParagraphRenderer extends React.Component<Paragraph> {
public override shouldComponentUpdate(nextProps: Readonly<Paragraph>): boolean {
const props = this.props;
return props.children !== nextProps.children;
}
public override render(): React.ReactElement {
const childNodes: Node[] = this.props.children;
// If there are some image / imageReferences element in the paragraph,
// then wrapper the content with div to avoid the warnings such as:
//
// validateDOMNesting(...): <figure> cannot appear as a descendant of <p>.
const notValidParagraph: boolean = childNodes.some(
child => child.type === ImageType || child.type === ImageReferenceType,
);
if (notValidParagraph) {
return (
<div className={paragraphDisplayCls}>
<NodesRenderer nodes={childNodes} />
</div>
);
}
return (
<p className={paragraphCls}>
<NodesRenderer nodes={childNodes} />
</p>
);
}
}
const paragraphCls: string = cx(
astClasses.paragraph,
css({
overflow: "hidden",
padding: 0,
margin: "0px 0px 1.25em 0px",
marginBottom: "1em",
lineHeight: "1.8",
hyphens: "auto",
wordBreak: "normal",
overflowWrap: "anywhere",
"> :last-child": {
marginBottom: 0,
},
}),
);
const paragraphDisplayCls: string = cx(
paragraphCls,
css({
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem 0",
margin: 0,
}),
);

View file

@ -0,0 +1,34 @@
import { css, cx } from "@emotion/css";
import type { Node, Strong } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render `strong`.
*
* @see https://www.npmjs.com/package/@yozora/ast#strong
* @see https://www.npmjs.com/package/@yozora/tokenizer-emphasis
*/
export class StrongRenderer extends React.Component<Strong> {
public override shouldComponentUpdate(nextProps: Readonly<Strong>): boolean {
const props = this.props;
return props.children !== nextProps.children;
}
public override render(): React.ReactElement {
const childNodes: Node[] = this.props.children;
return (
<strong className={cls}>
<NodesRenderer nodes={childNodes} />
</strong>
);
}
}
const cls = cx(
astClasses.strong,
css({
fontWeight: 600,
}),
);

View file

@ -0,0 +1,145 @@
import { css, cx } from "@emotion/css";
import { isEqual } from "@guanghechen/equal";
import type { Table } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
import { NodesRenderer } from "../NodesRenderer";
/**
* Render yozora `table`, `tableRow` and `tableCell`.
*
* @see https://www.npmjs.com/package/@yozora/ast#table
* @see https://www.npmjs.com/package/@yozora/ast#tablecell
* @see https://www.npmjs.com/package/@yozora/ast#tablerow
* @see https://www.npmjs.com/package/@yozora/tokenizer-table
* @see https://www.npmjs.com/package/@yozora/tokenizer-table-row
* @see https://www.npmjs.com/package/@yozora/tokenizer-table-cell
*/
export class TableRenderer extends React.Component<Table> {
public override shouldComponentUpdate(nextProps: Readonly<Table>): boolean {
const props = this.props;
return !isEqual(props.columns, nextProps.columns) || !isEqual(props.children, nextProps.children);
}
public override render(): React.ReactElement {
const { columns, children: rows } = this.props;
const aligns = columns.map(col => col.align ?? undefined);
const [ths, ...tds] = rows.map(row =>
row.children.map((cell, idx) => <NodesRenderer key={idx} nodes={cell.children} />),
);
return (
<table className={cls}>
<thead>
<tr>
{ths.map((children, idx) => (
<Th key={idx} align={aligns[idx]}>
{children}
</Th>
))}
</tr>
</thead>
<tbody>
{tds.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((children, idx) => (
<td key={idx} align={aligns[idx]}>
{children}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
}
interface IThProps {
align: "left" | "center" | "right" | undefined;
children: React.ReactNode;
}
class Th extends React.Component<IThProps> {
protected readonly ref: React.RefObject<HTMLTableCellElement>;
constructor(props: IThProps) {
super(props);
this.ref = { current: null };
}
public override shouldComponentUpdate(nextProps: Readonly<IThProps>): boolean {
const props = this.props;
return props.align !== nextProps.align || props.children !== nextProps.children;
}
public override render(): React.ReactElement {
const { align, children } = this.props;
return (
<th ref={this.ref} align={align}>
{children}
</th>
);
}
public override componentDidMount(): void {
const th = this.ref.current;
if (th) {
th.setAttribute("title", th.innerText);
}
}
public override componentDidUpdate(): void {
const th = this.ref.current;
if (th) {
th.setAttribute("title", th.innerText);
}
}
}
const cls: string = cx(
astClasses.table,
css({
display: "block",
overflow: "auto",
width: "max-content",
maxWidth: "100%",
padding: 0,
borderCollapse: "collapse",
borderRadius: "6px",
borderSpacing: "0px",
border: "1px solid var(--colorBorderTable)",
margin: "0 auto 1.25em",
lineHeight: "1.6",
"> thead": {
backgroundColor: "var(--colorBgTableHead)",
borderBottom: "1px solid #f0f0f0",
th: {
padding: "0.5rem 1rem",
borderLeft: "1px solid var(--colorBorderTable)",
wordBreak: "normal",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
"&:first-child": {
borderLeft: "none",
},
},
},
"> tbody": {
tr: {
borderTop: "1px solid var(--colorBorderTable)",
backgroundColor: "var(--colorBgTableOddRow)",
},
"tr:nth-child(2n)": {
backgroundColor: "var(--colorBgTableEvenRow)",
},
td: {
padding: "0.5rem 1rem",
borderLeft: "1px solid var(--colorBorderTable)",
"&:first-child": {
borderLeft: "none",
},
},
},
}),
);

View file

@ -0,0 +1,19 @@
import type { Text } from "@yozora/ast";
import React from "react";
/**
* Render `text`.
*
* @see https://www.npmjs.com/package/@yozora/ast#text
* @see https://www.npmjs.com/package/@yozora/tokenizer-text
*/
export class TextRenderer extends React.Component<Text> {
public override shouldComponentUpdate(nextProps: Readonly<Text>): boolean {
const props = this.props;
return props.value !== nextProps.value;
}
public override render(): React.ReactElement {
return <React.Fragment>{this.props.value}</React.Fragment>;
}
}

View file

@ -0,0 +1,35 @@
import { css, cx } from "@emotion/css";
import type { ThematicBreak } from "@yozora/ast";
import React from "react";
import { astClasses } from "../context";
/**
* Render `thematicBreak`.
*
* @see https://www.npmjs.com/package/@yozora/ast#thematicBreak
* @see https://www.npmjs.com/package/@yozora/tokenizer-thematic-break
*/
export class ThematicBreakRenderer extends React.Component<ThematicBreak> {
public override shouldComponentUpdate(): boolean {
return false;
}
public override render(): React.ReactElement {
return <hr className={cls} />;
}
}
const cls = cx(
astClasses.thematicBreak,
css({
boxSizing: "content-box",
display: "block",
height: 0,
width: "100%",
padding: 0,
border: 0,
borderBottom: `1px solid #dadada`,
outline: 0,
margin: "1.5em 0px",
}),
);