anti scroll

ブラウザと小説の新しい関係を模索する

TypeNovelにおいて、注釈しなくても良い制約を明示的に宣言できるようになりました

どういうものか

次のように、制約の値を"?"で始まる値にすると、その制約は本文中で注釈されなくてもエラーになりません。

@scene({
  time:"?" // 注釈する必要のないtime制約
}){
  本文中でtimeの注釈をしなくてもエラーにならない!
}

どうして必要なのか

注釈しなくても良い制約なら、そもそも宣言しなければよいのでは? と思われるかもしれませんが、敢えて宣言したい場合があります。

例えば「時刻が"?"のとき」は「不明瞭な時刻のようなもの」を連想させるために、ビューアーのテーマをグレースケールにさせたい、とか。

つまり「時刻が不明瞭である」という情報を、HTMLを扱うアプリケーション側が欲しがる場合です。

例えば上の例をコンパイルすると、@sceneブロックは次のようなHTMLにコンパイルされます。

<scene data-time="?">
  本文中でtimeの注釈をしなくてもエラーにならない!
</scene>

このように、HTMLにdata-time="?"という形で「時刻が不明瞭ですよ」という情報が盛り込まれるわけです。

というわけで今回、新しい仕様を付け足すことにしました。

これにより、制約値が不明瞭であることをHTMLに反映しつつ、TypeNovel側の本文では注釈しなくてもエラーが出ないようになりました。

v0.9.1から有効になりますので、興味のある方は最新バージョン(すでにダウンロードできるようになっています)をご利用下さい。

TypeNovelで出力したhtmlを縦書き文庫のビューアーで開く方法

最初に

Windows用とLinux用のexeのzipができましたので、ご利用下さい。

まずは適当な文章を書く

適当にこんな感じの文章を書いたとします。

@scene({season:"summer"}){
  @scene({time:"morning"}){
    遅刻したので、$time("朝食")はたべません!
  }
  @scene({time:"noon"}){
    $time("ランチでも")いかが?
  }
  @scene({time:"night"}){
    $time("日も暮れたし")もうそろそろ帰らない?
  }
}

リリースモードでコンパイル

これをリリースモードコンパイルします(リリースモードは、途中の余計な空白を取り除くモードです。出力結果がコンパクトになります)。

[foo@localhost] tnc --release sample.tn > sample.html

コンパイルのexeがMacでは小文字のtncですが、Windowsでは大文字のTnc.exeだと思います。各自ここは書き換えて使って下さい。

できあがったsample.htmlを見ると、こんな感じになっています。

/Users/u1/Downloads/sample.tn(line:1) 'season' is not annotated in this block!
error count = 1

<scene data-season='summer'><scene data-time='morning'>遅刻したので、<time>朝食</time>はたべません!</scene><scene data-time='noon'><time>ランチでも</time>いかが?</scene><scene data-time='night'><time>日も暮れたし</time>もうそろそろ帰らない?</scene></scene>

冒頭に「season制約(summer)を注釈してないよ!」というエラーが出ていますが、ここでは気にしないことにします。

縦書き文庫のビューアーにドラッグ・アンド・ドロップ

とりあえずhtmlファイルができたので、縦書き文庫に行って、適当な作品を開きます。

開いたら、作品の本文部分にsample.htmlをドラッグ・アンド・ドロップしてみましょう。するとダイアログが開くので、一般には次のように設定して「読み込む」を押します。

f:id:convertical:20190709172558p:plain
確認ダイアログ

すると次のように内容が表示されます。

f:id:convertical:20190709181258p:plain
表示結果(おかしい)

しかしちょっと変だということに気付いたでしょうか。冒頭のエラーのことではありません。別々の@sceneで分けたブロックが、すべてつながって一行の中にまとめられてしまっているのが問題です。

なぜこんなことになるかというと「本来HTMLに<scene>なんていうタグはないから」ということになります。

そして、一般に登録されていないタグは、インライン・タグと認識されるのがお約束です。

つまり<scene><b>とか<span>とかと同じようなタグと認識されたので、全ての文章が一行につながってしまったわけです。

設定ファイルを作成する

これをどうにかするために、まずは次のコマンドを打って、tnconfig.jsonを作成します。

[foo@localhost] tnc --init

上のコマンドをうつと、作業ディレクトリにtnconfig.jsonというファイルができるはずです。

さっそくこのtnconfig.jsonをエディタで開いてみると、途中に@sceneタグを<scene>ではなく<div>タグにマッピングする処理が書かれています。次がそれに該当する箇所です。

{
  "markupMap":{
    (中略)
    "@scene":{
      "tagName": "div",
      "className": "<name> <arg2>"
    },
    (中略)
  }
}

上の抜粋した部分では、初期状態のtnconfig.json@scene<div class='scene'>に変える、と書いてあります。

