Info
#

2025/06/23 (Mon) 07:40:00 GMT+0000 (UTC)
Type: PC | System: Unknown | Browser: Unknown ... More

Menu
.
+
#
  • @ /note/deno_fresh/post-ConvertMarkdownToJsxElement
Content
.
+
#

Post: Nekoformi
Date: 2024/04/13

Convert Markdown to JSX Element

本ウェブサイトのNoteは全てMarkdown形式で記述されていますが、これらをHTML(JSX Element)として変換する際に特定の要素を自作したコンポーネントへ置き換えるためにはDOMに対する理解と多少の狂気が必要になります。特にDenoはMDXを取り扱うモジュールが(2024年04月の時点では)存在しないため、私が勝手に関数を作ってしまいました。

目標

  • Markdownに記述されている特定の要素(コンポーネントではなく、ユーザーが設定した独自の記法)を自作のコンポーネントに置換します。
  • 置換の過程で非同期関数(ファイルの読み込み等)の実行を可能にします。
  • プロパティー:dangerouslySetInnerHTMLを使用しません。

1. 準備

初めに、以下のモジュールを導入・使用します。

  • Marked:Markdown(文字列)をHTML(文字列)にパースします。
  • front-matter:Markdown(文字列)に記述されたメタデータを抽出します。
  • sanitize-html:HTML(文字列)をサニタイズします。
  • Deno DOM:DOMを取り扱います。
  • Assertions:変数の内容をアサーションします。
deno.json
+
#
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
{ ... "imports": { ... "marked": "https://esm.sh/marked@12.0.1", "front-matter": "https://esm.sh/front-matter@4.0.2", "sanitize-html": "https://esm.sh/sanitize-html@2.13.0", "deno-dom-wasm": "https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm.ts", "deno-assert": "https://deno.land/std@0.216.0/assert/assert.ts", ... }, ... }

2. ページの作成

ルーティング(記事を表示)するページを作成します。

/routes/note/[...post].tsx
+
#
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
50:
51:
52:
53:
54:
55:
56:
57:
58:
59:
60:
61:
62:
63:
64:
65:
66:
67:
68:
69:
70:
71:
import { Head } from '$fresh/runtime.ts'; import { Handlers, PageProps } from '$fresh/server.ts'; import convertHtmlStringToJsxElement from '@function/original/convertHtmlStringToJsxElement.tsx'; // We'll explain in the next step. import convertMarkdownToHtmlString from '@function/original/convertMarkdownToHtmlString.tsx'; // We'll explain in the next step. import { myParsedRenderer } from '@function/original/myMarkdownConfig.tsx'; // We'll explain in the next step. import Board from '@myBoard'; // This is the original component. type postProps = { req: Request; title: string; date: string; author: string; content: string; }; export const handler: Handlers<postProps> = { async GET(req, ctx) { try { const { post } = ctx.params; const postData = await Deno.readTextFile(`${Deno.cwd()}/data/page/note/${post}.md`); const { meta, content } = await convertMarkdownToHtmlString(postData); // We'll explain in the next step. return ctx.render({ req: req, title: meta.title || 'Untitled', date: meta.date || 'Unknown', author: meta.author || 'Unknown', content: content, }); } catch (e) { console.error(e); return new Response('', { status: 303, headers: { Location: '/error', }, }); } }, }; export default async function Post(_: Request, { data }: PageProps<postProps>) { const req = data.req; const path = req && new URL(req.url).pathname; const post = await convertHtmlStringToJsxElement(data.content, { renderer: myParsedRenderer }); // We'll explain in the next step. return ( <> <Head> <title>{data.title} - Duskectrum</title> </Head> <Board path={path} type='common' className='document'> <> <p class='gray_tc'> Post: {data.author} <br /> Date: {data.date} </p> {post} </> </Board> </> ); }
  • ファイル名を[...文字列].tsxにすることで動的なパスをホストできます。また、カスタムハンドラー(ctx.params)のプロパティーからパス(文字列)を取得することができます。
  • 使用しない変数は名前を_もしくは_変数名にすることで警告を無視できます。
  • importに記述されている@function/originalは私が個別に定義したパスなので、実際に使用する際は正式なパスを記述するか、deno.jsonで定義する必要があります。

