Kotlin Script + kotlinx.htmlで.htmlファイルを作成する

# 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](https://github.com/Kotlin/kotlinx.html)を知ってから、今後HTMLを書く時はぜひこれを使って書きたいと思っていた。
* Kotlin/JSについては[チュートリアル](https://github.com/kotlin/kotlinx.html/wiki/DOM-trees)どおりにやれば、多少困るところはあるが可能。
* Kotlin/JVMで使うとしたら[Ktor](https://ktor.io/)とかになるので、通常は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行が必要](https://github.com/Kotlin/KEEP/blob/master/proposals/scripting-support.md#kotlin-main-kts)になる。

```
@file:Repository("https://jcenter.bintray.com")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.1")
```

ライブラリはJVM用のライブラリになる。

### ファイル名に気をつかう
通常の.ktsファイルに上記を記載しても`Repository`と`DependsOn`が未定義としてエラーになる。上記を使うにはファイル名の拡張子が.ktsでなく[.main.ktsでなければならない](https://youtrack.jetbrains.com/issue/KT-27853)。

### 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で修正](https://youtrack.jetbrains.com/issue/KT-37765)されているが、1.3にバックポートされる予定はないらしい。

## Documentのインスタンス
### インスタンスの取得
[チュートリアル](https://github.com/kotlin/kotlinx.html/wiki/DOM-trees)には、Kotlin/JVMではDocumentのインスタンスは自分で作ればいいというように書かれているが、Documentはインターフェースでコンストラクタが使えない。Ktorなどでは使用する場面で予めDocumentのインスタンスかその子Nodeが与えられるのだが...。
[`DocumentBuilderFactory`という抽象クラス](https://docs.oracle.com/javase/jp/8/docs/api/javax/xml/parsers/DocumentBuilderFactory.html)を使って以下のような~~回りくどい~~方法をとらないといけないらしい。

```kotlin
import javax.xml.parsers.DocumentBuilderFactory
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
```

### HTMLソースへの変換
DocumentオブジェクトをHTMLソースに変換するメソッドはインターフェースには規定されておらず、方法を見つけるのに難渋した。

[以下のような~~とても回りくどい~~方法](https://stackoverflow.com/questions/10356258/how-do-i-convert-a-org-w3c-dom-document-object-to-a-string)をとらないといけないらしい。拡張関数にした。

```kotlin
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で作った子要素は自動では追加されない
最初は前述のチュートリアルを意識して

```kotlin
val body = document.create.body {
    // BODYを使った処理
}
```

としていたが、これだと出力が

```
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
```

となり、内容が空のままだった。`body`を`document`に追加する操作が必要で

```kotlin
val body = document.create.body {
    // BODYを使った処理
}
document.appendChild(body)
```

とする必要があった。

### HTMLでなくXML扱いになる
ところが、これだとtextareaタグが空タグ<textarea />に変換されてしまいブラウザで正しく表示されなかった。HTMLでなくXMLドキュメントとして扱われるらしい。
`create.body`ではなく`create.html`とするとHTMLドキュメントとして扱われる。

```kotlin
val html = document.create.html {
    body {
        // BODYを使った処理
    }
}
document.appendChild(html)
```

### 余談: HTMLDocument
`org.w3c.dom.html.HTMLDocument`というインターフェースがあるが、標準で定義されているのはインターフェースだけで[標準の実装が存在しない](https://stackoverflow.com/questions/29041855/how-can-i-build-an-html-org-w3c-dom-document)らしい。

[`javax.swing.text.html.HTMLDocument`というクラス](https://docs.oracle.com/javase/jp/8/docs/api/javax/swing/text/html/HTMLDocument.html)があるが、このクラスが継承している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`もインスタンスを生成する方法がない](https://docs.oracle.com/javase/jp/8/docs/api/org/w3c/dom/DocumentType.html)。[`org.w3c.dom.DOMImplementation`を使えば何とかなりそう](https://www.techscore.com/tech/Java/JavaSE/DOM/6-2/)に見えたが、実際にやってみてもDOCTYPE宣言は追加されなかった。`HTMLDOMImplementation`というものがあるらしいが、やはり[外部ライブラリ](https://xerces.apache.org/xerces-j/apiDocs/org/w3c/dom/html/class-use/HTMLDOMImplementation.html)なしでインスタンス化する方法がなさそうだった。`<!DOCTYPE html>`だけだし、あきらめて素直に手動で追加する道を選んだ。

## コードまとめ
ここまで書いたことをまとめると、以下のような感じになる。ちなみにKotlin Scriptでは`main()`を定義する必要はない。

```kotlin
@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"を選べば実行される。Kotlin Scriptは"Script"といいながらコンパイルされて実行される([KEEP](https://github.com/Kotlin/KEEP/blob/master/proposals/scripting-support.md#components)ではCompilerとEvaluatorに分かれており、"interpreter"という単語は出てこない)からか、上記の`println("started")`が実行されるまでに2020年時点のハイエンドCPUでも数秒を要した。ちなみにスティック型PCレベルの低性能マシンで試すと、実行開始までに40秒以上かかった。実行が始まってからは遅いという印象はなかった。

2回目以降の実行でネットワークを切断しても動作したことから、ライブラリはどこかにキャッシュされていると考えられ、毎回ダウンロードされることはない模様。

### 出力

```html
<!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を本体のKotlin/JSでも使用することがあり、そういった定義を共有するためにはHTMLファイルを予め生成しておくよりJavaScriptで初期化する時に生成する方がいいのではと思った。

kotlinx.htmlは今後も使ってみたいと思っているが、CSSやBootstrapなどとの組み合わせをするような複雑な事例を考えると、IntelliJのコード補完などには期待出来ないのかもしれない。しかし、HTMLの内部で繰り返しや計算を記述することは出来ないが、kotlin.htmlを使えば容易に実現できるという利点には変わりがない。

また、KotlinのDSLを使って、サーバーサイドとフロントエンドのJavaScript部分だけでなくHTML部分までKotlinで統一できるのは大きな魅力ではないかと考えている。

## おまけ
kotlinx.htmlは使っている人をあまり見かけないので、Kotlin/JSでDOMを構築した方法を記しておく。

```kotlin
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: 実行前コンパイルだと思う理由、ヘッダー、キャッシュの挙動について追加