anti scroll

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

アプリケーションにTypeNovelのコンパイラを組み込む

この記事は、TypeNovelコンパイラをアプリケーションから利用したい開発者向けのものです。

導入

npm install --save typenovel

コンパイラの呼び出し

ソーステキストからコンパイルするときは、Tnc.fromStringです。

import { Tnc } from 'typenovel';

const result = Tnc.fromString('@scene(){ foo }', {
  format: 'html', // もしくは 'text'
  minify: false // trueだと圧縮表示
});

console.error(result.errors); // エラー
console.log(result.output); // コンパイル結果

ソースファイルからコンパイルするときは、Tnc.fromFileです。

import { Tnc } from 'typenovel';

const result = Tnc.fromFile('sample.tn', {
  format: 'html',
  minify: false
});

console.error(result.errors); // エラー
console.log(result.output); // コンパイル結果

コンパイラを拡張する

Compileクラスを使うと、コンパイルの各段階におけるVisitorを自前のものに置き換えることができます。

例えば、TypeNovelのノードツリー(TnNode)から出力文字列に変換するVisitor(NodeFormatter)を自前のものにするときは、こんな風にします。

import { Compile } from 'typenovel';

const myFormatter = new MyNodeFormatter(); // 自前で実装したFormatter
const result = Compile.fromString('@scene(){ foo }', {
  nodeFormatter: myFormatter // 自前のFormatterを設定
});

console.log(result.output); // コンパイル結果

ここで、MyNodeFormatterは、NodeFormatter interfaceを実装したクラスです。

NodeFormatter interfaceはnode-formatter.tsにて、次のように定義されています。

export interface NodeFormatter {
  visitTextNode: (args: {
    content: string;
    isWhiteSpacePre: boolean;
    isFirstChild: boolean;
    isLastChild: boolean;
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }) => string;

  visitAnnotNode: (args: {
    name: string;
    tagName: string;
    id: string;
    className: string;
    attrs: any;
    content: string;
    selfClosing: boolean;
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }) => string;

  visitBlockNode: (args: {
    name: string;
    tagName: string;
    id: string;
    className: string;
    attrs: any;
    content?: string;
    children: TnNode[];
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }) => string;
}

それぞれの関数の引数に、各ノードの情報が入っています。

ようするに、これらの情報を元にして、テキストノード(TextNode)、注釈タグ(AnnotNode)、制約ブロック(BlockNode)のそれぞれを、なんらかのテキストに変換して返せばよいわけです。

例えば注釈タグではクラス属性やid属性など無視して、タグ名だけあれば十分!などと思うなら、次のようにvisitAnnotNodeを実装すればいいわけです。

class MyNodeFormatter implements NodeFormatter {

  (省略)

  visitAnnotNode: (args: {
    name: string;
    tagName: string;
    id: string;
    className: string;
    attrs: any;
    content: string;
    selfClosing: boolean;
    prev?: TnNode;
    next?: TnNode;
    indent: number;
  }){
    return `<${args.tagName}>${args.content}</${args.tagName}>`;
  }
}

拡張できるinterfaceはノードから出力テキストの変換処理だけではありません。

次の部分をユーザーが自由に拡張できます。

TypeNovelParser(String -> Ast)

TypeNovelParserは、TypeNovelのソースからAstを作成するインターフェイスです。

