# coding: utf-8
# 
# HTMLドキュメントを文法に正しく沿って構築するためのクラス。
# 拡張ライブラリによらずRubyだけで書かれているので処理系によらず動作する。s
# 簡明実用のため、DOM操作の機能はない。
# Fileに対するプレーンテキストの出力のように必要な順で #tag と #putsを呼べば
# HTMLが作られる。

class HTMLBuilder

  class << HTMLBuilder

    # 引数 str を文字列化し、XML文書のテキストノードに
    # 使えるようにメタキャラクタを実体参照に
    # 置換した結果(String)を返す。
    #
    def xmltext str
      str.to_s.encode(Encoding::UTF_8, :xml => :text)
    end

    # 引数 str を文字列化し、XML文書の属性値に
    # 使えるようにメタキャラクタを実体参照に
    # 置換した結果(String)を返す。
    # 先頭・末尾がダブルクォートとなることに注意。
    def xmlattr str
      str.to_s.encode(Encoding::UTF_8, :xml => :attr)
    end

    # 引数strが要素・属性の名前として適切な文字列の場合に真を返す。
    # さしあたり字種をチェックしているだけである。
    def good_name? str
      /\A[0-9A-Za-z][-0-9A-Za-z]*\z/ === str
    end

  end

  # HTMLでは一部のタグは内容を持つことが許されず、閉じタグを記載してはならないこととされている。
  # VOID_ELEMSはこのようなタグ名にマッチする正規表現である。
  # /\A(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)\z/

  VOID_ELEMS = /\A(area|base|br|col|embed|hr|img|input|link|meta|param|(?#--
  --)source|track|wbr)\z/

  # 読みやすさのため、タグ名がこの正規表現にマッチする場合、開きタグの前と閉じタグの後に改行が挿入される。

  BLOCKY_ELEMS = /\A(hr|h[1-6]|p|div|center|pre|blockquote|address|noscript|(?#--
  --)noframes|ul|ol|dir|menu|d[ltd]|table|caption|col|thead|tbody|tfoot|tr|td|(?#--
  --)form|script|fieldset|legend|meta|title|head|link|style)\z/

  # HTMLドキュメントの構築を始める。引数 titleが(要すれば文字エスケープをした上で)title要素に設定される。このtitle要素のほかhead要素の一部は最初にタグを出力するまで変更可能である( #header 参照).

  def initialize title
    @buf = ['<!DOCTYPE html>']
    # @head holds the content of <head> tag until _start_body is called.
    # _start_body clears it to indicate <head> can no longer be modified.
    @head = {
      :lang => 'ja',
      :title => title,
      :buf => []
    }
    # @stack is list of nested tags
    @stack = []
  end

  # 引数itemの値に応じて各種メタ情報を設定する。
  # #tag または #puts より前に呼ばねばならない。 
  # :call-seq:
  # header('lang', [String] lang) => self
  # header('title', [String] title) => self
  # header('base', 'href'=>url) => self
  # header('meta', 'http-equiv'=>fieldname, 'content'=>content) => self
  # header('link', 'rel'=>relation, 'href'=>url) => self
  # header('script', 'src'=>url) => self
  # header('script', 'text'=>text) => self
  # header('style', 'text'=>text) => self
  #
  # [lang] HTMLドキュメントの言語を指定する。
  #        デフォルトは日本語。英語ならば 'en' とする。
  #        その他は http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes をみてほしい。
  #
  # [title] ページの表題を指定する。コンストラクタ #new で指定した表題を変更する。
  #
  # [text] <script> 及び <style> タグの内容。
  # 
  # 例外
  # ArgumentError:: 未知のitemが指定された場合に発生する。
  # RuntimeError:: 既に #tag 又は #puts が呼ばれたためメタ情報を変更不可。
  #
  def header item, attrs = {}
    raise RuntimeError "Can't modify frozen <head>" if @head.empty?
    case item
    when 'lang'
      @head[:lang] = attrs.to_s
    when 'title'
      @head[:title] = attrs.to_s
    when 'base', 'meta', 'link', 'isindex', 'script', 'style'
      @head[:buf].push [item, attrs]
    else
      raise ArgumentError "Unknown item #{item} for <head>"
    end
    self
  end

  # :call-seq:
  # tag(name, attr=>{}) {|self| ... } => self
  # tag(name, attr=>{}) => self
  #
  # 名前 name のHTMLタグを出力する。
  # ブロックを与えた場合、ブロック内で #tag 又は #puts を呼んだ結果は
  # タグ内に作られ、閉じタグが自動的に作られる。
  # ブロックを与えない場合は #close_tag 又は #close_all_tags によって
  # タグを閉じなければならない。
  # ただし、名前が VOID_ELEMS にあたる場合はHTML文法でタグ内容が
  # 禁止されているので、 #tag により開き閉じタグが出力され、閉じる必要はない。

  def tag name, attrs = {}
    _start_body
    if block_given?
      _tag(name, attrs) { yield self }
    else
      _tag(name, attrs)
    end
    self
  end

