PureBuilder2 TopicPath機能の追加

変更点

最新の変更@Gist

概要

これまでTopicPath機能はPureBuilderで提供されて来なかった。
そのため、個別のドキュメントとテンプレートにおいて実装可能な機能として紹介されてきたが、一般的な要望であるため今回の変更で取り込んだ。

このTopicPath機能は整形されたHTMLを返すわけではなく、シンボルと文字列からなる配列を返す。
PureBuilderの本来の設計に従い、サイトの階層構造に等しいディレクトリ構成を採用し、かつドキュメントごとにTopicPathが固定される状態であれば、かなり楽にTopicPathが生成できる。

これまではTopicPathはドキュメントあたりで設定することが勧められていた。これは、ACCS indexが組み込みで機能するためだ。

この機能を使えば設定ファイルによって、そのディレクトリの親パスを定義し、そして文書タイトルがStringとして追加される。

詳細

つまり、ディレクトリで設定されているのが

[ :Foo, :Bar ]journal/

で、文書タイトルがBazであるならば、

[ :Foo, :Bar, "Baz" ]

となる。

別にシンボルである必要はない。だが、シンボルを推奨している。マップを使用することが推奨されているためだ。
だが、特にシンボルであることを期待しているコードは組み込まれていない。

だから、例えば

