ネストされた構造のための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には変更が反映されている。

Markdownにまつわるもろもろ

Markdownで書くということ

Markdownはもうだいぶ普及している形式だと思う。

様々なマークアップ言語や記法がこれまで発達してきた。 それは例えばWikiであったり、plain2だったり、textileだったり、 場合によってはRDocだったり。

しかしながら、そのいずれもそれほど普及しなかった。 だが、Markdown記法はもはやスタンダードとも言うべき普及を見せている。

PureDocは当初、Markdownのような形式をとっていた。 実際に正規表現パーサだった時期もあるし、もう少し発展してZshの内部DSLだった時代もある。

この目的はHTMLと印刷用フォーマットの両方を生成するドキュメントメタフォーマットで、 かつHTMLと比べ簡潔に記述できるフォーマットを求めていた。

その要求を満たしてくれるのだ。 現在はPureDocはReasonSetに特化した多彩な機能を持つためMarkdownに乗り換えるということはしないが、 多くの場合Markdownで事足りるのも事実だ。

Markdown Editor

Markdownは普及している分、専用のEditorが多く存在する。 単に強調表示や入力支援があるだけでなく、リアルタイムで表示を確認できる。

主要な候補となるのは

  • Markdown#Editor(Windows)
  • Remarkable(Linux)
  • CuteMarkEd
  • Haroopad

の4つであるようだ。

Markdown#Editorに関しては表示領域がマッチしないことが多く、 いまひとつ使いにくい。この問題はCuteMarkEdでも生じる。

Haroopadはクロスプラットフォームで、最初はフォントに違和感があったが、 CSSによってフォントを含め見栄えを指定することができる。

Haroopadの弱点は、改行を反映してしまうことだろう。 だが、全体にはスタイリッシュで見やすく使いやすい。 ドキュメントの動的なリロード機能がないのと、Donateのバルーンがちょっとしつこいのは残念。 だが、Windowsではこれを使っている。 基本的に表示位置は狭い画面ではエディタ側の入力位置を上のほうにもってくると適切に表示してくれる。

Haroopadの欠点として、Fcitxで入力できなくなることが結構あるというのもある。 この対応として、入力のない、空のHaroopadを立ち上げておくと入力できるようだ。

RemarkableはUbuntu的なUIを持つ。 使いやすいといえば使いやすいが、D&Dによって開くことができないため、ファイル操作がちょっと面倒。

おもしろいのが、Remarkableはビューワがめいっぱい上までスクロールすると下にループする。下はしない。

またエクスポート機能もあり、CSSにも対応する。 今のところ最も安定しているということもあり、LinuxではRemarkableを使用している。

Remarkableで記述する場合は、エディタの記述部分を上のほうになるようにするか、めいいっぱい下にすると適切に表示される。 当然ながら、中途半端で適切に表示されない位置はどうしても生じる。

だが、なるべくならどのエディタも最も下に書いていくのが良いようだ。

Markdownで既存のテキストを引用する

HTMLを含め引用はpreされるべきではないかと思うのだが、そうなっていない。 プレーンなテキストを引用するには、次のようにすると良いようだ。

sed -i "s/\(.*\)/> \1\n> /" file

sedの出力は改行を伴うので最後には改行はいらない。 段落を分けてもらう必要があるため、空行を入れておく。 ちょっと複雑だ。

MarkdownをPureBuilderに取り込み

Markdownのほうが楽に、適切に書けるケースが多いようなので、MarkdownをPureBuilderの一部として取り込んでみた。

とりあえずblog用で、変換にはpandocを使う。 これはいずれPureBuilderの一部となる。

ちなみに、今回コードの埋め込みは次のようにした。

sed "s/\(.*\)/\t\1/" ~/local/devel/reasonset_builder02/scripts/md_processor.rb >| ~/tmp/out

主な動作としては、PureDoc同様のヘッダの取り扱いと、 pandocからbodyだけを切り出すことである。

#!/usr/bin/ruby
# -*- mode: ruby; coding: UTF-8 -*-

require 'yaml'

module YEK
  class ReasonBuild

