XMLをJSX (TSX)で文字列を直接使わずに安全に生成するための技術
やりたいこと
JSX (TSX) を使って、sitemap.xmlのXMLを生成したい。
ここで書くことはSitemapに限らずSVGなど他のXMLでも利用できるはず。
JSXを使う理由は、
XMLを文字列を足し算やinterpolationで生成することもできるが静的に検査しづらいから。
Array.prototype.map()などのコンビネータが使える。
今回のsitemap.xmlを生成するためにクエリパラメタ名もハードコーディングせずにimportしたい。
TypeScriptでJSXを書いてしっかりと型をつけたい。つまりTSXを使いたい。
サーバーサイドでもクライアントサイドでも利用可能か?
Webpackでのビルド時にsitemap.xmlが決定して作られるため、サーバーサイドJavaScript(Node.js 12.xの初のLTSのバージョンのマージプルリクエスト)でReactを使ってJSX (TSX) を使うことになる。だがReactはブラウザで動くためこのJSX(TSX)を使ってXMLを生成する技術はブラウザ側でも利用可能。
なぜJSXを使うのか?
JSXはHTMLリテラルが使えるJavaScriptだと思っている。
上記に「Array.prototype.map()などのコンビネータが使える。」と書いたが、JSXはHTMLすらも式であり値になっている世界だと考えられる。1や-84393431が数値リテラルで"hello, world"や"GET / HTTP/1.1"が文字列リテラルであるように、<h1></h1>や<br />が式として値として使える。
つまり今まで培ってきた式や値に関する知識をHTMLに対しても使うことができる。つまりHTMLを受け取る関数やHTMLを返す関数、HTMLを配列に詰め込むこともできる。文字列のHTMLを頑張って足し算したりするよりバグが入り込みづらく安心できる。
最終目的のsitemap.xml
今回はsitemap.xmlだが任意のXMLに利用できる方法のはず。
code:sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" data-reactroot="">
<url>
<loc>https://piping-ui.org/</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://piping-ui.org/?lang=en"></xhtml:link>
<xhtml:link rel="alternate" hreflang="ja" href="https://piping-ui.org/?lang=ja"></xhtml:link>
</url>
</urlset>
href=に含まれる?lang=というパラメタ名は他のファイルからimportしていてハードコーディングされていない。
<xhtml:link ...>は今回は"en"か"ja"かの違いだけでここは.map()が使われる。
今回はSitemapの生成だがSVGでもなんでもXMLならこの技術が使えるはず。
TSX (JSX)でXMLを書く技術
まずtsconfig.jsonのcompilerOptionsは"module": "commonjs",と "jsx": "react"を指定した。
追記: 後に"module": "commonjs"を指定するとDynamic importをしていてもCode splittingが効かなくなるので、ts-nodeのオプションで"module": "commonjs"に設定したtsconfig.jsonを読み込むように変更した。
現象:
実際の変更:
以下のようにReactをdevDependenciesとしてインストールする。
クライアントサイドで動的にXMLを生成するなら-Dは-S。
code:console
$ npm i -D react react-dom @types/react @types/react-dom
以下が必要なimport。
ReactはReact.createElement()するために必要。
renderToStringはReactElement型を受け取って文字列としてXMLを吐き出してくれる関数。
code:tsx
import * as React from 'react';
import {ReactElement} from "react";
import {renderToString} from 'react-dom/server';
次が重要。以下のdeclareが必要になる。
今回のXMLでは<urlset>や<url>などのタグを使うが以下がないとコンパイルエラーをする。
調べるとdeclare global {}で囲まない出てくるがそれでは上手くいかないので以下のようにした。
参考: TSX: Property does not exist on type 'JSX.IntrinsicElements' · Issue #15449 · microsoft/TypeScript
code:tsx
declare global {
namespace JSX {
interface IntrinsicElements {
elemName: string: unknown;
}
}
}
JSXで使用できない文字がタグに含まれている場合
<xhtml:link>など:が含まれると直接JSXで書くことができない。他にもJSXでは使用できない文字がタグに含まれている場合は以下の方法が使える。
以下のように関数を定義する。
code:tsx
const XhtmlLink = ({ children, ...props }: any) => {
return React.createElement("xhtml:link", props, children);
};
以下が使用例。
code:ts
<XhtmlLink rel="alternate" hreflang="en" href="https://example.com/?hl=en" />
参考: Using React for XML & SVG - ITNEXT
JSXで使用できない属性が含まれている場合
以下の<urlset>のように{...{"xmlns:xhtml": "http://www.w3.org/1999/xhtml"}}を使うことができる。ほぼイディオムかすると思われる。
code:typescript
const sitemapElement = <urlset {...{"xmlns:xhtml": "http://www.w3.org/1999/xhtml"}}></urlset>;
JSX (TSX)を文字列として書き出す
JSX(ReactElement型)を最終的に文字列として出力したくなる。
その時にはimport {renderToString} from 'react-dom/server';のrenderToStringを使えば良い。
以下がシンプルな使用例。
code:tsx
renderToString(<h1>hello, world</h1>);
実際はXMLにしたかったので<?xml version...>を上部につけるために以下の関数を定義して内部でrenderTtoString()を呼び出すようにしている。ここは局所的なので文字列の足し算を使っている。
code:tsx
function renderXmlToString(xml: ReactElement): string {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + renderToString(xml);
}
npm scriptでこのシングルファイルのスクリプトをts-nodeを使って動かしている。
import constants from '../src/constants';のようにすればsrc/で定義されているTypeScriptのファイルもインポートできる。
実際のコミット
以下が実際にこのsitemap.xmlを生成するためのコミット。より詳細を確認できるはず。
リンク先のscripts/generate-sitemap.tsxがTSXを使ってsitemap.xmlを生成するためプログラム。