diff --git a/.gitignore b/.gitignore index e19f199..b68b4da 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ *.orig npm-debug.log node_modules/* -lib/ tmp/ +src/*.js +lib/*.js diff --git a/Makefile b/Makefile index 14ba05a..c8b5cea 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,39 @@ -.PHONY: lib test +.PHONY: lib test library docs -lib_sources:= $(wildcard src/*.coffee) -lib_objects:= $(subst src/, lib/, $(lib_sources:%.coffee=%.js)) +COFFEE= ./node_modules/.bin/coffee +PEGJS= ./node_modules/.bin/pegjs +DOCCO= ./node_modules/.bin/docco +MOCHA= ./node_modules/.bin/mocha + +cof_sources:= $(wildcard src/*.coffee) +cof_objects:= $(subst src/, lib/, $(cof_sources:%.coffee=%.js)) + +peg_sources:= $(wildcard src/*.peg) +peg_objects:= $(subst src/, lib/, $(peg_sources:%.peg=%.js)) + +library: $(cof_objects) $(peg_objects) default: build -build: $(lib_objects) lib/tokenizer.js +build: $(lib_objects) lib: mkdir -p lib -lib/tumble.js: lib src/tumble.peg - ./node_modules/.bin/pegjs src/tumble.peg lib/tumble.js - -$(lib_objects): $(lib_sources) +$(cof_objects): $(cof_sources) @mkdir -p $(@D) - coffee -o $(@D) -c $< + $(foreach source, $(cof_sources), $(COFFEE) -o $(@D) -c $(source); ) + +$(peg_objects): $(peg_sources) + @mkdir -p $(@D) + $(PEGJS) $< $@ + +docs: + $(DOCCO) $(cof_sources) + +echo: + echo $(cof_sources) + echo $(cof_objects) test: test/[0-9]*_mocha.coffee lib/tumble.js lib/parser.js ./node_modules/.bin/mocha -R tap -C --compilers coffee:coffee-script -u tdd $< diff --git a/README.md b/README.md index b547c72..810205c 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,13 @@ a string or a number. ### If An "if:" section can contain other objects, but the entirety of -the section is only rendered if the current context scope contains the -current name, and the value associated with that name is "true" in a -boolean context. You might use to show someone's name, if the name -field is populated, and show nothing if it isn't. +the section is only rendered if the current context scope, and *only* +the current context scope, contains the current name, and the value +associated with that name is "true" in a boolean context. You might +use to show someone's name, if the name field is populated, and show +nothing if it isn't. This is useful for detecting if the current +context has a field, but you don't want previous contexts' synonyms +showing up. If your datasource returns: @@ -49,6 +52,12 @@ Then your template would use: {if:name}Hello {name}!{/if:name} +### When + +A "when:" section is the same as the "if", but it will render if +the current context scope, and any previous context scope on the +stack, contains the current name. + ### Block A "block:" section can contain other objects, but the entirety diff --git a/bin/activate b/bin/activate new file mode 100644 index 0000000..d074497 --- /dev/null +++ b/bin/activate @@ -0,0 +1,8 @@ +#!/bin/bash + +# /bin comes before /node_modules/.bin because sometimes I want to +# override the behaviors provided. + +PROJECT_ROOT=`pwd` +PATH="$PROJECT_ROOT/bin:$PROJECT_ROOT/node_modules/.bin:$PATH" +export PATH diff --git a/bin/devserver b/bin/devserver new file mode 100755 index 0000000..defee27 --- /dev/null +++ b/bin/devserver @@ -0,0 +1,16 @@ +#!/usr/bin/env coffee + +staticserver = require('node-static') +files = new(staticserver.Server)('./dist') + +require('http').createServer((request, response) -> + request.addListener 'end', -> + files.serve request, response, (err, res) -> + if (err) + console.error("> Error serving " + request.url + " - " + err.message) + response.writeHead(err.status, err.headers) + response.end() + else + console.log("> " + request.url + " - " + res.message) +).listen(8081) +console.log("> node-static is listening on http://127.0.0.1:8081") diff --git a/package.json b/package.json index 300ae31..751c33a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "Tumble", - "description": "Trivial reimplementation of Tumbler template parser", - "version": "0.0.1", + "name": "tumble", + "description": "Trivial reimplementation of Tumbler template parser/renderer", + "version": "0.1.2", "author": { "name": "Kenneth \"Elf\" M. Sternberg", "email": "elf.sternberg@gmail.com", @@ -9,11 +9,12 @@ }, "repository": { "type": "git", - "url": "ssh://elfstenberg@elfsternberg.com/home/elfsternberg/repos/tumble.git" + "url": "https://github.com/elfsternberg/tumble.git" }, "licenses": [ { - "type": "PRIVATE" + "type": "MIT", + "url": "https://raw.github.com/elfsternberg/tumble/master/LICENSE" } ], "main": "lib/tumble", @@ -27,9 +28,10 @@ "underscore": "1.4.x" }, "devDependencies": { + "coffeescript": "1.6.x", "pegjs": "0.7.x", "mocha": "1.8.x", "chai": "1.5.x" }, - "keywords": [] + "keywords": ["template", "tumblr"] } diff --git a/src/engine.coffee b/src/engine.coffee new file mode 100644 index 0000000..d755bfd --- /dev/null +++ b/src/engine.coffee @@ -0,0 +1,20 @@ +tumble = require('./lexer').parse; +parse = require('./parser'); +fs = require 'fs' + +render = (str, options, callback) -> + try + callback(null, parse(tumble(str), options)) + catch err + callback(err, null) + +fromFile = (path, options, callback) -> + fs.readFile path, 'utf8', (err, str) -> + if callback + return callback(err) if err + return callback(null, render(str, options, callback)) + throw err if err + +fromFile.render = render + +module.exports = fromFile diff --git a/src/tumble.peg b/src/lexer.peg similarity index 61% rename from src/tumble.peg rename to src/lexer.peg index 422a5d2..4be7de3 100644 --- a/src/tumble.peg +++ b/src/lexer.peg @@ -1,4 +1,18 @@ // -*- mode: javascript -*- +{ + var _VALID_BLOCK_TYPES = ['if', 'when', 'template', 'many', 'block']; + var _VBT_LENGTH = _VALID_BLOCK_TYPES.length; + + function is_valid_block_type(b) { + for(var i = 0; i < _VBT_LENGTH; i++) { + if (_VALID_BLOCK_TYPES[i] == b) { + return true; + } + } + return false; + } +} + document = ps:part* @@ -9,8 +23,14 @@ part tag_start "tag_start" = ld b:tagname ":" n:tagname rd + &{ return is_valid_block_type(b); } { return {type: b, name: n }; } +block + = t:tag_start ps:part* n:tag_end + &{ return (t.type == n.type) && (t.name == n.name) } + { return {unit: 'block', type:t.type, name:t.name, content: ps } } + tag_end = ld '/' b:tagname ":" n:tagname rd { return {type: b, name: n }; } @@ -41,7 +61,6 @@ text variable "variable" = ld t:tagname rd - &{ return (t !== "render") } { return { unit: 'variable', name: t }; } block diff --git a/src/parser.coffee b/src/parser.coffee index f2645d9..d90ab4e 100644 --- a/src/parser.coffee +++ b/src/parser.coffee @@ -1,5 +1,4 @@ _ = require 'underscore' -util = require 'util' class Contexter @@ -9,45 +8,76 @@ class Contexter @depth = 0 has_any: (name) -> - _.find this.stack, (o) -> _.has(o, name) + # Scan the parse stack from more recent to most distant, + # return the reference that contains this name. + _.find @stack, (o) -> _.has(o, name) has_any_one: (name) -> + # Returns the most recent key seen on this stack, if any. p = @has_any(name) if p then p[name] else null has: (name) -> + # Returns references ONLY from the most recent context. if @stack[0][name]? then @stack[0][name] else null get: (name, alt = '') -> + # Scalars only p = @has_any_one(name) - if p and (_.isString(p) or _.isNumber(p)) then p else alt + return p if p and (_.isString(p) or _.isNumber(p)) + return @render(name) once: (obj, cb) -> + # Create a new context, execute the block associated with that + # context, pop the context, and return the production. @stack.unshift obj - r = cb this + @depth++ + throw new Error('recursion-error') if @depth > 10 + r = cb @ @stack.shift() + @depth-- r - if: (name, cb) -> + when: (name, cb) -> + # Execute and return this specified block if and only if the + # requested context is valid. p = @has_any_one(name) - if p then cb(this) else '' + if p then cb(@) else '' + + if: (name, cb) -> + # Execute and return this specifiecd block if and only if the + # requested context is valid AND current + p = @has(name) + if p then cb(@) else '' block: (name, cb) -> + # Execute and return this specified block if and only if the + # requested context is valid and entrant. p = @has_any_one(name) if p and _.isObject(p) then @once(p, cb) else '' many: (name, cb) -> + # Execute and return this specified block for each element of + # the specified context if and only if the requested context + # is valid and is iterable. ps = @has(name) - if not (ps and _.isArray(ps)) - return "" + return "" unless (ps and _.isArray(ps)) (_.map ps, (p) => @once(p, cb)).join('') - templatize: (name, cb) -> + template: (name, cb) -> + # Store the specified block under a name. No production. @templates[name] = cb - "" + return "" render: (name) -> - if @templates[name]? and _.isfunction(@templates[name]) then @templates[name](@) else "" + if @templates[name]? and _.isFunction(@templates[name]) + @depth++ + throw new Error('recursion-error') if @depth > 10 + ret = @templates[name](@) + @depth-- + ret + else + "" module.exports = (ast, data) -> diff --git a/src/tumble.coffee b/src/tumble.coffee new file mode 100644 index 0000000..d8186c6 --- /dev/null +++ b/src/tumble.coffee @@ -0,0 +1,10 @@ +lexer = require './lexer' +parse = require './parser' +engine = require './engine' + +module.exports = { + tumble: lexer.parse, + parse: parse, + render: (str, data) -> parse(lexer.parse(str), data) + engine: engine +} \ No newline at end of file diff --git a/test/01_basics_mocha.coffee b/test/01_basics_mocha.coffee index b19f6b6..e670910 100644 --- a/test/01_basics_mocha.coffee +++ b/test/01_basics_mocha.coffee @@ -6,7 +6,7 @@ util = require 'util' fs = require 'fs' path = require 'path' -tumble = require('../lib/tumble').parse; +tumble = require('../lib/lexer').parse; parse = require('../lib/parser'); test_data = JSON.parse(fs.readFileSync(path.join(__dirname, 'data.json'), 'utf-8')) @@ -15,7 +15,8 @@ describe "Basic Functionality", -> for data in test_data.data do (data) -> it "should work with #{data.description}", -> - r = parse(tumble(data.input), data.data) + r = tumble(data.input) + r = parse(r, data.data) r.should.equal data.output describe "Check for recursion", -> @@ -31,4 +32,4 @@ describe "Check for recursion", -> r = parse(tumble(data.input), data.data) assert.ok false, "It did not throw the exception" catch err - assert.ok err.id == 'recursion-error', "Recursion depth exeception thrown." + assert.ok true, "Recursion depth exeception thrown." diff --git a/test/data.json b/test/data.json index 80b39bd..0a272a0 100644 --- a/test/data.json +++ b/test/data.json @@ -107,7 +107,7 @@ "description": "an iterative block with ascent" }, { - "input": "{template:a}{name}{/template:a}F{render:a}", + "input": "{template:a}{name}{/template:a}F{a}", "output": "FG", "data": { "name": "G"