Closed11

Remix+CloudflareでWebサイトを作る 14(Markdownのプレビュー・RemixでSCSS・:::message記法・Youtube埋め込み)

saneatsusaneatsu

【2024-03-04】MarkdownをQiitaの編集ページみたいにリアルタイムプレビューする

ある程度できた

TextareaにonChange() をつけてsetHtmlにHTMLを入れればいいのでそれっぽいものはすぐできた。

const [html, setHtml] = useState<string>("");

<div
  className="markdown"
  dangerouslySetInnerHTML={{ __html: html }}
/>

スタイルの指定方法が不適切っぽい

以下の方法でスタイルを指定していると、HMRが実行された際に画面が真っ白になってしまう。
次の投稿で修正する。

import "~/styles/markdown.scss";

export const links = () => [
  { rel: "stylesheet", href: "~/styles/markdown.scss" },
];
saneatsusaneatsu

【2024-03-04】RemixでSCSSを使用する

https://seasparta618.medium.com/integrating-scss-sass-and-storybook-with-a-remix-app-a-good-practice-benefiting-front-end-52be3e667c83

こちらを参考にした。

背景

1つ前 の投稿でSCSSをimportしたら画面に反映されるがその後動かなくなった。

原因

RemixはデフォルトではSASS/SCSSファイルを処理しない
まずはCSSのにコンパイルする必要がある。
開発時にいちいちコンパイルすることも手間なので、変更を監視して、SCSSファイルが変更されるたびにコンパイルしてReactで使用できるCSSファイルを生成する。

方針

現在SCSSファイルは app/styles/markdown.scss というパスに存在する。
このファイルの変更を常に監視して、変更されるたびにCSSファイルを生成する。

実装

1. ファイルを監視する

これでapp/styles/ のファイルを監視して、 app/styles/ にファイルを生成する。

$ npm i sass
package.json
{
  "scripts": {
+   "sass:watch": "sass --watch app/styles/:app/styles/",

以下を実行してapp/styles/markdown.scssを変更すると監視して、CSSをファイルを生成していることがわかる。

$ npm run sass:watch

> sass
> sass --watch app/styles/:app/styles/

Sass is watching for changes. Press Ctrl-C to stop.

[2024-03-04 19:08] Compiled app/styles/markdown.scss to app/styles/markdown.css.

2. .gititnoreの更新

sass コマンドによってapp/styles/markdown.css app/styles/markdown.css.map という2つのファイルが生成されるのでCommit対象から除く。

.gitignore
/app/styles/markdown.css*

3. concurrently の導入

concurrently を使うことでSCSSコンパイルとRemixの開発サーバーと同時に起動する。
npm run sass はbuildコマンドやdevコマンド実行前にしておきたいので修正する。

$ npm i concurrently
package.json
{
  "scripts": {
+   "dev": "concurrently \"npm run sass:watch\" \"remix vite:dev\"",
+   "build": "npm run sass:build && remix vite:build",
    "sass:watch": "sass --watch app/styles/:app/styles/",
+   "sass:build": "sass app/styles/:app/styles/",

4. CSSをimportする

以下のようにimportするとUncaught SyntaxError: The requested module '/app/styles/markdown.css' does not provide an export named 'default'が出て画面は表示されるがリンクをクリックして画面遷移をしようとしても動かなくなるしHMRも反映されず。

公式にも書いてある正規の方法だと思うんだけどな...。
Regular CSS | Remix

https://github.com/mcansh/remix-examples/tree/main/remix-sass
Exampleとも変わらないのになぁ...謎。

root.tsx
import styles from "~/styles/markdown.css";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: styles },
];

こうするとブラウザ側でエラーが出ないが、HMRが走ると画面が白くなる...。

root.tsx
import "~/styles/markdown.css";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: "~/styles/markdown.css" },
];

Upgrading to v2 | Remix

Please see the serverModuleFormat section.

と書いてあるのでリンクに飛んで以下の remix.config.js を作成して以下を書いてみた。

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverModuleFormat: "cjs",
};

無理だった。
わから〜ん。

いつか治す(飛ばす)

saneatsusaneatsu

今Joy UI→shadcn + Tailwindにリプレースしてるんだけどcssのimportでエラー出るやつなぜかエラーでなくなってた。

rootとかではな何もせずにコンポーネント内に以下書いておけば正常に動く。

MarkdownEditor.tsx
import "~/styles/markdown.css";
saneatsusaneatsu

【2024-03-05】GitHub Explore・Topics・Trending

