Chienomi

PureBuilder Simply v1.10 / v1.11

開発::util

PureBuilder Simply v1.10の大きな変更点は、従来別体であったACCSが取り込まれたことである。

PureBuilderでは別のスクリプトであったACCS1は、PureBuilder ngで本体に組み込まれた。 そしてPureBuilder Simplyではまた「添付スクリプト」に変更されたのだが、これを本体に組み込むという変更を加えた形である。

動機

当初、PureBuilder Simplyはできるだけコンパクトに本体の設計を保つ、という前提があり、そのために本体は

  • ドキュメントインデックスを生成する
  • ドキュメントを変換する

の2点に焦点を絞っていた。 ACCSは別体だが、ドキュメントインデックスを利用することでPureBuilderの恩恵を受けられる、という考え方だ。

実際、この方法はうまくいった。 ACCSの場合、プログラマブルな要素を組み込む可能性が高いという点も踏まえており、それなりに役割分けは存在した。

だが、当初から問題もあった。 ACCSを処理するとき、メタデータの処理や設定の読み込みなど、ドキュメントプロセッサにも存在する機能を重複して書いている部分がほとんどであり、ドキュメントプロセッサ側の変更に追従していないために発生したバグもあった。

加えて、ACCSプロセッサは必ずドキュメントプロセッサの処理後に実行されるものであり、ACCSドキュメントディレクトリを更新するときは必ずACCSプロセッサを通すべきだろうという想定もあった。

結局、ドキュメントプロセッサをとおした後ACCSプロセッサを通し忘れるということが頻発し、PureBuilder Simplyの機能拡張によってACCSを別にしているデメリットが目立つようになった。 別にACCS組み込みを意識したわけではない変更だが、特にv1.9での変更がACCS組み込みにはちょうど良いものになったとも言える。というより、「分ける意味がなくなった」といったほうが正確だろうか。

そこで、ACCSは組み込み機能になり、.accs.yamlファイルがあれば処理する、という方式になった。

大きな変更

なんだか簡単な話のように思えるが、実はコード的には大幅な変更である。 というのは、従来の処理フローだとドキュメント処理中はACCSを処理する状態になっていないのだ。 言い換えると、従来ドキュメント処理中は他のドキュメントのメタデータを参照することはできないという想定になっていた。

だからこそ、ドキュメントプロセッサでデータベースを出力し、そのあとで改めてACCSプロセッサを通すという形になっていたのだ。

そもそもの話、PureBuilderはファイルを処理するもので、PureBuilder ngはディレクトリを処理するものである。 PureBuilder Simplyは設計的にはPureBuilderに近く、これにインデックスデータベースを追加したような構造をしていた。

PureBuilder Simplyは既に620行ほどあるプログラムで、その構造は意外と複雑だ。 初期と比べ複雑化しているのは、PureBuilder Simply本体が抽象化レイヤーとして動作するようになったためである。

再設計を繰り返したことからやや不整合も存在する。 v1.9, v1.10では不整合を解消する変更やコメントの追加も行われている。

なるべく小さな変更で実現するように注意を払ってはいるのだが、v1.9からv1.10への変更は割と大きい。

diff --git a/pbsimply-pandoc.rb b/pbsimply-pandoc.rb
index 74a2841..3678932 100755
--- a/pbsimply-pandoc.rb
+++ b/pbsimply-pandoc.rb
@@ -8,6 +8,10 @@ require 'fileutils'
 require 'optparse'
 
 class PureBuilder