#=NAME
#
#ReasonBuild MD Processor - PureBuilder script for Markdown file.
#=SYNOPSIS
#
#  md_processor.rb [ _file_ ] [ -- _pandocoptions_ ... ]
#
#=DESCRIPTION
#
#MD Processor reads ARGF and process with pandoc.
#
#If +-s+ any _file_ given, MD Processor understands header with same style as PureDoc,
#modify timestamp, and write out to given _file_.
#
#If pandoc options given, MD Processor invoke pandoc with these options.
#Otherwise, MD Processor invoke pandoc 
#
# pandoc -t -s -p
#
    class MDProceessor
      
      def proc_header(file=nil)
        @file_content ||= ARGF.read
        # Any file given?
        if file && @file_content =~ /^##--.*$/ && $' =~ /^##--.*$/

          begin
            yax = $`.each_line.map {|i| i.sub(/^# /, "") }.join
            header_meta = YAML.load(yax) || Hash.new
    
            # Is Header missing last-update or since?
            if  ( ! header_meta.key?("last-update") ) || ( ! header_meta.key?("since") )
                
              now = Time.now
            
              modsince, modupdate = nil, nil

              # Set to since
              if ! header_meta.key?("since") 
                modsince = true
                header_meta["since"] = now
              end
            
              # Set to last-update.
              if ! header_meta.key?("last-update") 
                modupdate = true
              end
            
              # OK, Header is ready.
              # Open the file!
              File.open(file.first, "r+") do |f|
                content = f.gets(nil)
            
                if content.sub!(/^##--.*?^##--.*?$/m) {
                  el = $&.each_line.to_a # Get header texts.
                
                  el.insert(1, "# since : #{now.strftime '%Y-%m-%d %H:%M:%S %:z'}\n") if modsince # Add since if since was not exist.
                
	          # Update last update time if last-upadte is not set or last-update is older than mtime.
                  el.insert(1, "# last-update : #{File.mtime(file.first).strftime '%Y-%m-%d %H:%M:%S %:z'}\n") if modupdate# Add last update timestamp 
              
                  el.join
                }

                  # Write to file if updated.
                  f.seek(0)
	          f.truncate(0)
	          f.write( content )
                end
              end # Close file.
            end # Missing header
            
          rescue # YAML or IO Rescue.
            STDOUT.puts $!
          end
          
        end # file given.
      end #proc_header
      
      # Invoke pandoc, format, and out.
      def pandoc(options)
        
        outstr = nil
        
        # filter pandoc.
        IO.popen(( ["pandoc"] + options ), "w+") do |io|
          io.write @file_content
          io.close_write
          
          outstr = io.gets(nil)
        end
                                                  
        # subscribe content
        flag = false
        outstr = outstr.each_line.select do |line|
            
          if line =~ /^<\/body>$/
            flag = false
          end

          if line =~ /^<body>$/
            flag = true
            next false
          end
            
          flag
        end.join
        
        return outstr
        
      end
      
      
      def initialize
          
        pandoc_opt = nil
        
        # Get pandoc options from argv.
        if sep = ARGV.index("--")
          pandoc_opt = ARGV[(sep + 1) .. -1]
          ARGV.pop
        else
          pandoc_opt = [ "-t", "html", "-s", "-p" ]
        end
        
        proc_header( ( ARGV.empty? ? nil : ARGV.dup ) )
        
        doc = pandoc(pandoc_opt)
        print doc

      end
   
    end #MDProceessor
    
  end
end

YEK::ReasonBuild::MDProceessor.new

PureDoc:インデント機能、段落字数制限機能

段落字数制限

きちんと章立てせずにだらだら長い文章を書くことを抑制するために、指定した文字数を超える連続する段落がある場合警告する機能を搭載した。

通常、段落はブロックによるan Arrayであるので、マークアップではなくPureDocクラスで面倒をみることにした。

# Paragraph element.
# For notify length for too long flat text.
def p(*text)

text = block_given? ? yield : text

if @par_length_limit

if text.join.length < @par_length_limit
STDERR.puts "*WARNING : A single paragraph is too long (#{text.join.length} characters.)"
end
end

return text

end

基本的な値の取得も面倒を見るようになった。

制限文字数はユーザーが@par_length_lumitを設定する。設定しなければ機能しない。

インデント

HTMLでの出力を基本としてきたため、いままで改行すら入れていなかったが、ちょっとひどいのでインデントも入れることにした。

インデントの考え方としては、reading spaceを含めタグで始まる行をインデントする。このため、ネストしているものはこのインデント処理が複数呼ばれることになり、うまく処理できる。また、codeなどについては触らないようにできる。

改行については各エレメントのフォーマッタで面倒をみる。といっても、大半はstdメタメソッドで作られているので触るところは少ない。さらに、stringifyメソッドによってインデント処理とともにString化も行う。これにより、各エレメントは必ず文字列を返す、という仕様に統一された(これまでは文字列または配列を返していたが、Array#to_sが変更された現在、その仕様は不適切となった)。

ちなみに、stringifyに渡されてくる時点では文字列か配列かが確定できないので、これを処理して一旦文字列にしたあと(改行処理もする)、String#each_lineでインデントしてから、配列をまた文字列にしている。アルゴリズムとして効率はよくないが、開発を優先した。

ちなみに、インデントは@indent_spacesにセットするか、もしくは$puredoc_indent_spaces環境変数にセットすることでコントロールできる。デフォルトはタブ文字ひとつである。

これに伴って、要素はインラインかどうかを判定するためのキーワード引数が追加された。

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が入るようになった。