Cloudflare AnalyticsのAPIを用いてなんやかんやしたいからやり方調べようと思ったらGitHubのTopicsのページにたどりついた

Topics について - GitHub Docs

https://github.com/topics/cloudflare-analytics

Explore・Topics・Trendingとかたまに眺めてみるの面白そうだなぁ。

Exploreはフォローしている人とかスターしているRepositoryを元におすすめしてくれて面白い。

以下はTypescriptのTrending

https://github.com/trending/typescript?since=daily

saneatsusaneatsu

【2024-03-06】Youtubeの動画を埋め込む

react-player

https://www.npmjs.com/package/react-player

実装方法

Exampleコードにある通りに書くだけでOK。簡単。

import ReactPlayer from 'react-player'
import type React from "react"; // これが無いとエラーになる??

<ReactPlayer url='https://www.youtube.com/watch?v=LXb3EKWsInQ' />

注意点

ただし、注意点として2行目がないと以下のエラーが出る。
サーバーでは出るけどコード的には何もエラーが無いので分かりづらい。

ErrorType: DefaultError
ErrorTitle: Error
ErrorMessage: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.
ErrorCategory: ApplicationError
ModuleErrorMessage: 
Warning: React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: object.

エラー治っていなかった

と思いきや、「エラー出る→コード変更→HMRで反映」だと表示されるがブラウザをリロードすると上記エラーがサーバーででてしまいブラウザも以下のようになる。

エラー治った

このエラー分はMUIのimportのときと同じなので疑うのはimport元のパスか。

下のUsageのところに書いてあるけどimport先のパスを変えたらうまくいった。

- import ReactPlayer from "react-player";
+ import ReactPlayer from "react-player/youtube";

<ReactPlayer url='https://www.youtube.com/watch?v=LXb3EKWsInQ' />

react-youtube

https://www.npmjs.com/package/react-youtube

これもあったが1年間くらいメンテされていない。
これに対してreact-playerは4日まえにリリースされているし色々対応できそうだし週間のダウンロード数も約3倍近くあった。

次やること

Markdownに https://www.youtube.com があったらこのコンポーネントを使ってYoutubeを埋め込むようにする。

saneatsusaneatsu

【2024-03-07 ~ 2024-03-09】Markdownを拡張してZennのメッセージ記法を作る

https://zenn.dev/link/comments/578566be58860c

コード的にはこの続き。

方針

https://zenn.dev/januswel/articles/745787422d425b01e0c1#message-ノードから-msg-class-を持つ-div-ノードへ変換する

最初はこの記事を読み込んだ。
が、結論からいうとremark-directiveを使うことでこの記事よりもコードの記述量が少なくて済んだ。具体的には以下。

スタイルは当てていないが以下のようになる

DOMを出力させてみると <message></message> 内の改行なども正しく行われていることがわかる。
<message class="error"> とクラスも指定できている👏🏻

<h1>これはH1タイトル</h1>
<h2>これはH2タイトル</h2>
<h3>これはH3タイトル</h3>
<ul>
<li>リスト1</li>
<li>リスト2</li>
<li>リスト3</li>
</ul>
<message><p>正しいメッセージ1<br>
正しいメッセージ2</p></message>
<message class="error"><p>正しいエラーメッセージ1<br>
正しいエラーメッセージ2</p></message>
<div></div>
<p>2つコロンのメッセージ<br>
::</p>
<p></p><div></div><br>
1つコロンのメッセージ<br>
:<p></p>
<div><p>message12345</p></div>

実装

processor.ts
processor.ts
import rehypePrettyCode from "rehype-pretty-code";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import remarkBreaks from "remark-breaks";
import remarkDirective from "remark-directive";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import { inspect } from "unist-util-inspect";

import { reamrkMessage } from "./remark-message";

import type { Processor } from "unified";

/**
 * - unified: テキスト(HTML, Markdown, Plain Text など)をシンタックスツリーに変換し、それを別の形式にコンパイルするインターフェイス
 * - remark: Markdownを扱うためのプロセッサで、unistを拡張したmdastと呼ばれる構文木を使用する
 * - rehype: HTMLを扱うためのプロセッサで、unistを拡張したhastと呼ばれる構文木を使用する
 *
 * 適用順序
 * 1. remarkParseでMarkdownをmdastに変換(= parser)
 * 2. remark-xxx のプラグインを適用
 * 3. remarkRehypeでmdastをhastに変換(= transformer)
 * 4. rehype-xxx のプラグインを適用
 * 5. hastをReactElementに変換(= stringifier)
 */