[ { Address: "http://example.com/foo/", Title: "Foo" }, "Baz ]

のような構造にしても構わない。

実際にそれを使うコードとしては

% tp = DOC.mktopicpath
% tp.each do |i|
%   if i.kind_of?(String)
        <li><%= i %></li>
%   else
        <li><a href="<%= 	DOC.pbenv[:TopicPathMap][i][1] %>"><%= DOC.pbenv[:TopicPathMap][i][0] %></a></li>
%   end
% end if tp

のようになる。

ただし、Indexの場合はディレクトリで定義されている階層は今いるページなのであるから、その場合はtitleとpathの最終エレメントは重複しているはずだ。

そこで、:Indexが定義されている場合は、最終エレメントを取り除くことにした。

過渡期のコード

タイトルは"title"なのか"Title"なのか:Titleなのか、といったところに揺れがある。

正式には"title"を使用することになっている。だが、おそらくはreservedなキーは大文字で始めることにしたほうがいいだろう。

この互換性を維持するコードにしてある。

また、すでにページあたりでTopicPathを設定している場合に備え、現状はdevelブランチのみの対応だ。


途中で放置したため、書くべきことを忘れました。
ゴメンナサイ。

PureBuilder2 (2)

Kramdown拡張でPDocオブジェクト化

PureBuilder2はもともと思っていたよりもかなり大規模なものになっているが、MarkdownオブジェクトをPureDocと同様に扱えるようにする、というのが今回のテーマ。

例えばテンプレートで

DOC.body

のように書かれている場合がある。この場合は当然、HTMLへ変換したのであればHTML文字列が得られなくてはいけない。 また、

DOC.meta["title"]

のようにもアクセスできる。 それだけなら単にアクセッサを拡張してやればいい話なのだが、PureDocオブジェクトはTOCのためのループ機能が組み込まれている。 これにより章立てをループさせることができ、簡単に任意の形式でTOCを組める。 これはどうしてもパース時に情報を取らなくてはいけない。

もし、HTMLに出力するものである、というのであれば、単純に結果のHTMLをパースして取得する方法もある。 だが、KramdownライブラリはLaTeXとPDFをサポートする。PureDocもゆくゆくはLaTeX形式での出力をサポートする予定である。

であれば、やはりKramdownでのMarkdownパース時にTOCを作りたい。

基本的な方針としては、実際にPureDocオブジェクトを使用する。 これはパーサ/コンバータを含まないベースクラスで、本来は直接このクラスのインスタンスを生成することは想定していなかった。 だが、外側から使用するメソッドは一通り持っており、インターフェイスは揃っている。

DOC.bodyで返すべき@bodyDOC.body=を用いて入れ、DOC.metaに関してはPureDocクラスが持っている機能によってドキュメントから取り込むといったことが可能。 そのため、DOCPureDocインスタンスであり、Kramdownの結果はDOC.body=によって入れるだけだ。

だが、DOC.stock_ehaderを用いてヘッダを入力し、TOCを生成できるようにしなければいけない。 そこで、Kramdownに手を入れる必要があった。

ソースコードを追っていったが、結局Kramdown::Parser::Kramdown#new_block_elをオーバーライドするのが良いと分かった。 ヘッダを取得するパートはあるが、new_block_elメソッドはメソッド自体が短く、あくまでパース時に各エレメント対して呼ばれるものだ。何のために呼ばれているかを判定する必要もなく、引数を丸々渡すだけで良いため、overrideしやすかった。

require 'kramdown'

# Override Kramdown
class Kramdown::Parser::Kramdown

  alias _new_block_el_orig new_block_el
  

def new_block_el(*arg)

   	if arg[-1].kind_of?(Hash)
    
      case arg[0]
      
      # Is Header?
      when :header
        p arg[-1][:level]
        p arg[-1][:raw_text]
      end
      
    end
    
    _new_block_el_orig(*arg)

end end

p Kramdown::Document.new(ARGF.read).to_html

というテストコードを書き、実際に動作することを確認、when :header部分を

::DOC.stock_header(arg[-1][:level], arg[-1][:raw_text])

と書き換えた。

KramdownはPure Rubyで書かれているため、扱いやすいし、ソースコードを書くのも楽だ。 だが、できればサブクラス化するなど、もう少しスマートな方法でできればよかったな、と思う。クラスが細かく分割されて連携しているため、置き換えるのはかなり難しいと判断した。

Kramdownは非常に良いライブラリなのは間違いない。

forkの代わりに

RubyのKernel.forkをはじめとするfork機能(例えば、IO.popen-を渡すことを含む)はWindowsでは動作しない。 Perlerだった私としてはこれはかなり不満な点だ。Perlはコミュニティの努力により、forkがWindows上で動作する。これは、Windows版Perlではforkをエミュレートするためだ。

今回は、設定やドキュメントオブジェクトなどをセットアップした状態で、forkによって環境を独立させたいと考えていた。 これはグローバルなオブジェクトに変更を加えるためであり、また出力先の制御をSTDOUT.reopenによって行うことができるかということについて考えていたためだ。

RubyのforkとWindowsについて検索すると、「forkは邪悪だ、threadを使え」という内容があふれる。 だが、今回は並列化のために使いたいわけではないため、Threadは用を成さない。

また、大量のドキュメントを変換する際のオーバーヘッド低減という目的もある。

Unicorn(Webアプリケーションサーバー)がこのforkによるCOWを活用した設計となっている。Unicornはどうしているのかと調べてみたら、UnicornもMongrelもWindowsでは動作しないらしい。

というわけで、forkの利用は諦めて、グローバルな名前に対する変更をいなす方向とした。

グローバルな名前のオブジェクトが変更されるのは、ほとんど

DOC.is {
...
}

という書式で記述するためだ。 これはPureDocドキュメントを分かりやすく記述するためであり、実際にテンプレートもDOCオブジェクトを利用したデザインとなっている。 つまり、DOCはthe PureDoc objectであることを期待している。

この設計を維持するため、Delegateライブラリを使用することとした。 実体はDOCではなく、DOCはただのDelegatorというデザインだ。これはDOCに実体はなく、ただの代名詞となるわけだ。

DOC = SimpleDelegator.new(nil)

とすることにより、まずDOCという名前を用意しておく。 実際に新しいドキュメントを生成する場合は、

::DOC.__setobj__ @@config[:puredoc_class].new

のようにする。 これにより、DOCが意味するドキュメントを入れ替えることができ、DOCを変更しても、変更されるのはDOCではなく、移譲されているドキュメントであり、DOCをまた新しいドキュメントにすることもできる。

ネストされた構造のためのPureDocのTOC展開

ネストTOC機能

文書からTOCを作る上で、やはり構造的にネストしたいことはあると思う。 最もポピュラーなのは、ulをネストさせることだろう。

だが、難しいのは、例えば最初にh4が来て、次にh2が来て、などということがありうるのだ。そして、h3は存在しないかもしれない。

間の全てのレベルが存在することにするのか。順に礼儀正しく登場すると仮定していいのか。

結局だが、汎用性のある仕様として次のようにした。

  • 最低レベルはオフセットかまたは実際に使われた最も大きいヘッダーに基づく(数え方としてはmin)
  • レベルの変遷に応じて変遷分proc4openproc4closeを呼ぶ。例えば->(l, ol) { "<ul>" }のように書く。
  • 当該レベルまではopen/closeした後はproc4eachを呼ぶ。

    def nest_expand(proc4open, proc4close, proc4each, offset=nil) result = [] mi = self.min { |i| i.level } or return nil mi = mi.level

     if ! offset.respond_to?(:to_int) || offset > ( mi - 1 )
             offset = mi - 1
     end
    
     cur = offset
    
     self.each do |i|
         if i.level > cur
             (i.level - cur ).times {|n| result << proc4open.call( ( i.level - (i.level - cur - 1 - n) ), ( i.level - offset - (i.level - cur - 1 - n) )) }
         elsif i.level < cur
             result << (cur - i.level).times {|n| proc4close.call( (cur - n), ( cur - n - offset ) ) }
         end
    
         result << proc4each.call(i.level, (i.level - offset), i.title)
    
         cur = i.level
     end
    
     result.join

    end

eRubyでは内部のメソッドがputsすればいいような言い方をされることが多いが、それは先に出力されてしまっていたので、置換できるようにするために一旦配列に格納した。

テンプレート側の記述量が多く、また直感的でないというデメリットはあるが、なんとかうまく処理できた。

instance_evalと定数

しかし、むしろ苦戦したのは、ProfileでTOCを含めることだった。

Profileは基本的にそれ自体がPureDocを拡張したRubyコードである。

文章としてヘッダーを含めているわけでもないので、TOCを作るためのとっかかりがないのだ。

そこで結局は

  • テンプレート側でテーブル手前にリンクを貼る
  • profileであとから各カテゴリをヘッダとして登録する

という方法を取ったのだが、意外な理由でうまくいなかった。 というのは、

instance_evalで評価した場合、そのコンテキストが認識する定数にアクセスできない」

のだ。PureDocはソースをObject#instance_evalを使って解析するため、この問題にひっかかっってヘッダーの登録ができなかった。

そこで、PureDocに登録用のメソッドを追加することとなった。

簡単に書いているが、profileは整頓されていない部分が多く、結構大変だった。

PureDocのTOC機能

PureDocにTOC機能をつけた。

これまでPureDocで生成されたドキュメントにはドキュメント内リンクのためのID振りがなかった。

そのため、長文になると結構たどりにくい。 また、文書を参照してもらうのが難しかった。

そこで暫定的にHTMLに直接IDを書いたあと、更新に備えてその機能を急造した。

今回はかなり書き直した。 ヘッダー関連のメタメソッドを書き換えただけではない。 専用のクラスまで書き足した。

TOCクラスはStructだが、TOCContainerArrayのサブクラスとなっている。 これは、TOCを作るためにストックされたヘッダ情報を展開するためのメソッドをもたせるためだ。

だが、ネストした構造(例えばリストでネストさせる)のTOCを作るための展開用メソッドはなかなか作れない。

必ずしもh1から順にあるわけでもなく、例えば

h3->h4->h2ということもありうる。 ネストした状態でこれを処理するのはかなり難しい。徐々に深くなる前提にしてしまわない限りどうやって開き、どうやって閉じるかは難しい。

とりあえず単純にインデクシングのためのヒントと共にイテレータを回す構造としたが、 インデックスの1増減の問題が激しく大変だった。

既にReasonsetのサイトは適用されている。まだ整頓されていない状態だが、あまりにも使いやすかったため、実際に全体に取り入れてしまっている。 ただし、IDの付け方は将来的には変更されるだろう。現在のままではコンフリクトする可能性が高い。

PureDocの変更によって実装された(ReasonSiteでの採用はテンプレートも編集された)ため、既にGitHubには変更が反映されている。

PureDocの自動update timestamp

先日、PureDocで自動last-updateを入力するコードをいれたが、これでは生成時に元ドキュメントをいじるため、mtimeが変化してしまう。たとえ、mtimeをチェックするようにしても、「mtimeが変わっているから」変更すると、書き込むほうが遅いためやはり毎回mtimeが変更されupdateされたことになってしまう。

色々試したのだが、結局はlast-updateがメタYAMLにない場合のみ設定する、ということにした。変更した時は手動で削除してくださいね、という仕様。全自動とはならなくなったが、恐らくこれが最も無難な方法だろう。

begin
if docstr =~ /^##--.*$/ && $' =~ /^##--.*$/
yax = $`.each_line.map {|i| i.sub(/^# /, "") }.join
header_meta = YAML.load(yax) || Hash.new

# Set since timestamp if a document generation is first time. (And update Last update time stamp.)
if ! argv_first.empty? and
( ! header_meta.key?("last-update") )
( ! header_meta.key?("since") )

File.open(argv_first, "r+") do |f|
content = f.read

# Set since Time object if since is not set.
if ! header_meta.key?("since")
since = Time.now
header_meta["since"] = since
end

content.sub!(/^##--.*?^##--.*?$/m) do
el = $&.each_line.to_a # Get header texts.

el.insert(1, "# since : #{since.strftime '%Y-%m-%d %H:%M:%S %:z'}\n") if since # Add since if since is not exist.
# Update last update time if last-upadte is not set or last-update is older than mtime.
if ! header_meta.key? "last-update"
el.insert(1, "# last-update : #{File.mtime(argv_first).strftime '%Y-%m-%d %H:%M:%S %:z'}\n") # Add last update timestamp
end
el.to_s
end or next

f.seek(0)
f.truncate(0)
f.write( content )

end

end

DOC.meta = header_meta
end
rescue
DOC.meta = {}
end

PureDocの自動since入れ

ヘッダに使用し、これまで手動で書いてきたsinceプロパティだが、本来であれば「書き始めた時」でなく「はじめて生成された時」であるべきだし、それは自動で行われるべきものだ。

そこで、実際にそのような機能を追加してみた。PureDocトランスレータ本体ではなく、トランスレーションユーティリティ(puredoc-translation)が処理する。

# Process META DATA
# META DATA is start and end line beginning ##--.
# Lines between them is proceed as YAML after strip beginning "# " .
argv_first = ARGV[0]
docstr = ARGF.read # Content

begin
if docstr =~ /^##--.*$/ && $' =~ /^##--.*$/
yax = $`.each_line.map {|i| i.sub(/^# /, "") }.join
header_meta = YAML.load(yax) || Hash.new

# Set since timestamp if a document generation is first time. (And update Last update time stamp.)
if ! argv_first.empty?
File.open(argv_first, "r+") do |f|
content = f.read

# Set since Time object if since is not set.
if ! header_meta.key?("since")
since = Time.now
header_meta["since"] = since
end

content.sub!(/^##--.*?^##--.*?$/m) do
el = $&.each_line.reject {|i| i =~ /^# last-update/ }.to_a # Get header texts but except last-update.

el.insert(1, "# since : #{since.strftime '%Y-%m-%d %H:%M:%S %:z'}\n") if since # Add since if since is not exist.
el.insert(1, "# last-update : #{File.mtime(argv_first).strftime '%Y-%m-%d %H:%M:%S %:z'}\n") # Add last update timestamp
el.to_s
end or next

f.seek(0)
f.truncate(0)
f.write( content )

end

end

DOC.meta = header_meta
end
rescue
DOC.meta = {}
end

従来は単にヘッダーを読んでいたところだが、ヘッダーがある場合に限り別の処理をしている。

最初のARGVを保存しているのは、ARGFをよんでしまうとARGVが変化するため。もともとSTDOUTに吐くプログラムなので、処理するのは最初に引数として与えられたファイルに対してだけ、もしSTDINから入れた場合も触らない。

last-updateはmtimeに設定している。YAMLから作られるタイムスタンプスカラーがTimeなのかDateTimeなのか、実験してみたら、Timeだった。DateTimeにしてくれたほうがいいのに…

last-updateは更新するため、ヘッダー文字列を分解する時点で除外している。sinceプロパティが存在するかどうかについてはYAMLとして理解した上でやっているが、書き換え操作は純粋に文字列処理となっていて、別物だ。

ファイルの書き換えは、seek, truncate, writeの順が正しいらしい。まぁ、seekしてwriteしてからfile.truncate(file.pos)でも問題ないとは思うが。

ともかく、これで自動的にsinceとlast-updateが入るようになった。