CoffeeScript用プリプロセッサ「goldblend」作ってみた

#続き書いた
[コメント欄や文中でのセルフ突っ込みや思いつきを形にして書いてみた新しいgoldblendと新しいページ](http://qiita.com/mrpepper/items/5750257057e44cb01fb3)をアップしました。

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

#やはり画面遷移毎にごりごり書くのはめんどい
CoffeeScriptを使うようにしてみて、圧倒的にタイプ量は減った。減ったのだが、やはり画面遷移をごりごり書くのはめんどい。expressとSocket.IOと同居させてごにょごにょし始めたら、そっちをごりごりやりたいので、遷移は楽ちんに書きたい。

で、これは個人的な話なのですが、画面遷移を書くとき、どうしてもリクエストを処理する「受信機」とレスポンスを返す「送信機」は別に書いた方が良くって、「/」が「受信機」、「*」が「送信機」みたいな書き方をしています。CGIをCで書いて、クラッシュしてめそめそしていた頃からですね。でもって、「これに近い形で画面遷移のコードを書いて、プリプロセッサを噛ませば良いじゃん！」というのが、今回の話です。なんだかんだ、色々嵌まって数時間かかってしまいましたことよ。

```lang:memo.txt
/adminloginform(retry)
*adminloginform(retry)
　　ユーザとパスワード入力ページ
　　(POST)⇒/adminlogin(user,pass)

/adminlogin(user,pass)
　　パスワード認証
　　NG⇒*adminloginform(true)
　　OK⇒(rd)*dashboard(user)

```

#技術的な説明はごめんよ
CoffeeScriptにもまだそんなに詳しくないし、JavaScriptはマウスカーソルにまとわりつくウザい無意味な物体を制御するくらいしか経験がないので、ここでいきなり、プリプロセッサの正体に迫ります。
後述のgoldblend.coffeeを適当な場所に置き、

```coffee ./goldblend.coffee -e mytest.gold
```
以上です。mytest.coffeeが出力されます。なんか、coffee -wcとかを真似て、「-w」オプションとか仕込んでおりますが、怖くて動作確認していないので、ちゃんと動かないかも知れません。動作確認の取れている使い方は、上記の使い方だけです。

#インスタントに珈琲だからgoldblend
さあ、こうしてインスタントに珈琲ができあがります。入出力ほとんど同じだって？　そりゃそうです。map出力なんてしないので、あんまりいじったらデバッグできなくなりますよう。
さて、新味のある場所はひとつだけ

```<<<classname(req,res,arg1,arg2)<routername>(middleware) nocache
```
というところだけですね。スペースは適当に見やすく入れても大丈夫です（たぶん）。
で、それぞれの意味は、これだ。
<dl>
  <dt>&lt;&lt;&lt;</dt>
  <dd>ここいらで画面を出力するよ</dd>
  <dt>classname</dt>
  <dd>クラス名であり、「受信機」のそこかしこから classname.redirect req,res,arg1,arg2 とすれば、リダイレクトで到達しますし、 classname.direct req,res,arg1,arg2 とすれば直接表示します。また、http://your.domain/classname/arg1/arg2 で到達することもできます（リダイレクトできるのだから当たり前）また、使用するテンプレート名もこのクラス名で決まります。例えば、views/classname.jadeみたいなものを用意することになります。</dd>
  <dt>req,res</dt>
  <dd>req,resは必須の引数。ただし、request,responseなど、変数名を変えることは可能</dd>
  <dt>arg1,arg2</dt>
  <dd>req,resの後には、任意の個数の引数（出力画面を特定するために必要充分であること）が付けられる。引数は必要なければ、別段なくても良い。ただし多い方はちょいと注意で、引数は好きな数だけ設定できるが、URL長は1000byte程度までしか許されないのですね。そのへんのエラー処理はしてません</dd>
  <dt>routername</dt>
  <dd>文字通り、ここへのアクセスを仕切るrouterオブジェクト。「<>」と省略すると、デフォルトの「router」というオブジェクト名として扱う。今思ったけど、classname中のダブルアンダースコアを、URLに使うときには「/」として扱うようにしたら、も少し規模の大きいときには便利かも。後でやる</dd>
  <dt>middleware</dt>
  <dd>カンマ区切りで、classname.getをrouterにセットする際、middlewareとして登録する。URLでアクセスしたり、リダイレクトしたりした際に、セッション確認をするためのミドルウェアを設定することを想定している。つまり、ログイン後に通る画面には、logincheckをぽちぽちコピペするわけですな</dd>
  <dt>nocache</dt>
  <dd>最後にキーワード「nocache」を付けると、リダイレクトする際「?ランダム文字列」をURLの最後に付加してくれる（付けなければ、何も付加しない）。まああれだ、キャッシュが効いてしまって画面が更新されない事件のためですな</dd>
</dl>

と、こんな仕様だ。えらくキモいと思ったあなた、正解！　いやね。ここまで可変長のパラメータやらフラグやらを付けると、yaml形式とかでぶら下げるしかないじゃん？　でも、それってめんどいじゃん？　そもそもめんどくさいのから逃げるためにプリプロセッサかまそうとしてるのに、本末転倒じゃん？

というわけで、キモいのが嫌いな人は、鮮やかな解決策を作って、ついでにnpmで入れられるようにして欲しいな。ついでにECTのLINTも作って欲しいな。ECTをdev版で動かしたとき、ブラウザ上に実行時エラーだしてくれるってのもいいな（さすがにdevであのスピードを維持したい御仁も少なかろう）

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

まったくもー、誰の役にたつってんだ。バッドノウハウと言えばバッドノウハウ的なのだけど、「このニーズに合った、同じくらいコーディング量が少ない解法を教えてもらえるなら本望なので、ぜひコメントで正攻法を教えて頂ければ幸いです。「文字列からクラス生成」というような、JavaScript内で閉じてはいるけど、「逆にそっちがバッドノウハウじゃね？」的な解法はやあよ。

```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()

#==========================================
#== 送信部
#「<<<classname」というトークンで、クラス名＝テンプレート名＝URLとなる
#　表示用クラスができあがる
#　http://your.domain/classname/arg1/arg2 でも到達できるし、
#　classname.redirect req,res,arg1,arg2 ではリダイレクトで到達できるし、
#　classname.direct req,res,arg1,arg2 では、そのまま表示もできる
#　
#　書式は <<<classname(req,res,arg1,arg2)<routername>(middleware) nocache

<<<loginform(req,res,defaultname,retry)<router>() nocache
  @render @@, params

<<<dashboard(req,res,username)<router>() nocache
  @render @@, params

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


#==========================================
#== 受信部（単純に表示を求めるだけではない表示部）
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()

#==========================================
#== 送信部
#「<<<classname」というトークンで、クラス名＝テンプレート名＝URLとなる
#　表示用クラスができあがる
#　http://your.domain/classname/arg1/arg2 でも到達できるし、
#　classname.redirect req,res,arg1,arg2 ではリダイレクトで到達できるし、
#　classname.direct req,res,arg1,arg2 では、そのまま表示もできる
#　
#　書式は <<<classname(req,res,arg1,arg2)<routername>(middleware) nocache

class loginform
  @path: () => '/'+@.name
  @install: (obj) => obj.get @path()+'/:defaultname/:retry',@get
  @redirect: (req,res,defaultname,retry) =>
    params = ''
    params += '/'+encodeURIComponent(defaultname)
    params += '/'+encodeURIComponent(retry)
    res.redirect @path()+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 @.name, params
  

loginform.install router
class dashboard
  @path: () => '/'+@.name
  @install: (obj) => obj.get @path()+'/:username',@get
  @redirect: (req,res,username) =>
    params = ''
    params += '/'+encodeURIComponent(username)
    res.redirect @path()+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 @.name, params
  

dashboard.install router
class dummy
  @path: () => '/'+@.name
  @install: (obj) => obj.get @path()+'',@get
  @redirect: (req,res) =>
    params = ''

    res.redirect @path()+params
  @get: (req,res) =>

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

    params['this_is'] = 'dummy'
    res.render @.name, params
  
  
  #==========================================
  #== 受信部（単純に表示を求めるだけではない表示部）

dummy.install router
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'

console.log filesrc?
console.log filedst?

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


doconv = (dst, src) ->
  console.log 'processing... '+src
  content = fs.readFileSync(src).toString().split("\n")
  if fs.existsSync(dst)
    fs.writeFile dst, '', 'utf8', (err) -> console.log err
  firstline = true
  comments = false
  generate = false
  indentsp = ''
  for line,index in content
    if firstline
      firstline = false
    else
      fs.appendFileSync dst,"\n",'utf8', (err) -> console.log err
    if comments
      fs.appendFileSync dst,line,'utf8', (err) -> console.log err
      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)
          if generate
            #generate末尾出力
            fs.appendFileSync dst,indentsp+"\n",'utf8', (err) -> console.log err
            fs.appendFileSync dst,indentsp+classname+'.install '+routername+"\n",'utf8', (err) -> console.log err

          #newgenerate
          #indentlv計測
          #argreq,argres,argsの取得
          #classnameの取得
          #<router>
          #(middleware)の取得
          result = line.match /^(\s*)<<<\s*(\S+)\s*\(([^()]+)\)\s*<([^<>]*)>\s*\(([^()]*)\)\s*([a-z]*)/
          if result == null
            console.log 'syntax error ['+index+']'
            process.exit()
          #generate要素読み取り
          indentsp = result[1]
          classname = result[2]
          tempargs = result[3].split(',')
          args = []
          for str,index in tempargs
            str = str.replace(/^\s+/, '').replace(/\s+$/, '')
            switch index
              when 0
                argreq = str
              when 1
                argres = str
              else
                args.push str
          routername = result[4].replace(/^\s+/, '').replace(/\s+$/, '')
          if routername.length == 0
            routername = 'router'
          middleware = result[5].replace(/^\s+/, '').replace(/\s+$/, '')
          if middleware.length > 0
            middleware = middleware + ','
          nocache = (result[6] == 'nocache')
          #generate冒頭定型出力
          slashcolonparams = ''
          cvparams = ''
          buildparams4sp = ''
          reqparams4sp = ''
          yamlparams6sp = ''

          for str,index in args
            slashcolonparams += '/:'+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 ''
          
          appendnocache = if nocache then "+'"+'?'+"'+"+'Math.random().toString(36).slice(-8)'+"+"+'Math.random().toString(36).slice(-8)' else ''
          
          text = """
          #{indentsp}class #{classname}
          #{indentsp}  @path: () => '/'+@.name
          #{indentsp}  @install: (obj) => obj.get @path()+'#{slashcolonparams}',#{middleware}@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}
          """
          fs.appendFileSync dst,text,'utf8', (err) -> console.log err
          generate = true
          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 /@@/, '@.name'
            fs.appendFileSync dst,'  '+line,'utf8', (err) -> console.log err
            normaloutput = false
          else
            #generate末尾出力
            fs.appendFileSync dst,indentsp+"\n",'utf8', (err) -> console.log err
            fs.appendFileSync dst,indentsp+classname+'.install '+routername+"\n",'utf8', (err) -> console.log err
            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

doconv filedst,filesrc

if watch?
  fs.watchFile filesrc, (curr, prev) ->
    doconv filedst,filesrc
```