export const markdownToHtml = async (markdown: string) => {
  const processor = unified();

  // Parser
  processor
    // Markdown → mdast(Markdown文字列ASTにしたもの)
    .use(remarkParse, { fragment: true })
    // :::message 記法に対応
    .use(reamrkMessage)
    .use(remarkDirective)
    // GitHubのMarkdown記法を使えるようにする
    .use(remarkGfm)
    // 改行を<br>にする
    .use(remarkBreaks)
    // mdast → hast(HTML文字列をASTにしたもの)
    .use(remarkRehype, {
      allowDangerousHtml: true, // rehype-rawのために直接記載されたタグを許可する
      handlers: {},
    });

  // Transformer
  processor
    // Markdown内でHTMLを使えるようにする
    .use(rehypeRaw)
    // シンタックスハイライト
    .use(rehypePrettyCode, {
      theme: "github-dark",
      keepBackground: true, // テーマの背景色をそのまま使用
    });

  // Stringifier
  processor.use(rehypeStringify); // hast → HTML

  debug(processor, markdown);

  const vfile = await processor.process(markdown);
  return vfile.toString();
};

/**
 * processorの関数
 * - parse(): データをパースする
 * - run(): AST を操作する
 * - stringify(): フォーマットする
 * - process(): 全て実行する
 */
function debug(processor: Processor, markdown: string) {
  processor.process(markdown).then((contents) => {
    console.log(contents.value);
  });

  const parsed = processor.parse(markdown);
  console.log(inspect(parsed)); // 上の「方針」でDOMを出力しているのはココ
}
remark-message.ts
remark-message.ts
import { h } from "hastscript";
import { visit } from "unist-util-visit";

import { isNode } from "./utils";

import type { Root, Paragraph } from "mdast";

function isMessage(node: unknown): node is Paragraph {
  if (
    isNode(node) &&
    node.type === "containerDirective" &&
    "name" in node &&
    node.name === "message"
  ) {
    return true;
  }

  return false;
}

const TAG_NAME = "message";

export function reamrkMessage() {
  return (tree: Root) => {
    visit(tree, isMessage, (node) => {
      const data = node.data || (node.data = {});

      data.hName = TAG_NAME;
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      data.hProperties = h(TAG_NAME, (node as any).attributes || {}).properties;
    });
  };
}
utils.ts
utils.ts
import type { Node } from "unist";

function isObject(target: unknown): target is { [key: string]: unknown } {
  return typeof target === "object" && target !== null;
}

// https://github.com/syntax-tree/unist#node
export function isNode(node: unknown): node is Node {
  return isObject(node) && "type" in node;
}

参考

saneatsusaneatsu

【2024-03-09】MarkdownでYoutubeの埋め込み(たかったができない)

やること

上の例では適当に<message>という名前のDOMを作ってみたけど、実際にはTSXファイルにオリジナルのものを定義してそれを表示するようにしたい。

Youtubeのリンクがあったらここで試して<ReactPlayer>を埋め込みたい。

実装

TSX→HTML

まず、TSXファイルを素のHTMLに変換できるようにしたい。

utils.ts
import ReactDOMServer from "react-dom/server";

export function tsxToHtml(jsx: React.ReactElement): string {
  return ReactDOMServer.renderToStaticMarkup(jsx);
}

すると、<ReactPlayer>が以下のようになる。
本来<iframe>がdiv内に入っているはずなのに中身がない。
URLの内容を描画するまでに時間かかるから空で表示されてしまっている的なことなんだろうか??

<div style="width:640px;height:360px"><div style="width:100%;height:100%"><div></div></div></div>
saneatsusaneatsu

【2024-03-09】そういやZennはMarkdownパーサー何使ってんだ?

markdown-it

https://zenn-dev.github.io/zenn-docs-for-developers/guides/zenn-editor/zenn-markdown-html
https://zenn.dev/team_zenn/articles/intro-zenn-markdown

Zennはどうやっているんだろう markdown-it 使ってるっぽい。

mdLinkifyToCard: 埋め込みに対応しているURLへのリンクを埋め込み用のiframeに変換します。

おお?Youtubeとかもいい感じにしてくれるのかな。
調べて書いてみよう。

markdown-it-container

https://endaaman.me/-/oreore_markdown_on_react

これのおかげで任意のコンテナをReactのコンポーネントとしてレンダリングできるので、オレオレMarkdown化が一気に加速する。