+  module ACCS
+    DEFINITIONS = {}
+  end
+
   POST_PROCESSORS = {
     ".rb" => "ruby",
     ".pl" => "perl",
@@ -36,23 +40,18 @@ class PureBuilder
     @outfile = nil # Fixed output filename
     @add_meta = nil
     @accs = nil
-    
+    @accs_index = {}
+
     # Options definition.
     opts = OptionParser.new
     opts.on("-f", "--force-refresh") { @refresh = true }
     opts.on("-I", "--skip-index") { @skip_index = true }
     opts.on("-o FILE", "--output") {|v| @outfile = v }
     opts.on("-m FILE", "--additional-metafile") {|v| @add_meta = YAML.load(File.read(v))}
-    opts.on("-A", "--accs") { 
-      @accs = true
-      @singlemode = true
-      @skip_index = true
-    }
     opts.parse!(ARGV)
 
     if File.exist?(".pbsimply-bless.rb")
       require "./.pbsimply-bless.rb"
-      abort "Blessing file is exist but PureBuilder::BLESS Proc is not defined." unless (Proc === PureBuilder::BLESS rescue nil)
     end
 
     # Set target directory.
@@ -65,8 +64,10 @@ class PureBuilder
     @docobject
   end
 
+  attr :indexes
+
+  # Load config file.
   def load_config
-    # Load config file.
     begin
       File.open(".pbsimply.yaml") do |f|
         @config = YAML.load(f)
@@ -110,7 +111,7 @@ class PureBuilder
     @frontmatter.merge!(@config["default_meta"]) if @config["default_meta"]
 
     # Merge ACCS Frontmatter
-    if @accs && @config["alt_frontmatter"]
+    if @accs_processing && @config["alt_frontmatter"]
       @frontmatter.merge!(@config["alt_frontmatter"])
     end
 
@@ -120,8 +121,8 @@ class PureBuilder
     end
   end
 
+  # Load document index database (.indexes.rbm).
   def load_index
-    # Load document index.
     if File.exist?([@dir, ".indexes.rbm"].join("/"))
       File.open([@dir, ".indexes.rbm"].join("/")) do |f|
         @indexes = Marshal.load(f)
@@ -132,22 +133,44 @@ class PureBuilder
     @docobject[:indexes] = @indexes
   end
 
+  # Directory mode's main function.
+  # Read Frontmatters from all documents and proc each documents.
   def parse_frontmatter
+    target_docs = []
     STDERR.puts "in #{@dir}..."
+
     Dir.foreach(@dir) do |filename|
       next if filename =~ /^\./ || filename =~ /^draft-/
       next unless File.file?([@dir, filename].join("/"))
       next unless %w:.md .rst:.include? File.extname filename
       STDERR.puts "Checking frontmatter in #{filename}"
-      frontmatter = @frontmatter.merge read_frontmatter(@dir, filename)
+      frontmatter, pos = read_frontmatter(@dir, filename)
+      frontmatter = @frontmatter.merge frontmatter
       frontmatter.merge!(@add_meta) if @add_meta
-      next if frontmatter["draft"]
+      
+      if frontmatter["draft"]
+        @indexes.delete(filename) if @indexes[filename]
+        next
+      end
+
+      @indexes[filename] = frontmatter
 
       if check_modify([@dir, filename], frontmatter)
-        STDERR.puts "Processing #{filename}"
-        lets_pandoc(@dir, filename, frontmatter)
+        target_docs.push([filename, frontmatter, pos])
       end
     end
+
+    target_docs.each do |filename, frontmatter, pos|
+      ext = File.extname filename
+      @index = frontmatter
+      File.open(File.join(@dir, filename)) do |f|
+        f.seek(pos)
+        File.open(".current_document#{ext}", "w") {|fo| fo.write f.read}
+      end
+
+      STDERR.puts "Processing #{filename}"
+      lets_pandoc(@dir, filename, frontmatter)
+    end
   end
 
   def main
@@ -168,7 +191,16 @@ class PureBuilder
       load_config
       load_index
 
-      frontmatter = read_frontmatter(dir, filename)
+      frontmatter, pos = read_frontmatter(dir, filename)
+      frontmatter = @frontmatter.merge frontmatter
+      check_modify([dir, filename], frontmatter)
+      @index = frontmatter
+
+      ext = File.extname filename
+      File.open(File.join(dir, filename)) do |f|
+        f.seek(pos)
+        File.open(".current_document#{ext}", "w") {|fo| fo.write f.read}
+      end      
 
       lets_pandoc(dir, filename, frontmatter)
 
@@ -178,11 +210,14 @@ class PureBuilder
       # Normal (directory) mode.
       load_config
       load_index
-      parse_frontmatter
 
+      @accs = true if File.exist?(File.join(@dir, ".accs.yaml"))
+      
       # Check existing in indexes.
       @indexes.delete_if {|k,v| ! File.exist?([@dir, k].join("/")) }
 
+      parse_frontmatter
+
       unless @skip_index
         File.open([@dir, ".indexes.rbm"].join("/"), "w") do |f|
           Marshal.dump(@indexes, f)
@@ -191,6 +226,9 @@ class PureBuilder
 
       post_plugins
 
+      if @accs
+        process_accs
+      end
     end
   ensure
     File.delete ".pbsimply-defaultfiles.yaml" if File.exist?(".pbsimply-defaultfiles.yaml")
@@ -257,8 +295,19 @@ class PureBuilder
 
   private
 
+  # Turn on ACCS processing mode.
+  def accsmode
+    @accs_processing = true
+    @singlemode = true
+    @skip_index = true
+  end
+
+  # Read Frontmatter from the document.
+  # This method returns frontmatter, pos.
+  # pos means position at end of Frontmatter on the file.
   def read_frontmatter(dir, filename)
     frontmatter = nil
+    pos = nil
 
     case File.extname filename
     when ".md"
@@ -286,8 +335,7 @@ class PureBuilder
           raise e
         end
 
-        # Output document
-        File.open(".current_document.md", "w") {|fo| fo.write f.read}
+        pos = f.pos
       end
 
     when ".rst"
@@ -360,19 +408,56 @@ class PureBuilder
           next
         end
 
-        # Output document
-        File.open(".current_document.rst", "w") do |fo|
-          fo.write f.read
-        end
+        pos = f.pos
+
       end
     end
 
     abort "This document has no frontmatter" unless frontmatter
     abort "This document has no title." unless frontmatter["title"]
 
-    return frontmatter
+
+    ### Additional meta values. ###
+    frontmatter["source_directory"] = dir # Source Directory
+    frontmatter["source_filename"] = filename # Source Filename
+    frontmatter["source_path"] = File.join(dir, filename) # Source Path
+    # URL in site.
+    this_url = (File.join(dir, filename)).sub(/^[\.\/]*/) { @config["self_url_prefix"] || "/" }.sub(/\.[a-zA-Z0-9]+$/, ".html")
+    frontmatter["page_url"] = this_url
+    # URL in site with URI encode.
+    frontmatter["page_url_encoded"] = ERB::Util.url_encode(this_url)
+    frontmatter["page_url_encoded_external"] = ERB::Util.url_encode((File.join(dir, filename)).sub(/^[\.\/]*/) { @config["self_url_external_prefix"] || "/" }.sub(/\.[a-zA-Z0-9]+$/, ".html"))
+    frontmatter["page_html_escaped"] = ERB::Util.html_escape(this_url)
+    frontmatter["page_html_escaped_external"] = ERB::Util.html_escape((File.join(dir, filename)).sub(/^[\.\/]*/) { @config["self_url_external_prefix"] || "/" }.sub(/\.[a-zA-Z0-9]+$/, ".html"))
+    # Title with URL Encoded.
+    frontmatter["title_encoded"] = ERB::Util.url_encode(frontmatter["title"])
+    frontmatter["title_html_escaped"] = ERB::Util.html_escape(frontmatter["title"])
+    fts = frontmatter["timestamp"] 
+    fts = fts.to_datetime if Time === fts
+    if DateTime === fts
+      frontmatter["timestamp_xmlschema"] = fts.xmlschema
+      frontmatter["timestamp_jplocal"] = fts.strftime('%Y年%m月%d日 %H時%M分%S秒')
+      frontmatter["timestamp_rubytimestr"] = fts.strftime('%a %b %d %H:%M:%S %Z %Y')
+      frontmatter["timestamp_str"] = fts.strftime("%Y-%m-%d %H:%M:%S %Z")
+    elsif Date === fts
+      frontmatter["timestamp_xmlschema"] = fts.xmlschema
+      frontmatter["timestamp_jplocal"] = fts.strftime('%Y年%m月%d日')
+      frontmatter["timestamp_rubytimestr"] = fts.strftime('%a %b %d')
+      frontmatter["timestamp_str"] = fts.strftime("%Y-%m-%d")
+    elsif Date === frontmatter["Date"]
+      fts = frontmatter["Date"]
+      frontmatter["timestamp_xmlschema"] = fts.xmlschema
+      frontmatter["timestamp_jplocal"] = fts.strftime('%Y年%m月%d日')
+      frontmatter["timestamp_rubytimestr"] = fts.strftime('%a %b %d')
+      frontmatter["timestamp_str"] = fts.strftime("%Y-%m-%d")
+    end
+
+    bless(frontmatter)
+
+    return frontmatter, pos
   end
 
+  # Check is the article modified? (or force update?)
   def check_modify(path, frontmatter)
     modify = true
 
@@ -408,7 +493,6 @@ class PureBuilder
     frontmatter["date"] ||= now.strftime("%Y-%m-%d %H:%M:%S")
 
     @indexes[path[1]] = frontmatter
-    @index = @indexes[path[1]]
 
     if @refresh
       # Refresh (force update) mode.
@@ -418,59 +502,51 @@ class PureBuilder
     end
   end
 
+  def bless(frontmatter)
+    # BLESSING (Always)
+    if PureBuilder.const_defined?(:BLESS) && Proc === PureBuilder::BLESS
+      begin
+        PureBuilder::BLESS.(frontmatter, self)
+      rescue
+        STDERR.puts "*** BLESSING PROC ERROR ***"
+        raise
+      end
+    end
+
+    # BLESSING (ACCS)
+    if @accs && PureBuilder::ACCS.const_defined?(:BLESS) && Proc === PureBuilder::ACCS::BLESS
+      begin
+        PureBuilder::ACCS::BLESS.(frontmatter, self)
+      rescue
+        STDERR.puts "*** ACCS BLESSING PROC ERROR ***"
+        raise
+      end
+    end
+
+    # ACCS DEFINITIONS
+    if @accs
+      if Proc === PureBuilder::ACCS::DEFINITIONS[:next]
+        i = PureBuilder::ACCS::DEFINITIONS[:next].call(frontmatter, self)
+        frontmatter["next_article"] = i if i
+      end
+      if Proc === PureBuilder::ACCS::DEFINITIONS[:prev]
+        i = PureBuilder::ACCS::DEFINITIONS[:prev].call(frontmatter, self)
+        frontmatter["prev_article"] = i if i
+      end
+    end
+  end
+
   # Invoke pandoc, parse and format and write out.
   def lets_pandoc(dir, filename, frontmatter)
     STDERR.puts "#{filename} is going Pandoc."
     doc = nil
 
-    ### Additional meta values. ###
-    frontmatter["source_directory"] = dir # Source Directory
-    frontmatter["source_filename"] = filename # Source Filename
-    frontmatter["source_path"] = File.join(dir, filename) # Source Path
-    # URL in site.
-    this_url = (File.join(dir, filename)).sub(/^[\.\/]*/) { @config["self_url_prefix"] || "/" }.sub(/\.[a-zA-Z0-9]+$/, ".html")
-    frontmatter["page_url"] = this_url
-    # URL in site with URI encode.
-    frontmatter["page_url_encoded"] = ERB::Util.url_encode(this_url)
-    frontmatter["page_url_encoded_external"] = ERB::Util.url_encode((File.join(dir, filename)).sub(/^[\.\/]*/) { @config["self_url_external_prefix"] || "/" }.sub(/\.[a-zA-Z0-9]+$/, ".html"))
-    frontmatter["page_html_escaped"] = ERB::Util.html_escape(this_url)
-    frontmatter["page_html_escaped_external"] = ERB::Util.html_escape((File.join(dir, filename)).sub(/^[\.\/]*/) { @config["self_url_external_prefix"] || "/" }.sub(/\.[a-zA-Z0-9]+$/, ".html"))
-    # Title with URL Encoded.
-    frontmatter["title_encoded"] = ERB::Util.url_encode(frontmatter["title"])
-    frontmatter["title_html_escaped"] = ERB::Util.html_escape(frontmatter["title"])
-    fts = frontmatter["timestamp"] 
-    fts = fts.to_datetime if Time === fts
-    if DateTime === fts
-      frontmatter["timestamp_xmlschema"] = fts.xmlschema
-      frontmatter["timestamp_jplocal"] = fts.strftime('%Y年%m月%d日 %H時%M分%S秒')
-      frontmatter["timestamp_rubytimestr"] = fts.strftime('%a %b %d %H:%M:%S %Z %Y')
-      frontmatter["timestamp_str"] = fts.strftime("%Y-%m-%d %H:%M:%S %Z")
-    elsif Date === fts
-      frontmatter["timestamp_xmlschema"] = fts.xmlschema
-      frontmatter["timestamp_jplocal"] = fts.strftime('%Y年%m月%d日')
-      frontmatter["timestamp_rubytimestr"] = fts.strftime('%a %b %d')
-      frontmatter["timestamp_str"] = fts.strftime("%Y-%m-%d")
-    elsif Date === frontmatter["Date"]
-      fts = frontmatter["Date"]
-      frontmatter["timestamp_xmlschema"] = fts.xmlschema
-      frontmatter["timestamp_jplocal"] = fts.strftime('%Y年%m月%d日')
-      frontmatter["timestamp_rubytimestr"] = fts.strftime('%a %b %d')
-      frontmatter["timestamp_str"] = fts.strftime("%Y-%m-%d")
-    end
-
     # Preparing and pre script.
     orig_filepath = [dir, filename].join("/")
     ext = File.extname(filename)
     procdoc = sprintf(".current_document%s", ext)
     pre_plugins(procdoc, frontmatter)
 
-    begin
-      PureBuilder::BLESS.(frontmatter)
-    rescue
-      STDERR.puts "*** BLESSING PROC ERROR ***"
-      raise
-    end
-
     File.open(".pbsimply-defaultfiles.yaml", "w") {|f| YAML.dump(@pandoc_default_file, f)}
     File.open(".pbsimply-frontmatter.yaml", "w") {|f| YAML.dump(frontmatter, f)}
 
@@ -497,7 +573,7 @@ class PureBuilder
     outpath = case
     when @outfile
       @outfile
-    when @accs
+    when @accs_processing
       File.join(@config["outdir"], @dir, "index") + ".html"
     else
       File.join(@config["outdir"], @dir, File.basename(filename, ".*")) + ".html"
@@ -511,6 +587,38 @@ class PureBuilder
     @this_time_processed.push({source: orig_filepath, dest: outpath})
   end
 
+  # letsaccs
+  #
+  # This method called on the assumption that processed all documents and run as directory mode.
+  def process_accs
+    STDERR.puts "Processing ACCS index..."
+    if File.exist?(File.join(@dir, ".accsindex.erb"))
+      erbtemplate = File.read(File.join(@dir, ".accsindex.erb"))
+    elsif File.exist?(".accsindex.erb")
+      erbtemplate = File.read(".accsindex.erb")
+    else
+      abort "No .accesindex.erb"
+    end
+
+    # Get infomation
+    @accs_index = YAML.load(File.read([@dir, ".accs.yaml"].join("/")))
+
+    @accs_index["title"] ||= (@config["accs_index_title"] || "Index")
+    @accs_index["date"] ||= Time.now.strftime("%Y-%m-%d")
+    @accs_index["pagetype"] = "accs_index"
+
+    @index = @frontmatter.merge @accs_index
+
+    doc = ERB.new(erbtemplate, nil, "%<>").result(binding)
+    File.open(File.join(@dir, ".index.md"), "w") do |f|
+      f.write doc
+    end
+
+    accsmode
+    @dir = File.join(@dir, ".index.md")
+    main
+  end
+
 end
 
 PureBuilder.new.main

最大の変更は、Dir.foreachの中でFrontmatterのリード、処理ドキュメントの生成、ドキュメントの処理という流れがあったものを、Dir.foreachの中ではFrontmatterをリードして、配列にFrontmatterとドキュメントのオフセットを記録するだけになったことだ。そして、そのあとイテレータで処理ドキュメントの生成とドキュメントの処理を行っている。 つまり、

Dir.foreach(@dir) do |filename|
  # ...
  frontmatter, pos = read_frontmatter(@dir, filename)
  frontmatter = @frontmatter.merge frontmatter
  # ...
  target_docs.push([filename, frontmatter, pos])
end

target_docs.each do |filename, frontmatter, pos|
  # ...
  File.open(File.join(@dir, filename)) do |f|
    f.seek(pos)
    File.open(".current_document#{ext}", "w") {|fo| fo.write f.read}
  end
  # ...
  lets_pandoc(@dir, filename, frontmatter)
  end
end

ということである。 (このためにPureBuilder#read_frontmatter内にあった処理ドキュメント生成は外に出されている)

一見あまり意味はなさそうだが、予めメモリ上のドキュメントデータベースである@indexesを更新することができ、@indexesを参照することでドキュメント処理中に他のドキュメントのメタデータを見ることができるようになった。

このあたりはかなりバグを含んでいたので大きく修正された。 まず、@indexesへの反映が、変更によって異なるオブジェクトを参照しているために全てのメタデータが反映されない状態になっていたため、@indexesの各値が明示的にマージされた実際のメタデータを指すように変更した。 なおかつ、これはドキュメント処理前に行うように変更した。

また、PureBuilder#letspandocでメタデータの処理が行われていたが、PureBuilder#read_frontmatterがシングルモードでも呼ばれるメソッドであり、メタデータのセットはこちらでやるほうが適切であるため、ここで一貫して処理されるようになった。これは、blessもこちらへ変更された。

そのため、v1.9では.current_document.$extで処理中のドキュメントを見ることができたが、v1.10ではFrontmatterのsource_pathを見てファイルを処理する必要がある。また、これに伴ってblessingはPre Pluginsよりも先に行われるようになった

以前として現行のメタデータは@indexを参照することでこうした変更に影響されず完全な状態で参照できる。 また、@indexesを参照できるようにPureBuilder#indexesアクセサメソッドが追加され、blessing procは従来call(frontmatter)であったところがcall(frontmatter, self)に変更されている。

v1.10の効果

実際にChienomiで必要だったかはともかくとして、v1.10の変更がわかりやすいように、Chienomiでもv1.10の機能を利用したものに変更している。 なお、Frontmatterの値が連想配列になっている Pandoc >=2.8 仕様だ。

#!/usr/bin/ruby

load "./.lib/categories.rb"

TOPICPATH = {
  "" => ["TOP", "/"],
  "/articles" => ["Articles", "/#Category"],
  "/override" => ["Override", "/"],
  "/archives" => ["Old Archives", "/articlelist-wp.html"]
}

ARTICLE_CATS.each do |k,v|
  TOPICPATH[["/articles", k].join("/")] = [v, ["", "articles", k, ""].join("/")]
end

PureBuilder::BLESS = ->(frontmatter, pb) {
  content = nil
  filetype = nil
  content = File.read(frontmatter["source_path"])
  filetype = File.extname(frontmatter["_filename"])

  url = frontmatter["page_url"].sub(/^\.?\/?/, "/")
  frontmatter["topicpath"] = []
  url = url.split("/")
  (1 .. url.length).each do |i|
    path = url[0, i].join("/")
    if v = TOPICPATH[path]
      frontmatter["topicpath"].push({"title" => v[0], "url" => v[1]})
    else
      frontmatter["topicpath"].push({"title" => frontmatter["title"]})
      break
    end
  end

  if frontmatter["category"] && url.include?("articles")
    frontmatter["category_spec"] = [ARTICLE_CATS[url[-2]], frontmatter["category"]].join("::")
  end

  if content
    if((filetype == ".md" && content =~ %r:\!\[.*\]\(/img/thumb/:) || (filetype == ".rst" || filetype == ".rest") && content =~ %r!\.\. image:: .*?/img/thumb!)
      frontmatter["lightbox"] = true
    end
  end
}

article_order = nil
rev_article_order_index = {}

PureBuilder::ACCS::BLESS = -> (frontmatter, pb) {
  frontmatter["ACCS"] = true
  unless article_order
    article_order = pb.indexes.to_a.sort_by {|i| i[1]["date"]}
    article_order.each_with_index {|x,i| rev_article_order_index[x[0]] = i }
  end
}

PureBuilder::ACCS::DEFINITIONS[:next] = ->(frontmatter, pb) {
  index = rev_article_order_index[frontmatter["_filename"]] or next nil
  if article_order[index + 1]
    {"url" => article_order[index + 1][1]["page_url"],
     "title" => article_order[index + 1][1]["title"]}
  end
}

PureBuilder::ACCS::DEFINITIONS[:prev] = ->(frontmatter, pb) {
  index = rev_article_order_index[frontmatter["_filename"]] or next nil
  if index > 0
    {"url" => article_order[index - 1][1]["page_url"],
     "title" => article_order[index - 1][1]["title"]}
  end
}

ACCSの機能は基本的に

  • 一連の記事をリストする
  • 前後の記事へのリンクを生成する

の2つであり、後者はPureBuilder Simply版ACCSではサポートされていなかった。 今回の変更はこれをサポートする意味が強い。

Mimir Yokohamaでは以前から前後リンクがあったが、あれはドキュメントに埋め込むという方式を取っていた。

このデモンストレーションを兼ねて、Chienomiでは現行記事の中でカテゴリ内の前後記事がナビゲーションされるようになった。 v1.9でblessingを追加したことで従来、手書きで明示する必要があったLightboxの使用を自動判別できるようになっており2、「ドキュメントへの手書きで済むからいいじゃない」という考え方から離れる方向にある。 設計上のsimplicityを損なうので、100%良い案だとは言い難いが、なるべく柔軟に扱えるように考えている。今後は、よりシンプルで統一的な考え方で扱えるように設計を整理していくことになるだろう。 まぁ、一見より複雑になったように見えて、実はこの変更もコード的にあっちこっちいってしまう複雑さを解消し、より集約されるような変更になっているので、高機能化で「必要なこと」がわかりにくくなってしまったユーザーのための情報整理という話なのだが。

やや変わりつつあるPureBuilder Simply

当初、PureBuilder Simplyは初代PureBuilderを洗練させたようなソフトウェアであった。 処理部分にPandocを採用したということが大きく、ソフトウェアの実装そのものが楽で、あまり機能がないものの実用性が高く、ユーザーがドキュメントを書くのが簡単、というのが主な特徴でありコンセプトでもあった。

サイトそのものの構築よりもドキュメントを楽に、どんどん書けるというのが主眼にあった。 サイト構築の手間が少なく短時間でサイトを構築でき、それ以降の更新が楽、というのは、Mimir Yokohamaでサービス展開する上で重要であった。 PureBuilder Simply自体が持っている機能は少ないが、その処理フローに割り込むチャンスが多く、フィルタスクリプトを書く能力さえあれば力技で様々な機能を実現できるというキャラクターだ。

これを助けていたのがドキュメントメタデータを外部にデータベースとして出力するという機能であり、これらの設計がピタリとはまって、至って単純なプログラムであるにもかかわらず、潜在的には非常に強力であり、スキルがある人には柔軟で強力なソフトウェアとして使えるし、スキルがなくても簡単な習得できる知識で使える。そういうソフトウェアだった。

PureBuilder SimplyはMimir Yokohamaのウェブサイトを開発するために開発され、Mimir Yokohamaはそのショーケースとして非常に複雑なテクニックを使ってPureBuilder Simplyで実現できる極端なことをやってみせていた。 「PureBuilder Simplyでこんなことできるんだぜ」という芸だ。

v1.4まではより柔軟に扱える機能の追加、機能の充実、データ処理の改善などリファイン関連がほとんどであった。 このあたりはPureBuilder Simplyは最初のコンセプトで完成していると考えており、大きな変更は加えずに問題点を直していたのだ。

v1.5はサンプルファイルの追加が主であり、この時期に動画が制作されたこともあり、「普及とプロモーション」という意識の強い変更である。 ここからv1.6までは1年以上の時があく。大きな変更なくここまでリファインを重ねたことからもわかるように、この時点まではPureBuilder Simplyは当初のデザインを完成形としていた「第一世代」であると言える。

v1.6は14ヶ月を経てリリースされた。変更点は-fオプションの追加であり、長くPureBuilder Simplyを使っていて感じていた不満を解消した形だ。これは、出力ディレクトリの自動作成からもうかがえる。 v1.7はサンプルファイルの追加で、「使いやすくする」という方向に振っている。 このあたりは「PureBuilder Simplyにフレンドリーさが足りない」という考えをもっており、PureBuilder Simplyには改善の余地があるとした「第二世代」である。 ただし、変更は小さく、リファイン程度に留まるという点ではあまり変わらない。

そこから4ヶ月でリリースされたv1.8はPandoc 2.8に合わせた改良版である。 これはPureBuilder Simplyそのものの改善が前提としてあったわけではなく、Pandoc 2.8を前提にすればより良いデザインにできると考えたということだ。 これは大幅な改修となったが、この改修はデザイン上の改善を伴っていた。コード上の柔軟性が向上し、PureBuilder Simply本体に機能追加しやすくなった。

v1.8はきっかけである。v1.9はv1.8による改善を活用してはじめて「機能設計の変更」がなされたということを意味する。 「PureBuilder Simplyの機能を拡充する」という考え方にシフトした「第三世代」の誕生だ。 v1.9はblessing scriptのサポートであり、ひとつの機能追加に過ぎない。しかし従来こうした「ドキュメントの編集」はPre Pluginsで行うことが前提であり、このような機能変更(追加)はなされてこなかったのだ。

一見するとv1.8は機能追加ではなく改修に属するため、この「第三世代の流れ」とは関係ないように見える。 だが、v1.7からv1.8の間では「検索機能の追加」「コメント機能への対応」「WordPressドキュメントのインポート」という待望の機能追加が行われており、v1.8は既に「PureBuilder Simplyの機能を拡張する」という流れの中にあった。3

v1.10はv1.9で初めて行われた機能変更を大規模にやった形だ。 blessing scriptが良い結果を得られたことから、単純な方法(ドキュメント内に手書きで情報を入れる)や強引な方法(プラグインでドキュメントを書き換える)だけでなく、よりスマートな方法を提供するものであり、そのような機能を提供できるように、そして将来的にそうした拡張がしやすいコードになるようにリファクタリングを兼ねている。 そのための大きなコード変更であり、これは「PureBuilder Simplyはこれから変わっていく」ことを示唆している。

今後は、フィルタスクリプトで処理しやすいよう、メタデータをJSONで出力できる機能を検討している。 PureBuilder SimplyはRubyで書かれており、v1.9, v1.10で追加された機能はRubyを書ける必要があるものだ。 一方、フィルタスクリプトによるプラグインは「標準入力から読んで標準出力に書く」というルールさえ守れば良く、非Rubyプログラマにとってはフィルタスクリプトのほうが使いやすいだろう。 しかし、現状ではデータベースはRuby Marshal形式であり、Rubyでなければ読むことができない。そこで、一部の情報はそこなわれるが、非RubyプログラマがPureBuilder Simplyをより積極的に活用できるよう、JSONでデータベースを書くオプションを作ろう、というわけだ。

PureBuilder Simplyは内部的にDate/DateTimeオブジェクトを使っているため難しいが、blessing scriptをRuby以外で書けるようにする構想もある。 方法自体は簡単で、Blessingのタイミングでファイル名が固定されたJSONファイルに出力し、設定ファイルで指定したコマンドを実行し、実行が終わったらJSONファイルから読み戻せば良い。

この機能はv1.11における有力な候補だが、テストするのも大変な変更になるので、トピックブランチを切って開発することになるだろう。

とはいえ、PureBuilder Simplyの「それ自体はRubyで書かれているが、その機能を活用するのはお好きな言語を使える」というのは非常に大きな魅力であると考えていて、プラグインを特定の言語に依存しないインターフェイスというのは一般に類を見ないものだと思う。 そうした機能は私には特にメリットがないが、PureBuilder Simplyは特に広く使ってほしいソフトウェアなので、こうした改良で魅力を増していきたい。

追記: v1.11

前節で書いた「JSONによる他言語拡張のサポート」だが、試しに30分ほどでちょっと書いてみたら、何の問題もなく動作したのでmasterに取り込んだ。

機能としては2種類ある。

まず、dbstyle: jsonとすることで、.indexes.rbmの代わりに.indexes.jsonが使われるようになる。 これはRuby MarshalでなくJSONなので、Ruby以外による外部ツールと連携したい場合に便利だ。

dbstyle: ojとするとJSONの代わりにOjが使用されて高速化するが、PureBuilder Simplyはそこまで速度に影響はない(といっても、対象ディレクトリや記事が多い場合は、Marshalと比べても体感できる程度には速くなる)。

もうひとつはblessingである。

bless_style: cmdとすると.pbsimply-bless.rbを使用せず、bless_cmd及びbless_accscmdで定義された外部コマンドを呼び出すようになる。 ドキュメントメタデータは.pbsimply-frontmatter.jsonを通じて受け取ることができ、またこのファイルを書き換えることで変更を適用することができる。

こんなに簡単にこの拡張がうまくいったのは、v1.10で子なったリファクタリングによってそれぞれの処理を行うタイミングを変更したことにあり、期せずしてこの目玉機能の追加に備えるものとなった。

これによって、PureBuilder SimplyはRubyで書かれているにもかかわらず、Ruby以外の任意の言語でプログラマブルに拡張できるツールになった

なかなか衝撃的じゃないだろうか。

もう一度言うぞ。

今やPureBuilder Simplyは、シェルスクリプトでも、Pythonでも、PHPでも、Goでも、Rustでも、とにかくあなたが書けるどんなツールでも機能拡張できる。


  1. 初代PureBuilderはACCSコンポーネントは全くの別物で、PureBuilderとACCS4を組み合わせて使うことが想定されていた。ACCSのほうがよりプログラム的であるため、PureBuilderはZshで、ACCS4はRubyで書かれていた。↩︎

  2. 常にLightboxスクリプトを読むのであれば別にこんなことはしなくて良いのだが、「必要がないものを読み込まない」というスタイルを実現したくてLightboxが必要な場合のみLightboxスクリプトをロードするようにしている。↩︎

  3. 別の言い方をすると、第一世代は「Mimir Yokohamaのために生まれ、Mimir YokohamaでデモされていたMimir Edition」、第二世代は「広く使ってもらうためにプロモーションを強化したPublic Edition」、第三世代は「Chienomiのために生まれ変わったChienomi Edition」と言うこともできる。↩︎