ちょっとWikiをつくってみる

GoogleChrome は、インターフェイスが気持ちいいですね。コレを使っているとなんだか Webアプリが作りたくなってきます。というわけで、Smalltalk の Webフレームワーク である Seaside を使った お手軽ファイルベースWiki を作ってみました。


元ネタはこちら。

A Simple File Based Wiki in Seaside

Seaside の練習の為だけの超お手軽 Wiki で、なんと 2時間でかけちゃったんだよ! と言うので、私も 仕事のコンパイル時間の合間に(ぉ) 気楽にチャレンジしてみました。で、せっかくなので、これをなぞりながらエントリーも書いてみました。(訳ではないです。勉強ノートといった体。)


まずは準備

梅澤さんのページ SeasideJOnePlus から、SeasideJOne をダウンロードします。解凍して、直下にある win_seaside.bat 等のうち、自分の環境にあったものを実行して起動します。

次に 正規表現を使いたいので、regex パッケージを持ってきます。

Regex -Squeak Source(download)

VB-Regex-sd.9.mcz みたいなのが落としてこれるので、これを ファイルリストなどからロードすれば OK です。

いざ作成!

クラスブラウザを起動して、早速バリバリコーディングです。まずクラス作成。

WAComponent subclass: #WikiPage
   instanceVariableNames: 'isEditing currentContent currentPage'
   classVariableNames: ''
   poolDictionaries: ''
   category: 'SimpleFileWiki'

Seaside では、Webアプリを WAComponent のサブクラスとして作成します。WAComponent のサブクラスは VisualComponent になります。このコンポーネントは Seaside から #renderContentOn: メッセージが送られたときに引数で渡された WARenderCanvas を使って 画面を表示する責務を持ちます。


アプリ設定用のメソッドをクラス側に実装します。

canBeRoot
   ^true

initialize
   self registerAsApplication: #wiki

#canBeRoot は、そのまんま、ルートに慣れる Webコンポーネント化否かを答えるメソッド。 #initialize は、中のコードをワークスペースで実行しても良いので、別に定義しなくても大丈夫です。このコードを実行すると、Seaside のトップページに このアプリがリストアップされます。


さて、次にこのクラスのインスタンスを初期化するコードを書きます。

initialize
   super initialize.
   currentPage := ''.
   isEditing := false.

さぁ、ここまで作ったら、まずは下回りの整備。必要なアクセスメソッドを定義します。

currentContent
   ^ currentContent ifNil: [currentContent :='']

currentContent: aString
   currentContent := aString

style
   ^'textarea {width:90%; height:500px;}'

インスタンスフィールド currentContent のアクセサと、 スタイルシートを出力するための #style メソッドを実装です。

続けて、ファイルシステム周りを固めます。

