diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8fce0a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2012 Elf M. Sternberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile index 9d7d2a3..54703ae 100644 --- a/Makefile +++ b/Makefile @@ -5,16 +5,19 @@ lib_objects:= $(subst src/, lib/, $(lib_sources:%.coffee=%.js)) default: build -build: $(lib_objects) lib/parser.js +build: $(lib_objects) lib/tokenizer.js -lib/parser.js: src/parser.peg - ./node_modules/.bin/pegjs src/parser.peg lib/parser.js +lib: + mkdir -p lib -$(lib_objects): lib/%.js: src/%.coffee +lib/tumble.js: lib src/tumble.peg + ./node_modules/.bin/pegjs src/tumble.peg lib/tumble.js + +$(lib_objects): lib lib/%.js: src/%.coffee @mkdir -p $(@D) coffee -o $(@D) -c $< -test: test/[0-9]*_mocha.coffee lib/tumble.js lib/parser.js +test: test/[0-9]*_mocha.coffee lib/tumble.js ./node_modules/.bin/mocha -R tap -C --compilers coffee:coffee-script -u tdd $< diff --git a/README.md b/README.md index 3b7cad7..347266d 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,173 @@ +# Tumble - Generalized, hyper-simple templating engine + +## Purpose + +I like Tumblr's templating system, with its simplicity and brevity. +The idea that, for small projects, one should embed everything in a +single page and switch sections on and off as needed appeals to me, so +I decided to write my own implementation of the Tumblr parser. + Tumble is an implementation of the Tumblr parser/compiler/renderer, with keyword substitutions suitable to my needs on my story website. The idea is that the database side will produce an object consisting -of the title of a series +of variable, object, and array substitutions, and that the +corresponding template will describe how to unpack and illustrate that +object. + +## Usage + +There are five kinds of objects in my templating language: plain text, +variables, "if", "block", and "many" sections. + +### Plain Text + +Plain text is just that, the text of your template without any +substitutions. + +### Variable + +Tumble tags are contained in { } entries. Sadly, there's no current +way to escape this. + +A variable is a Tumble tag without a colon in it. It refers to a valid +name in the current context scope (more on that below) that is either +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. + +If your datasource returns: + +obj = { "name": "Mr. Smith"} + +Then your template would use: + +{if:name}Hello {name}!{/if:name} + +### Block + +A "block:" section can contain other objects, but the entirety +of the section is only rendered if the current context scope contains +the current name, a value exists for that name, and the value is an +object itself. When the block is entered, the object referred to +becomes the top of the current context scope. You might use it to +render "next/prev" blocks in a webcomic. + +If your datasource returns: + +obj = { "next": {"title": "The Next Story"}, + "prev": {"title": "The Previous Story"}} + +Then your template would use: + +{block:next}The next story is entitled {title}{/block:next} +{block:prev}The next story is entitled {title}{/block:prev} + +### Many + +A "many:" section can contain other objects, but the entirety of +the section is only rendered if the current context scope contains the +current name, a value exists for that name, and the value is an array +that itself contains objects. When the block is entered, each object +in the named array is serially made the top object of the current +context scope, the section is rendered, and the object is popped off +the context scope stack once more. You might use it to render a +series of titles in a series: + +If your datasource returns: + +obj = { + "series": + [ {"title": "A Story"}, + {"title": "A Second Story"}, + {"title": "A Third Story"} + ] + } + +Then you could render a list of your stories this way: + + {if:series} +

Table of Contents:

+
    + {many:series}
  • {title}
  • {/many:series} +
+ {/if:series} + + +### The current context scope + +The Tumble parser is intended to render a website for series and +stories. Both of which have titles, so you might have an object that +says: + +obj = { + title: "An awesome series", + author: "Twilight Sparkle", + stories: [{title: "The first awesome story"}, + {title: "The second awesome story"}] + } + +In both "block" and "many", the current context descends into these +objects in a deterministic way. While inside a block or many, when +searching for a variable substitution (and *only* variable +substitutions), the context handler will scan upwards from the current +context scope to the root to find a possible substitution. + +For example + +

{title}

+ {if:stories} +

Table of Contents:

+
    + {many:stories}
  • {title} by {author}
  • {/many:stories} +