# 引数 str を文字列化してテキストノードとして出力する。
# 不等号などメタキャラクタは HTMLBuilder.xmltext で処理されるので、表示したいように出力すればよい。

  def puts str
    _start_body
    @buf.push HTMLBuilder.xmltext(str)
    self
  end
  
  alias :<< :puts

# 直近に  #tag で開いたタグを閉じる。
# この文法を使わず、 #tag にはすべてブロックを与えてタグを自動的に閉じることをおすすめする。
# [name] 閉じたいタグ名。直近に開いたタグと一致しない場合例外となる。
# 次の例外が生じうる。
# [RuntimeError] すべてのタグが閉じられているのに #close_tag が呼ばれた。
# [ArgumentError] 引数 name が直近に開いたタグと不一致。なにか間違えている。

  def close_tag name = nil
    raise RuntimeError 'already closed HTML' if @stack.empty?
    sname = @stack.pop
    if name
      raise ArgumentError "<#{sname}> ... </#{name}>" if name != sname
    end
    @buf.push('</', sname, ">")
    _newline if BLOCKY_ELEMS === sname
  end

# 表組みに決まって用いられるタグ構造に従って #tag を呼び出す。
# 複雑なマークアップ(スタイルシートのクラス指定など)を伴う表は、自力で #tag
# を組み合わせて書いてほしい。
# [cols] 列名を指定する配列。
# [opts] オプション。
# 次のオプションが実装されている。
# ['caption'] 指定するとキャプションとして出力される。
# ブロックでは <tbody> タグの中身(つまり必要数の <tr> タグ)を記載することが期待される。

  def table cols, opts = {}
    tag('table') {
      tag('thead') {
	tag('caption') { puts(opts[:caption]) } if opts[:caption]
	tag('tr') {
	  cols.each {|colname|
	    tag('th') { self.puts(colname) }
	  }
	}
      }
      tag('tbody') { yield self }
    }
  end

# すべての開きっぱなしのタグを閉じる。
# 実は #to_s や #write の中で呼び出されるので、自分で呼び出す必要はあまりない。

  def close_all_tags
    while ! @stack.empty?
      close_tag
    end
  end

# HTMLドキュメントを構築して output の指すファイルに書き出す。

  def write output=$stdout
    close_all_tags
    output.set_encoding(Encoding::UTF_8)
    @buf.each {|str| output << str }
    self
  end

# HTMLドキュメントを構築して文字列(String)として返す。
# この場合、文字列のエンコーディングを確定させてファイルに書き出すのはプログラマーの責任である。

  def to_s
    close_all_tags
    @buf.flatten.join
  end

private

  def _puts str
    @buf.push HTMLBuilder.xmltext(str)
  end

  def _start_body
    return unless @stack.empty?
    _tag('html', 'lang'=>@head[:lang])
    _tag('head') {
      _tag('meta', 'charset'=>'utf-8') { :no_op }
      _tag('title') { _puts @head[:title] }
      @head[:buf].each {|tagn, attrs|
	a = attrs.reject{|k, v| k == 'text'}
	_tag(tagn, a) { _puts attrs['text'] }
      }
    }
    _tag('body')
    # to indicate <head> is frozen
    @head = {}
  end

  def _newline
    return if @buf.last == "\n"
    @buf.push "\n"
  end

  def _tag name, attrs = {}
    name = HTMLBuilder.xmltext(name).downcase
    @stack.push name
    raise ArgumentError unless HTMLBuilder.good_name?(name)
    _newline if BLOCKY_ELEMS === name
    tagexp = ['<', name]
    attrs.each {|key, val|
      key = HTMLBuilder.xmltext(key).downcase
      raise ArgumentError unless HTMLBuilder.good_name?(key)
      tagexp.push(' ', key, '=', HTMLBuilder.xmlattr(val))
    }
    if VOID_ELEMS === name
      tagexp.push(' />') # IE hack space
      @buf.push(tagexp.join)
      @stack.pop
      return
    end
    tagexp.push('>')
    @buf.push(tagexp.join)
    if block_given? then
      yield self
      self.close_tag
    end
  end
  private :_tag

end