Notion apiからhtmlに変換する方法

公開日:2024年6月22日 更新日:2024年9月5日
programming

notion apiは以下のようなブロックで値を返してくる。

[  
	{
    type: 'paragraph',
    blockId: '0ce996f3-ec3c-41eb-a938-a62a5bf175b1',
    parent: '',
    children: []
  }
]

これをhtmlに変換していく必要がある。

全てのブロックに対してhtmlに変換する処理をれてもいいのだがそれだと結構手間である。

そこでnotion-to-mdを使ってまずブロックからマークダウン形式に変換する。

それをreact-markdownを使ってマークダウンからHTMLに変換する。

これである程度のブロックはいい感じに変換されるのだが、動画やツイッター、bookmark(embed)などは、全部以下のような感じのリンクになってしまう。

// bookmark
[bookmark](https://www.yahoo.co.jp/)

// video
[video](https://www.youtube.com/watch?v=UvHjYtmOSXk)

// tiwtter
[embed](https://twitter.com/infoNumber333/status/1801148281315025350?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1801148281315025350%7Ctwgr%5Eceafc7a3d9f8a71a966156d81bb481f6fa329b94%7Ctwcon%5Es1_&ref_url=https%3A%2F%2Fpublish.twitter.com%2F%3Furl%3Dhttps%3A%2F%2Ftwitter.com%2FinfoNumber333%2Fstatus%2F1801148281315025350)

これを動画なら動画再生出来るようにtwitterならtwitterカードにbookmarkならembedカードとして出力出来るようにしたい。

画像だけは

![image](https://images.unsplash.com/photo-1492707892479-7bc8d5a4ee93?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb)

という感じで!がついていて自動でimgタグに変換される。

ブロックからマークダウンに変換するタイミングか、

マークダウンからHTMLに変換するタイミングか、

結果として後者でやることになった。

理由としては前者の場合notion-to-mdのCustom Transformersを使うことになるのだが、このタイミングでreact componentを使うことはできなかった。

なので結局マークダウンに変換された状態からbookmarkやvideoを自作のvideoやbookmarkのコンポーネントに変換するようにすることにした。

そこでvideoを変換する際の例を紹介します。

import Markdown from "react-markdown";
import { visit } from "unist-util-visit";
import NotionVideo from "@/components/notionBlocks/Video";
import NotionEmbed from "@/components/notionBlocks/Embed";

const remarkVideo = () => {
  return (tree: any) => {
    visit(tree, "link", (node: any) => {
      const { url } = node;

      if (url && url.includes("youtube.com/watch")) {
        node.type = "video";
        node.data = {
          hName: "video",
          hProperties: { src: url },
        };
      }
    });
  };
};

const remarkEmbed = () => {
  return (tree: any) => {
    visit(tree, "link", (node: any) => {
      const { url } = node;

      if (
        (url && node.children[0].value === "embed") ||
        node.children[0].value === "bookmark"
      ) {
        node.type = "html";
        node.value = `<embed-link url="${url}"></embed-link>`;
      }
    });
  };
};

const customVideo = (props: any) => {
  const { src } = props;
  return <NotionVideo url={src} />;
};


const MdContent = ({ content }: { content: string }) => {
  return (
    <Markdown
      remarkPlugins={[remarkVideo]}
      components={{
        video: CustomVideo,
        @ts-ignore
        "embed-link": ({ node }: { node: any }) => (
          <NotionEmbed url={node.properties.url} />
        ),
      }}
    >
      {content}
    </Markdown>
  );
};

まずはremarkVideoでは[video](https://www.youtube.com/watch?v=UvHjYtmOSXk) があった場合videoのnodeに変換する処理になります。

customVideoではvideoタグをNotionVideoというコンポーネントに変換する処理をしています。

remarkVideoでこの処理も含めたかったのですが無理でした。

NotionVideoは

easy-notion-blogのnotion-blocks中の一部を参考にさせていただきました。

NotionVideo
"use client";

import React from "react";
import YouTube, { YouTubeProps } from "react-youtube";

const isYouTubeURL = (urls: URL): boolean => {
  if (["www.youtube.com", "youtube.com", "youtu.be"].includes(urls.hostname)) {
    return true;
  }
  return false;
};

const parseYouTubeVideoId = (urls: URL): string => {
  if (!isYouTubeURL(urls)) return "";

  if (urls.hostname === "youtu.be") {
    return urls.pathname.split("/")[1];
  } else if (urls.pathname === "/watch") {
    return urls.searchParams.get("v") || "";
  } else {
    const elements = urls.pathname.split("/");

    if (elements.length < 2) return "";

    if (
      elements[1] === "v" ||
      elements[1] === "embed" ||
      elements[1] === "live"
    ) {
      return elements[2];
    }
  }

  return "";
};

const Video = ({ url }: { url: string }) => {
  let urls: URL;
  try {
    urls = new URL(url);
  } catch {
    return null;
  }

  if (!isYouTubeURL(urls)) {
    return null;
  }

  const onPlayerReady: YouTubeProps["onReady"] = (event) => {
    event.target.pauseVideo();
  };
  const opts: YouTubeProps["opts"] = {
    height: "390",
    width: "640",
    playerVars: {
      autoplay: 1,
    },
  };

  const videoId = parseYouTubeVideoId(urls);
  if (videoId === "") {
    return null;
  }

  return <YouTube videoId={videoId} opts={opts} onReady={onPlayerReady} />;
};

export default Video;

その他の参考になるcomponents

ただeasy-notion-blogはアーカイブされているのでastroで書かれてはいるが現在も開発中のastro-notion-blogも参考にした方が良さそうです。

astroの方では無駄なpackageをinstallしないで良い記述方になっていました。

ちなみにzenn-markdown-htmlを使ってmarkdownからhtmlにする方法も有りました。

参考: Notion APIとzenn-markdown-htmlを利用したブログのテンプレート「n2zm-blog-nextjs」を公開しました!

以上のようなやり方だとpタグに括られた状態になってしまいembedカードでdevタグなどを使うとpタグの中にdivが含まれいることによるerrorが出てしまいまいました。

const CustomParagraph = (props: any) => {
  const { node, children } = props;
  if (
    node.tagName === "p" &&
    node.children[0]?.tagName === "a" &&
    node.children[0]?.children.length === 1
  ) {
    if (
      node.children[0]?.children[0]?.value === "embed" ||
      node.children[0]?.children[0]?.value === "bookmark"
    ) {
      const url = node.children[0]?.properties?.href;
      return <NotionEmbed url={url} />;
    } else if (node.children[0]?.children[0]?.value === "video") {
    }
  }
  return <p>{children}</p>;
};

const MdContent = ({ content }: { content: string }) => {
  return (
    <Markdown
      components={{
        p: CustomParagraph,
      }}
    >
      {content}
    </Markdown>
  );
};
{
  type: 'element',
  tagName: 'p',
  properties: {},
  children: [
    {
      type: 'element',
      tagName: 'a',
      properties: [Object],
      children: [Array],
      position: [Object]
    }
  ],
  position: {
    start: { line: 129, column: 1, offset: 906 },
    end: { line: 129, column: 333, offset: 1238 }
  }
}

参考