さて、tncは作業ディレクトリにtnconfig.jsonがあれば、それを設定ファイルとして利用する仕様なので、このファイルがある状態でコンパイルし直したら、@scene<div class="scene">として出力されそうです。

もういっかい先程と同じようにリリースモードでビルドし、出来上がったhtmlを見てみると、今度は次のようになっています。

/Users/u1/Downloads/sample.tn(line:1) 'season' is not annotated in this block!
error count = 1

<div class='scene' data-season='summer'><div class='scene' data-time='morning'>遅刻したので、<time>朝食</time>はたべません!</div><div class='scene' data-time='noon'><time>ランチでも</time>いかが?</div><div class='scene' data-time='night'><time>日も暮れたし</time>もうそろそろ帰らない?</div></div>

今度は<scene>タグではなく、<div>タグで出力されていまるのがわかりますでしょうか。<div>というのはもちろん通常のHTMLに用意されているタグで、ブロックタグを表すタグですから、今度は別々のシーンが別々の段落になっているはずです。

ではもう一度、同じファイルを縦書き文庫のビューアーの本文部分にドラッグ・アンド・ドロップしてみると、、、

f:id:convertical:20190709181337p:plain
表示結果(正常)

やりました! だいたい望み通りの出力です。

というわけで、こうやって縦書き文庫を利用することで、一応は出力結果を本のようにして読むことが可能になっています。

ちなみに「縦書きは嫌だ!」とか「文字が小さい!」とか不満がある方は「表示設定」というボタンから変更できます。

いずれ専用のビューアーは別に作るつもりですが、しばらくはこれでご容赦いただきたく。

