Kotlin Script + kotlinx.htmlで.htmlファイルを作成する
環境
- OS: Ubuntu 20.04 LTS
- JDK: Liberica JDK 1.8.0_265
- IDE: IntelliJ IDEA 2020.2.2
- Kotlin: 1.4.10
動機
- kotlinx.htmlを知ってから、今後HTMLを書く時はぜひこれを使って書きたいと思っていた。
- Kotlin/JSについてはチュートリアルどおりにやれば、多少困るところはあるが可能。
- Kotlin/JVMで使うとしたらKtorとかになるので、通常はHTMLファイルを作成することはない。
- そうでない場合でもKotlin/JVMなら、プライベートなテストコードにでも書いておけば生成できるので、Kotlin Scriptの出番はない。
- Kotlin/JSプロジェクトにおいて、HTML内のDOMをJavaScriptで生成するのではなく予め用意しておきたいという想定。しかもIntellJ IDEAの同一プロジェクト上で。
- プライベートなテストコードに書くとするとKotlin/JSになるのでファイルの書き込みの段階でnode.jsに依存するが、ここで本来開発すべきは件のHTMLファイルと共同してブラウザ上で動作するJavaScriptなのだから、node.jsへの依存は回避したい。
- gradleのタスクに書くと、build.gradle.ktsが肥大するので外部化したい。
上記に勘違いが混じっていて、回避可能なことも大いにありそうな気がするがご容赦を。
やってみて躓くことが多かったので、kotlinx.html、Kotlin Scriptのいずれか一方でも使う人に何らかの参考になればと。
kotlinx.htmlライブラリを使う
Kotlin Scriptに書こうとして最初にぶち当たる壁がライブラリのimport。通常の開発で依存関係はbuild.gradle.ktsに記載されているが、build.gradle.ktsも含めてKotlin Scriptの依存関係はそういった方法では解決されない。
kotlinx.htmlのような外部ライブラリを使うには先頭に以下の2行が必要になる。
@file:Repository("https://jcenter.bintray.com")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.1")
ライブラリはJVM用のライブラリになる。
ファイル名に気をつかう
通常の.ktsファイルに上記を記載してもRepository
とDependsOn
が未定義としてエラーになる。上記を使うにはファイル名の拡張子が.ktsでなく.main.ktsでなければならない。
Kotlin 1.3だとエラー
上記ライブラリのimport方法はKotlin 1.3だと
error: org/jetbrains/kotlin/scripting/compiler/plugin/impl/KJvmCompiledModuleInMemory (foo.main.kts): java.lang.NoClassDefFoundError: org/jetbrains/kotlin/scripting/compiler/plugin/impl/KJvmCompiledModuleInMemory
のようなエラーが出る。Kotlin 1.4で修正されているが、1.3にバックポートされる予定はないらしい。
Documentのインスタンス
インスタンスの取得
チュートリアルには、Kotlin/JVMではDocumentのインスタンスは自分で作ればいいというように書かれているが、Documentはインターフェースでコンストラクタが使えない。Ktorなどでは使用する場面で予めDocumentのインスタンスかその子Nodeが与えられるのだが...。
DocumentBuilderFactory
という抽象クラスを使って以下のような回りくどい方法をとらないといけないらしい。
import javax.xml.parsers.DocumentBuilderFactory
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
HTMLソースへの変換
DocumentオブジェクトをHTMLソースに変換するメソッドはインターフェースには規定されておらず、方法を見つけるのに難渋した。
以下のようなとても回りくどい方法をとらないといけないらしい。拡張関数にした。
import java.io.StringWriter
import org.w3c.dom.Node
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
fun Node.toHtmlSource(): String {
val tf: TransformerFactory = TransformerFactory.newInstance()
val trans = tf.newTransformer()
val sw = StringWriter()
trans.transform(DOMSource(this), StreamResult(sw))
return sw.toString()
}
Document.createで作った子要素は自動では追加されない
最初は前述のチュートリアルを意識して
val body = document.create.body {
// BODYを使った処理
}
としていたが、これだと出力が
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
となり、内容が空のままだった。body
をdocument
に追加する操作が必要で
val body = document.create.body {
// BODYを使った処理
}
document.appendChild(body)
とする必要があった。
HTMLでなくXML扱いになる
ところが、これだとtextareaタグが空タグに変換されてしまいブラウザで正しく表示されなかった。HTMLでなくXMLドキュメントとして扱われるらしい。
create.body
ではなくcreate.html
とするとHTMLドキュメントとして扱われる。
val html = document.create.html {
body {
// BODYを使った処理
}
}
document.appendChild(html)
余談: HTMLDocument
org.w3c.dom.html.HTMLDocument
というインターフェースがあるが、標準で定義されているのはインターフェースだけで標準の実装が存在しないらしい。
javax.swing.text.html.HTMLDocument
というクラスがあるが、このクラスが継承しているDocumentはSwing内のDocumentであり、org.w3c.dom.HTMLDocument
ではない。
という訳でいずれも使用しなかった。
Kotlin Script移植時の問題
実は今回Kotlin/JSで既に動いていたコードを移植したのだが、ここでも壁があった。
トップレベルでconstが使えない
トップレベルでconst
を使用するとエラーが出る。素直に使うのをやめた。
トップレベル関数でprivate修飾子が使えない
当然と言えば当然か。素直に使うのをやめた。この2つだけでエラー表示が消えたので、移植性は高いといえる。
トップレベル変数は先に定義しなければならない
文法上のエラーを指摘されなくなったので、実行してみたところNullPointerException
が出た。
原因をたどると、トップレベル関数の定義は順不同で、呼び出し元より後に定義されていても参照できるのだが、トップレベル変数は大元の呼び出し元より前に定義されていないとnull
扱いになることがわかった。
実はImportで移植できる?
@file:Import("ファイル名")
で.ktsだけでなく.ktのファイルも取り込める模様。相対パス可。この際は上記の問題は発生しない。ただし、Import
で取り込んだファイルは.ktsも.ktもコンパイル後キャッシュされるらしく、キャッシュをクリアするまで?変更が反映されないようである。今後仕様が変わるかもしれない。
DOCTYPE宣言
DocumentType
もインスタンスを生成する方法がない。org.w3c.dom.DOMImplementation
を使えば何とかなりそうに見えたが、実際にやってみてもDOCTYPE宣言は追加されなかった。HTMLDOMImplementation
というものがあるらしいが、やはり外部ライブラリなしでインスタンス化する方法がなさそうだった。<!DOCTYPE html>
だけだし、あきらめて素直に手動で追加する道を選んだ。
コードまとめ
ここまで書いたことをまとめると、以下のような感じになる。ちなみにKotlin Scriptではmain()
を定義する必要はない。
@file:Repository("https://jcenter.bintray.com")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.1")
import java.io.File
import java.io.StringWriter
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
import kotlinx.html.*
import kotlinx.html.dom.create
import org.w3c.dom.Node
println("started")
File("sample.html").bufferedWriter().use {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
val html = document.create.html {
head {
title { +"This is sample.html" }
}
body {
p { +"Hello, world!" }
}
}
document.appendChild(html)
it.write("<!DOCTYPE html>\n")
it.write(document.toHtmlSource())
}
fun Node.toHtmlSource(): String {
val tf: TransformerFactory = TransformerFactory.newInstance()
val trans = tf.newTransformer()
val sw = StringWriter()
trans.transform(DOMSource(this), StreamResult(sw))
return sw.toString()
}
println("finished")
動作確認
IntelliJ IDEAでは右クリックから"Run"を選べば実行される。Koltin Scriptは"Script"といいながらコンパイルされて実行される(KEEPではCompilerとEvaluatorに分かれており、"interpreter"という単語は出てこない)からか、上記のprintln("started")
が実行されるまでに2020年時点のハイエンドCPUでも数秒を要した。ちなみにスティック型PCレベルの低性能マシンで試すと、実行開始までに40秒以上かかった。実行が始まってからは遅いという印象はなかった。
2回目以降の実行でネットワークを切断しても動作したことから、ライブラリはどこかにキャッシュされていると考えられ、毎回ダウンロードされることはない模様。
出力
<!DOCTYPE html>
<html>
<head>
<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>This is sample.html</title>
</head>
<body>
<p>Hello, world!</p>
</body>
</html>
<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
は、head要素が存在する時に、document.toHtmlSource()
の段階で勝手に挿入されるようだが、除外方法がわからなかった。
考察
ここまでしておいて言うのもなんだが、HTMLを書く際に選択肢列挙のために定義したListを本体のKoltin/JSでも使用することがあり、そういった定義を共有するためにはHTMLファイルを予め生成しておくよりJavaScriptで初期化する時に生成する方がいいのではと思った。
kotlinx.htmlは今後も使ってみたいと思っているが、CSSやBootstrapなどとの組み合わせをするような複雑な事例を考えると、IntelliJのコード補完などには期待出来ないのかもしれない。しかし、HTMLの内部で繰り返しや計算を記述することは出来ないが、kotlin.htmlを使えば容易に実現できるという利点には変わりがない。
また、KotlinのDSLを使って、サーバーサイドとフロントエンドのJavaScript部分だけでなくHTML部分までKotlinで統一できるのは大きな魅力ではないかと考えている。
おまけ
kotlinx.htmlは使っている人をあまり見かけないので、Kotlin/JSでDOMを構築した方法を記しておく。
import kotlinx.browser.document
import kotlinx.html.dom.create
import kotlinx.html.js.body
import org.w3c.dom.*
fun main() {
val bodyHTML = document.create.body {
// BODYを使った処理
}.innerHTML
document.body!!.innerHTML = bodyHTML
}
このmain()
を呼び出すHTMLがdocumentとして事前に存在するので、document.appendChild()
を使うわけにはいかず、innerHTML
を介するより簡単な方法を思いつかなかった。
改訂履歴(表現の修正を除く)
2020/10/2: 初版
2020/10/3: 実行前コンパイルだと思う理由、ヘッダー、キャッシュの挙動について追加