PureBuilder2(3) Markdown for Blog

今やブログは主に Markdownで書かれている。

当初、blogtrというPuredoc用のスクリプトを書いたが、ブログ程度であれば圧倒的にMarkdownで事足りることが多いからだ。
実のところ、ACCSコンテンツなどでもMarkdownで十分なケースは多く、そのためにPureBuilder2はMarkdownをサポートする。

Markdownを変換し、ヘッダーを操作するblogmdはPureBuilder1.5で既に実装されたが、これはPandocを使ったスクリプトである。
PureBuilder2は全面的にRubyでいく予定であるため、MarkdownトランスレータにはKramdownを使用している。

これに合わせてKramdownを採用することにした。
機能的にはほとんど変わらないが、唯一の違いとして、手前に

* TOC
{:toc}

を入れるようにした。
これにより、KramdownはTOCを自動生成する。Pandocにはなかった便利な機能で、ちゃんとオフセットしたTOCを生成してくれる。

ちなみに、PureBuilderのMarkdown用ライブラリがKramdownをモンキーパッチングで拡張し、自動的にPureDocオブジェクトのメタ情報としてヘッダーを埋め込むので、XHTPureDocを使ってTOCを作ることもできなくはない。

まだpush予定はないため、コードを掲載する。

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

require "purebuilder/purebuilder"
require "kramdown"
require "optparse"

opt = {}
op = OptionParser.new

op.on("-m", "--marshal") {|v| opt[:marshal] = true }
op.on("-M", "--without-meta") {|v| opt[:marshal] = false }
op.parse!(ARGV)


# String for Kramdown's TOC
TOC_PREFIX = "* TOC\n{:toc}\n\n"

# Get article file
sourcefile = ARGV.length == 1 ?  ARGV[0] : nil
sourcestr = ARGF.read

pbp = PureBuilder::Parser.new(sourcestr, sourcefile)
pbp.proc_header

