DenoでGFMをレンダリングして、Syntax highlightとコピーボタンを置きたい
動かないみたい
どうやってレンダリング済みのmarkdownをインタラクティブにするか
予想:
- どうにかしてhydrationする
- useEffect内でDOMを直接いじる
- WebComponentsでなんかする
欲を言えばMDXのレンダリングとかもしたいんだよな
まずはhydrationの復習。
公式ページのここにかかっているハイドレーション。
コメントで囲まれている。
ノードの種類に応じてmarkerStackに積んでいく。
っていうか説明がコメントにあった。
Islandを復活させ、サーバーレンダリングされたコンテンツを繋ぎ合わせます。
概念的には、DOM上で中間順の深さ優先探索を行い、
IslandまたはサーバーレンダリングされたJSX(=Islandのスロット)のマーカーとなる
<!--frsh-something--> コメントノードを見つけます。
各IslandまたはサーバーJSXには開始と終了のマーカーがあり、
したがってこれらの要素には 単一の ルートノードがありません。
仮想DOMツリーのために構築する階層は、DOM内で平坦な方法でレンダリングされるかもしれません。
例:
<div>
<!--frsh-island:0-->
<!--frsh-slot:children-->
<p>server content</p>
<!--/frsh-slot:children-->
<!--/frsh-island:0-->
</div>
ここでは、フラットなDOM構造がありますが、仮想DOMの観点からは次のようにレンダリングする必要があります:
<div> -> <Island> -> ServerComponent -> <p>server content</p>
これを解決するために、仮想DOMの階層構造をスタックのような方法で追跡し、
実際の反復処理はHTMLElementの子リストをリストベースで行います。
なんかむずい。実際のrendering処理はpreact内にある気がする。下記のgistがリンクされていた。
まあともかくとして、コメントでhydrationがかかる範囲を表してそれを集めておき、実際のpreactコンポーネントに変換する。
でバンドル済みのソースは下記に付け加えられていて、リンク先ではesbuildがバンドルしてくれる様になっている。
難しいからおいておこう…
あんまりマジカルなやり方は避けて、普通のやり方からやっていこう。
まずはstaticなMarkdown renderingを試す。
// components/MyMarkdown.tsx
import { JSX } from "preact";
import { unified } from "https://esm.sh/unified@10.1.2";
import remarkParse from "https://esm.sh/remark-parse@10.0.2";
import remarkRehype from "https://esm.sh/remark-rehype@10.1.0";
import rehypeSanitize from "https://esm.sh/rehype-sanitize@5.0.1";
import rehypeStringify from "https://esm.sh/rehype-stringify@9.0.3";
import remarkGfm from "https://esm.sh/remark-gfm@4.0.0";
interface MyMarkdownProps extends JSX.HTMLAttributes<HTMLDivElement> {
content: string;
}
function render(input: string) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
.processSync(input)
.toString();
}
export function MyMarkdown(props: MyMarkdownProps) {
const html = render(props.content);
return (
<div
class={"markdown-body " + props.class}
{...props}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// routes/index.tsx
import { MyMarkdown } from "../components/MyMarkdown.tsx";
export default function Home() {
const example = `
# Hello, world!
- a
- b
- c
\`\`\`ts
console.log("Hello, world!");
\`\`\`
`;
return (
<div class="m-16 p-8 border rounded-xl">
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown.min.css"
/>
<style>
{`
.markdown-body {
background-color: transparent;
}
`}
</style>
<MyMarkdown content={example} />
</div>
);
}
ここまでは簡単。
react-markdownがdenoで動きますぜというコメントが。
// components/MyMarkdown.tsx
import { JSX } from "preact";
import PreactMarkdown from "preact-markdown";
interface MyMarkdownProps extends JSX.HTMLAttributes<HTMLDivElement> {
content: string;
}
export function MyMarkdown(props: MyMarkdownProps) {
return (
<div class="markdown-body" {...props}>
<PreactMarkdown>
{props.content}
</PreactMarkdown>
</div>
);
}
確かに動いた!ありがたい
mdxが使えるかも、と思ってpreact版を試しているが…
hooks.js:2 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'context')
みたいな感じで、今のところ動きそうな雰囲気はない。
本当にCopy buttonが必要かわからなくなってきたな…
Freshで作られていてMarkdownをレンダリングしている公式DocやBlogではCopy buttonを実装している例はない。
どうしてもIslandsを使いたければ、プロパティを分けてコードスニペットだけmarkdownとは別に渡してあげればいい。
コードハイライトのほうが優先かもしれない。
待って、react-markdown
にいいオプションあるじゃん。コンポーネント置き換えるやつ
import { JSX } from "preact";
import PreactMarkdown from "preact-markdown";
import { ReactNode, useState } from "preact/compat";
interface MyMarkdownProps extends JSX.HTMLAttributes<HTMLDivElement> {
content: string;
}
export default function MyMarkdown(props: MyMarkdownProps) {
const [copied, setCopied] = useState(false);
return (
<div class="markdown-body" {...props}>
<PreactMarkdown
components={{
code(props: { children: ReactNode }) {
return (
<div class="relative">
<button
class="absolute right-0"
onClick={() => {
const content = props?.children?.toString() ?? "";
navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}
>
{copied ? "Copied!" : "Copy"}
</button>
<code>
{props.children}
</code>
</div>
);
},
}}
>
{props.content}
</PreactMarkdown>
</div>
);
}
で…できた…!(クリックすると動く。ただし複数コードブロックがあると同時に動いちゃう)
複数のコードブロックが置けるように、内容比較もするようにした。
import { JSX } from "preact";
import PreactMarkdown from "preact-markdown";
import { ReactNode, useState } from "preact/compat";
interface MyMarkdownProps extends JSX.HTMLAttributes<HTMLDivElement> {
content: string;
}
export default function MyMarkdown(props: MyMarkdownProps) {
const [copied, setCopied] = useState<string | null>(null);
return (
<div class="markdown-body" {...props}>
<PreactMarkdown
components={{
code(props: { children: ReactNode }) {
const content = props?.children?.toString() ?? "";
return (
<div class="relative">
<button
class="absolute right-0"
onClick={(el) => {
navigator.clipboard.writeText(content);
setCopied(content);
setTimeout(() => setCopied(null), 1000);
}}
>
{ content === copied ? "Copied!" : "Copy"}
</button>
<code>
{props.children}
</code>
</div>
);
},
}}
>
{props.content}
</PreactMarkdown>
</div>
);
}
syntax highlightに対応。
import { JSX } from "preact";
import PreactMarkdown from "preact-markdown";
import { ReactNode, useState } from "preact/compat";
import rehypeHighlight from "https://esm.sh/rehype-highlight@5.0.2";
interface MyMarkdownProps extends JSX.HTMLAttributes<HTMLDivElement> {
content: string;
}
function reactNodeToString(node: ReactNode): string {
if (typeof node === 'string') {
return node;
} else if (Array.isArray(node)) {
return node.map(reactNodeToString).join('');
} else if (node === null) {
return '';
} else if (typeof node === 'object' && 'props' in node && node.props) {
return reactNodeToString(node.props.children);
} else {
return ""
}
}
export default function MyMarkdown(props: MyMarkdownProps) {
const [copied, setCopied] = useState<string | null>(null);
return (
<div class="markdown-body" {...props}>
<link
rel="stylesheet"
href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css"
/>
<PreactMarkdown
rehypePlugins={[rehypeHighlight]}
components={{
code(props: { children: ReactNode }) {
const content = reactNodeToString(props.children);
return (
<div class="relative">
<button
class="absolute right-0"
type="button"
onClick={(el) => {
navigator.clipboard.writeText(content);
setCopied(content);
setTimeout(() => setCopied(null), 1000);
}}
>
{content === copied ? "Copied!" : "Copy"}
</button>
<code>
{props.children}
</code>
</div>
);
},
}}
>
{props.content}
</PreactMarkdown>
</div>
);
}
処理途中のcodeを取れないもんで、ReactNodeからもとのstringを取り出す必要があり、なんかエグいコードになっちゃったな…
見た目はこんな感じ(テーマこんな赤かったっけ?)
さて、改めてこれ必要か?を考えたいが…
個人的にはそんなにいらないかなあ。Markdownでこれを書くというよりは、むしろコンポーネントそのもののコードを表示する機能を追加して、snippetsオプションで上書きしたいという気がする。markdownは補助的な機能になるだろう。
ニーズがあればつけてもいいけど、まだリリースしてもいない段階で考えることじゃないな。
実装はここに置いた。