2013/02/08

nanocカスタマイズ‐コメント欄の追加

コメント欄を追加する方法は Facebook のコメント欄や Disqus コメントを利用したり、いくつかあると思いますが、自作してみました。
mod_ruby をインストールして ruby で作ってみようかなと試行錯誤しましたが、うまくできませんでした。ruby でうまく動作しなっかので、php で作ってみました。
コメントの表示、入力を php で作成し、その php ファイルを Apache の SSI(Server Side Includes) を利用して nanoc サイトの layouts/default.html に埋め込みます。コメント用のファイルは output ディレクトリ下に comment ディレクトリを作成し、そこにすべて配置しました。
ソースが必要な方はダウンロードしてください。comment.zip
以下は、それぞれのファイルの内容です。

layouts/default.html赤字の部分を追加、変更)

<!DOCTYPE HTML>
<html> ←  lang="en" を削除
  <head>
    <meta http-equiv="Content-Type" Content="text/html;charset=UTF-8">
    <meta http-equiv="Content-Style-Type" content="text/css">
    <meta http-equiv="Content-Script-Type" content="text/javascript">
    <title>A Brand New nanoc Site - <%= @item[:title] %></title>
    <link rel="stylesheet" type="text/css" href="/style.css" media="screen">
    <link rel="stylesheet" type="text/css" href="/comment/comment.css">
    <script type="text/javascript" src="/comment/comment.js"></script>

    <!-- you don't need to keep this, but it's cool for stats! -->
    <meta name="generator" content="nanoc <%= Nanoc::VERSION %>"> 
  </head>
  <body onLoad="onLoad();">
    <div id="main">
      <%= yield %>
      <div><!--#include virtual="/comment/comment_get.php" --></div>
    </div>
    <div id="sidebar">
      <h2>Documentation</h2>
      <ul>
        <li><a href="http://nanoc.stoneship.org/docs/">Documentation</a></li>
        <li><a href="http://nanoc.stoneship.org/docs/3-getting-started/">Getting Started</a></li>
      </ul>
      <h2>Community</h2>
      <ul>
        <li><a href="http://groups.google.com/group/nanoc/">Discussion Group</a></li>
        <li><a href="irc://chat.freenode.net/#nanoc">IRC Channel</a></li>
        <li><a href="http://projects.stoneship.org/trac/nanoc/">Wiki</a></li>
      </ul>
    </div>
  </body>
</html>

output/comment/comment.css

.onDefault
{
  color: #999;
}

.comment_title
{
  margin-top: 1.0em;
  display: none;
}

.comment_content
{
  margin-top: 0.5em;
  margin-left: 0.5em;
  font-size: 0.9em;
}

.comment_name
{
  color: #c00;
}

.comment_datetime
{
  margin-left: 0.5em;
  font-size: 0.8em;
  color: #f93;
}

.comment_input
{
  margin-top: 0.5em;
  margin-left: 0.5em;
  font-size: 0.9em;
}

.dispImage
{
  width: 128px;
  height: 32px;
  border: 1px solid #ccc;
}

.selectImage
{
  float: left;
  width: 128px;
  height: 32px;
  border: 1px solid #ccc;
}

.clearSelect
{
  visibility: hidden;
  position: relative;
  top: 11px;
}

.clickImage
{
  width: 320px;
  height: 32px;
  border: 1px solid #ccc;
}

.selectImage1
{
  float: left;
  background-image: url(1.png);
  width: 32px;
  height: 32px;
}

.selectImage2
{
  float: left;
  background-image: url(2.png);
  width: 32px;
  height: 32px;
}

.selectImage3
{
  float: left;
  background-image: url(3.png);
  width: 32px;
  height: 32px;
}

.selectImage4
{
  float: left;
  background-image: url(4.png);
  width: 32px;
  height: 32px;
}

.selectImage5
{
  float: left;
  background-image: url(5.png);
  width: 32px;
  height: 32px;
}

.selectImage6
{
  float: left;
  background-image: url(6.png);
  width: 32px;
  height: 32px;
}

.selectImage7
{
  float: left;
  background-image: url(7.png);
  width: 32px;
  height: 32px;
}

.selectImage8
{
  float: left;
  background-image: url(8.png);
  width: 32px;
  height: 32px;
}

.selectImage9
{
  float: left;
  background-image: url(9.png);
  width: 32px;
  height: 32px;
}