html = Kramdown::Document.new(TOC_PREFIX + sourcestr.gsub(//m, "") ).to_html

if opt[:marshal]
  Marshal.dump({body: html, head: DOC.meta}, STDOUT)
else
  puts html
end

これだけ見るとシンプルだが、PureBuilderとの関係が深く、結構中まで突っ込んでみることになってしまった。
PureBuilderの設計があまりよくないのかもしれないとも思ったが、そもそもPureBuilderのMarkdownサポートは「MarkdownをPureDocに見せかける」ものなので、PureBuilderとPureDocの密結合はやむをえまい。

これで初めて動かすこととなったPureBuilderだが、これによってPureBuilder、さらにPureBuilder登場によって修正されたPureDocのバグが発見され、デバッグにかなりの手間を費やした。

基本的にはシンプルだが、オプションへの対応を加えたため、いくらか複雑化した。
オプションは、将来的にパイプしてAPI経由でのブログアップロードに対応するため、メタを含めたMarshalで渡すためのものだ。

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をまた新しいドキュメントにすることもできる。

MailDeliver2 : 完全にプログラマブルなフィルタ機能を持つMDA

MailDeliver2がついに完成した。 おおよそできてはいたのだが、テストできておらず、テストしたらまだまだ足りない機能があることに気づき、 また不完全な部分が多かったため、2日かけてデバッグ、テスト、修正を繰り返した。

概要

保存・配送

Mail Deliverは完全にプログラマブルなフィルタを持つMDAと、それを補助するユーティリティ群である。 フィルタ、ソーター、通知の基本機能を備える。

基本的にはLinuxあるいはUNIX向けで、MH形式のメールフォルダに対して保存することになっている。

localdelivがMDAだ。その役割としては、フィルタを呼び出し、フィルタに従って処理することにある。 フィルタはメールの保存の機能を兼ねる。もしフィルタがメールを保存も破棄もしなかった場合は、localdeliv自身がデフォルトのメールフォルダへと保存する。

デフォルトのメールフォルダもフィルタ同様の方法で決定することができる。 MailDeliver2はRubyで書かれており、保存先メールフォルダの決定はRubyのprocオブジェクトによって行われる。このprocオブジェクトにはメールのヘッダの情報に加え、それを取り扱いやすくした情報を加えたメールオブジェクトが渡される。 そのため、静的な文字列ではなく、より動的に保存先を決定することができる。 ほとんどの場合、これで事足りるだろう。

例えば、設定サンプルでは次のように書かれている。

DefaultRule: ->(mail) { "inbox/address/#{mail.in || "Default"}/#{mail.domain || mail.address }/#{mail.address}" },

これはつまり、

inbox/address/<ユーザーのアカウント名 | Default>/<相手のドメイン名>/<相手のメールアドレス>

というフォルダに保存される、ということを意味している。 多数のメールアドレスを使用して使い分けている人は、そのメールアドレスによって相手の意味は既に分かれているだろう。まず、自分のどのアカウントに送られたものかをフォルダで分けてしまうことで相手を分類でき、この3段階の分け方があれば「だいたい分かる」。

ちなみに、副次的な効果として、詐欺にひっかかりにくくなる。受け取りアドレスとドメインを必然的に意識するからだ。 メールの「名前」をAmazonにしてお知らせを装った詐欺メールを大量に受け取っているが、ドメインがAmazonでないためすぐ分かる。 銀行関連もすぐわかるだろう。なぜならば、名前はメールを開くまでわからないが、アドレスは到着した時点から既に意識しているからだ。

フィルタは完全にプログラマブルであり、Rubyで自由に書くことができる。 もちろん、保存と破棄はよくある処理なのでそれを助ける機能がある。

さらに、保存してそのメールの処理を完了するかどうかは自由に選べる。 例えば複数のフォルダに保存するとか、特別に直ちに通知するとか、あるいは保存はするけれど通知の対象からは外す、ということも可能だ。 destroymail()は通知のためのファイルを削除するため、savemail()のあとdestroymail()すると通知はされない。

フィルタは条件自体がプログラマブルなので、例えば差出人によってのみ判定できる、というようなことはない。条件に単にtrueと書けば、常に適用されるフィルタが書ける。 現在デフォルトではclamAVしかサポートしないが、もっと強力な何らかのフィルタによって判定することもできる。その場合は、その判定処理を条件式に書けば良い。条件式に渡されるメールオブジェクトから完全なメール本文を得ることも可能で、IO.popen$?を用いて外部プログラムを条件として使用できる。

そのためにルール記述がやや難しいことは否定しないが、最低限であればマニュアル通りに記述することで、常識的に判断できる人ならば動かすことはできるだろう。

通知

通知機能はプラグイン方式を取っている。

そのため、して欲しい通知方式を好きに組み合わせることができる。 現在はnotify-sendを用いたGUI表示機能と、play(SOX)を用いた音声通知機能を備える。

ディスプレイ表示機能はプログラマブルではない。選択式だ。 「アドレス」または「Fromの値」のどちらかで、各差出人あたりの通数か、 または総数を通知する。

音声通知機能はプログラマブルだ。 音声ファイルは静的に指定しなければならないが、適否に関してはProcオブジェクトなので、その気になれば判定とは関係のないプログラムを書くこともできる。

その重要性は、アドレスのマッチングにしても

  • 完全一致
  • case insensitiveな一致
  • Glob
  • 正規表現

とそれらの否定から選べることにある。また、論理和、論理積も使用可能だ。 一致だけでは大量のルールを書かなくてはいけない場合に、同じファイルを再生させる場合も楽になる。 設定ファイルの中でインスタンス変数を定義しておくことで、例えば複数のアドレスをマッチさせる、というようなことも可能だ。 マスター設定ファイル内でアドレスごとのグループを設定しておくこともできる。

何がしたかったかというと、「ケータイメールのような使い心地」だ。 ケータイならば、着信音で相手が分かる。通知で、メールを開かなくても誰から来たかも分かる。 「着メロ」機能があるMUAは極めて少ない。着メロ機能と強力な振り分け機能を兼ね備えるものはない。 そもそも、振り分けのルール記述が極めて面倒だ。

そのような、大量のメールを受け取る人が、効率的にルールを書くことができ、メールを確認したり処理するための効率を大幅に向上させる、画面にかじりついていなくても、その時がきたことをアクティブに教えてくれる、という使い心地を、MUAではなく、MDAで実現した形となる。

1からの変更

まず、完全にRubyになった。 フルプログラマブルにするために、今まで開発効率からZshを使用していた部分に関しても、全てRubyとした。外部プログラムでなく、ライブラリの呼び出しとすることで、連続したマッチングも高速化でき、また渡せる情報も大幅に増えた。

一部手段としてUNIX系OS固有のものを使用しているが、恐らくWindowsに対して移植可能なものになったというのもメリットだろうか。 問題はKernel.forkを使用していることと、playコマンドやnotify-sendを使っていること、Sound Notifyはログを/dev/nullに書いていることだろう。

当然、このこととセットになって、よりプログラマブルになった。 そもそもの出発点が、フィルタが保存する内容には関与できない(保存するフォルダを出力するだけだったため、必ず保存されるし、保存内容を加工することもできない)という点が要求を満たさないケースがあったことについてだ。

これに対応したため、メールの破棄や、保存内容の加工も可能になった。 メールオブジェクトがメール全文を持っており、それがそのまま書き込む内容でもある。 メールを破棄した場合は、通知にも残らない。

構造がすっきりして、手を加えることもしやすくなった。 これまで通知系はプログラム自体を変更して、手元で専用のものにしていたが、汎用性があるものとなり、 単にプラグインフォルダに入れているものが適用されることとなった。

このあたりは、自分用だったものが、使ってもらうことを考えた変更が加えられたといっていい。

このほか、開発効率を優先して非常に複雑な構成(トリッキー)だったプログラムが、しっかりと設計されたものに変更されたため、挙動を把握しやすく、プログラマブルな部分がちゃんと活かせるようになった。 従来はフィルタが動くはずのものを書いても動かないことが多く、デバッグも難しかった。 今回はデバッグしやすいようにログもわかりやすいしてある。 これは、プログラマブルなためにユーザー定義部分でプログラムのエラーがでる場合が多いからだ。

プログラム

見どころは多いが、いくつか紹介。

プラグイン

単純にロードしているが、

class StandardNotify
  PLUGINS = []

と名前に約束を作り、プラグインは自身のオブジェクトをStnadardNotify::PLUGINSにpushする。 プラグインはfireメソッドをインターフェイスとして義務付けられている。

メールの準備

ヘッダとボディは次のようにして取得。

head, body = NKF.nkf( "-w -Lu -m", mailstr ).split(/\r?\n\r?\n/, 2)

ヘッダは次のコード

    head.each_line do | l |
      if l =~ /^\s*$/
        break
      elsif l =~ /^\s+/
        headerlines.last.concat(l.lstrip)
      else
        headerlines.push(l)
      end

    end
    
    headerlines.each do |i|
      if i =~ /\A([-_A-Za-z0-9]+)\s*:/ # match header format?
        mailobj[$1.upcase] = $'.strip
      else
        next
      end
    end

caseやspaceなどを守らない変なメールに対応するための措置を取っている。

メールアドレスの抜き出し

恐らく最もテクニカルだ。

  def extract_addr(f)
    if f =~ /(?:[^"<]*(?>"[^"]*"))*<([^>]+)>/ # Do From term have NAME<addr> format?
      address = $1.delete("\" \t/")
    else
      address = f.delete("\" \t/")
    end
    
    address
  end

単に仕様だけでなく、実際に使われている形式に則っている。 アドレスをクォートしているものに対しては対応しない。

メール方向の判定

    if @maildeliv_conf[:MyAddress].any? {|k, v| in_k =k; File.fnmatch(v, from) }
      mailobj.direction = :send
      mailobj.address = to
      mailobj.in = in_k
      
    elsif @maildeliv_conf[:MyAddress].any? {|k, v| in_k =k; File.fnmatch("*" + v + "*", mailobj["TO"]) }
      mailobj.direction = :recieve
      mailobj.address = from
      mailobj.in = in_k
      
    else
      
      mailobj.direction = :unknown
      mailobj.address = from
      mailobj.in = nil
    end

fromtoもアドレスを抽出したものだ。 差出人アドレスがマイアカウントとして定義されたものと一致するか?をチェックしている。 ちなみに、Toは単一とは限らないので、Fromを先に判定するのが確実で好ましい。

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環境変数にセットすることでコントロールできる。デフォルトはタブ文字ひとつである。

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

サイトの大幅手直し

ウェブサイトに直すべき点が大量にあったためにかなり手をいれることになった。

CSSグラデーション、デザインの修正

まずベンダープレフィクスを用いたCSS3のグラデーションを入れた。

/* headline level2 decoration */
h2 {
width: 100%;
background: -moz-linear-gradient(left -65deg, #fff, #acf);
background: -webkit-gradient(linear, left top, right bottom, from(#fff), to(#acf));
}

これはAki SI&Eのほうのもの。これを使い、サイトロゴは透過画像として(新規に作成した)、背景にグラデーションを入れた。画像はフォアグラウンドのイメージ

/* Header container (Top position of Main container) */
#Header {
background: #fff;
padding: 0 1.3em;
max-height: 125px;
}

#SITE_REASONSET #Header img.sitebanner {
margin-right : 40%;
width: 50%;
}

#SITE_REASONSET #Header {
background: -moz-linear-gradient(left -65deg, #f0f8ff, #acf);
background: -webkit-gradient(linear, left top, right bottom, from(#f0f8ff), to(#acf));
}

SI&Eのほうはメインコンテナが950pxとっているのに切り替えを750pxにしていたのでこれも修正。

Aki SI&Eのほうはキャプチャのスタイルを変更。

h1 {
border-top : double 5px;
border-bottom: double 5px;
color: #39f;
margin-left: -1em;
margin-right: -1em;
margin-top: -0.8em;
text-align: center;

}

マイナスのmarginはあまり見ない気がする。このようなデザインはほとんど見ることはないが、なかなかパッとみてすっきりと見えて目を引く良いデザインだと思う。このために、テキストインデントのマイナスは消した。テキストインデントのマイナスは一行目にのみ適用されるらしい(brで改行すればその都度適用されるのだろうが、折り返しには適用されない)。

サイトバーウィジットの変更

AKi SI&Eが先行で変更され、それがReasonSetに反映され、さらにReasonSetで追加された機能がAki SI&Eのほうに入った。

<!-- Sub column -->

<!-- Menu -->

-*- Menu -*-

<%= ENV["SUBCOLUMN_CONTENT"] || "" %>

<!-- Notes if any -->
% unless DOC.notes.empty?

-*- Notes -*-
    % DOC.notes.each do |note|

  1. <%= note %>
  2. %end

% end

<!-- Navigation links with PureDoc and PureBuilder template -->
% if DOC.meta["link"] && ( DOC.meta["link"]["next"] || DOC.meta["link"]["content"] || DOC.meta["link"]["prev"] || DOC.meta["link"]["start"] )|| ENV["reasonset_link_content"] || ENV["reasonset_link_start"]

-*- Referrances -*-

% end

<!-- / Sub column -->

このために、これまでSubCloumnコンテナ直下にメニューがあったが、それとcontent_boxクラスを分離して、同クラスのdiv要素を追加している。

/* White background */
.content_box {
background-color: #fff;
}

/* Padding for continuous content box. */
.content_box + .content_box {
margin-top: 11px;
}

Aki SI&Eのほうはもう少し手の込んだものになっている。

#SideBar .container {
padding : 1.28em;
}

#SideBar .container:last-child {
margin: 0;
}

CSS3の:last-child擬似プロパティを使うことで最後が間延びしてしまうことを防ぎながら、要素を分離してチェックをみせるようにしている。

注釈をサイドバー内にも表示するようにした他、内容的にはこれまでコンテンツ側にあったACCSのserial articleのreferranceをサイドバーに表示するように変更した。

レイアウト方法の変更

これでサイドバーが長くなったのだが、なぜかこれではメインコンテナが短縮されてしまう。あくまでメインカラムの高さに合わせるのだ。構造としては

なのでこの挙動は謎だが、MozillaでもBlinkでも同様の挙動を示す。

floatすると一見うまくいくようだが、逆にサイドカラムの高さに合わせてしまい、メインカラムが突き抜けてしまう。また、幅もきちんと規定しなくてはいけない。

結局、3者全てinline-blockにしてしまえば高さは正しく確保される。ただし、これでは2つのカラムはmarginがとられないためぴったりくっついてしまう。

そこでこれをmargin, padding, border-widthが全て0のコンテナとし、paddingは別のブロックで確保する。paddingは設定してもよかったのかもしれないが、このほうが管理しやすい。

エッセイ用のほうが先行して開発されたので、構造が甘かった。ReasonSetはもう少し安定しているが、やはり作りなおしたほうが良いと思われる。ちなみに、ReasonSetが安定しているのは、高さが規定されているためでもある。

ここまでしたのだが、inline-blockで幅を%指定だとGeckoでは見られるが、Blinkでは上下にレイアウトされてしまう。ピクセル指定にすれば大丈夫だが、relativeにした上でレイアウトし、さらにfloatすれば%指定でもBlinkで正常に表示される。

ACCSの修正

まず、TOC機能がエラーになるようになっていた。以前はRuby2.0でやっていたが、2.1になったからだろうか?どうも、クラスが違うオブジェクトを持つArrayを比較しようとしている、ということらしい。文字列しか格納しないように思うのだが、一応to_s

essaies.push [ sortitem.to_s, fp.to_s, meta["title"] ]

TOCに戻るリンクが間違っていた。これは、設定ファイルがreasonset_link_contentsとしているのに、テンプレートはreasonset_link_contentとしているという単純な理由だった。これは、HTMLのlink要素に対応するのでcontentsが正しい。ちなみに、Essayにはその機能がそもそも含まれていなかった。

そして、ACCS記事間のリンクが機能しない。Talkin’ About(Aki’s Palace)との違いが見つからず、一体…と思ったのだが、grepしてみるとその設定はrbutilに含まれており、chienomiが専用で使っている。chienomiのリンクが機能していたので、当然Talkin’ Aboutでも機能しているものと思い込んでいたが、実際はTalkin’ Aboutでは機能しないわけだ。単に記事がひとつしかないので気づかなかっただけだ。

基本的にはACCS記事内でそのスクリプトをロードすれば機能するのだが、ロードすべきスクリプトが存在しない。chienomiのものはパスがハードコーディングされている。そのため、それを修正したバージョンを用意した。

また、「最初の記事」だが、

export reasonset_link_start="si/$wd_element/${artdir:+${$(print $artdir/*.pdoc):r}.html}"

となっていた。Talkin’ Aboutでは機能していたが、これは記事がひとつしかないからだ。つまり、複数のファイルがあると、それが連なった文字列(スペース区切り)の最後だけpdocをhtmlに変えることになってしまう。

当然、先頭のファイル名のみをとり出さなくてはいけない。(#q[1])で機能しなかったので、

export reasonset_link_start="si/$wd_element/${artdir:+${$(print -l $artdir/*.pdoc | head -n 1):r}.html}"

と変更した。単純な方法だが、そこまで繰り返し呼び出すわけではないので、head(1)を呼び出す程度どうということはないだろう。

ノートインデックスの作成

ノートのインデックスを作るため、まずPureDoc側を修正。その機能をスーパークラスに追加。

### Notes ###
# Add note text to an array
def addnote(note)
@notes.push note
end

attr :notes

サブクラスで呼び出し

# Note
def n(text, ¬e)
puredoc_element(:n) do
note = note.call
addnote note
'%s</pure:note>' % [esc!(note), esc(text) ]
end
end

ちなみに、HTMLクラスタイプだけノートとテキストが逆になっていた。どちらが正しいのかよくわからなくなっている。仕様を規定すべきだろう。

そして今修正した。

def n(note, &text)
puredoc_element(:n) do
addnote esc!(note)
'%s</pure:note>' % [esc!(note), esc(text.call) ]
end
end

これでノートインデックスの作成が可能になった。だが、これに対応した表示が本文に必要だ。これはCSSに任せる。

body {
counter-reset : notes;
}

/* Note element */
.puredoc_note:after /*, puredoc|note:after */ {
vertical-align: super;
font-size: 80%;
content : "\002020" counter(notes);
counter-increment: notes;
text-decoration: none;
}

content中の文字参照はどうするのだろう、と思ったら、16進数にした上でクォート内で

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

codebreak;

GitHubに容量制限があるらしい、と知った。というのも、サイトのリポジトリはマルチメディアデータを含むため、サイズがかなり大きくなる。

そこで代わりとなるリポジトリとしてcodebreak;を試してみることにした。codebreakは「無制限!」と言っている。で、使ってみると使い心地は良いが、プレビュー機能がいまいち。ダウンロードできないので、リポジトリをcloneして使う前提か?

そして、無制限というが、容量に関しては「大きくなると拡張するために申請いただく」とあり「無制限」とはニュアンスが異なっている。無制限なのは

  • (Githubと違い)プライベートリポジトリの数が無制限
  • (BitBucketと違い)プロジェクトの参加ユーザー数が無制限

だが、「codebreak」で検索してみると興味深い記事がみつかる。

BIZREACHというIT系リクルートサイトがはじめたプログラミングSNSであるcodebreak;。これをみると、登録しようとするとウィザードが始まり、これが完全にリクルート登録的なものであること(職場、地位、年収など)、さらに使いはじめるには審査があり、審査に合格しないと使い始められないこと、しかもその審査がかなり「リクルート登録的に」厳しいことから相当不興を買っていた。ほとんど転職サイトで、gitホスティングサービスとしての体をなしていなかった、ということのようだ。

公開は2013-05となっていて、その頃話題となり、ぱっと消えてしまった、という感じだろうか。だが、今回登録した限りでは特にそのような入力項目自体がなく、ごくごく普通だった。審査がいる、というものも撤回され、誰にでも使える、とFAQで明記されている。コードブレイクは、IT・Webエンジニアの方向けのソフトウェア開発を行うためのツールです。特段アカウント作成に条件はありません。Webデザイナー、Webプロデューサー、ディレクターの方々など、Git関連サービスをご利用になられたい方なら誰でもご登録いただけます。

問題視されていた「httpsのみ」「pull request不可」「wikiなし」なども修正されている。もしかしたらバックが代わったのではないか、と思うくらいに「普通」だ。容量については200MB制限だったらしいのだが、それについてはパブリックリポジトリ、およびプライベートリポジトリの作成制限はありませんが、一定以上の大量のデータをホスティングした場合、さらに容量を拡張するために申請をしていただく必要がある場合があります。と言い方がやわらかい。

というわけで普通に使えるのだが、それでもちょっと容量が怖い。BitBucketも試したのだが、容量無制限ではあるものの、「コードだけだ!」とかなり強く書いてあるので、ドキュメントだとダメなのかもしれない。

そのため、結局マルチメディアファイルは.gitignoreでgitの管理から外して、Google Driveにアップして使用することにした。一応、データ自体は本サーバーにアップしてあるが、PDFに関してはもしもということがあるので。

PureDocのエスケープ機能

ずっと機能しない、と言ってきたPureDoc組み込みHTMLエスケープ機能だが、修正されて動くようになった。

問題のコードはこんな感じ。

STANDARD_ESCAPE_EX = /<(?![a-z/])|(?<![/"a-z])>|&(?![a-z0-9]+;|#[x0-9]+;)/
FORCE_ESCAPE_EX = /[<>"&]/
ESCAPE_INVOKE = ->(m, ptn) {
return "" if m.nil?
m.gsubz(ptn) do
case m
when "<"
"&lt;"
when ">"
"&gt;"
when '"'
"&quot;"
when "&"
"&amp;"
end
end
}

修正版はこうなった

STANDARD_ESCAPE_EX = /<(?:(?:/[a-z]+|[a-z]+(?: +[a-z]+="[^"]*"+)*)>)?|(?:<(?:/[a-z]+|[a-z]+(?: +[a-z]+="[^"]*"+)*))?>|&(?![a-z0-9]+;|#[x0-9]+;)/
FORCE_ESCAPE_EX = /[<>"&]/
ESCAPE_INVOKE = ->(m, ptn) {
return "" if m.nil?
m.gsub(ptn) do
case $&
when "<"
"&lt;"
when ">"
"&gt;"
when '"'
"&quot;"
when "&"
"&amp;"
else
$&
end
end
}

動かない原因は、caseでマッチする文字列が、正規表現のマッチ文字列ではなく元文字列になっていた、という単純なもので、気付くまでにデバッグに時間がかかったが、気付いてしまえばあっという間だった。

ところが、よく見るとSTANDARD_ESCAPE_EXがまるごと変わっている。これは、「<及び>がタグを構成する一部か」を判定する部分が甘かったからなのだが、ちょっとハマったのが、るりまには書かれていない「後読みアサーションに量指定子は使えない」という仕様だった。どうしても量指定子を使いたかったので、選択(?)でタグを構成する場合はタグ全体がマッチするようにした。このようにすると、case文で判定しているのは文字が含まれているかではなく、文字が===でマッチするかであるため(標準では文字列の同一性を見る)、タグ全体がマッチした場合はcase文でのマッチに失敗する。マッチに失敗した場合はelseでマッチ文字列全体を返すためString#grubの内容が唯一のcase文である時それにマッチしない文字列は変更されない。結果的に「タグを構成しているとみられる文字列は変更しない」となる。

なお、#escメソッドはこのように「タグ、もしくは文字参照と見られる文字列はエスケープしない」が、これは全ての要素メソッドが自動的に呼ぶためで、ユーザーが明示的にエスケープしたい場合は#esc!メソッドを呼ぶことで対応できる。これは、このような判定は一切行わず、全ての当該シンボルをエスケープする。

なお、PureDocは特殊文字入力にHTML/XMLの文字参照を使うことになっている。そのため文字参照のエスケープを回避しているのだが、HTML以外への編訳においては当然文字参照のアンエスケープまたは変換が必要となる。

ReasonSet サイト構築システム PureBuilder

PureBuilderは以前から何度か登場し、完全にやり直し、と繰り返している。今回は、「本体スクリプト」というものがない状態で登場し、ついに実用となった。

まず下地となるPureBuilderだが、かなり更新され、GitHubにも既に反映されている。まずこれがベースになる。

そして、「PureDocを使った翻訳を自動化する」というのがPureBuilderの基本的なところだ。PureBuilderのGitHubにあるpurebuild-puredoc.zshがその根幹となるスクリプトである。

はっきり言ってGitHubにある設定ファイルのサンプルとREADMEを読むのが一番早いと思うのだが、つまりこのスクリプトは.purebuild-puredoc.rc, .up_*, .rebuild-rulesファイルに従って実行するための補助ユーティリティでしかない。だが、設定ファイルを適切に書くことでほぼ全面的な自動化を実現している。サンプルをみれば分かるように、メニューを動的生成していたりする(eRubyを使っている)ため、サイトでユーザーからのアクションが自動的に反映されるような仕組みを持たないドキュメントに関してはこの方法で動的に静的ドキュメントを生成することができる。これは、著しいオーバーヘッド削減につながる。

参考までに、ReasonSetのサイトのメニューの構築を行うテンプレートは次のようなものだ。

<% menu = [
[ ["ReasonSet / Help", "//reasonset.net/", "ReasonSet全体の情報"], (ENV["SITETYPE"] == "REASONSET"), [["Profile", "//reasonset.net/profile.html", "正木はるか(柊美亜紀)のプロフィール"]] ],
[ ["HARUKA Sound", nil, "音楽プロダクション/プロジェクト HARUKA Soundの公式ページ(お仕事)"], nil, [] ],
[ ["Aki's palace", nil, "正木はるか(柊美亜紀)に関するウェブサイト。本人について、意見など"], (ENV["SITETYPE"] == "AKI"), [ ["Talkin' about", nil, "様々なテーマに対しそのフィルタを通して語る" ] ] ],
[ ["ちぇのみ-Chienomi-", nil, "コンピュータ"] , (ENV["SITETYPE"] == "CHIENOMI"), ["COLUMN", nil, "コラム" ], ["+Play programming with JavaScript", nil, "JavaScriptでプログラミングを遊ぶ"] ["+Easy step programming with Perl", nil, "Perlではじめるプログラミング入門"], ["+Easy step object oriented programming with Ruby", nil, "Rubyではじめるオブジェクト指向入門"] , ["Live with Linux", "http://reasonset.net/journal/archives/category/livewithlinux", "Linux日記" ] ],
[ ["Feel the Earth", nil, "バイク"], (ENV["SITETYPE"] == "MOTO"), [["Impression",nil,"所有車両のインプレッション"], ["Impression:HONDA VT250J SPADA", nil], ["Impression: SUZUKI SV400S", nil], ["Impression:YAMAHA MT-09", nil], ["Column", nil, "コラム"] ] ],
[ ["Blogs", "//reasonset.net/blog.html", "ブログ"], (ENV["SITETYPE"] == "BLOG") , [["journal de Aki", "http://reasonset.net/journal/", "本来のブログ"], ["Ameblo", "http://ameblo.jp/reasonset", "くだらないブログ"]] ],
[ ["SI Service", "http://reasonset.net/si/", "コンピュータの技術を供与するサービスについて(お仕事)"], nil, [] ]
]
%>

<div id="ReasonsetMenu">
% menu.each do | name, cond, items |
% if name[1]
<div class="menu_title"><a href="<%= name[1] %>" title="<%= name[2] %>"><%= name[0] %></a></div>
% else
<div class="menu_title"><span title="<%= name[2] %>"><%= name[0] %></span></div>
% end
% items.each do |i|
% if i[1]
<div class="menu_item"><a href="<%= i[1] %>" title="<%= i[2] %>"><%= i[0] %></a></div>
% else
<div class="menu_item"><span title="<%= i[2] %>"><%= i[0] %></span></div>
% end
% end if cond
% end
</div>

さらに、よりプロフィールに関してはプロフィール自体がRuby scriptとなっており、それを実行することでPureDocを拡張した形式で書かれたプロフィールを展開する。

もっとも、プロフィールを含むスクリプトはあくまで出力するだけであり、実際にアップロードする処理は.up_profileというスクリプトを書いて実行している。

全体には伝播する環境変数をうまくつかった仕組みになっていると思う。