3. Markdown → HTML

MarkdownをHTMLに変換するための関数:convertMarkdownToHtmlString()を作成します。

@function/original/convertMarkdownToHtmlString.tsx
+
#
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
// deno-lint-ignore-file no-explicit-any import fm from 'front-matter'; import { marked } from 'marked'; import sanitize from 'sanitize-html'; import { myMarkedRenderer, mySanitizeOption } from '@function/original/myMarkdownConfig.tsx'; // We'll explain in the next step. type convertMarkdownToHtmlStringRes = { meta: any; content: string; }; export default async function convertMarkdownToHtmlString(postData: string): Promise<convertMarkdownToHtmlStringRes> { const metaData = {}; // Reference: https://github.com/markedjs/marked/blob/master/docs/USING_ADVANCED.md // Reference: https://github.com/markedjs/marked/blob/master/docs/USING_PRO.md const preprocess = (markdown: string) => { const { attributes, body } = fm(markdown); Object.assign(metaData, attributes); return body; }; marked.use({ async: true, breaks: true, pedantic: false, gfm: true, extensions: myMarkedRenderer, hooks: { preprocess }, }); const postMarked = await marked(postData); // Reference: https://github.com/apostrophecms/sanitize-html return { meta: metaData, content: sanitize(postMarked.trim(), mySanitizeOption), }; }
  • 保守性を高めるため、各オプションを別のファイルに記述しています。
  • marked.useのプロパティーについて、extensionsで変換時の処理、hooksで変換前の処理を設定することができます。
  • 文字列.trim()でテキストの先頭と末尾に含まれる空白や改行を除去することができます。
@function/original/myMarkdownConfig.tsx
+
#
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
50:
51:
52:
53:
54:
55:
56:
57:
58:
59:
60:
61:
62:
63:
64:
65:
66:
67:
68:
69:
70:
71:
72:
73:
74:
75:
76:
77:
78:
79:
80:
81:
82:
83:
84:
85:
86:
87:
88:
import { TokenizerAndRendererExtension } from 'marked'; import sanitizeHtml, { IOptions } from 'sanitize-html'; import { unescapeHtml } from '@function/htmlMarkup.ts'; // We'll explain in the next step. export const myMarkedRenderer: TokenizerAndRendererExtension[] = [ { name: 'strong', renderer(token) { return `<b>${token.text}</b>`; }, }, { name: 'em', renderer(token) { return `<i>${token.text}</i>`; }, }, { name: 'del', renderer(token) { return `<s>${token.text}</s>`; }, }, { name: 'code', renderer(token) { return `<div class='content'><pre prop='${token.lang}'><code>${token.text}</code></pre></div>`; }, }, { name: 'listitem', renderer(token) { const order = Number(token.raw.split('.')[0]); if (isNaN(order)) { return `<li>${token.text}</li>`; } else { return `<li value='${order}'>${token.text}</li>`; } }, }, { name: 'tablecell', renderer(token) { const textAlign = token.flags.align; if (token.flags.header) { return `<th class='${textAlign}'>${token.text}</th>`; } else { return `<td class='${textAlign}'>${token.text}</td>`; } }, }, { name: 'image', renderer(token) { const attributes: string[] = []; if (token.title) attributes.push(`title='${token.title}'`); if (token.text.split('')[0] === '?') { const params = new URLSearchParams(unescapeHtml(token.text)); const queryEntries = params.entries(); const queryParamsObject = Object.fromEntries(queryEntries); for (const key in queryParamsObject) attributes.push(`${key}='${queryParamsObject[key]}'`); } return `<img src='${token.href}' ${attributes.join(' ')} />`; }, }, ]; export const mySanitizeOption: IOptions = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['s', 'del', 'ins', 'img']), allowedAttributes: { div: ['class'], span: ['class'], a: ['href'], pre: ['prop'], li: ['value'], table: ['class'], th: ['class', 'colspan', 'rowspan'], td: ['class', 'colspan', 'rowspan'], img: ['src', 'title', 'width', 'height'], }, };
  • Markedの設定については公式の資料をご覧ください。
  • sanitize-htmlの設定については公式の資料をご覧ください。
  • tokenに与えられるコンテンツはエスケープされているため、該当する記号を使用する場合はアンエスケープ:unescapeHtml()する必要があります。