pageDirectory
   ^ (FileDirectory default
        directoryNamed: self session application name, #Pages)
      assureExistence

pageAt: aPage
   isEditing := (self pageExists: aPage) not.
   isEditing ifTrue: [^''].
   ^ FileStream readOnlyFileNamed:
                      (self pageDirectory fullNameFor: aPage)
      do: [:file | file contentsOfEntireFile]

ページ単位に生成されるファイルを保存するディレクトリの取得 & 生成(アプリケーション名+'Pages' と言うフォルダを作ります)と、ページ内容をファイルから取得するメソッドです。(その名前のページが無いときは、編集モードに以降するという賢い子!...っていいのか!?)

って、あれま、ページの有無をテストするメソッドを作り忘れました (^^;

pageExists: match
   ^self pageDirectory fileExists: match


* * *


ここまでで、モデルはオシマイ、こんどはコントローラの作成に入ります。

loadPage: aPage
   isEditing := false.
   currentPage := aPage.
   self currentContent: (self pageAt: aPage)

savePage
   self currentContent
      ifEmpty:
         [ (self pageExists: currentPage) ifTrue:
           [ self pageDirectory deleteFileNamed: currentPage.
           self loadPage: #FrontPage ] ]
      ifNotEmpty:
         [ FileStream
           forceNewFileNamed:
               (self pageDirectory fullNameFor: currentPage)
           do: [ :file | file nextPutAll: self currentContent ].
         isEditing := false ]

cancel
   isEditing := false

save側が やたらと複雑ですが、これはページ内のコンテンツを全部消しちゃえば、ページ自体も消えるようにするためです。


* * *


さて、いよいよレンダリングです。

#renderContentOn: では、引数 html で渡されてくる WARenderCanvas に対して、ページの描画を行います。まずはメインのレンダリングメソッド。

renderContentOn: html
   isEditing
      ifTrue: [self renderEditorOn: html]
      ifFalse: [self renderViewerOn: html]

モードに応じて他のメソッドに委譲します。まずは、ページ編集画面のレンダリングメソッド、

renderEditorOn: html
   (html heading)
         level1;
         with: ((self pageExists: currentPage)
                   ifFalse:
                      ['Page', 
                      currentPage,
                      'hasn''t been created yet, go for it!']
                   ifTrue:
                      ['Editing', currentPage]).
   html form:
      [html textArea on: #currentContent of: self.
      html break.
      html submitButton on: #savePage of: self.
      html text: 'or'.
      html anchor on: #cancel of: self]

Seaside では html を直接記述する必要はありません。こういうところでも普通に OOP でうれしいなー、というのが Seaside のいいところ。

次は、Viewer モードのレンダリングメソッド。

renderViewerOn: html
   self withLineBreaks: (self currentContent
          copyWithRegex: '\[\[\w+\]\]'
          matchesTranslatedUsing:
             [:match |
             | wikiWord |
             wikiWord := match copyFrom: 3 to: match size -2.
             (self pageExists: wikiWord)
                ifTrue:
                  ['<a href=''',
                  (html urlForAction:
                      [self loadPage: wikiWord]) displayString,
                  '''>', wikiWord, '</a>']
                ifFalse:
                  [wikiWord, '<a href=''',
                  (html urlForAction: 
                      [self loadPage: wikiWord]) displayString,
                  '''>?</a>']])
      on: html.
   html paragraph:
      [(html anchor)
          callback: [isEditing := true];
          text: 'Edit'.
      html space.
      (html anchor)
          callback: [self loadPage: #frontPage];
          text: 'FrontPage']

参考にした オリジナルでは、Wikiワード (アッパーキャメルケースな単語) が勝手にリンクに置き換わるコードだったのですが、日本人には使いにくくってしかたがないので、ダブルブラッケットに変更しました。( match copyFrom: 3 to: match size -2 なんてダサダサなことやっているのが私の書いた部分 (^^; )

(それにしても、String >> #copyWithRegex:matchesTranslateUsing: って便利だなぁ。)

最後は、上で使っている <br> しながら書き出すこんなツールメソッド。

withLineBreaks: aString on: html
   | stream |
   stream := aString readStream.
   [stream atEnd] whileFalse:
      [html html: stream nextLine.
      stream atEnd ifFalse: [html break]]


ここまで書けば動くハズです。ワークスペースで、

KomSeasideJ startOn: 9090.
WikiPage initialize.

の二行を doit して、http://localhost:9090/seaside/config にアクセスすれば、wiki というリンクが出来ているので、そこをクリックすれば、

Wikiらしく、ちょっと改造


ここまででも、かなりWikiしてるのですが、あともう一歩。

まずはページタイトルのカスタマイズ。#updateRoot: をオーバライドします。

updateRoot: aRoot
   super updateRoot: aRoot.
   aRoot title: currentPage

こうすると、

のほうにページ名がタイトル名に。


次は、ブックマーカブルなURL を作る作業。

updateUrl: aUrl
   super updateUrl: aUrl.
   aUrl addToPath: currentPage withFirstCharacterDownshifted

#updateUrl:は、アンカーURLを作成するときに Seaside から呼ばれるメソッドです。なので、ここで aUrl をチョメチョメ。こうすると、

これで .../seaside/wiki?クエリ文字列 だったのが、 .../seaside/wiki/pagename?クエリ文字列 に変わります。


で、今度は、いきなり URL打ちで、 ...seaside/wiki/pagename でアクセスされたとき、ページpagename にアクセス出来るようにします。

initialRequest: aRequest
   | page url |
   url := aRequest url stringAfter: self application basePath.
   page := url copyAfterLast: $/.
   self loadPage:
          (page ifEmpty: ['FrontPage' ] ifNotEmpty: [ page ])

うにゃうにゃ。#initialRequest: は、新しいセッションを開始するときに呼ばれるメソッドです。たりしたときにそのリクエスト引数に呼ばれるメソッドです。側 から呼ばれるメソッドです。ここで アプリケーションベースパス (...seaside/wiki)の後ろを取ってきて、そのページを表示させる処理を書きます。*1

ちなみに #initialRequest: 使われている String >> #stringAfter: なんてありませんので追加します。

String >> stringAfter: aDelim
   | list |
   list := self split: aDelim.
   ^list isEmpty 
             ifTrue: [self] 
             ifFalse: [list last withBlanksTrimmed]

String >> split: aString
   | index lastIndex |
   index := lastIndex := 1.
   ^ Array streamContents:
      [:stream |
      [index <= self size] whileTrue:
         [index := self
                     findString: aString 
                     startingAt: lastIndex.
         index = 0 ifTrue: [index := self size +1].
         stream nextPut:
                 (self copyFrom: lastIndex to: index -1).
         lastIndex := index + aString size]]

実は、元エントリで、最初はこの下りはなかったみたい。それというのも作者さん、この拡張版String を普段から愛用していて、なのでそれが独自拡張で有ることをすっかり忘れてしまったみたい。コメント欄で突っ込まれてました。こういうところが Smalltalk ぽくていいですね。


* * *


今も Seaside を求めてこの blog に来られる方が結構いるみたいなのですが、わたしのSeaside チュートリアルは、内容が古いので、今の Seaside ではそのままでは動きません。 #renderContentOn: で渡されるレンダラが Seaside 2.8 から WAHtmlRenderer から WARenderCamvas に変わっています。こんなんでいいのかと、ちょっと心苦しく思っていましたので、chrome をだしに、新しいエントリをでっち上げた次第。


Seaside のチュートリアルは、今回使用した SeasideJOne 付属の pdf ファイルや

があります。SeasideでGo!! は今まさに連載中。本当に手取足取りな素敵な記事です。これからが楽しみです。(そして、本に成らないかしら)

英語でよければ、こちらもよいと思います。

*1:元エントリでは、url の頭が / で始まっている場合、それをカットしてから copyAfterLast:$/ してるのですが、それだと期待通りに動かない。先方のコメント欄をみるとどうやらこれはバグフィックスとして入れられたコードみたいなんだけれど、よくわかりませんでした。(application basePath の挙動が違うのかしらん?)