AstMapper(Ast -> Ast')

AstMapperは、Astを別のAstに入れ替えるインターフェイスです。

AstConverter(Ast -> TnNode)

AstConverterは、AstTnNodeに変換するインターフェイスです。

NodeMapper(TnNode -> TnNode')

NodeMapperは、TnNodeを別のTnNodeに入れ替えるインターフェイスです。

NodeValidator(TnNode -> ValidationError[])

NodeValidatorは、TnNodeを検査してValidationErrorを出力するインターフェイスです。

NodeFormatter(TnNode -> String)

NodeFormatterは、TnNodeから出力テキストを生成するインターフェイスです。

拡張例

例えばTypeNovelにおいて、別のTypeNovelソースを展開する構文である

$include('別ソース')

などは、Ast -> Ast'な処理なので、IncludeExpanderクラスとしてAstMapperインターフェイスで実装されています。

ast-mapper.ts

あるいは、ノードから無駄なホワイトスペースを削除するNodeWhitespaceCleanerクラスは、TnNode -> TnNode' な処理なので、NodeMapperインターフェイスで実装されています。

node-mapper.ts

こうやって、コンパイラの各段階を自分好みのものに置き換えることで、各自でコンパイラが拡張できるような作りになっています。

TypeNovelをTypeScriptで書き直しました

TypeNovelは、制約と注釈の組み合わせによって、型付きの小説を記述するための言語です。

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

これまでF#で開発してきたのですが、今後はTypeScriptで開発することになりました。

これによって、コンパイラのインストールは

npm install -g typenovel

で、完了します。

インストールが成功すると、/usr/local/bin/tncが使えるようになるはずです。

[foo@local] tnc --version
v1.0.0

書き直した理由

一言でいうと「TypeNovelReaderのファイルサイズが大きくなってしまうから」です。

F#のアプリケーションを配布するときには、.NETCoreのランタイムも一緒に配布しないといけないのですが、これが80Mbyte近くあります。

これを除外して、ファイルサイズを削減したかった、というのが主な理由です。

しかしMac/Linux用のバイナリを100Mb以下にする、という当初の目標は達成できませんでした…つまりGithub(100MBの制約がある)にはMac/Linux版をアップロードできません。

その他にも「コンパイラの配布が簡単になる」とか「メジャーな言語なので開発者を募りやすくなる」とかもありますが…

Fsharpの良かった点

  • パーサーが書きやすい

Fsharpの辛かった点

  • 開発者が少ない(ように感じる)
  • ドキュメントが少ない
  • paket周りの運用が少し面倒に感じた
  • ランタイムがでかい(80M前後)
  • プログラムの立ち上がりが少し遅い

TypeScriptの良い点

  • TypeNovelReaderのファイルサイズが減る(20Mbyte近く削減)
  • npmで簡単に配布できる(インストーラーを配布する必要がない)
  • 書ける人がたくさんいる

TypeScriptにして辛い点

  • Nearley(パーサー生成系)やMoo(字句解析機の生成系)などの優れたライブラリを使うことで軽減されるが、F#に比べるとパーサーを書くのは面倒くさい

変わった仕様について

  • コンパイルオプションの--release--minifyに変更されました。
  • --formatオプションを指定することで、出力フォーマットとして、htmlだけではなくtextも選べるようになりました。
  • tnconfig.jsonにおいて、warnXXX系のフラグは、compilerOptionsというフィールドで記述する仕様に変更されました。
// 変更前
{
  warnUndefinedConstraint: true,
  ...
}

// 変更後
{
  "compilerOptions": {
    warnUndefinedConstraint: true,
    ...
  }
}

TypeNovelで電子書籍を公開する方法

TypeNovelで記述した原稿を公開する時、そのファイルをそのままTypeNovelReaderで開いても、それなりには表示されます。

しかし、どうせなら作品タイトルや、作者の情報や、作中キャラクターの情報などが表示されるように公開したいものです。

ここではそうした情報を盛り込んだ上で作品を公開する方法を簡単に説明します。

ちなみにTypeNovelReaderは以下のURLからダウンロードできます。

Windows版は.exeで、Mac版は.dmgで、Linux版は.AppImageです。TypeNovelコンパイラも付属しているので、別途TypeNovelをダウンロードする必要もありません。

メイン原稿はindex.tn

まずはindex.tnというファイルを作って、次のように記述します。

@scene(){
  あいうえお。
}

作品情報はdata.json

次にdata.jsonというファイルを作って、こんな感じで記述します。コピペして必要な箇所だけ書き換えたらいいでしょう。

ちなみにwritingModeの部分をhorizontal-tbとすると、横書きになります。

その他の項目についての詳細はSemanticNovelというページを参照して下さい。

{
  "title": "作品のタイトル",
  "theme": "default",
  "author": "あなたのお名前",
  "email": "あなたのメールアドレス",
  "homepage": "あなたのホームページ",
  "writingMode": "vertical-rl",
  "displayTypeNovelError": true,
  "enableSemanticUI": true,
  "speechAvatarSize": 50,
  "characters": {
    "hanako": {
      "names": ["田中", "花子"],
      "images": {
        "normal": "images/tanaka-hanako.png"
      },
      "description": "田中花子の詳細をここに書く"
    },
    "taro: {
      "names": ["山田", "太郎"],
      "images": {
        "normal": "images/yamada-tarou.png"
      },
      "description": "山田太郎の詳細をここに書く"
    }
  }
}

必要に応じて画像ファイルを追加

上のdata.jsonでは、キャラクターの画像としてimages/tanaka-hanako.pngなどのようなファイルが入っているので、キャラクター画像が用意できるなら、imagesフォルダーを作って、そこにtanaka-hanako.pngのようなファイルを放り込んで下さい。

画像がなければ、data.jsonからimagesの項目そのものを削除しても構いません。

ファイルをzipでまとめる

あとは上記のファイルすべてを一つのフォルダーに入れて、zipで圧縮してまとめます。

zipにするのが面倒なら、index.tnと同じディレクトリにdata.jsonをおいておくだけでも構いません。

ファイルをTypeNovelReaderで開く

上で作ったzipファイル(もしくはdata.jsonと同じディレクトリにあるindex.tnファイル)をTypeNovelReaderで開きます。

基本的にはこれだけですが、原稿の中身がちょっと寂しいので、少し台詞などを足してみましょう。

原稿に台詞を足してみる

@scene(){
  @speak("taro"){ 「ぼくは太郎さ」 }
  @speak("hanako"){ 「わたしは花子よ」 }
}

原稿を修正したらリロード

TypeNovelReaderの上部メニューに「Rebuild」というボタンがあるので、押すと変更が反映されます(ファイルを開き直す必要はありません)。

リビルドすると、台詞が表示されるだけじゃなく、それぞれの台詞の上に人物アイコンのようなものが表示されます。

アイコンにカーソルを乗せると、人物名がポップアップし、さらにそのアイコンをクリックすると、data.jsonで記述したキャラクターの詳細文がダイアログで表示されます。

その他の表現

よく使いそうなものを紹介します。

ルビ

$ruby("漢字", "かんじ")にルビをふる。

圏点・傍点

日本語で$fdot("圏点")を打つ。
日本語で$odot("圏点")を打つ。
日本語で$ftriangle("圏点")を打つ。
日本語で$otriangle("圏点")を打つ。
日本語で$fsesame("傍点")を打つ。
日本語で$osesame("傍点")を打つ。

縦中横

これは事件だ$tcy("!!")

チップ・リンク

チップ・リンクが最初に登場したのは、@tip("街"){
 @p(){ 1998年に現在のSpikeChunsoftの前身であるChunsoftが発売したゲーム。 }
 @p(){ ザッピング等のシステムが斬新で、今も不朽の名作として知られるアドベンチャーゲームの最高峰。}
}というゲームだったように思う。

脚注リンク

メッシはクラッキ@notes(){
  ポルトガル語で「名手」を意味する言葉
}である。

画像

$img("images/tb.png", 100, 100)

画像(行頭方向に寄せる)

$img("images/tb.png", 100, 100, "float start")

画像(行末方向に寄せる)

$img("images/tb.png", 100, 100, "float end")

シーンの制約(時間)

season(季節)とtime(時刻)とdate(日付)をシーンの制約に含めると、その結果がUIのテーマに反映されます。

「読者に読解力と記憶力を求めない小説」は可能か? TypeNovel用の電子書籍リーダー「TypeNovelReader」を公開しました

TypeNovel用のリーダーアプリ「TypeNovelReader」を公開しました。

github.com

TypeNovelで記載した「時間」とか「人物」などといった情報も、表示に反映されます。

ちなみにTypeNovelコンパイラも一緒に入っているので、別途ダウンロードする必要はありません。

Windows用のインストーラーは.exe Mac用のインストーラーは.dmg Linux用は.AppImage

使い方

TypeNovelで書かれた原稿を、ドラッグ・アンド・ドロップで放り込むだけです。

FileメニューからOpenを選んでもいいです。

設定ファイル(この記事の下で解説)や、複数ファイルをまとめてzip化したものを開くこともできます。あと*.html, *.txt, *.mdも可。

コンパイルエラーは(もしあれば)画面下のほうに表示されます。

特徴

TypeNovelで執筆した原稿をTypeNovelReaderで表示させることで、以下のような「読者を補助する機能」が、簡単に利用できます。

誰のセリフなのかが分かり、かつその人物が何者なのかを思い出せるUI

@speakタグを使って台詞を記述すると、各セリフの上に人物アイコンが出ます。

f:id:convertical:20190727091724p:plain
通常の台詞

カーソルを上に乗せると人物名がポップアップします。

f:id:convertical:20190727091808p:plain
名前がポップアップ

また@sb-start@sb-endを使うと、画像つきの吹き出しセリフを表示します。

f:id:convertical:20190727091946p:plain
吹き出しを使った台詞

@sb-startを使うとアバター画像が先に表示され、台詞が次に表示されます。@sb-endはその逆で、台詞が先でアバターが後です。

人物アイコンやアバター画像をクリックすると、詳細なプロフィールが表示されます。

f:id:convertical:20190727092017p:plain
キャラクターの詳細を表示

一般に登場人物の多い作品は、それぞれの名前を覚えるのに難儀しますので、こういうUIは有効なのではないでしょうか。

シーンの切り替わりで改ページが入る

一般にシーンの切り替えは、場所、時間などの切り替えを伴うはずです。

よって、そのタイミングで読者の視覚に全く変化がないのは直感的におかしいと考え、シーンの切り替えで改ページが入るようにしました。

シーンに記述された季節や時刻がUIのテーマに反映される

シーンにseason(季節)とdate(日付)とtime(時刻)の記述があった場合、その情報がUIに反映されます。

例えば季節が秋だと、メニューと本文の間にある補助線が紅葉に代わり、時刻が夕方だとオレンジ色の空の画像が左上に表示されます。

f:id:convertical:20190727092044p:plain
秋の装い

「逆に情緒が削がれる!」という場合は、画面上部のメニューボタンから無効にすることも出来ます。あるいは後ほど解説する設定ファイル(data.json)で無効にすることも可能です。

チップ機能と脚注機能

チップ機能は、文章の中の特定の単語に補足の説明を付け足したいときに使います。

f:id:convertical:20190727092125p:plain
チップリンク

クリックすると、補足情報が表示されます。

f:id:convertical:20190727092144p:plain
クリックで補足情報が表示される

部分的にちょっとした情報を付け足したいときには、脚注が便利です。

f:id:convertical:20190727092206p:plain
脚注リンク

これも同じく、クリックすると補足情報が表示されます。

f:id:convertical:20190727092224p:plain
クリックすると補足情報が表示される

目次項目を追従

作中に目次が設定されていれば、ステータス部分で現在読んでいる章が表示されます。

縦書きにも横書きにも対応

nehanを使っているので当たり前なのですが、縦書き横書き(のページ送り)に対応しています。

ルビ、圏点・傍点、ドロップキャプス、画像などにも対応

基本的に縦書き文庫で使える組版は全て使用可能です。

f:id:convertical:20190727093941p:plain
ルビ・圏点・傍点

ルビの書き方とかは、公式のサンプルを見て下さい。

執筆する人たち向けの情報

以下は執筆者向けの情報です。

まずはサンプルを動かす

先の表示例は公式のサンプルによるものです。

公式サンプルのsemantic-novel-jp.zipをTypeNovelReaderにドラッグ・アンド・ドロップしてください。

あるいは解凍して、中身のindex.tnを放り込んでも同じ結果になります。

次にサンプルを解凍して、中にあるindex.tnとかdata.jsonを眺め、表示結果と比べてみてください。

なんとなく書き方がわかると思います。

ちなみに解凍した先のディレクトリからindex.tnだけをTypeNovelReaderに放り込んでも、同じように作品を表示させることができます。

なお、原稿の中身を書き換えた場合は、画面上部のメニューに再構築(Rebuild)用のボタンが便利です。押すと作品が再コンパイルされ、変更が反映されます。

f:id:convertical:20190727092250p:plain
リビルドボタン

注意点

執筆するにあたり、いくつかの注意点があります。

注意1 メインの原稿ファイルはindex.tn

TypeNovelでは、各ソースの中で外部の原稿を$include("chapter1.tn")のようにして取り込むことができます。

なので、複数のファイルをzip化して開くときなどに、どのファイルが原稿の入り口なのかがわからないと、困ったことになります。

というわけで、TypeNovelReaderでは、その入り口に該当するファイルをindex.tnである、と規定しています。

とはいえzip化せず、直に原稿ファイル(*.tn)を放り込むのであれば、どんなファイル名でも問題ないのですが、いちおう「そういうルールがあるんだな」ぐらいに覚えておいてください。

注意2 作品情報はdata.jsonに記述

中身はこんな感じのファイルです。なんとなくですが、見たらわかるのではないでしょうか。

作品のタイトル、作者情報、初期の書式モード(縦書きか、横書きか)、登場人物など、色々なことについて設定できます。

内容については、各自で自分の作品用に直して使ってください。

{
  "title": "サンプル作品",
  "author": "縦書き文庫",
  "email": "lambda.watanabe@gmail.com",
  "homepage": "https://tb.antiscroll.com",
  "writingMode": "vertical-rl",
  "speechAvatarSize": 50,
  "enableSemanticUI": true,
  "displayTypeNovelError": true,
  "characters": {
    "john": {
      "names": ["John", "Adams"],
      "images": {
        "normal": "images/avatar2.svg"
      },
      "description": "Desription of John Adams"
    },
    "taro": {
      "names": ["山田", "太郎"],
      "images": {
        "normal": "images/avatar1.svg"
      },
      "description": "山田太郎の詳細をここに書く"
    }
  }
}

注意3 tnconfig.jsonはなくても大丈夫

サンプルにも含まれていませんが、tnconfig.jsonがない場合は公式の初期設定ファイルが勝手に使用されます。

公式の設定に加えて独自のマークアップなどを追加したい場合は、このファイルを編集して、index.tnと同じ場所に置くことになります。

しかし不注意に弄ると公式のマークアップと矛盾したり、公式の更新と衝突したりするので、あまり手を出さないほうが無難だと思います…

注意4 SemanticNovelについて

サンプルのファイル名がsemantic-novel-jp.zipとあるのですが、実はこのサンプルはSemanticNovelというTypeNovelのマークアップ仕様に沿って記述されています。

で、TypeNovelReaderも、この仕様に沿ったものを表示するという前提で開発されています。

つまり、この仕様に則っていない場合は、せっかくTypeNovelで型を書いても、それを十分に活かせないということになります。

ただこの仕様、まだまだ検討中のもので、今後大きく変わることもあり得るので、ご注意下さい。

SemanticNovelについて、何かしら新しいアイデアや要望があるかたは、Githubの上のページでご提案いただけたらと思います。

オープンソースという文化の性質上、やむを得ずページは英語で公開していますが、もちろん日本語で投稿していただいても全く問題ないです。

ライセンス

ライセンスはGPLv3です。

つまり無償で利用できるし、無償で改変できるけれども、改変したソースもまたGPLv3であることが求められます。

ということで、ソフトウェアを改変した後に、ソースを秘密にしたまま別アプリとかウェブサービスとして再配布、みたいなことはできません。

利用するにしても、ソースはGPLv3で公開して下さい、ということになります。

不具合について

不具合の報告はGithubや、Twitterまでどうぞ。

TypeNovelでルビをふる

ルビタグをそのまま記述しても良いのですが、こういう感じの設定をtnconfig.jsonに加えると(気持ち?)楽になります。

{
  "markupMap":{
    "$ruby":{
      "validate": false,
      "content": "<arg1><rt><arg2></rt>"
    }
  }
}

本文中はこんな感じでマークアップします。

$ruby("漢字", "かんじ")にふりがなをふる。

コンパイルすると、こうなります。

<ruby>漢字<rt>かんじ</rt></ruby>にふりがなをふる

ちなみに$rubyのmarkupMapで"validate":falseとなっているので、制約としての警告は出ないことに注意して下さい。

このように、純粋にインラインタグとして使いたいだけの場合は、markupMapで"validate":falseと設定しておくのが便利です。

ちなみに、この$rubyについてはv0.9.2より、最初から設定に含まれるようになる予定です。

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です。

最後に

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