@function/htmlMarkup.ts
+
#
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
const patterns = [ ['<', '&lt;'], ['>', '&gt;'], ['&', '&amp;'], ['"', '&quot;'], ["'", '&#x27;'], ['`', '&#x60;'], ]; const getRegExp = (i: number) => new RegExp(`(${patterns.map((item) => item[i]).join('|')})`, 'g'); const replaceChar = (match: string, before: number, after: number) => { const rec = patterns.find((item) => item[before] === match); return rec ? rec[after] : match; }; export function escapeHtml(text: string): string { return text.replace(getRegExp(0), (match) => replaceChar(match, 0, 1)); } export function unescapeHtml(text: string): string { return text.replace(getRegExp(1), (match) => replaceChar(match, 1, 0)); }
  • new RegExp()で文字列から正規表現を生成することができます。

4. HTML → JSX Element

文字列で出力されたHTMLをJSX Elementに変換するための関数:convertHtmlStringToJsxElement()を作成します。

@function/original/convertHtmlStringToJsxElement.tsx
+
#
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
50:
51:
52:
53:
54:
55:
56:
57:
58:
59:
60:
61:
62:
63:
64:
65:
66:
67:
68:
69:
70:
71:
72:
73:
74:
75:
76:
77:
78:
79:
80:
81:
82:
83:
84:
85:
86:
87:
88:
89:
90:
91:
92:
93:
94:
95:
96:
97:
98:
99:
100:
101:
102:
103:
104:
105:
106:
107:
108:
109:
110:
111:
112:
113:
114:
115:
116:
117:
import { assert } from 'deno-assert'; import { DOMParser, HTMLDocument } from 'deno-dom-wasm'; import { h } from 'preact'; import { JSX } from 'preact/jsx-runtime'; type elementBufferNodeType = { node: Node; element: Element; type: number; name: string; children: elementBufferNodeType[] | null; content: string | null; }; export type rendererType = { tag?: string; id?: string; className?: string; function: ((_node: Node, _element: Element, _content: JSX.Element) => JSX.Element | Promise<JSX.Element>) | null; }; export type convertHtmlStringToJsxElementProps = { renderer: rendererType[] | undefined; }; const parseElementBufferNode = async (item: elementBufferNodeType, renderer?: rendererType[]): Promise<JSX.Element> => { const tag = item.name.toLocaleLowerCase(); const id = item.element.id; const className = item.element.className; const content = await (async (): Promise<JSX.Element> => { const res = []; if (item.children) { for (let i = 0; i < item.children.length; i++) res.push(await parseElementBufferNode(item.children[i], renderer)); } else { res.push(item.content); } return <>{res}</>; })(); if (renderer) { const rec = renderer.find( (renderer) => (!renderer.tag || renderer.tag === tag) && (!renderer.id || renderer.id === id) && (!renderer.className || renderer.className === className), ); if (rec) return rec.function ? await rec.function(item.node, item.element, content) : <>{content}</>; } if (item.type === HTMLDocument.ELEMENT_NODE) { const attributes = item.element.attributes; // deno-lint-ignore no-explicit-any const props: any = {}; for (let i = 0; i < attributes.length; i++) { const attribute = attributes[i]; attribute.value && (props[attribute.name] = attribute.value); } const element = h(tag, props, content); return <>{element}</>; } else if (item.type === HTMLDocument.TEXT_NODE) { return <>{item.content}</>; } else { return <></>; } }; const getElementBufferNode = (nodeList: NodeList): elementBufferNodeType[] => { const res: elementBufferNodeType[] = []; nodeList.forEach((node) => { res.push({ node: node, element: node as unknown as Element, type: node.nodeType, name: node.nodeName, children: node.hasChildNodes() ? getElementBufferNode(node.childNodes) : null, content: node.textContent, }); }); return res; }; const setElementBufferNode = async (elementBufferNode: elementBufferNodeType[], renderer?: rendererType[]): Promise<JSX.Element> => { return await (async (): Promise<JSX.Element> => { const res = []; for (let i = 0; i < elementBufferNode.length; i++) res.push(await parseElementBufferNode(elementBufferNode[i], renderer)); return <>{res}</>; })(); }; const debugElementBufferNode = (elementBufferNode: elementBufferNodeType[] | elementBufferNodeType) => { return JSON.stringify(elementBufferNode, (key, val) => (key === 'node' ? '[Node]' : key === 'element' ? '[Element]' : val), ' '); }; export default async function convertHtmlStringToJsxElement(html: string, props?: convertHtmlStringToJsxElementProps): Promise<JSX.Element> { const doc = new DOMParser().parseFromString(html, 'text/html'); assert(doc); const buf = getElementBufferNode(doc.childNodes as unknown as NodeList); // console.log(debugElementBufferNode(buf)); const res = await setElementBufferNode(buf, props?.renderer); return res; }
  • 非同期(asyncawait)の無名関数を作成する場合はawait (async (): Promise<T> => { ... })();と記述します。
  • JSX Elementの配列を出力する際はjoin()等の正規化を行わないでください。
  • rendererに登録されたオブジェクトについてfunctionが記述されていない(nullの)場合、子の内容を返します。
  • HTMLDocumentに含まれるNodeをElementとして使用する場合は変数 as unknown as Elementでダブルアサーションする必要があります。
@function/original/myMarkdownConfig.tsx
+
#
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
50:
51:
52:
53:
54:
55:
56:
57:
58:
59:
60:
61:
62:
63:
64:
65:
66:
67:
68:
69:
70:
71:
72:
73:
74:
75:
76:
77:
78:
79:
80:
81:
82:
83:
84:
85:
86:
87:
88:
89:
90:
91:
92:
93:
94:
95:
96:
import { JSX } from 'preact'; import Code from '@components/Common/Code.tsx'; // This is the original component. import Link from '@components/Common/Link.tsx'; // This is the original component. import PixelatedImage from '@components/Common/PixelatedImage.tsx'; // This is the original component. import { rendererType } from '@function/original/convertHtmlStringToJsxElement.tsx'; export const myParsedRenderer: rendererType[] = [ { tag: 'html', function: null, }, { tag: 'head', function: null, }, { tag: 'body', function: null, }, { tag: 'a', function: (_node: Node, _element: Element, _content: JSX.Element) => { const href = _element.getAttribute('href') || undefined; return <Link href={href}>{_node.textContent || ''}</Link>; }, }, { tag: 'p', function: (_node: Node, _element: Element, _content: JSX.Element) => { if (_node.hasChildNodes()) { if (_node.childNodes[0].textContent !== '') { return <p>{_content}</p>; } else { return <>{_content}</>; } } else { return <></>; } }, }, { tag: 'pre', function: async (_node: Node, _element: Element, _content: JSX.Element) => { const prop = _element.getAttribute('prop') || undefined; const language = prop && prop.includes(':') ? prop.split(':')[0] : undefined; const title = prop && language ? prop.split(':').slice(1).join('') : prop; const code = _element.textContent || 'Note: This is an empty code!'; const source = code.match(/^\[\]\(((https?:\/\/\S+)|(\/\S+))\)$/); if (source !== null) { const path = (/^https?:\/\/\S+$/.test(source[1]) ? '' : Deno.cwd()) + source[1]; try { const code = (await Deno.readTextFile(path)).trim(); return <Code title={title} language={language} content={code || ''} />; } catch (e) { console.error(e); return <Code title={title} content='Error: Failed to retrieve file!' />; } } else { return <Code title={title} language={language} content={code || ''} />; } }, }, { tag: 'img', function: (_node: Node, _element: Element, _content: JSX.Element) => { const src = _element.getAttribute('src') || undefined; const title = _element.getAttribute('title') || undefined; const width = Number(_element.getAttribute('width')) || undefined; const height = Number(_element.getAttribute('height')) || undefined; if (width || height) { return <PixelatedImage src={src || ''} title={title} caption={title} width={width} height={height} />; } else { if ((_node.parentElement as unknown as Element).getAttribute('class') === 'gallery') { return <img src={src} />; } else { return ( <figure> <img src={src} /> {title && <figcaption>{title}</figcaption>} </figure> ); } } }, }, ];
  • Deno.readTextFile()でサイト内にホストされているデータへアクセスする場合はDeno.cwd() + 絶対パスを指定する必要があります。