Paket(.NETのパッケージマネージャー)とFAKE(F#のMake)について

調べてみたのですが、日本語の文献がほとんどなかったので、ここに記しておきます。

間違いなどありましたら、指摘していただけますと幸いです。

予備知識

PaketFAKEの説明の前に、dotnetの予備知識などを少々。

管理単位「ソリューション」と「プロジェクト」

プロジェクトは個々に別々のプログラムを指します。例えばアプリケーション本体とかテストプログラムとか。

ソリューションは、プロジェクトをまとめた単位です。

monoアプリケーションとその実行について

dotnetでは色々な*.exeを必要とすることが多いのですけど、*.exeの形式になってるのは大抵monoのアプリケーションです。

そういうプログラムは(少なくともMacでは)単体での実行ができないので(Windowsではどうか知らない)、次のようにmonoコマンドを経由する必要があります。

mono some_app.exe

Paketのインストール

ちょっと紛らわしいですが、Packetではなく、Paketです(間の「c」がいらない)。

グローバルにインストールするなら

dotnet tool install --global Paket --version 5.215.0

とします。

しかし通常はソースをcloneした人が個別にそんなことをしなくてもいいように、paket.bootstrapper.exeというものを提供する方式が一般的なようです。

その場合どうやって運用するかというと、

  1. ソリューションのルートディレクトリに.paketというディレクトリを作る。
  2. .paketの中に、公式が配布しているpaket.bootstrapper.exeというファイルをpaket.exeという名前で保存する(後述するMagic Mode)。
  3. で、mono .paket/paket.exe initとすると、Paketを実行するために必要なものが用意される。

Magic Modeについて

objectxさんに教わったのですが、.paketディレクトリにpaket.bootstrapper.exepaket.exeという名前で置いて、それをあたかも本物のpaket.exeのように使用するのはMagic Modeと呼ばれる使い方です。

Magic Modepaket.exe(実体はpaket.bootstrapper.exeで70kbyteぐらいしかない)は、初めて起動したときに本体のpaket.exe(8Mbyteもある)をネット越しにとってきて、dotnetの一時ディレクトリに展開します。

で、それ以降はpaket.exe(実際はpaket.bootstrapper.exe)に対するコマンドは、その本体に対して送られるようになる、ということみたいです。

本体ではないのに、本体のように使えるからMagic Modeということなんだと思います。

なんでそんな事をするかというと、

  1. 本体のpaket.exeは頻繁に更新されるし、サイズもでかいのでリポジトリに入れるのはしんどい。
  2. めったに更新されないpaket.bootstrapper.exepaket.exeとしてリポジトリに入れておき、それを経由してpaket.exe(本体)を実行する仕組みにすれば、paket.exe(本体)の更新に強くなる。

といった理由だと思います。

ちなみにPaketのドキュメントでpaket.exeと記述されているときは、それがMagic Modepaket.exe(つまり本当はpaket.bootstrapper.exe)のことを指すのか、本体のpaket.exeなのかを、文脈で判断する必要があって、ちょっとややこしいです。

Paketの依存性管理ファイル群

色々な管理ファイルがありますが、先に説明したソリューションプロジェクトという単位を知らないと混乱します。

paket.dependencies

paket.dependenciesはソリューション単位の依存関係を記述するファイルです。ソリューションはプロジェクトの集合でしたから、つまりは全プロジェクトを横断して必要なパッケージを記述するファイルになります。

このファイルでの依存性の書き方はThe packet.dependencies fileを参照して下さい。

ちなみに自分の場合は、objectxさんに全ておまかせしてしまいました(笑)。

paket.references

paket.referencesはプロジェクト単位の依存性を記述するファイルです。当該プロジェクトで必要とする依存性だけを記述します。

先のpaket.dependenciesとは違い、単にパッケージ名を羅列するだけで良いみたいです。

例えばTypeNovelの場合、コンパイラ本体のプログラム(プロジェクト名はTnc)のpaket.referencesはこんな感じです。

FSharp.Core
Argu

で、コンパイラが参照するTypeNovel用のライブラリ(プロジェクト名はLib)だとこうなっています。

FSharp.Core
FSharp.Data
FsLexYacc

paket.lock

これは後述するコマンド(packet updateやpacket restoreなど)を利用して依存性を更新すると、自動的に作られるファイルです。

paket.dependenciesに記述したすべてのパッケージが間接的に必要とするものまで含めて、すべての依存性がこのファイルに記述されます。

gitなどでは、依存性をはっきりと固定させるために、commitの対象にすることが推奨されています(参考:Why should I commit the lock file?)。

.paket/Paket.Restore.targets

paket.lockと同様に、パッケージの更新やインストールによって、自動で更新されるファイルです。

.paket/Paket.Restore.targetsは、*.fsproj(各プロジェクトのルートディレクトリにあるプロジェクト構成ファイル)にて、次のように取り込まれます。

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.2</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="Program.fs" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Lib\Lib.fsproj" />
  </ItemGroup>
  <Import Project="..\.paket\Paket.Restore.targets" />
</Project>

最終行の<Import Project="..\.paket\Paket.Restore.targets" />というタグがそれです。

このタグによって、使用しているパッケージへの参照が通ります。

パッケージの更新処理など

グローバルにすでにpaketがインストールされてるならpaket [コマンド名]でいいのですが、そうじゃない場合は、先に説明したMagic Modeでの実行、つまりmono .paket/paket.exe [コマンド名]として下さい。以降は記述を簡潔にするため、グローバルにインストールされている体裁で説明します。

色々とコマンドがあるのですが、ようするにpaket.lockというファイルの扱いがそれぞれ異なるからコマンド名が分かれている、と考えればわかりやすいです。

paket outdated

新しいバージョンが用意されているパッケージの一覧を表示してくれます。

一覧するだけなので、paket.lockファイルには影響がありません。

paket install

paket.lockの内容に従い依存関係を調べ、必要なパッケージをインストールしてくれます。

ただしpaket.dependenciesに変更があった場合は、先にpaket.lockの更新処理が入ります。

paket update

現在の依存関係を更新して、必要ならば新しいパッケージをインストールします。

paket updateとするとすべての依存関係、paket update [パッケージID]とすると、指定したパッケージだけが更新されます。

このコマンドを走らせると、まず最初にpaket.lockファイルが削除されて、一から作り直されます。

で、この新しいpaket.lockを元に(必要ならば)新しいパッケージがインストールされます。

先のpaket installとは違い、paket.lockを作り直してからインストールすることに注意して下さい。

paket restore

このコマンドを走らせると、paket.lockファイルが更新されます。ただし更新されるだけで、インストールまではされません。

つまりpaket update = paket restore + paket installです。

ちなみになにも引数を与えないとpaket.lockに対しての更新になりますが、 次のように--references-fileオプションで各プロジェクトのpaket.referencesを指定することもできます。

paket restore --references-file App/paket.references
paket install

FAKEについて

FAKEはF#のMakeです。ドメイン固有言語で記述するということになっていますが、実体は普通のF#コードです。

概念についても通常のMakeと同じで、それぞれの生成物(Target)が何に依存し、何を実行して作られるのか、みたいなことを記述します。

ちなみにfake-cliがインストールされてなかったら、勝手にダウンロードして実行、みたいなことまでしてくれるみたいです。

インストール

グローバルにインストールするなら

dotnet tool install fake-cli -g

そして、ディレクトリを指定するなら

dotnet tool install fake-cli --tool-path /path/to/tool

とやります。

ちなみにdotnet tool installのグローバルインストール先は、初期設定では$HOME/.dotnet/tools/です。

テンプレート作成

Fakefileをその都度つくるのはしんどいので、テンプレートから雛形を作ることができます。

まずはテンプレート一覧みたいなのを次のコマンドで取得します。

dotnet new -i "fake-template::*"

で、テンプレートの作成は次のようにします。

dotnet new fake

すると、カレントディレクトリに.fakeディレクトリ、build.fsxfake.cmdfake.shが作成されます。

fake.cmdWindows用のビルドスクリプトで、fake.shMac/Linux用です。

で、もっぱらMake処理を書く先はbuild.fsxで、中身は普通のF#コードです。こんな感じになっています。

#load ".fake/build.fsx/intellisense.fsx"
open Fake.Core
open Fake.DotNet
open Fake.IO
open Fake.IO.FileSystemOperators
open Fake.IO.Globbing.Operators
open Fake.Core.TargetOperators

Target.create "Clean" (fun _ ->
    !! "src/**/bin"
    ++ "src/**/obj"
    |> Shell.cleanDirs 
)

Target.create "Build" (fun _ ->
    !! "src/**/*.*proj"
    |> Seq.iter (DotNet.build id)
)

Target.create "All" ignore

"Clean"
  ==> "Build"
  ==> "All"

Target.runOrDefault "All"

Targetの作り方

Target.create関数を使います。

Target.create "Build" (fun _ ->
  Trace.log "--- Building the app ---"
  DotNet.build (Path.Combine ("src", "demo"))
)

https://gist.github.com/KarandikarMihir/7776c887cd73364c5cfc279f8c270934#file-build-fsx

DotNet.buildはFAKEが提供するビルド用のモジュールで、ようするにdotnet buildというコマンドラインの処理をしてくれます。

依存関係の書き方

open Fake.Core.TargetOperators

"Clean" ==> "Restore" ==> "Build"

https://gist.github.com/KarandikarMihir/1b6903ef77655ed65bd2f6c6c1cd618f#file-build-fsx

上の==>とかは、Fake.Core.TargetOperatorsからインポートされたものです。

で、上のコードの意味するところはBuildしたかったらRestoreしろ、RestoreしたかったらCleanしろ、ということです。

実行される順番を記述したもの、と考えてもいいかもしれませんね。

BuildRestoreに依存し、RestoreCleanに依存している、ということだから、makeで書くとこんな感じでしょうか。

build: restore
       @echo "do build"

restore: clean
       @echo "do restore"

clean:
       @echo "do clean"

FakeのOperators

Fakeには記述を読みやすくするための二項演算子がたくさん定義されています。

見ておいたほうがよさそうなのは、

です。

例えばGlobbingOperatorは検索するファイルの候補のリストを検索して作成するものなんですが、こんなイメージ。

!! x // [x]
++ y // [x; y]
-- y // [x]

!!は引数をIncludeのただ一つの候補として初期化。

++はそのIncludeの候補に追加。

--は削除です。

次にTargetOperatorsは、Makeの依存関係を書くための二項演算子を定義しているのですが、

"Clean" ?=> "Build"

というのは、Buildの前にCleanを実行するけど、Cleanが実行できなくてもBuildを実行しちゃってもいいよ、という緩やかな依存関係を意味します。一方で、

"Clean" ==> "All"

は、Allの処理をするまえにCleanは絶対に実行しなくちゃ駄目だよ、という厳密な依存関係です。

ビルドの仕方

winならfake.cmd buildで、Linxu/Macならfake.sh buildです。

最後に

なんていうか、知らない間にすごい洗練されていたんだなあと実感しました。

プロとアマの小説の特徴を数値化して比較してみたらやっぱり差があったので、それを埋めるための型付き小説記述用言語 TypeNovel を公開した件について

ラノベのタイトルみたいな記事を書く、という夢が叶いました。

github.com

開発に至った動機

以前から、アマチュアの小説はプロに比べると、描写不足な傾向があるのかもしれない、と思っていました。

特に不足がちだと感じるのは「時間」に関する描写です。

季節がわからなかったり、昼か夜か、平日か休日かみたいなことが不明瞭な作品が多い気がします。

しかし印象だけで語ってもアレなので、実際に差があるのかどうかを計測してみました。

計算式は、

時間描写の文の数 * 時間描写分布のエントロピー / 文の数

です。

「時間描写分布のエントロピー」というのは「全体を通じて、どれだけ満遍なく時間表現が書かれているか」という数字だと思ってください。

例えば時間描写が冒頭部にしかなかったりすると数値が小さくなり、全編を通じて満遍なく描写されていると、数値が大きくなります。

あと時間描写というのは、一応「季節、曜日、朝昼晩、平日・休日」みたいなことがわかる表現のことを指しています(例:初春、休日、早朝、深夜、夏休み、など)

例として、以下に夏目漱石「門」の計測結果を示します。

histgram: [30, 24, 33, 21, 29, 29, 30, 24, 24, 17]
score: 0.221913(sentence_size = 3879, total = 261, entropy = 3.298082)

histgramの部分は、全編を10分割して、それぞれの区間の時間表現の個数を記したものです。

夏目漱石の「門」は、わりと満遍なく描写されているのがわかると思います。

時間表現の個数は、形態素解析した名詞や形容詞の組み合わせから、時間表現か否かを判定するスクリプトを作って計測しました。

中身は「春」とか「夏」とか、「暗い」+「空」とか、そういう時間表現っぽい文言をいっぱい定義して、どれかに合致するかどうかを判定しただけのものです。

一応ソースです。

https://github.com/tategakibunko/time-heatmap

さて、実際に上記の計算式で計算すると、拙作サイトである縦書き文庫にてランダムに選んだアマチュアさんの平均スコアは、だいたい0.1に届かないことがわかりました。

user1: average score:0.055084
user2: average score:0.057143
user3: average score:0.020505
user4: average score:0.071971
user5: average score:0.045247
user6: average score:0.042603
user7: average score:0.086316
user8: average score:0.051926
user9: average score:0.089533

しかしプロ(文豪)の平均スコアを計算すると、だいたい0.1を超えています。

pro1: average score:0.120076, アーサー・コナン・ドイル
pro2: average score:0.210235, チェーホフ
pro3: average score:0.124371, ジェイムズ・ジョイス
pro4: average score:0.101132, ドストエフスキー
pro5: average score:0.138546, 野村胡堂
pro6: average score:0.153463, 森鴎外
pro7: average score:0.148902, クリスチャン・アンデルセン
pro8: average score:0.110636, 夏目漱石
pro9: average score:0.118976, 中島敦

重要なのは、これが「平均値」である、という点です。

個々の作品では0.1を超える作品を書いているユーザーさんもいるのですが「全作品の平均」を取ると、だいたいみんなスコアが低くなります。

上に挙げた文豪さんたちは、一般的にアマチュアさんよりも多い数の小説を出しているので、平均をとったら不利になりそうなものですが、実際はより作品数の少ないアマチュアさんよりもスコアが高くなっています。

以上の結果から、どうやらアマとプロで、少なからぬ差がありそうな雰囲気は確認できました。

というわけで、あとはこうした差を埋めるためのツールを作るだけ…ということで開発したのが、表題の型付きの小説記述用言語「TypeNovel」です。

型付き小説記述用言語「TypeNovel」とは

まず小説における「型」とはなんでしょうか?

自分は「制約(constraint)」と「注釈(annotation)」の組み合わせである、と定義しました。

小説の各シーンに「制約」を与え、作者はその制約を本文で「注釈」する。しなければ型エラー。そういう塩梅です。

見てもらったほうが早いかもしれません。

@scene({
  season:"春",
  time:"午前"
}){
  $time("朝の通勤時間")、電車の窓から、隅田川の$season("桜")が見えた。
}

上の例では、シーンが満たす「制約」として

  1. 季節(season)は「春」である。
  2. 時刻(time)は「午前」である。

と設定しています。

なので本文では、この制約を明示的に満たすように注釈(annotation)することが求められます(しないとコンパイル時にエラー)。

例えば$time("朝の通学時間")という箇所は、time制約の「午前」を「朝の通学時間」と描写することで満たそうとしています。

同様に$season("桜")も、season制約の「春」を「」と描写することで満たそうとしています。

こういう風に「各シーンに課した制約を注釈しないとコンパイルエラー」とすることで、書き手は自分が何を書かなければならないのかについて、常に自覚的になることを強制されます。

ちなみに制約は別に時間に限らず、何を設定するのも作者の自由です。例えばplace:"学校", person:"花子"とか。シーンの設計図を事前に自覚的に書く、というのが重要なところです。

先の時間描写が不足している件ですが、おそらくアマチュアの作者は、自分の頭の中では「午前か午後か」「平日か休日か」「春か夏か」などを、ちゃんと思い浮かべていることが多いのではないかと思います。

でもそれは、書かなければ第三者には伝わりません。

だから各シーンに明示的に「客観的な事実」を宣言するというお作法を強要し、かつそれを説明しないとコンパイルが通らない、というアプローチをとり、作者の客観的な自覚を促そうとしているわけです。

プログラマもよく、色々なチョンボをやらかして、コンパイラに叱られます。煩わしいけど、それによって正確なプログラムが書けるようになるわけです。

しかし小説って、基本的には誰の力も借りずに黙々と書くものじゃないですか(違ったらすみません)。

だとしたら、こういう方法でコンピューターを「機械編集者」のように利用するというのは、プログラムの質を上げるのと同じように、作品の品質を上げ得るのではないでしょうか。

作者とプラットフォームのWinWin

実はこの仕組みを導入することは、作者とプラットフォーム運営者(出版社や投稿サイト)の双方に利益があります。

作者はこの言語を使うことで、作品の質を上げることができますが、同時にプラットフォーム側も「注釈付きのHTML」を入稿してもらうことで「自然言語処理」がしやすい原稿データを取得できるのです。

例えば次のようなマークアップ

@scene({season:"春"}){
  $season("桜")が満開だ。
}

次のようなHTMLを出力します(ちなみに出力HTMLのタグや属性についてはtnconfig.jsonという設定ファイルで設定できます)。

<scene data-season="春">
  <season></season>が満開だ。
</scene>

これは自然言語処理にとっては有利です。

例えば生のテキストで「桜が満開だ」という情報から「春」という情報を機械的に取得するのは、なかなか面倒なことです。

我々がよく知る小説原稿のテキストは、あまりに形式として素朴すぎるため、自然言語処理と相性がよくないのです。

しかし出力結果が上記のようなマークアップなら、最初から作者がさまざまな情報を注釈してくれているのですから、格段に仕事が簡単になります。なにより、質の良い機械学習用のデータとしても利活用できそうではありませんか。

世の中の検索エンジンは、HTMLに意味情報を含ませることで(セマンティック・ウェブ)、検索精度を向上させてきました。

なので、小説のように「素朴な形式」で書かれる媒体も、時代とともに意味情報を含んだ形式(セマンティック・ノベル)に進化していくのではないかなあと思っています。

後述しますが、この「セマンティック・ノベル」については、個人的に仕様を模索している最中です。

インストール(2019/10/14日追加)

npmからインストールできます。

npm install -g typenovel

コンパイラの使い方

色々なオプションがありますが、基本的には次のような感じで大丈夫でしょう。

[foo@localhost] tnc mynovel.tn

上のようにすると標準出力にHTMLが出力されます。

細かいオプションについては、

[foo@localhost] tnc --help

で確認できます。

出力HTMLのタグを変えるには

詳しくはドキュメントを読んでもらうこととして、簡単に言うとtnconfig.jsonというファイルを編集することで、出力するHTMLタグを自由に編集することができます。

tnconfig.jsonというのは、もちろんTypescriptのtsconfig.jsonを真似したものです。

最初はそんなファイルはないので、コマンドラインから次のコマンドで作成します。

[foo@localhost] tnc --init

こうすると、現在のディレクトリに初期設定で書かれたtnconfig.jsonが出力されます。

このファイルのmarkupMapという欄を編集すると、色々とタグを変えることができます。

例えば@sceneというタグを<scene>じゃなくて、<div class='scene'>にしたかったら、次のようにします。

{
  "markupMap": {
    "@scene": {
      "tagName": "div",
      "className": "<name>"
    }
  }
}

上記で<name>とありますが、これはプレースホルダーといって、@sceneタグに使うと、sceneという文字列に変更されます。その他にも<arg1>, <arg2> ... とか、<nth>, <nthOfType>, <index>, <indexOfType>とか色々なプレースホルダーがあります。

詳しくはCheatsheetのページで確認してください。

開発言語:F#

開発言語なのですが、今回は実行ファイル(コンパイラ)を配布するのがメインなので、Win/Mac/LinuxにクロスコンパイルできるF#を採用しました。

F#はちょっとマイナーな言語?なのかもしれませんが、OCamlとよく似て非常に言語処理系が書きやすい言語です。

久しぶりに触ってみたら、色々な周辺ツールがよく整備されていて驚きました。

.NETの資産も活用できるので、生産性も高いし、パッケージマネージャーNugetも使いやすくていい感じです。

(2019/10/14 Update)

TypeNovelをTypeScriptで書き直しました

今後の展望

TypeNovelと並行して、SemanticNovelという仕様と、そのビューアーについて構想中です。

SemanticNovelのビューアーは、例えば

  1. シーンの切り替わるタイミングで改ページが入る。
  2. シーンの時刻に応じてビューアーのテーマが変わる(夜ならダークモードとか)。
  3. 各セリフが誰によるものなのかを明示する補助UI

などなど、作品の「文脈情報」を生かし、読者に親切な読書環境を提供することを目指して開発しています。

ただしその前に、SemanticNovel の仕様をきっちり作っておかねばなりません。ちょっと時間がかかるかもです。

で、いずれ縦書き文庫にも導入できたらなあと妄想しています。

追記(2019/07/27)

追記2(2019/11/09)

https://raw.githubusercontent.com/tategakibunko/vscode-typenovel/master/images/capture.gif

最後に

TypeNovelリポジトリオープンソースで公開されています。興味のある方はお気軽にご参加ください。

https://github.com/tategakibunko/TypeNovel

SemanticNovelについても、一応Githubに場所を用意しておきました。ぼちぼちと更新していく予定です。

https://github.com/tategakibunko/SemanticNovel

投稿画面からプレビューできるボタンを復活させました

やはりプレビューが使えないと色々と不便なので、プレビューボタンを復活させました。

投稿ボタンの下に「プレビュー」ってボタンがありますので、使ってみてください。

f:id:convertical:20190209221753p:plain
プレビューボタン

プレビューを押すと、

f:id:convertical:20190209221836p:plain
プレビュー結果

こんな感じで、簡易ビューアーが表示されます。

プレビューボタンが表示されない方は、リロードするか、それでも駄目ならタブを閉じてから開き直してみてください。

縦書き文庫をリニューアルしました

縦書き文庫をリニューアルしました。

新しくなったところ

小説投稿画面の機能がスマホでも全て使えるように

これまではスマホの投稿画面ではルビや太字ぐらいしか選べなかったのですが、すべてのボタンがPCの投稿画面と同様に使えるようになりました。

PCだとこんなふうで、

f:id:convertical:20190207121350p:plain
PCの投稿画面

スマホとかだとこういう感じです。

f:id:convertical:20190207121425p:plain
スマホの投稿画面

しおりが無制限に挟めるように

これまでは10個までの制限があったのですが、いくらでも挟めるようになりました。

以下は、しおりを入れるボタン

f:id:convertical:20190207121647p:plain
しおりを挟む

以下はしおりを開くボタン

f:id:convertical:20190207121712p:plain
しおりを開く

しおりを開くと、しおりの一覧が出るので選択します。

f:id:convertical:20190207121748p:plain
しおりの一覧

ちなみに、しおりの数に制限はありませんが、一つの作品に大して10個以上の栞を挟んだ場合、直近の10件までが表示されます。

マテリアルデザインの採用

スマホ等のタッチで操作する端末に親和性が高い(とされている)マテリアルデザインを採用しました。

色々な操作がアニメーションするので、操作がより直感的になったのではないでしょうか。

シリーズ作品の並び替えがドラッグ・アンド・ドロップに対応

シリーズ作品の並び替えも、より直感的にドラッグ・アンド・ドロップで可能になりました。

f:id:convertical:20190207122216p:plain
シリーズの並び替え

ダッシュボードに読んだ、読まれたの情報を表示

ダッシュボードページを新設し、作品を読んだ、読まれたの履歴がパッと確認できるようになりました。

f:id:convertical:20190207122435p:plain
ダッシュボード

「読まれた」のタブには、自作がページ送りされてポイントが入った履歴が出ます。

「読んだ」のタブには、自分が他者の作品を読んで得たカルマの履歴が出ます。

廃止された機能について

埋め込み作品の廃止

利用者があまりいないのと、サーバーへの負担を考慮して廃止となりました。

一部の古いコードは動くかもしれませんが、動作は保証されません。

キャラクターとファンのタブ(プロフィールページ)を廃止

スマホの横幅に足りないというのが主な理由なのですが「ファン」についてはPC限定で復活するかもしれません。

表紙画像の投稿機能を廃止

廃止したのは、表紙の有無が作品のクリック率にあんまり寄与しない(ように見える)ほか、著作権の確認なども大変だったからです。

賛否両論あるかもしれませんが、表紙画像をなくすことで、サイトの読み込みも速くなるので、自分としては良かったかなあと思っています。

開発の振り返り

去年の年末にPCがぶっ壊れてしまったのですが、奇跡的にデータが残ったまま修理が完了したので、そのまま最後まで開発することができました。

自分にとっては今回が人生初Angular(とNgrx)だったのですが、すごく開発しやすかったです。

Angular+Ngrxでは、状態をObservableで閉じることができるので、論理的に矛盾した状態が作られにくい、というところが素晴らしいと思いました。

Angular + Ngrx所感

先月から試験的にAngular+Ngrxを使って縦書き文庫を作り直してみる、ということをしていたんですけど、パソコンが壊れバックアップも取ってなかったので、全て消え去りました(この記事はChromebookで書いてます)。

真面目にやると一年ぐらいかかる作業なので、一ヶ月程度の作業で済んだのは不幸中の幸い?なんでしょうかね。。。

Angular+Ngrxの感想としては、コツを掴むまでかなり時間がかかるということと、fluxなんでaction/reducer/state/effectの開発ループを回すのが面倒くさいなあ、ということです。

正直なところ「このまま続けるべきか?」と思ってたところなんで、もしかしたら中止できてよかったのかも。。。

学習コスト

TypescriptとRxに関しては多少ですが経験があったのでなんとかなりそうだったのですが、それ以外に覚えないといけないことが沢山あって大変でした。

よくAngular+Ngrxは大規模なチーム開発に最適などと言われますが、学習コストを嫌ってVueという選択をする人が多いのも、なんとなくわかるような気がします。

Ngrxについて

Ngrxを採用すると、とにかくRxでEffectsを書きまくることになるのですが、ここで割とわかりにくい地雷を踏みまくってしまいました。

特に危険なのはストリームを止めちゃう位置にcatchErrorを書いちゃったり、逆にストリームを止めるべきところでdispatch:falseを指定しないで無限ループを仕込んじゃったりとかです。

地雷1 catchErrorの場所

まずEffects中のcatchErrorですが、actions$直下のpipeではなく、map中のpipeで宣言しないといけません。

@Effects()
getFoo$ = this.actions$.pipe(
  ofType("foo"),
  switchMap(action => 
    return fetchFoo(action.payload.id).pipe(
      map(result => new GetFooDone(result.body)),
      // エラーは内側のpipeで補足する
      catchError(error => of(new GetFooFail({error})));
    );
  ),
  // ここに書いてはいけない
  // catchError(error => new GetFooFail({error}))
);

上の例は、fooに対応するactionを直下のactions$からフィルタリングし、そのデータを元にfetchFooでサーバーから何かしらのデータを取得しようとするコードです。

この処理の失敗を、action$直下のcatchErrorで補足すると、fetchFooでエラーが起きたときだけじゃなく、大本のactions$で起きたエラーも補足してしまいます。

上の例だと、GetFooFailというアクションを出したいのは、fetchFooが失敗したときだけですよね。

気をつけましょう。

地雷2 別のアクションに変化しない副作用

Effectsが新しいActionを返さないのにdispatch:falseをデコレーターに設定しないと、永遠にその処理が繰り返されます。

@Effect({dispatch: false})
logFoo$ = this.actions$.pipe(
  ofType("foo"),
  tap(_ => console.log("foo"))
);

上の処理で{dispatch: false}を忘れると、無限ループです。

自分はCPUがうるさくてパソコンが熱くなったので気づきました。自動ビルドで開発してたのですが、開いてる先のタブがいつのまにかCPU使用率100%になっていたりとか。

これがパソコンの熱暴走を起こし、寿命を縮めたんじゃないのか、と今では疑っています。

地雷3 Angular Materialとの相性

ngrx/storeの状態をangular materialのコンポーネントのディレクティブにダイレクトに反映させてしまうと、アニメーションが効かなかったり(例えばチェックボックスとか)、一部のUI要素が正しく計算できなくて表示できなくなったり(例えばタブコンポーネントの下線)みたいなことがあります。

なのでマテリアルコンポーネントを使用するときは、コンテナーというUIクッションを用意して、マテリアル要素はその下に置くようにします。

で、マテリアルなコンポーネントを含む下層のコンポーネントには「状態のコピー」を渡し、ローカルでその状態を操作させるようにします。そうしないと、アニメーションが正しく機能しないからです。その上で、子供の最新状態は@Outputを使って吸い上げます。

つまり一元管理された状態を使用して制御できるのはコンテナーUIまでで、そこから下は状態のコピーが作成されることになります。全てが理想通り、とはいかないですね。

余談1

ちなみに壊れたMacbookの修理はざっくり7万ぐらいとか言われたんですけど、まあ買い直すよりかは、と思って依頼しちゃいました。

でも年末で忙しいから14営業日かかるとのこと。。。今からだと来年の10日前後?

余談2

そういえば最近、中国の開発者が「Immutableなんてほとんどのケースで必要ない。本当の未来はshadow-domを使い、素直にmutableな構造で、かつVueっぽいものだ」みたいなコンセプトでなんかフレームワーク作ってて、すごいスターをもらってた記憶があるのですが、名前を忘れました。

AngularのExpressionChangedAfterItHasBeenCheckedErrorに関する覚え書き

ネットでExpressionChangedAfterItHasBeenCheckedErrorを検索すると、よく「子が親の状態を変更するとビューに一貫性がなくなることが警告される」という説明を目にしますが、少し混乱を招きかねない説明なんじゃないかなあと思いました。

正確には「Angularが変更検知をする際に行う探索順において、後からチェックされるビューから、先にチェックされるビューの状態を変える処理を書くと警告される」と説明すべきなのではないでしょうか。

なぜなら、HTMLには親子関係だけではなく、隣接(兄弟)関係というものがあるからです。親が子より先に探索されるのは当たり前ですが、隣接関係については自明ではありません。

例えば次のようなツリーがあったとします。

A, B
|
C

AとBは隣接(sibling)関係で、Cの親はAです。このときCの親であるAがCより先に探索されることは自明ですが、Bはどうでしょうか。Cより先でしょうか、後でしょうか。

もしAngularの変更検知が「幅優先探索」の順で行われるなら、Bが先です。しかし実際のAngularの変更検知順は「深さ優先探索」なので、検知される順に番号をふると、次のようになります。

A(1), B(3)
|
C(2)

つまりBは、Cよりも「後に」変更検知されるわけです。

だから探索順に置いて最後になるBにおいて、Cが依存するような状態を変更すると(先に確定されたCの状態を後からBが変えることになるので)ExpressionChangedAfterItHasBeenCheckedErrorというやつが出ます(ただし警告してくれるのは開発ビルドのときだけ)。

よくウェブで見る「親と子」という説明の仕方だと、幅優先か深さ優先かで受け取る人の解釈が曖昧になるので「探索順」を使った説明をしたほうが親切なのではないでしょうか。