# 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