Open17

DenoでGFMをレンダリングして、Syntax highlightとコピーボタンを置きたい

hashrockhashrock

どうやってレンダリング済みのmarkdownをインタラクティブにするか

予想:

  • どうにかしてhydrationする
  • useEffect内でDOMを直接いじる
  • WebComponentsでなんかする

欲を言えばMDXのレンダリングとかもしたいんだよな

hashrockhashrock

まずはhydrationの復習。

公式ページのここにかかっているハイドレーション。

コメントで囲まれている。

ノードの種類に応じてmarkerStackに積んでいく。

hashrockhashrock

っていうか説明がコメントにあった。

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の子リストをリストベースで行います。
hashrockhashrock

まあともかくとして、コメントでhydrationがかかる範囲を表してそれを集めておき、実際のpreactコンポーネントに変換する。

でバンドル済みのソースは下記に付け加えられていて、リンク先ではesbuildがバンドルしてくれる様になっている。

hashrockhashrock

難しいからおいておこう…
あんまりマジカルなやり方は避けて、普通のやり方からやっていこう。

hashrockhashrock

まずは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>
  );
}

ここまでは簡単。

hashrockhashrock

react-markdownがdenoで動きますぜというコメントが。

https://github.com/remarkjs/react-markdown/issues/730#issuecomment-1461489645

// 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>
  );
}

確かに動いた!ありがたい

hashrockhashrock

mdxが使えるかも、と思ってpreact版を試しているが…

hooks.js:2 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'context')

みたいな感じで、今のところ動きそうな雰囲気はない。

hashrockhashrock

本当にCopy buttonが必要かわからなくなってきたな…

Freshで作られていてMarkdownをレンダリングしている公式DocやBlogではCopy buttonを実装している例はない。

どうしてもIslandsを使いたければ、プロパティを分けてコードスニペットだけmarkdownとは別に渡してあげればいい。

コードハイライトのほうが優先かもしれない。

hashrockhashrock

待って、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>
  );
}

で…できた…!(クリックすると動く。ただし複数コードブロックがあると同時に動いちゃう)

hashrockhashrock

複数のコードブロックが置けるように、内容比較もするようにした。

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>
  );
}
hashrockhashrock

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を取り出す必要があり、なんかエグいコードになっちゃったな…

hashrockhashrock

見た目はこんな感じ(テーマこんな赤かったっけ?)

hashrockhashrock

さて、改めてこれ必要か?を考えたいが…

個人的にはそんなにいらないかなあ。Markdownでこれを書くというよりは、むしろコンポーネントそのもののコードを表示する機能を追加して、snippetsオプションで上書きしたいという気がする。markdownは補助的な機能になるだろう。

ニーズがあればつけてもいいけど、まだリリースしてもいない段階で考えることじゃないな。