CoffeeScriptで画面遷移を楽に書くgoldblendで、♪だばだー♪だーば♪だばだー♪

#予定外！続きがあります
[前進してgoldblendはどこへ行ったか](http://qiita.com/mrpepper/items/b5f9b3a9221411bbd5c5)。こんな平日の昼間になぜか前進したのは、他にやらなきゃいけないことがあるからですね、はい。反省しております。

#環境
* node.js 0.10.33
* express 4.0.0-rc4
* CoffeeScript 1.9.2

#続・やはり画面遷移毎にごりごり書くのはめんどい
[早朝書いた記事](http://qiita.com/mrpepper/items/c6f7aa46402c66f232a2)の続きです。

画面遷移をぱっぱかぱっぱか書きたいので、字句解析も構文解析もしない、怪しいプリプロセッサをかましてなんとかしたろ、と思っている悪い人の話。

#続・技術的な説明はごめんよ
下の方に、出力されたクラスが書いてありますが、「=>」で束縛とか地味に大事です、とか技術ブログっぽいことを書いてみたりするものの、ガチ勢に襲われるのが怖いので多くは語るまい。こうして見てみると、CoffeeScriptによって、Javascriptの **クラスが使い物になる** というのが大きいなあ、と思うのです。さすがに、こういうプリプロセッサを、Javascriptを出力言語にして作ろうとは思いません。

さて早速なのですが、後述のgoldblend.coffeeを適当な場所に置き、

```coffee ./goldblend.coffee -e mytest.gold
```
以上です。mytest.coffeeが出力されます。監視用の「-w」オプションとか仕込んだままになっておりますが、なんと！　未だに怖くて動作確認していないので、ちゃんと動かないかも知れません。動作確認の取れている使い方は、上記の使い方だけです。まあ、監視オプションなんかなくても、どうせみんなGruntとか使ってるよね！

#続・インスタントに珈琲だからgoldblend
さあ、こうしてインスタントに珈琲ができあがります。折角なのでここからは「goldblend」の公式BGMである「[例の曲](https://www.youtube.com/watch?v=gDVd6n-WTW8)」を聞きながら、だばだー、だーーば、だばだー、と読んで頂ければ結構です。

さて、プリプロセッサが処理する構文はこれだけです。

```
#設定行
#　全項目の設定変更をします
<<<{routername:router, middleware:logincheck, nocache: no}
#　middlewareを無効化し、キャッシュ対策を有効にします
<<<{middleware:, nocache: yes}

#画面定義
<<<classname(req,res,arg1,arg2)
  params['title'] = 'タイトルとか挿しこんじゃう？'
  params['author'] = '私だ'
  @render @@, params
```
こんな感じになりました。設定行が新たにお目見えし、画面定義が何だか関数っぽい見た目になりました。いっそ、カッコの省略も認識しろ、と言いたいところなのですが、あんまりいわゆる「俺様ユーティリティ」に手間暇かけたくないので、もう今度こそ放置です。きっと湯水のように時間を使えて、賢くてお茶目でロリっぽくて、生まれたときからLLと一緒に育ってきた大学院生(14)が、こういうニーズに答えるもっと素敵なソリューションをnpmにあげてくれるのを信じています。

で、それぞれの意味を下に示します。

## 設定行の要素
<dl>
  <dt>&lt;&lt;&lt;{}</dt>
  <dd>画面出力関係の設定だよ。ファイルの中で、直前の設定が有効だよ。設定行がある度に、設定は上書きされるよ。実装の都合っぽい感じの仕様なのは錯覚じゃないよ</dd>
  <dt>routername</dt>
  <dd>文字通り、ここへのアクセスを仕切るrouterオブジェクト。「routername:」と値を省略すると、デフォルトの「router」というオブジェクト名として扱う。nullとかfalseとかも受け付ければほっこり人情を感じるのですが、世の中は世知辛いです</dd>
  <dt>middleware</dt>
  <dd>classname.getをrouterにセットする際、middlewareとして登録する。URLでアクセスしたり、リダイレクトしたりした際に、セッション確認をするためのミドルウェアを設定することを想定している。つまり、ログイン後に通る画面には、logincheck。カンマで複数並べる仕様は廃止したので、複数のmiddlewareを使うときは、ラッパー関数を書いてそれを設定すれば良いよね！
なお、middlewareを外す際は、「middleware:」と値を省略して書けば良い。nullとかfalseとかも受け付ければほっこり人情を感じるのですが、世の中は世知辛いです</dd>
  <dt>nocache</dt>
  <dd>nocacheをtrueにすると、リダイレクトする際「?ランダム文字列」をURLの最後に付加してくれる（付けなければ、何も付加しない）。まああれだ、キャッシュが効いてしまって画面が更新されない事件のためですな</dd>
</dl>
なお、設定行のあったところには、出力されたCoffeeScriptの中で「#routername|middleware|nocache」の設定状態を示すコメントが入りますのでご参考までに。

## 画面定義の要素
<dl>
  <dt>&lt;&lt;&lt;</dt>
  <dd>ここいらで画面を出力するよ</dd>
  <dt>classname</dt>
  <dd>クラス名であり、「受信機」のあっちこっちから<br />
classname.redirect req,res,arg1,arg2 とすれば、一旦リダイレクトして、このクラスにぶら下がったスコープに到達しますし、<br />
classname.direct req,res,arg1,arg2 とすれば直接いきなりスコープ内の表示処理を実行します。また、<br />
http://your.domain/classname/arg1/arg2 でブラウザから実行させることもできます（リダイレクトできるのだから当たり前ですが、まあ、デバッグ用としても欲しいもんですよね）<br />
また、使用するテンプレート名もこのクラス名で決まります。例えば、views/classname.jadeみたいなものを用意することになります。<br />
なお、URLに展開されるとき、classname中のダブルアンダースコアは「/」に置換します。&lt;&lt;&lt;class__name(req,res,arg)にアクセスするURLは、http://your.domain/class/name/argとなります。routerを使い分けられる仕様にしたのに、深い階層へのルーティングができなかったら、express4使ってる意味ないもんね</dd>
  <dt>req,res</dt>
  <dd>req,resは必須の引数。ただし、request,responseなど、変数名を変えることは可能</dd>
  <dt>arg1,arg2</dt>
  <dd>req,resの後には、任意の個数の引数（出力画面を特定するために必要充分であること）が付けられる。引数は必要なければ、別段なくても良い。ただし多い方はちょいと注意で、引数は好きな数だけ設定できるが、URL長は1000byte程度までしか許されないのですね。そのへんのエラー処理はしてません</dd>
  <dt>後に続くスコープ</dt>
  <dd>画面定義行の後にはスコープが続く必要がある。そのスコープは、classname.directの後半としてそのまま用いられる。paramsオブジェクトには既にここで言うarg1,arg2が格納されている。想定しているのは、ここでキャッシュやDBから追加の情報を引っ張ってきて、paramsに積み増すこと。この辺、なんかラッパーを噛ませた方が良いでしょう。<br />
params['main'] = dbquery(username, 'main')<br />
params['sidebar'] = dbquery(username, 'link')<br />
みたいなのが並ぶイメージです。このスコープ内では、「@render」は「res.render」として展開され、「@@」はclassname文字列に展開される。分岐したり継続の中に放り込んだりするなど自由だけど、必ず@renderを通るように作りましょう。そのために、「@render」をシンタックスハイライトしておくと便利。（このclassname.directからさらにリダイレクトするのは、ブラウザによってはリダイレクトの連続をぶった切ってくれるので、あんまりよろしくないです。画面遷移は「ぐちゃぐちゃ有向グラフ」にならざるを得ないことが多いので、無限ループがちょう怖いです）<br />
@render @@+'-suffix', params<br />
というように、付加文字列を付けて、classname-suffix.jade（いや、ECTでもいいのですぞ）を参照させるようなことが可能で、複数種類のテンプレートを使い分けるように分岐する場合に用います。<br />
いずれにせよ、何らかのclassnameに対応したテンプレートが必要となります。</dd>
</dl>

と、こんな仕様。キモさは減った。ちょっとだけだけど減った。でもまだキモいね。でも、もう自分的にはこれで使っていこうと思った。構文解析なんてしないもーん。

というわけで、キモいのが嫌いな人は、鮮やかな解決策を作って、ついでにnpmで入れられるようにして欲しいな。**ついでに誰か！ECTのLINTも作って欲しいな（血涙）**

#続・閑話休題
話がそれましたが、そんなわけで、下記に入力例「mytest.gold」出力例「mytest.coffee」プリプロセッサ「goldblend.coffee」を列挙しました。

まったくもー。

```goldblend:mytest.gold
#==========================================
#== ライブラリ
require 'coffee-script/register'
debug          = console.log
http           = require('http')
express        = require('express')
path           = require('path')
logger         = require('morgan')
session        = require('express-session')
redis          = require('connect-redis')(session)
cookieParser   = require('cookie-parser')
bodyParser     = require('body-parser')
#multer         = require('multer')
errorHandler   = require('errorhandler')

#jade           = require('jade')
ect            = require('ect')


#==========================================
#== express4を使うよ
app = express()
app.set('port', process.env.PORT || 3000)

#==========================================
#== ビューエンジンのセットアップ
#app.set 'views', path.join(__dirname, 'views')
#app.set 'view engine', 'jade'
renderer = ect 
  watch: true
  root: path.join(__dirname, 'views')
  ext : '.ect'
app.engine 'ect', renderer.render
app.set 'view engine', 'ect'

#==========================================
#== 各種の「その他」アクセスはpublicとかに流す
app.use logger('dev')
app.use bodyParser.json()
app.use bodyParser.urlencoded(extended: false)
#app.use multer()
app.use cookieParser()
app.use express.static(path.join(__dirname, 'public'))

#==========================================
#== 主たる処理はこちら
router = express.Router()

#==========================================
#== 送信部

<<<{routername: router, middleware:logincheck, tocache:false}
<<<loginform(req,res,defaultname,retry)
  @render @@, params

<<<dashboard(req,res,username)
  @render @@, params

<<<dummy(req,res)
  params['this_is'] = 'dummy'
  @render @@, params

true

#==========================================
#== 受信部（単純に表示を求めるだけではない表示部）
router.get '/', (req, res) ->
  userpass.redirect req,res,''

router.post '/passinput', (req, res) ->
  #パスワード受入処理
  if result is ok
    loginform.direct req,res,req.body.name,true
  else
    dashboard.redirect req,res,req.body.name


#==========================================
#== 送受信部登録
app.use '/', router


#==========================================
#== エラー処理諸々

#== 404エラーを発生させる
app.use (req, res, next) ->
  err = new Error('Not Found')
  err.status = 404
  next err

#== 開発時エラーハンドラ（StackTrace表示）
if app.get('env') == 'development'
  app.use (err, req, res, next) ->
    res.status err.status or 500
    res.render 'error',
      message: err.message
      error: err

#== 運用時エラーハンドラ
app.use (err, req, res, next) ->
  res.status err.status or 500
  res.render 'error',
    message: err.message
    error: {}

#== HTTPサーバの "error" へのイベントリスナ
onError = (error) ->
  if error.syscall != 'listen'
    throw error
  bind = if typeof port == 'string' then 'Pipe ' + port else 'Port ' + port
  # handle specific listen errors with friendly messages
  switch error.code
    when 'EACCES'
      console.error bind + ' requires elevated privileges'
      process.exit 1
    when 'EADDRINUSE'
      console.error bind + ' is already in use'
      process.exit 1
    else
      throw error
  return

#== HTTPサーバの "listening" へのイベントリスナ
onListening = ->
  addr = server.address()
  bind = if typeof addr == 'string' then 'pipe '+addr else 'port '+addr.port
  debug 'Listening on ' + bind
  return


#==========================================
#== express4サーバ立ち上げ
normalizePort = (val) ->
  port = parseInt(val, 10)
  return if isNaN(port) then val else if port >= 0 then port else false
port = normalizePort(process.env.PORT or '3000')
app.set 'port', port
server = http.createServer(app)
server.listen port
server.on 'error', onError
server.on 'listening', onListening

#==========================================
#== 外部からモジュールへのアクセス
module.exports = app
```

でもって、変換結果がこれ。

```coffeescript:mytest.coffee
#==========================================
#== ライブラリ
require 'coffee-script/register'
debug          = console.log
http           = require('http')
express        = require('express')
path           = require('path')
logger         = require('morgan')
session        = require('express-session')
redis          = require('connect-redis')(session)
cookieParser   = require('cookie-parser')
bodyParser     = require('body-parser')
#multer         = require('multer')
errorHandler   = require('errorhandler')

#jade           = require('jade')
ect            = require('ect')


#==========================================
#== express4を使うよ
app = express()
app.set('port', process.env.PORT || 3000)

#==========================================
#== ビューエンジンのセットアップ
#app.set 'views', path.join(__dirname, 'views')
#app.set 'view engine', 'jade'
renderer = ect 
  watch: true
  root: path.join(__dirname, 'views')
  ext : '.ect'
app.engine 'ect', renderer.render
app.set 'view engine', 'ect'

#==========================================
#== 各種の「その他」アクセスはpublicとかに流す
app.use logger('dev')
app.use bodyParser.json()
app.use bodyParser.urlencoded(extended: false)
#app.use multer()
app.use cookieParser()
app.use express.static(path.join(__dirname, 'public'))

#==========================================
#== 主たる処理はこちら
router = express.Router()

#==========================================
#== 送信部

#router||true
class loginform
  @install: (obj) => obj.get '/loginform/:defaultname/:retry',@get
  @redirect: (req,res,defaultname,retry) =>
    params = ''
    params += '/'+encodeURIComponent(defaultname)
    params += '/'+encodeURIComponent(retry)
    res.redirect '/loginform'+params+'?'+Math.random().toString(36).slice(-8)+Math.random().toString(36).slice(-8)
  @get: (req,res) =>
    defaultname = req.params.defaultname ? ''
    retry = req.params.retry ? ''
    @direct req,res,defaultname,retry
  @direct: (req,res,defaultname,retry) =>
    params =
      defaultname: defaultname
      retry: retry
    res.render 'loginform', params
  
loginform.install router

class dashboard
  @install: (obj) => obj.get '/dashboard/:username',@get
  @redirect: (req,res,username) =>
    params = ''
    params += '/'+encodeURIComponent(username)
    res.redirect '/dashboard'+params+'?'+Math.random().toString(36).slice(-8)+Math.random().toString(36).slice(-8)
  @get: (req,res) =>
    username = req.params.username ? ''
    @direct req,res,username
  @direct: (req,res,username) =>
    params =
      username: username
    res.render 'dashboard', params
  
dashboard.install router

class dummy
  @install: (obj) => obj.get '/dummy',@get
  @redirect: (req,res) =>
    params = ''

    res.redirect '/dummy'+params+'?'+Math.random().toString(36).slice(-8)+Math.random().toString(36).slice(-8)
  @get: (req,res) =>

    @direct req,res
  @direct: (req,res) =>
    params = {}

    params['this_is'] = 'dummy'
    res.render 'dummy', params
  
dummy.install router

true

#==========================================
#== 受信部（単純に表示を求めるだけではない表示部）
router.get '/', (req, res) ->
  userpass.redirect req,res,''

router.post '/passinput', (req, res) ->
  #パスワード受入処理
  if result is ok
    loginform.direct req,res,req.body.name,true
  else
    dashboard.redirect req,res,req.body.name


#==========================================
#== 送受信部登録
app.use '/', router


#==========================================
#== エラー処理諸々

#== 404エラーを発生させる
app.use (req, res, next) ->
  err = new Error('Not Found')
  err.status = 404
  next err

#== 開発時エラーハンドラ（StackTrace表示）
if app.get('env') == 'development'
  app.use (err, req, res, next) ->
    res.status err.status or 500
    res.render 'error',
      message: err.message
      error: err

#== 運用時エラーハンドラ
app.use (err, req, res, next) ->
  res.status err.status or 500
  res.render 'error',
    message: err.message
    error: {}

#== HTTPサーバの "error" へのイベントリスナ
onError = (error) ->
  if error.syscall != 'listen'
    throw error
  bind = if typeof port == 'string' then 'Pipe ' + port else 'Port ' + port
  # handle specific listen errors with friendly messages
  switch error.code
    when 'EACCES'
      console.error bind + ' requires elevated privileges'
      process.exit 1
    when 'EADDRINUSE'
      console.error bind + ' is already in use'
      process.exit 1
    else
      throw error
  return

#== HTTPサーバの "listening" へのイベントリスナ
onListening = ->
  addr = server.address()
  bind = if typeof addr == 'string' then 'pipe '+addr else 'port '+addr.port
  debug 'Listening on ' + bind
  return


#==========================================
#== express4サーバ立ち上げ
normalizePort = (val) ->
  port = parseInt(val, 10)
  return if isNaN(port) then val else if port >= 0 then port else false
port = normalizePort(process.env.PORT or '3000')
app.set 'port', port
server = http.createServer(app)
server.listen port
server.on 'error', onError
server.on 'listening', onListening

#==========================================
#== 外部からモジュールへのアクセス
module.exports = app
```

で、変換用スクリプトがこれ。

```coffeescript:goldblend.coffee
fs = require('fs')

for val,index in process.argv
  if index>1
    if /^-/.test(val)
      if /w/.test(val)
        watch = true
    else
      if /\.gold$/i.test(val) and fs.existsSync val
        filesrc = val
        filedst = val.replace /gold$/i, 'coffee'

if not(filesrc?)
  console.log 'mi a ta ra naaai!'
  process.exit 1

writetext = (dst, text) ->
  fs.appendFileSync dst,text,'utf8', (err) -> console.log err

generateTail = (dst, indentsp, classname, routername) ->
  text = indentsp+classname+'.install '+routername+"\n\n"
  writetext dst,text

generateMain = (dst,indentsp,classname,temp,routername,mw,nocache) ->
  args = []
  for str,index in temp
    str = str.replace(/^\s+/, '').replace(/\s+$/, '')
    switch index
      when 0
        argreq = str
      when 1
        argres = str
      else
        args.push str
  urlprm = ''
  cvparams = ''
  buildparams4sp = ''
  reqparams4sp = ''
  yamlparams6sp = ''

  for str,index in args
    urlprm += '/:'+str
    cvparams += ','+str
    if index>0
      buildparams4sp += "\n"
      reqparams4sp += "\n"
      yamlparams6sp += "\n"
    buildparams4sp += "#{indentsp}    params += '/'+encodeURIComponent(#{str})"
    reqparams4sp += "#{indentsp}    #{str} = #{argreq}.params.#{str} ? ''"
    yamlparams6sp += "#{indentsp}      #{str}: #{str}"
  emptyobject = if args.length == 0 then ' {}' else ''

  randomstring = 'Math.random().toString(36).slice(-8)'
  appendnocache = if nocache then "+'?'+#{randomstring}+#{randomstring}" else ''
  path = classname.replace /__/,'/'
  
  text = """
#{indentsp}class #{classname}
#{indentsp}  @install: (obj) => obj.get '/#{path}#{urlprm}',#{mw}@get
#{indentsp}  @redirect: (#{argreq},#{argres}#{cvparams}) =>
#{indentsp}    params = ''
#{buildparams4sp}
#{indentsp}    #{argres}.redirect '/#{path}'+params#{appendnocache}
#{indentsp}  @get: (#{argreq},#{argres}) =>
#{reqparams4sp}
#{indentsp}    @direct #{argreq},#{argres}#{cvparams}
#{indentsp}  @direct: (#{argreq},#{argres}#{cvparams}) =>
#{indentsp}    params =#{emptyobject}
#{yamlparams6sp}
"""
  writetext dst,text
  return argres

doconv = (dst, src) ->
  console.log 'processing... '+src
  content = fs.readFileSync(src).toString()
  content = content.replace(/\r\n/, "\n").replace(/\r/, "\n").split("\n")
  if fs.existsSync(dst)
    fs.writeFile dst, '', 'utf8', (err) -> console.log err
  comments = false
  generate = false
  indentsp = ''
  defaultmode = true
  routername = 'router'
  mw = ''
  nocache = true
  for line,index in content
    if index>0
      writetext dst,"\n"
    if comments
      writetext dst,line
      if /^\s*###/.test(line)
        comments = false
    else
      normaloutput = false
      addindent = false
      
      if /^\s*###/.test(line) #コメントのみ行を影響させない
        comments = true
        normaloutput = true
      else if /^\s*#/.test(line) #コメントのみ行を影響させない
        normaloutput = true
        addindent = generate
      else if /^\s*$/.test(line) #空行を影響させない
        normaloutput = true
        addindent = generate
      else
        if /^\s*<<</.test(line)
          if generate
            #generate末尾出力
            generateTail dst,indentsp,classname,routername

          result = line.match /^(\s*)<<<\s*{([^{}]*)}/
          if result == null
            #newgenerate
            if defaultmode
              defaultmode = false
              writetext dst,'#'+routername+'|'+mw+'|'+nocache.toString()
            
            #indentspの取得
            #argreq,argres,argsの取得
            #classnameの取得
            result = line.match /^(\s*)<<<\s*(\S+)\s*\(([^()]+)\)/
            if result == null
              console.log 'syntax error ['+index+']'
              process.exit 1
            #generate要素読み取り
            indentsp = result[1]
            classname = result[2]
            temp = result[3].split(',')
            argres = generateMain dst,indentsp,classname,temp,routername,mw,nocache
            generate = true
            normaloutput = false
          else
            #routernameの取得
            #mwの取得
            #nocache
            defaultmode = false
            scopeindentsp = result[1]
            tempkv = {}
            keyvalues = result[2].split(',')
            for keyvalue in keyvalues
              keyvalue = keyvalue.replace(/^\s+/, '').replace(/\s+$/, '')
              keyvalue = keyvalue.split(':')
              key = keyvalue[0].replace(/^\s+/, '').replace(/\s+$/, '')
              value = keyvalue[1].replace(/^\s+/, '').replace(/\s+$/, '')
              tempkv[key] = value
            if tempkv.routername?
              routername = tempkv.routername
              if routername.length == 0
                routername = 'router'
            if tempkv.middleware?
              mw = tempkv.middleware
              if mw.length > 0
                mw = mw + ','
            if tempkv.nocache?
              nocache = (/(true|ok|yes)/i.test(tempkv.nocache))
            writetext dst,'#'+routername+'|'+mw+'|'+nocache.toString()
            normaloutput = false
        else if generate
          spaces = line.match /^\s*/
          if spaces == null
            console.log 'a ri e nai error!'
            process.exit()
          if spaces[0].length > indentsp.length
            #generateスコープ内出力
            line = line.replace /@render\b/, argres+'.render'
            line = line.replace /@@/, "'"+classname+"'"
            writetext dst,'  '+line
            normaloutput = false
          else
            #generate末尾出力
            generateTail dst,indentsp,classname,routername
            generate = false
            normaloutput = true
        else
          normaloutput = true
      
      if normaloutput
        if addindent
          fs.appendFileSync dst,'  '+line,'utf8', (err) -> console.log err
        else
          fs.appendFileSync dst,line,'utf8', (err) -> console.log err
  console.log 'completed!'



doconv filedst,filesrc

if watch?
  fs.watchFile filesrc, (curr, prev) ->
    doconv filedst,filesrc
else
  process.exit 0