+ {/if:stories} + +The first "title" will be the series title, but the titles in the +"many" block will be story titles, in order. Because each story does +not have an "author" block, the context will scan up to the parent +scope and find the author's name. + +## Requirements + +nodejs & npm. + +Underscore is a dependency. + +PegJS is a required build tool. Mocha & Chai are used for testing. + +All of these are specified in the package.json file. + +## LICENSE AND COPYRIGHT NOTICE: NO WARRANTY GRANTED OR IMPLIED + +Copyright (c) 2012 Elf M. Sternberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + - Elf M. Sternberg -block:Series - Must be found within a block:toc - It must contain the special tag {titles} as a child tag. - - This will be rendered if this is a series page. - -block:Subseries - - Must be found within a block:toc - It must contain the special tag {titles} as a child tag. - - This will be rendered for any subseries of the main series. This - will recurse to a maximum depth of four. Users can use clever CSS - to make this look awesome. There may be settings in the database - that prevent subseries recursion deliberately. - -block:Story - Has two meanings, based on context. - - If it is found in the *document* (i.e. *not* within a , it is treated at a story block, - and will only be rendered if this is a story page. - - If it is found in a *series* or *subseries* block, it's contents - are rendered during the rendering of a series or subseries for - each story found. - -block:Next - Valid only in a block:story - Will render if there is a "next" story in the parent series. - -block:Prev - Valid only in a block:story - Will render if there is a "previous" story in the parent series. - -block:Title - Will render if there is a title in the current context - -block:Blurb - Will render if there is a blurb. - -block:Excerpt - Will render if there is an excerpt - -Variables: - -Title - The title in the current context. - -Body - The body of the story. Only available in top-level block:story. - -SeriesTitle - The title of the series in the current context. - -MainSeriesTitle - The title of the top-level series for the current page. - -AuthorsName - -Blurb - Valid in a series or series/story - -Excerpt - Valid in a series or series/story - -URL - Context-sensitive. In a Story, refers to the URL used to access - that story. In a Next or Prev, refers to the URL of the next or - previous story, respectively. - -SeriesURL - -MainSeriesURL - -AuthorsURL - -# {block:Series}{URL}{Title}{Contents}{/block:Series} - -# > Handle these first - -Minus the actual content of a template, the HTML that we use to build -every page, a document tends to look like this: - -{URL} -{SeriesTitle} -{AuthorName} -{AuthorDescription} - -{block:IfSeries} - {Title} - {Description} - {block:TableOfContents} - {block:Story}{URL}{Title}{/block:Story} - {/block:TableOfContents} -{/block:IfSeries} - -The important trick here is that the TableOfContents will be recursed -wherever the {Contents} block is seen, up to a maximum depth of four. -IfContents will be true if this is a subseries and there is content of -a subseries; the reason for this is to prevent the rendering of an -empty subseries. -{block:IfStory} - {Title} - {Body} - {block:Next}{URL}{Title}{/block:Next} - {block:Prev}{URL}{Title}{/block:Prev} - {Pubdate} - {block:IfLicense}{License}{/block:IfLicense} - {Copyright} -{block:/IfStory} - diff --git a/package.json b/package.json index bce0bcf..300ae31 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,7 @@ "devDependencies": { "pegjs": "0.7.x", "mocha": "1.8.x", - "chai": "1.5.x", - "coffee-script": "1.6.x", - "docco": "0.3.x" + "chai": "1.5.x" }, "keywords": [] } diff --git a/src/parser.peg b/src/parser.peg deleted file mode 100644 index 6287456..0000000 --- a/src/parser.peg +++ /dev/null @@ -1,51 +0,0 @@ -document - = p:part* { return p } - -part - = text / variable / block - -text - = b:(!tag c:. {return c})+ - { return { type: "text", text: b.join('') }; } - -variable "variable" - = t:tag_start rd - { return { type: "variable", name: t }; } - -tag_start "tag_start" - = ld n:tagname - { return n; } - -tagname "tagname" - = t:[a-zA-Z]+ - { return t.join(''); } - -block "block" - = t:block_tag_start p:part* n:block_end_tag - &{ return t == n } - { return { type: "block", name: n, data: p }; } - -block_tag_start "tag_start" - = ld "block:" n:tagname rd - { return n; } - -block_end_tag - = ld "/block:" n:tagname rd - { return n; } - -tag - = ld (!rd !eol [a-zA-Z\:\/])+ rd - -ld - = "{" - -rd - = "}" - -eol - = "\n" - / "\r\n" - / "\r" - / "\u2028" - / "\u2029" - diff --git a/src/tumble.coffee b/src/tumble.coffee deleted file mode 100644 index 62894f4..0000000 --- a/src/tumble.coffee +++ /dev/null @@ -1,39 +0,0 @@ -tumble = require('./parser') -util = require('util') - -module.exports = (template) -> - - ast = tumble.parse(template) - - # Using the AST, return a function that will render each component - # of the AST out, as long as the data provided to the AST makes - # sens. - # - - (content) -> - subtypes = (name) -> - return 'cond' - - handler = (content, ast) -> - - isLegal = (name) -> true - - { - 'text': () -> - ast.text - - 'variable': () -> - return '' if not (isLegal(ast.name) and content.hasOwnProperty(ast.name)) - content[ast.name] - - 'block': () -> - return '' if not (isLegal(ast.name) and content.hasOwnProperty(ast.name)) - f = { - 'cond': () -> handler(content[ast.name], ast.data) - 'loop': () -> (handler(c, ast.data) for c in content[ast.name]) - }[subtypes(ast.name)] - console.log("F:", f) - f() - }[obj.type]() - - (handler(i) for i in ast).join("") diff --git a/src/tumble.peg b/src/tumble.peg new file mode 100644 index 0000000..702ef68 --- /dev/null +++ b/src/tumble.peg @@ -0,0 +1,220 @@ +{ + var _ = require('underscore'); + var depth = 0; + + var Contexter = function(c) { + this.content = c + this.stack = [c]; + this.templates = {}; + } + + _.extend(Contexter.prototype, { + has_any: function(name) { + return _.find(this.stack, function(o) { return _.has(o, name); }); + }, + + has: function(name) { + if (typeof this.stack[0][name] != 'undefined') { + return this.stack[0][name]; + } + return null; + }, + + get: function(name) { + var p = this.has_any(name); + if (p && (_.isString(p[name]) || _.isNumber(p[name]))) { + return p[name]; + } + if (arguments.length > 1) { + return arguments[1]; + } + return ''; + }, + + once: function(obj, cb) { + this.stack.unshift(obj); + var r = cb(this); + this.stack.shift(); + return r; + }, + + if: function(name, cb) { + var p = this.has_any(name); + if (p && p[name]) { + return cb(this); + } + return ""; + }, + + descend: function(name, cb) { + var p = this.has(name); + if (p && _.isObject(p)) { + return this.once(p, cb); + } + return ""; + }, + + many: function(name, cb) { + var ps = this.has(name), + _this = this; + if (ps && _.isArray(ps)) { + return _.map(ps, function(p) { + return _this.once(p, cb); + }).join(""); + } + return ""; + }, + + templatize: function(name, cb) { + this.templates[name] = cb; + return ""; + }, + + template_render: function(name) { + if (this.templates[name] && _.isFunction(this.templates[name])) { + return this.templates[name](this); + } + return ""; + } + }); + +} + +document + = ps:document_part* { + return function(content) { + var context = new Contexter(content); + return _.map(ps, function(p) { + return p(context); + }).join(""); + } + } + + +document_part + = iterative / descendant / conditional / variable / text + + +text + = b:(!tag c:. {return c})+ { + return (function() { + var t = b.join(""); + return function(content) { + return t; + } + }()); + } + + +variable "variable" + = t:tag_start rd { + return function(content) { return content.get(t, ""); }; + } + + +simple_part + = descendant / variable / conditional / text + + +conditional + = t:ifblock_tag_start ps:simple_part* n:ifblock_tag_end + &{ return (t == n) } + { + return function(content) { + return content.if(t, function(c) { + return _.map(ps, function(p) { + return p(c); + }).join(''); + }); + } + } + + +ifblock_tag_start "tag_start" + = ld "if:" n:tagname rd + { return n; } + + +ifblock_tag_end + = ld "/if:" n:tagname rd + { return n; } + + +descendant + = n:blockblock_tag_start ps:simple_part* t:blockblock_tag_end + &{ return (t == n) } + { + return function(content) { + return content.descend(t, function(c) { + return _.map(ps, function(p) { + return p(c); + }).join(''); + }); + } + } + + +blockblock_tag_start + = ld "block:" n:tagname rd + { return n; } + + +blockblock_tag_end + = ld "/block:" n:tagname rd + { return n; } + + +iterative + = t:loopblock_tag_start ps:simple_part* n:loopblock_tag_end + &{ return t == n } + { + return function(content) { + return content.many(t, function(c) { + return _.map(ps, function(p) { + return p(c); + }).join(''); + }); + } + } + +loopblock_tag_start "tag_start" + = ld "many:" n:tagname rd + { return n; } + + +loopblock_tag_end + = ld "/many:" n:tagname rd + { return n; } + + +tag_start "tag_start" + = ld n:tagname + { return n; } + + +tagname "tagname" + = t:[a-zA-Z]+ + { return t.join(''); } + + +tag + = ld (!rd !eol [a-zA-Z\:\/])+ rd + + +ld + = "{" + + +rd + = "}" + + +eol + = "\n" + / "\r\n" + / "\r" + / "\u2028" + / "\u2029" + + + diff --git a/test/01_basics_mocha.coffee b/test/01_basics_mocha.coffee index 5acac02..512e9a8 100644 --- a/test/01_basics_mocha.coffee +++ b/test/01_basics_mocha.coffee @@ -3,7 +3,7 @@ assert = chai.assert expect = chai.expect should = chai.should() -tumble = require('../lib/tumble') +tumble = require('../lib/tumble').parse; test_data = [ { @@ -30,12 +30,60 @@ test_data = [ 'description': "two simple substitutions" } + { - 'input': '
    {block:Stories}{Title}{/block:Stories}