ほう。Zennの :::message の記法はこれ使ってるのかな?

書いてみる

https://markdown-it.github.io/markdown-it/

import markdownit from "markdown-it";
import markdownitContainer from "markdown-it-container";

import type { Token } from "markdown-it";

export function markdownIt(markdown: string): string {
  const md = markdownit({
    html: true, // HTML タグを有効にする
    linkify: true, // URLに似たテキストをリンクに自動変換する
    typographer: true, // 言語に依存しないきれいな 置換 + 引用符 を有効にします。
    breaks: true, // 改行コードを<br>に変換する
    langPrefix: "language-",
    // highlight: function (/*str, lang*/) {
    //   return "";
    // },
  });
  md.use(markdownitContainer, "message", {
    validate: function (params: string) {
      return params.trim().match(/^message\s+(.*)$/);
    },

    render: function (tokens: Token[], idx: number) {
      const m = tokens[idx].info.trim().match(/^message\s+(.*)$/);

      if (m && tokens[idx].nesting === 1) {
        return (
          '<div class="message ' +
          md.utils.escapeHtml(m[1]) +
          '"><div class="message-body">'
        );
      } else {
        return "</div></div>\n";
      }
    },
  });

  const result = md.render(markdown);

  return result;
}

Markdownを以下のように書く。

:::message info
info
:::

こんなHTMLが出力される。

<div class="message info">
  <div class="message-body">
    <p>info</p>
  </div>
</div>
saneatsusaneatsu

【2024-03-10】Twitterの埋め込み

zenn-markdown-htmlmarkdown-itcustomEmbed: を使用しているコードを見ていくとtweetを表示しているところがある。

Zennの埋め込みサーバーを使用するやり方は商用利用不可なので自前で実装する。
しかし、customEmbed: で非同期処理を書く方法がわからない。

参考

saneatsusaneatsu

【2024-03-10】markdown-it のimport関係でビルドエラー

エラー内容

12:26:09.497	✘ [ERROR] 2 error(s) and 0 warning(s) when compiling Worker.
12:26:09.498
12:26:09.498
12:26:09.501
12:26:09.503	✘ [ERROR] Could not resolve "markdown-it/lib/common/utils"
12:26:09.503
12:26:09.503	    ../build/server/index.js:38:27:
12:26:09.503	      38import { escapeHtml } from "markdown-it/lib/common/utils";
12:26:09.503	         ╵                            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
12:26:09.503
12:26:09.503	  The module "./lib/common/utils" was not found on the file system:
12:26:09.504
12:26:09.504	    ../node_modules/markdown-it/package.json:23:16:
12:26:09.504	      23"import": "./*"
12:26:09.504	         ╵                 ~~~~~
12:26:09.504
12:26:09.504	  You can mark the path "markdown-it/lib/common/utils" as external to exclude it from the bundle, which will remove this error.
12:26:09.505
12:26:09.505
12:26:09.505	✘ [ERROR] Could not resolve "markdown-it/lib/token"
12:26:09.505
12:26:09.505	    ../build/server/index.js:41:18:
12:26:09.505	      41import Token from "markdown-it/lib/token";
12:26:09.506	         ╵                   ~~~~~~~~~~~~~~~~~~~~~~~
12:26:09.506
12:26:09.506	  The module "./lib/token" was not found on the file system:
12:26:09.507
12:26:09.507	    ../node_modules/markdown-it/package.json:23:16:
12:26:09.508	      23"import": "./*"
12:26:09.508	         ╵                 ~~~~~
12:26:09.508
12:26:09.508	  You can mark the path "markdown-it/lib/token" as external to exclude it from the bundle, which will remove this error.
12:26:09.508
12:26:09.508
12:26:09.508	✘ [ERROR] Build failed with 2 errors:
12:26:09.508
12:26:09.509	  ../build/server/index.js:38:27: ERROR: Could not resolve "markdown-it/lib/common/utils"
12:26:09.509	  ../build/server/index.js:41:18: ERROR: Could not resolve "markdown-it/lib/token"

以下2点が問題

  • import { escapeHtml } from "markdown-it/lib/common/utils"
  • import Token from "markdown-it/lib/token";

解決方法

zenn-markdown-htmlのリポジトリを見てみると"markdown-it": "^12.3.2",と書かれている。自分は最新の"markdown-it": "^14.0.0",

バージョン下げたら解決しそうなのでとりあえず1つ下げて"markdown-it": "^13.0.2",にしてみると解決した。

このスクラップは2024/03/10にクローズされました