.selectImage0
{
  float: left;
  background-image: url(0.png);
  width: 32px;
  height: 32px;
}

output/comment/comment.js

var xmlHttp;
var selectedImage = "";
var selectedImageNum = 0;

// 投稿ボタン押下時の処理
function pressSubmit()
{
  xmlHttp = null;
  if(window.XMLHttpRequest)
  {
    // firefox ie7,8 safai opera
    xmlHttp = new XMLHttpRequest();
  }
  else if (window.ActiveXObject)
  {
    try
    {
      // ie6
      xmlHttp = new ActiveXObject("Msxml2.XMLHTTP");
    }
    catch(e)
    {
      // ie5.5
      xmlHttp = new ActiveXObject("Microsoft.XMLHTTP");
    } 
  }
  if (xmlHttp)
  {
    // サーバへデータ送出
    xmlHttp.onreadystatechange = responseServer;
    xmlHttp.open("POST", "/comment/comment_save.php", true);
    xmlHttp.setRequestHeader("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
    sendData = "";
    sendData += "docurl=" + document.URL;
    sendData += "&";
    sendData += "commentator_name=" + getValue(document.getElementById("commentator_name"));
    sendData += "&";
    sendData += "commentator_mail=" + getValue(document.getElementById("commentator_mail"));
    sendData += "&";
    sendData += "commentator_url=" + getValue(document.getElementById("commentator_url"));
    sendData += "&";
    sendData += "comment=" + getValue(document.getElementById("comment"));
    sendData += "&";
    sendData += "selectImage=" + selectedImage;
    xmlHttp.send(encodeURI(sendData));
  }
}

// サーバからの応答処理
function responseServer()
{
  if ((xmlHttp.readyState == 4) && (xmlHttp.status == 200))
  {
    response = xmlHttp.responseText;
    separate = response.indexOf(";");
    replaceImage = response.substr(0, separate);
    response = response.substr(separate + 1);
    separate = replaceImage.indexOf(":");
    replaceImage = (replaceImage.substr(0, separate) == "REPLACEIMAGE") ? replaceImage.substr(separate + 1) : "";
    separate = response.indexOf(":");
    header = response.substr(0, separate);
    body = response.substr(separate + 1);
    if (header != "DATA")
    {
      // エラー
      window.alert(body);
    }
    else
    {
      // 正常
      document.getElementById("comment_list").innerHTML = body;
      commentTitle();
      element = document.getElementById("commentator_name");
      element.value = "";
      onDefault(element);
      element = document.getElementById("commentator_mail");
      element.value = "";
      onDefault(element);
      element = document.getElementById("commentator_url");
      element.value = "";
      onDefault(element);
      element = document.getElementById("comment");
      element.value = "";
      onDefault(element);
    }
    if (replaceImage == "1")
    {
      document.getElementById("dispImage").innerHTML = '<img src="/comment/comment_image.php" />';
      clearSelect();
    }
  }
}

// コメントリストのタイトルの表示/非表示
function commentTitle()
{
  document.getElementById("comment_title").style.display = (document.getElementById("comment_list").innerHTML.trim()) ? "block" : "none";
}

// onLoad処理
function onLoad()
{
  textarea = document.getElementsByTagName("textarea");
  for (i = 0; i < textarea.length; i++)
  {
    if (textarea[i].className.search("nodes") < 0)
    {
      if (textarea[i].value == textarea[i].defaultValue)
      {
        textarea[i].className += " onDefault";
      }
      textarea[i].onfocus = function() {offDefault(this);}
      textarea[i].onblur = function() {onDefault(this);}
    }
  }
  input = document.getElementsByTagName("input");
  for (i = 0; i < input.length; i++)
  {
    if ((input[i].className.search("noDefault") < 0) && ((input[i].getAttribute("type") == "text") || (input[i].getAttribute("type") == null)))
    {
      if (input[i].value == input[i].defaultValue)
      {
        input[i].className += " onDefault";
      }
      input[i].onfocus = function() {offDefault(this);}
      input[i].onblur = function() {onDefault(this);}
    }
  }
  // クリック用のイメージの表示
  elementClickimg = document.getElementById("clickImage");
  for (i = 1; i <= 10; i++)
  {
    elementClickimg.innerHTML += '<div class="' + "selectImage" + (i % 10) + '" onClick="selectImage(this)"></div>';
  }
  // コメントリストのタイトルの表示/非表示
  commentTitle();
}

// デフォルト表示無効
function offDefault(element)
{
  if (element.className.search("onDefault") >= 0)
  {
    element.className = element.className.replace(/onDefault/g, "").trim();
    element.value = "";
  }
}

// デフォルト表示有効
function onDefault(element)
{
  if (element.value == "")
  {
    element.className += " onDefault";
    element.value = element.defaultValue;
  }
}

// デフォルト表示のデータ取得
function getValue(element)
{
  return (element.className.search("onDefault") < 0) ? element.value : "";
}

// 選択イメージの取得
function selectImage(element)
{
  if (selectedImageNum < 4)
  {
    selectedImageNum++;
    className = element.className;
    selectedImage += className;
    document.getElementById("selectImage").innerHTML += '<div class="' + className + '"></div>';
    document.getElementById("clearSelect").style.visibility = "visible";
  }
}

// 選択イメージの破棄
function clearSelect()
{
  document.getElementById("selectImage").innerHTML = "";
  document.getElementById("clearSelect").style.visibility = "hidden";
  selectedImage = "";
  selectedImageNum = 0;
}
入力された項目のチェックや登録は Ajax を使用し、php で処理するようにしました。
Ajax については を参考にしました。
また、TEXTARIA や INPUT にデフォルト表示をし、クリックするとデフォルト表示が消えるようにしました。 を参考にしました。
function pressSubmit() ではコメントが投稿されたページの URL(document.URL)、投稿者の名前、メールアドレス、ホームページ、コメント及びスパム予防のためのイメージデータを Ajax でサーバへ送ります。comment_save.php がサーバで処理します。
function responseServer() ではサーバからの応答を受け取り、エラーならばエラーメッセージを表示し、正常ならばコメントリストを表示します。そして、スパム予防のためのイメージデータを書き換えます。
function commentTitle() はコメントがあれば「コメント一覧」を表示し、コメントがなければ「コメント一覧」を表示しません。
function onLoad() ではTEXTARIA や INPUT にデフォルト表示するための処理を行い、イメージデータ選択用のイメージを表示します。この関数は body の onLoad イベントでコールします。
function offDefault(element) はデフォルト表示を無効にします。
function onDefault(element) はデフォルト表示を有効にします。
function getValue(element) はデフォルト表示へ入力されたデータを取得します。
function selectImage(element) は選択されたイメージの情報を保持します。
function clearSelect() はクリアボタンをクリックしたときの処理を行います。選択されたイメージを破棄します。

output/comment/comment_get.php

<div id="comment_title" class="comment_title"><strong>コメント一覧</strong></div>
<div id="comment_list">
<?php
  require_once("comment.php");

  $db = new Comment();
  echo $db->get_comments($_SERVER["DOCUMENT_URI"]);
  $db->close();
?>
</div>
<br />
<div>
  <strong>コメントする</strong>
  <div class="comment_input">
    <table>
      <tr>
        <td>
          <img src="/comment/commentator_name.png" width="16px" height="16px" />
        </td>
        <td>
          <input type="text" id="commentator_name" size="30" value="なまえ" />
        </td>
      </tr>
      <tr>
        <td>
          <img src="/comment/commentator_mail.png" width="16px" height="16px" />
        </td>
        <td>
          <input type="text" id="commentator_mail" size="30" value="メール(非公開)" />
        </td>
      </tr>
      <tr>
        <td>
          <img src="/comment/commentator_url.png" width="16px" height="16px" />
        </td>
        <td>
          <input type="text" id="commentator_url" size="50" value="URL" />
        </td>
      </tr>
      <tr>
        <td style="vertical-align: top;">
          <img src="/comment/comment.png" width="16px" height="16px" />
        </td>
        <td>
          <textarea id="comment" cols="50" rows="5">コメント</textarea>
        </td>
      </tr>
      <tr>
        <td colspan="2">
          <div id="dispImage" class="dispImage"><img src="/comment/comment_image.php" /></div>
        </td>
      </tr>
      <tr>
        <td colspan="2">
          <div id="selectImage" class="selectImage"></div>
          <input type="button" id="clearSelect" class="clearSelect" value="クリア" onClick="clearSelect()" />
        </td>
      </tr>
      <tr>
        <td colspan="2">
          上と同じものを下から選びクリックしてください。
        </td>
      </tr>
      <tr>
        <td colspan="2">
          <div id="clickImage" class="clickImage"></div>
        </td>
      </tr>
      <tr>
        <td colspan="2">
          <input type="button" value="投稿" onClick="pressSubmit()" />
        </td>
      </tr>
    </table>
  </div>
</div>
コメントの表示と入力部分を出力します。
comment.php はデータベース操作クラスです。
$_SERVER["DOCUMENT_URI"] には SSI で呼び出した URI が格納されているようです。この URI に対するコメントをデータベースより抽出します。
なまえ、メール、URL、コメントのアイコンは からダウンロードして使用しています。
comment_image.php はスパム予防のためのイメージデータを作成します。

output/comment/comment.php

<?php
  define("CryptSalt", '$2a$07$12345678901234567890$');

  class Comment extends SQLite3
  {
    protected $table = "comments";  // テーブル名

    // コンストラクタ
    function __construct()
    {
      parent::__construct("comment.sqlite");
      $sql  = "CREATE TABLE IF NOT EXISTS";
      $sql .= " " . $this->table;
      $sql .= " (";
      $sql .= "   id INTEGER PRIMARY KEY AUTOINCREMENT";
      $sql .= " , page TEXT";
      $sql .= " , name TEXT";
      $sql .= " , mail TEXT";
      $sql .= " , url TEXT";
      $sql .= " , comment TEXT";
      $sql .= " , datetime TEXT";
      $sql .= " , flag INTEGER DEFAULT 0";    // 0 : 新規、1 : 承認、2 : 拒否
      $sql .= "  )";
      $sql .= ";";
      $this->exec($sql);
    }

    // デストラクタ
    public function __destruct()
    {
      $this->close();
    }

    // 新規、承認されたコメントの取得(html)
    public function get_comments($page)
    {
      $retval = "";
      foreach ($this->select_page($page, array(0, 1)) as $row)
      {
        $retval .= $this->make_html($row["name"], $row["datetime"], $row["comment"]);
      }
      return $retval;
    }

    // html コメントの作成
    public function make_html($name, $datetime, $comment)
    {
      $retval  = '<div class="comment_content">';
      $retval .= '<span class="comment_name">';
      $retval .= $name;
      $retval .= '</span>';
      $retval .= '<span class="comment_datetime">';
      $retval .= date_format(date_create($datetime), "Y年m月d日 H:i");
      $retval .= '</span>';
      $retval .= '<div style="margin-left: 0.5em">';
      $retval .= $comment;
      $retval .= '</div>';
      $retval .= '</div>';
      return $retval;
    }

    // データ取得
    public function select_page($page, $flags = null)
    {
      $rows = array();
      $sql  = "SELECT * FROM " .  $this->table;
      $sql .= " WHERE page = '" . $this->escapeString($page) . "'";
      if (is_int($flags) != false)
      {
        $sql .= " AND flag = " . $flags;
      }
      if (is_array($flags) != false)
      {
        $rest = "";
        foreach ($flags as $flag)
        {
          if (strlen($rest) != 0)
          {
            $rest .= ", ";
          }
          $rest .= $flag;
        }
        if (strlen($rest) != 0)
        {
          $sql .= " AND flag IN (" . $rest . ")";
        }
      }
      $sql .= " ORDER BY id";
      $sql .= ";";
      $results = $this->query($sql);
      while ($row = $results->fetchArray())
      {
        array_push($rows, $row);
      }
      return $rows;
    }

    // データ保存
    public function save($page, $name, $mail, $url, $comment)
    {
      $sql  = "INSERT INTO " . $this->table;
      $sql .= " (";
      $sql .= "   page";
      $sql .= " , name";
      $sql .= " , mail";
      $sql .= " , url";
      $sql .= " , comment";
      $sql .= " , datetime";
      $sql .= " )";
      $sql .= " VALUES";
      $sql .= " (";
      $sql .= "   '" . $this->escapeString($page) . "'";
      $sql .= " , '" . $this->escapeString($name) . "'";
      $sql .= " , '" . $this->escapeString($mail) . "'";
      $sql .= " , '" . $this->escapeString($url) . "'";
      $sql .= " , '" . $this->escapeString($comment) . "'";
      $sql .= " , '" . $this->escapeString(date("Y-m-d H:i:s")) . "'";
      $sql .= " )";
      $sql .= ";";
      $this->exec($sql);
    }
  }
?>
define している CryptSalt は後述する comment_image.php 及び comment_save.php で使用する crypt() 関数に引数として与える salt です。"12345678901234567890" の20バイトを適当な文字に置き換えてください。
データベースは SQLite3 を使用します。データベースファイル名は comment.sqlite、テーブル名は comments とします。
function __construct() はデータベースファイルが存在しないならばデータベースファイルを作成します。従って、 comment ディレクトリには Apache ユーザに rw 権限を付与してください。テーブルが存在しないならばテーブルを作成します。page はコメントが投稿されたページの URI、name は投稿者の名前、mail は投稿者のメールアドレス、url は投稿者のホームページ、comment は投稿内容、datetime は投稿日時を格納します。flag は新規に投稿されたら 0、承認したら 1、拒否は 2 とします。
function __destruct() はデータベースをクローズします。
get_comments($page) は引数で指定されたページのコメントの中から新規と承認のデータを抽出し、表示可能な HTML 文字列にします。
function make_html($name, $datetime, $comment) は投稿者の名前、投稿日時、コメントを HTML 文字列にします。
select_page($page, $flags = null) は引数で指定された条件でデータを抽出し、抽出したデータを配列にします。
function save($page, $name, $mail, $url, $comment) はデータをテーブルに格納します。

output/comment/comment_image.php

<?php
  require_once("comment.php");

  // 乱数によりイメージファイルを決定する
  $selectImage = "";
  $imageFiles = array();
  for ($i = 0; $i < 4; $i++)
  {
    $num = intval(mt_rand()) % 10;
    $selectImage .= "selectImage" . $num;
    array_push($imageFiles, $num . ".png");
  }

  // image resourceの生成
  $widthTotal = 0;
  $heightMax = 0;
  $images = array();
  foreach ($imageFiles as $imageFile)
  {
    list($width, $height) = getimagesize($imageFile);
    $widthTotal += $width;
    $heightMax = max($heightMax, $height);
    array_push($images, array("image" => imagecreatefrompng($imageFile), "width" => $width, "height" => $height));
  }

  // ベースのimage resourceを生成
  $baseImage  = imagecreatetruecolor($widthTotal, $heightMax);

  // ベース画像にイメージファイルの画像を順に合成
  $dst_x = 0;
  foreach ($images as $image)
  {
    imagecopy($baseImage, $image["image"], $dst_x, 0, 0, 0, $image["width"], $image["height"]);
    $dst_x += $image["width"];
  }

  foreach ($images as $image)
  {
    imagedestroy($image["image"]);
  }

  // PNG形式で出力
  header('Content-Type: image/png');
  imagepng($baseImage);

  imagedestroy($baseImage);

  // セッションに出力したデータを暗号化して記録
  session_name("セッション名");
  session_start();
  $_SESSION["selectImage"] = substr(crypt($selectImage, CryptSalt), strlen(CryptSalt));
?>
スパム防止のため4つのイメージを選択してもらうようにしました。どの程度の効果があるかはわかりません。当サイトにスパムがあるのかどうか?イメージは適当に GIMP で数字として作成しました。
乱数で4つのイメージを決め、それのイメージを合成します。イメージ合成は を参考にしました。
4つのイメージのデータは暗号化してセッションに保存して、照合用に使用します。crypt() 関数に引数として与えた salt が crypt() 関数の戻り値の先頭にくっついてくるようですので、先頭の salt を除外したものをセッションに保存します。

output/comment/comment_save.php

<?php
  require_once("comment.php");

  function alltrim($str)
  {
    return mb_ereg_replace("(^[[:space:] ]+)|([[:space:] ]+$)", "", $str);
  }

  $errmsg = "";
  // なまえチェック
  $commentator_name = alltrim(@$_REQUEST["commentator_name"]);
  if (!strlen($commentator_name))
  {
    $errmsg .= (strlen($errmsg)) ? "\n" : "";
    $errmsg .= "なまえが入力されていません。";
  }
  // メールアドレスチェック
  $commentator_mail = alltrim(@$_REQUEST["commentator_mail"]);
  if (!strlen($commentator_mail))
  {
    $errmsg .= (strlen($errmsg)) ? "\n" : "";
    $errmsg .= "メールアドレスが入力されていません。";
  }
  else
  {
    if (!preg_match('/[!#-9A-~]+@+[a-z0-9]+.+[^.]$/i', $commentator_mail))
    {
      $errmsg .= (strlen($errmsg)) ? "\n" : "";
      $errmsg .= "メールアドレスをご確認ください。";
    }
  }
  // URLチェック
  $commentator_url = alltrim(@$_REQUEST["commentator_url"]);
  if ((strlen($commentator_url)) && (!preg_match('/(http|ftp):\/\/.+/i', $commentator_url)))
  {
    $errmsg .= (strlen($errmsg)) ? "\n" : "";
    $errmsg .= "URLをご確認ください。";
  }
  // コメントチェック
  $comment = alltrim(@$_REQUEST["comment"]);
  if (!strlen($comment))
  {
    $errmsg .= (strlen($errmsg)) ? "\n" : "";
    $errmsg .= "コメントが入力されていません。";
  }
  // セッションに記録したデータと選択されたデータを比較し
  //  違えばエラー、同じならデータ保存
  $replaceImage = false;
  session_name("セッション名");
  session_start();
  $selectImage = @$_SESSION["selectImage"];
  $selectedImage = @$_REQUEST["selectImage"];
  if ((strlen($selectedImage) == 0) || (crypt($selectedImage, CryptSalt) != (CryptSalt . $selectImage)))
  {
    $errmsg .= (strlen($errmsg)) ? "\n" : "";
    $errmsg .= "同じ画像を選んでください。";
    if (strlen($selectedImage) != 0)
    {
      $replaceImage = true;
    }
  }
  if (strlen($errmsg) == 0)
  {
    $replaceImage = true;
  }
  echo "REPLACEIMAGE:";
  echo $replaceImage;
  echo ";";
  if (strlen($errmsg) != 0)
  {
    // エラー送出
    echo "ERROR:";
    echo $errmsg;
  }
  else
  {
    // データ送出
    echo "DATA:";
    $page = parse_url(@$_REQUEST["docurl"], PHP_URL_PATH);
    $db = new Comment();
    $db->save($page, $commentator_name, $commentator_mail, $commentator_url, $comment);
    echo $db->get_comments($page);
    $db->close();
    // メール送信
    mb_language("ja") ;
    mb_internal_encoding("UTF-8") ;
    $message = "ページ: " . $page;
    $message .= "\r\n";
    $message .= "なまえ: " . $commentator_name;
    $message .= "\r\n";
    $message .= "メール: " . $commentator_mail;
    $message .= "\r\n";
    $message .= "URL: " . $commentator_url;
    $message .= "\r\n";
    $message .= "コメント: " . $comment;
    mb_send_mail("宛先メールアドレス", "コメント登録", $message, "From: 送信元メールアドレス");
  }
?>
投稿されたコメントを Ajax で受け取り、データベースに登録します。登録されたことをメールで通知するようにしました。
function alltrim($str) は全角スペースを含む trim です。 を参考にしました。
メールアドレスのチェックには を参考にしました。
URL のチェックには を参考にしました。
メールアドレスや URL のチェックは存在確認をするのがベターでしょうが、ここでは形式チェックのみをしています。
メール送信に mb_send_mail() 関数を使用しています。この関数は localhost がメールサーバであることを前提とした関数のようです。メールサーバが他のホストであったり、SMTP 認証が必要ならば PEAR::Mail を使用するのがいいでしょう。

Rules

route '*' do
  if item.binary?
    # Write item with identifier /foo/ to /foo.ext
    item.identifier.chop + '.' + item[:extension]
  else
    # Write item with identifier /foo/ to /foo/index.html
    item.identifier + 'index.shtml'
  end
end
Apache の SSI はデフォルトでは拡張子が shtml となっています。拡張子が html でも SSI が可能なように Apace の設定を変更してもいいでしょうが、ここでは nanoc が compile して出力するファイルの拡張子を shtml に変えるようにしました。
サイトディレクトリの直下にある Rules ファイルの index.html を index.shtml に変えれば出力するファイルの拡張子が shtml に変わります。

/etc/apache2/mods-available/dir.conf

<IfModule mod_dir.c>
  DirectoryIndex index.html index.cgi index.pl index.php index.xhtml index.htm index.shtml
</IfModule>
Apache のデフォルトでは DirectoryIndex に index.shtml は設定されていません。Ubuntu Linux 10.04.3 では /etc/apache2/mods-available/dir.conf にその指定があります。これに index.shtml を追加します。これでファイル名を省略してもアクセスできます。しかし、nanoc view で確認するときには index.shtml を指定する必要があります。nanoc view で確認するとき、WEBrick は SSI を処理しないようですのでコメント欄は表示されません。

投稿があった時にその投稿の承認、拒否をする手段が必要ですが、当分の間 TkSQLite を使用することにします。