' + 'input': '
    {if:title}{title}BBB{/if:title}
' 'output': '
    AAABBB
' - 'data': {'stories': {'title': 'AAA'}}, + 'data': {'title': 'AAA'} 'description': "a conditional block" - }] + } + + { + 'input': '
    {if:title}{title}BBB{/if:title}
' + 'output': '
    ' + 'data': {'title': ''} + 'description': "a conditional block with no input" + } + + + { + 'input': '
      {block:stories}{title}{/block:stories}
    ' + 'output': '
      ' + 'data': {'stories': {'title': ''}} + 'description': "a descendent block" + } + + + { + 'input': '
        {block:stories}{title}BBB{/block:stories}
      ' + 'output': '
        AAABBB
      ' + 'data': {'stories': {'title': 'AAA'}} + 'description': "a descendent block 2" + } + + { + 'input': '
        {many:stories}{title}{/many:stories}
      ' + 'output': '
        ' + 'data': {'stories': [{'title': ''}]} + 'description': "an iterative block" + } + + + { + 'input': '
          {many:stories}{title}BBB{/many:stories}
        ' + 'output': '
          AAABBBCCCBBB
        ' + 'data': {'stories': [{'title': 'AAA'}, {'title': 'CCC'}]}, + 'description': "an iterative block 2" + } + + { + 'input': '
          {author}{many:stories}{title}BBB{author}{/many:stories}
        ' + 'output': '
          DDDAAABBBDDDCCCBBBDDD
        ' + 'data': {'author': 'DDD', 'stories': [{'title': 'AAA'}, {'title': 'CCC'}]}, + 'description': "an iterative block with ascent" + } + +] describe "Basic Functionality", -> diff --git a/test/aimee.tmbl b/test/aimee.tmbl deleted file mode 100644 index ec1fad5..0000000 --- a/test/aimee.tmbl +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - Elf Sternberg | Aimee' | {block:title}{title}{/block:title} - - - -
        - - - {block:index} -
        - -

        The Aimee' series grew out of a longstanding desire on my part to - write stories outside of the comforting future depicted in the - Journal Entries. The fantasy setting allowed me to get away with - a number of tropes and scenes that I would otherwise never inflict - on my favorite characters. It has, like the Journal Entries, - grown from one kind of fantasy into another, one where meaning - matters.

        - -
        - {block:contents} - {block:series} -

        {title}

        - {/block:series} - {block:story} - - {/block:story} - {/block:contents} -
        -
        - {/block:index} - - {block:story} -

        {title}

        - {body} - -
        - {block:next}

        Next: {title}

        {/block:next} - {block:prev}

        Previous: {title}

        {/block:prev} -
        - -
        -
        {title}
        - -
        - - -
        is copyright © {pubdate|"F j, Y"} Elf Mathieu Sternberg and is available - under a Creative Commons License. -
        - - - - - - -
        - {/block:story} - - -