Remix+CloudflareでWebサイトを作る 14(Markdownのプレビュー・RemixでSCSS・:::message記法・Youtube埋め込み)
【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" },
];
【2024-03-04】RemixでSCSSを使用する
こちらを参考にした。
背景
1つ前 の投稿でSCSSをimportしたら画面に反映されるがその後動かなくなった。
原因
RemixはデフォルトではSASS/SCSSファイルを処理しない。
まずはCSSのにコンパイルする必要がある。
開発時にいちいちコンパイルすることも手間なので、変更を監視して、SCSSファイルが変更されるたびにコンパイルしてReactで使用できるCSSファイルを生成する。
方針
現在SCSSファイルは app/styles/markdown.scss
というパスに存在する。
このファイルの変更を常に監視して、変更されるたびにCSSファイルを生成する。
実装
1. ファイルを監視する
これでapp/styles/
のファイルを監視して、 app/styles/
にファイルを生成する。
$ npm i sass
{
"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対象から除く。
/app/styles/markdown.css*
concurrently
の導入
3. concurrently
を使うことでSCSSコンパイルとRemixの開発サーバーと同時に起動する。
npm run sass
はbuildコマンドやdevコマンド実行前にしておきたいので修正する。
$ npm i concurrently
{
"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
Exampleとも変わらないのになぁ...謎。
import styles from "~/styles/markdown.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
];
こうするとブラウザ側でエラーが出ないが、HMRが走ると画面が白くなる...。
import "~/styles/markdown.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: "~/styles/markdown.css" },
];
Please see the serverModuleFormat section.
と書いてあるのでリンクに飛んで以下の remix.config.js
を作成して以下を書いてみた。
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverModuleFormat: "cjs",
};
無理だった。
わから〜ん。
いつか治す(飛ばす)
今Joy UI→shadcn + Tailwindにリプレースしてるんだけどcssのimportでエラー出るやつなぜかエラーでなくなってた。
rootとかではな何もせずにコンポーネント内に以下書いておけば正常に動く。
import "~/styles/markdown.css";
【2024-03-05】GitHub Explore・Topics・Trending
Cloudflare AnalyticsのAPIを用いてなんやかんやしたいからやり方調べようと思ったらGitHubのTopicsのページにたどりついた
Explore・Topics・Trendingとかたまに眺めてみるの面白そうだなぁ。
Exploreはフォローしている人とかスターしているRepositoryを元におすすめしてくれて面白い。
以下はTypescriptのTrending
【2024-03-06】Youtubeの動画を埋め込む
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
これもあったが1年間くらいメンテされていない。
これに対してreact-playerは4日まえにリリースされているし色々対応できそうだし週間のダウンロード数も約3倍近くあった。
次やること
Markdownに https://www.youtube.com
があったらこのコンポーネントを使ってYoutubeを埋め込むようにする。
【2024-03-07 ~ 2024-03-09】Markdownを拡張してZennのメッセージ記法を作る
コード的にはこの続き。
方針
最初はこの記事を読み込んだ。
が、結論からいうとremark-directive
を使うことでこの記事よりもコードの記述量が少なくて済んだ。具体的には以下。
-
mdast-util-to-hast
で定義されているallなどを使わない -
firstChild.value.startsWith(MESSAGE_BEGGINING)
のような判定ロジックを書かない
スタイルは当てていないが以下のようになる
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
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
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
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;
}
参考
-
remarkjs/remark-directive: remark plugin to support directives
- READMEを参考にした
-
unified(remark,rehype)を利用してMarkdownをHTMLに変換する | Goodlife.tech
- 軽度の変更ならこれで良さそうだった
【2024-03-09】MarkdownでYoutubeの埋め込み(たかったができない)
やること
上の例では適当に<message>
という名前のDOMを作ってみたけど、実際にはTSXファイルにオリジナルのものを定義してそれを表示するようにしたい。
Youtubeのリンクがあったらここで試して<ReactPlayer>
を埋め込みたい。
実装
TSX→HTML
まず、TSXファイルを素のHTMLに変換できるようにしたい。
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>
【2024-03-09】そういやZennはMarkdownパーサー何使ってんだ?
markdown-it
Zennはどうやっているんだろう markdown-it
使ってるっぽい。
mdLinkifyToCard: 埋め込みに対応しているURLへのリンクを埋め込み用のiframeに変換します。
おお?Youtubeとかもいい感じにしてくれるのかな。
調べて書いてみよう。
markdown-it-container
これのおかげで任意のコンテナをReactのコンポーネントとしてレンダリングできるので、オレオレMarkdown化が一気に加速する。
ほう。Zennの :::message
の記法はこれ使ってるのかな?
書いてみる
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>
【2024-03-09】Youtubeの埋め込み
ここを参考にさせていただいた...🙏🏻
隅から隅までコード見て勉強しよう。
リンクをコピペしたら表示できるようにしたくて markdown-it-videoみたいに@[youtube](https://www.youtube.com/watch?v=dQw4w9WgXcQ)
と書きたくなかった。
【2024-03-10】Twitterの埋め込み
zenn-markdown-html
のmarkdown-it
で customEmbed:
を使用しているコードを見ていくとtweetを表示しているところがある。
Zennの埋め込みサーバーを使用するやり方は商用利用不可なので自前で実装する。
しかし、customEmbed:
で非同期処理を書く方法がわからない。
参考
markdown-it
のimport関係でビルドエラー
【2024-03-10】エラー内容
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 38 │ import { 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 41 │ import 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",
にしてみると解決した。