Repository: jamesramsay/hercule
Pull request #24 (2015-06-18 23:49:56)

Increased modularity
- Small simpler function
- Separated helper methods for testing
- Synchronous (removed async)

Known issues:

- Verbose logging not working

jamesramsay: TODOS:

- [ ] Fix verbose logging

Patch:
diff --git a/README.md b/README.md
index 7a758ef..c833cd1 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,11 @@
 
 Write large markdown documents, including API Blueprints, while keeping things DRY (don't repeat yourself).
 
-Hercule is a command-line tool for transcluding markdown documents, including API documentation written in [API Blueprint](http://apiblueprint.org) format. With Hercule you can easily break complex documents into smaller logical documents, preventing repetition and improving consistency.
+```bash
+hercule src/document.md -o document.md
+```
+
+Hercule is a command-line tool and library for transcluding markdown documents, including API documentation written in [API Blueprint](http://apiblueprint.org) format. With Hercule you can easily break complex documents into smaller logical documents, preventing repetition and improving consistency using transclusion links `:[abstract](src/abstract.md)`.
 
 -----
 
@@ -18,6 +22,8 @@ Hercule is a command-line tool for transcluding markdown documents, including AP
 
 Hercule is used by [Adslot](http://adslot.com) to help document our APIs in [API Blueprint](http://apiblueprint.org) more efficiently and more accurately. We also use [Apiary](http://apiary.io) to distribute our API documentation and [DREDD](https://github.com/apiaryio/dredd) (a tool by [Apiary](http://apiary.io)) to validate the documentation against implementation.
 
+-----
+
 ## Installation
 
 [Node.js](http://nodejs.org) and [NPM](http://npmjs.org) is required.
@@ -26,6 +32,8 @@ Hercule is used by [Adslot](http://adslot.com) to help document our APIs in [API
 $ npm install -g hercule
 ```
 
+Use
+
 ## Example: API Blueprint
 
 Borrowing from the Apiary [Gist Fox Tutorial example](http://apiary.io/blueprint), API Blueprint documents can be split into logical files and share common elements.
diff --git a/bin/transclude b/bin/hercule
similarity index 100%
rename from bin/transclude
rename to bin/hercule
diff --git a/package.json b/package.json
index 33a246a..193b993 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
   "name": "hercule",
   "version": "0.2.3",
   "description": "Markdown transcluder",
-  "main": "./lib/main",
+  "main": "./lib/hercule",
   "scripts": {
     "test": "NODE_ENV=test ./node_modules/.bin/mocha test --compilers coffee:coffee-script/register --reporter spec",
     "coverage": "NODE_ENV=test COVERAGE=1 ./node_modules/.bin/mocha --compilers coffee:coffee-script/register --require blanket --reporter html-cov > coverage.html && open coverage.html",
@@ -12,12 +12,13 @@
   },
   "config": {
     "blanket": {
-      "pattern": "lib",
-      "data-cover-never": "node_modules"
+      "pattern": "src",
+      "data-cover-never": "node_modules",
+      "loader": "./node-loaders/coffee-script"
     }
   },
   "bin": {
-    "hercule": "./bin/transclude"
+    "hercule": "./bin/hercule"
   },
   "repository": {
     "type": "git",
@@ -39,7 +40,6 @@
   },
   "homepage": "https://github.com/jamesramsay/hercule",
   "dependencies": {
-    "async": "^0.9.0",
     "dashdash": "^1.7.0",
     "lodash": "^3.0.0",
     "pegjs": "^0.8.0"
diff --git a/src/grammar.coffee b/src/grammar.coffee
index f1c35de..0786902 100644
--- a/src/grammar.coffee
+++ b/src/grammar.coffee
@@ -1,9 +1,8 @@
 transcludeGrammar = """
 start
-  = ':' '[' n:placeholder? ']' '(' f:filename? ' '? o:arg* ')' {
+  = ':' '[' placeholder? ']' '(' f:filename? ' '? o:arg* ')' {
       return {
-        "file": f,
-        "name": n,
+        "link": f,
         "references": o
       };
     }
diff --git a/src/hercule.coffee b/src/hercule.coffee
new file mode 100644
index 0000000..86e7b28
--- /dev/null
+++ b/src/hercule.coffee
@@ -0,0 +1,79 @@
+blanket = require 'blanket' if process.env.COVERAGE
+
+path = require 'path'
+_ = require 'lodash'
+utils = require './utils'
+
+log = (message) -> return
+
+processInput = (input, parents, dir) ->
+  rawLinks = utils.scan input
+  #log "Scan: #{rawLinks.length} links found"
+
+  links = _.forEach rawLinks, (rawLink) ->
+    rawLink.relativePath = dir
+    rawLink.parents = parents[..]
+
+    parsedLink = utils.parse rawLink
+    #log "Parse: #{parsedLink.references.length} references found"
+
+    return parsedLink
+
+  return links
+
+
+transclude = (input, relativePath, parents, parentRefs) ->
+  # TODO: rename processInput...
+  links = processInput input, parents, relativePath
+  if links.length < 1 then return input
+
+  links.forEach ({link, file, references, parents, whitespace, placeholder}) ->
+    references = _.merge parentRefs, references
+    match = utils.find link, references
+
+    linkType = "file"
+    if match?
+      #log "Expanding: #{link} -> #{match.value}"
+      link = match.value
+      linkType = match.type
+    else
+      link = path.join relativePath, link
+
+    if _.contains parents, link
+      # #{link} is in parents:\n#{JSON.stringify parents}
+      throw new Error("Circular reference detected")
+
+    #log "Transclude: #{link} into #{parents[-1..][0]}"
+    content = utils.inflate link, linkType
+    parents.push link
+    dir = path.dirname link
+
+    output = transclude content, dir, parents, references
+
+    if output?
+      # Preserve indentation if transclude is not preceded by content
+      # Remove new lines at EOF which cause unexpected paragraphs and breaks
+      output = output
+        .replace /\n/g, "\n#{whitespace}"
+        .replace /\n$/, ""
+
+      input = input.replace "#{placeholder}", output
+      log "Replaced: \"#{placeholder}\"\n    with: #{JSON.stringify output}"
+
+  return input
+
+
+transcludeString = (input, relativePath = "", parents = [], parentRefs = []) ->
+    return transclude input, relativePath, parents, parentRefs
+
+transcludeFile = (file, relativePath = "", parents = [], parentRefs = []) ->
+    fullFilePath = path.join relativePath, file
+    fullRelativePath = path.dirname fullFilePath
+
+    parents.push fullFilePath
+    content = utils.readFile fullFilePath
+
+    return transclude content, fullRelativePath, parents, parentRefs
+
+
+module.exports = {transcludeString, transcludeFile}
diff --git a/src/log.coffee b/src/log.coffee
deleted file mode 100644
index 971da83..0000000
--- a/src/log.coffee
+++ /dev/null
@@ -1,9 +0,0 @@
-VERBOSE = false
-
-logger = (message) ->
-  if VERBOSE then console.error message
-
-module.exports = {
-  logger
-  VERBOSE
-}
diff --git a/src/main.coffee b/src/main.coffee
index 676d040..3012d74 100644
--- a/src/main.coffee
+++ b/src/main.coffee
@@ -2,12 +2,10 @@
 # A simple markdown transclusion tool
 # Author: james ramsay
 
-
-blanket = require 'blanket' if process.env.COVERAGE
-md = require './transclude'
+hercule = require './hercule'
 dashdash = require 'dashdash'
 fs = require 'fs'
-async = require 'async'
+path = require 'path'
 
 parser = dashdash.createParser options: [
   names: ['help', 'h']
@@ -38,27 +36,15 @@ if opts.help
 
 
 main = ->
-  md.VERBOSE = opts.verbose
   transcludedOutput = ""
 
-  # Supports multiple input files?
-  # e.g. hercule -o output.md file1.md file2.md file3.md
-  # Each file will be processed sequentially and appended to output
-  async.eachSeries opts._args, (input, cb) ->
-    dir = path.dirname input
-    input = fs.readFileSync()
-
-    md.transclude input, dir, null, null, (err, output) ->
-      if err then return cb err
-      transcludedOutput += output
-      cb null
+  opts._args.forEach (input) ->
+    transcludedOutput += hercule.transcludeFile input, null, null, null
 
-  , (err) ->
-    throw err if err
-    if opts.output
-      fs.writeFile opts.output, transcludedOutput, (err) ->
-        throw err if err
-    else
-      console.log output
+  if opts.output
+    fs.writeFile opts.output, transcludedOutput, (err) ->
+      throw err if err
+  else
+    console.log transcludedOutput
 
 main()
diff --git a/src/transclude.coffee b/src/transclude.coffee
deleted file mode 100644
index f411814..0000000
--- a/src/transclude.coffee
+++ /dev/null
@@ -1,156 +0,0 @@
-async = require 'async'
-fs = require 'fs'
-path = require 'path'
-_ = require 'lodash'
-peg = require 'pegjs'
-
-{logger, VERBOSE} = require './log'
-
-# Link detection (including leading whitespace)
-linkRegExp = new RegExp(/(^[\t ]*)?(\:\[.*?\]\(.*?\))/gm)
-WHITESPACE_GROUP = 1
-LINK_GROUP = 2
-
-# Build the link parser once
-grammar = require './grammar'
-linkParser = peg.buildParser grammar.transcludeGrammar
-
-
-linksFromInput = (input, parents, dir) ->
-  rawLinks = scan input
-
-  links = _.forEach rawLinks, (rawLink) ->
-    rawLink.relativePath = dir
-    rawLink.parents = parents[..]
-
-    return parse rawLink
-
-  return links
-
-
-# Parse a link using pegjs
-# link: ':[name](path/to/file extendFile:another/file header:"String")'
-#  dir: directory of the input containing the link
-parse = (rawLink) ->
-  parsedLink = linkParser.parse rawLink.placeholder
-  logger "Parse: #{parsedLink.references.length} references found"
-
-  # References are relative to the document where declared
-  parsedLink.references.forEach (ref) ->
-    if ref.type is "file"
-      ref.value = path.join rawLink.relativePath, ref.value
-
-  return _.merge rawLink, parsedLink
-
-
-# Scan a document string for links
-scan = (input) ->
-  links = []
-
-  while (match = linkRegExp.exec input)
-    links.push
-      placeholder: match[LINK_GROUP]
-      whitespace: if match[WHITESPACE_GROUP] then match[WHITESPACE_GROUP] else ""
-
-  logger "Scan: #{links.length} links found"
-  return links
-
-
-# Read file to string
-readFile = (filename) ->
-  try
-    content = (fs.readFileSync filename).toString()
-    return content
-
-  catch err
-    if err.code = 'ENOENT'
-      logger "#{filename} not found."
-    else
-      throw err
-
-  return null
-
-
-# Substitute a placeholder link with the appropriate reference
-substitute = (file, references, dir = "") ->
-  # :[something](FILE) >> :[something](content.md)
-  [p, ...] = _.filter references, (ref) -> return ref?.placeholder is file
-
-  if p?
-    logger "Expanding: #{file} >> #{p.value}"
-    file = p.value
-    type = p.type
-  else
-    file = path.join dir, file
-    type = "file"
-
-  return {file, type}
-
-
-# File transclude for backwards compatibility
-transclude = (file, dir = "", parents = [], references = [], cb) ->
-  parents.push file
-  dir = path.dirname file
-  input = (fs.readFileSync file).toString()
-
-  transcludeString input, dir, parents, references, (err, output) ->
-    if (err) then return cb err
-    cb err, output
-
-
-#    input: input string containing transclude links
-#      dir: input directory (because links are relative)
-# filename: used for circular reference detection
-#  parents: list of parents for circular reference detection
-transcludeString = (input, relativePath = "", parents = [], parentRefs = [], cb) ->
-
-  links = linksFromInput input, parents, relativePath
-  if links.length < 1 then return cb null, input
-
-  async.eachSeries links, ({file, references, parents, whitespace, placeholder}, cb) ->
-
-    references = _.merge parentRefs, references
-    substitution = substitute file, references, relativePath
-
-    if substitution.file in parents
-      return cb "Circular reference detected. #{file} is in parents:\n#{JSON.stringify parents}"
-
-    if substitution.type is "string"
-      content = substitution.file
-    else
-      content = readFile substitution.file
-
-    parents.push substitution.file
-    dir = path.dirname substitution.file
-
-    logger "Transclude: #{substitution.file} into #{parents[-1..][0]}"
-
-    transcludeString content, dir, parents, references, (err, output) ->
-      if err then return cb err
-
-      if output?
-        # Preserve indentation if transclude is not preceded by content
-        output = output.replace /\n/g, "\n#{whitespace}"
-
-        # Remove new lines at EOF which cause unexpected paragraphs and breaks
-        output = output.replace /\n$/, ""
-
-        input = input.replace "#{placeholder}", output
-        logger "Replaced: \"#{placeholder}\"\n    with: #{JSON.stringify output}"
-
-      cb null
-
-  , (err) ->
-    cb err, input
-
-
-module.exports = {
-  transclude
-  transcludeString
-  scan
-  parse
-  substitute
-  readFile
-  linksFromInput
-  VERBOSE
-}
diff --git a/src/utils.coffee b/src/utils.coffee
new file mode 100644
index 0000000..f524c57
--- /dev/null
+++ b/src/utils.coffee
@@ -0,0 +1,81 @@
+fs = require 'fs'
+path = require 'path'
+peg = require 'pegjs'
+_ = require 'lodash'
+
+
+# Link detection (including leading whitespace)
+linkRegExp = new RegExp(/(^[\t ]*)?(\:\[.*?\]\(.*?\))/gm)
+WHITESPACE_GROUP = 1
+LINK_GROUP = 2
+
+
+# Build the link parser once
+grammar = require './grammar'
+linkParser = peg.buildParser grammar.transcludeGrammar
+
+
+# Scan a document string for links
+scan = (input) ->
+  links = []
+
+  while (match = linkRegExp.exec input)
+    links.push
+      placeholder: match[LINK_GROUP]
+      whitespace: if match[WHITESPACE_GROUP] then match[WHITESPACE_GROUP] else ""
+
+  return links
+
+
+# Parse a transclude link
+parse = (rawLink) ->
+  parsedLink = linkParser.parse rawLink.placeholder
+
+  # References are relative to the document where declared
+  parsedLink.references.forEach (ref) ->
+    if ref.type is "file"
+      ref.value = path.join rawLink.relativePath, ref.value
+
+  return _.merge rawLink, parsedLink
+
+
+# Read file to string
+readFile = (filename) ->
+  try
+    content = (fs.readFileSync filename).toString()
+    return content
+
+  catch err
+    if err.code = 'ENOENT'
+      console.error "File not found."
+
+  return null
+
+
+find = (link, references) ->
+  matches = _.filter references, (ref) ->
+    return ref?.placeholder is link
+
+  if matches.length > 1
+    console.error "Multiple matching references found for #{link}."
+    #log.error "Multiple matching references found for #{link}.", {matches}
+
+  return matches[0]
+
+
+inflate = (link, linkType) ->
+  switch linkType
+    when "string"
+      return link
+    when "file"
+      return readFile link
+    else
+      return ""
+
+module.exports = {
+  scan
+  parse
+  readFile
+  find
+  inflate
+}


Test patch:
diff --git a/test/hercule.coffee b/test/hercule.coffee
new file mode 100644
index 0000000..4651338
--- /dev/null
+++ b/test/hercule.coffee
@@ -0,0 +1,86 @@
+require 'coffee-script/register'
+assert = require 'assert-diff'
+hercule = require '../src/hercule'
+fs = require 'fs'
+path = require 'path'
+
+describe 'hercule', ->
+
+  describe 'transcludeString', ->
+
+    it 'should transclude files with valid links', (done) ->
+      input = "Jackdaws love my big sphinx of quartz."
+
+      output = hercule.transcludeString input, null, null, null
+      assert.equal output, 'Jackdaws love my big sphinx of quartz.'
+
+      done()
+
+    it 'should transclude files with valid links', (done) ->
+      file = __dirname + "/fixtures/test-basic/jackdaw.md"
+      input = (fs.readFileSync file).toString()
+      dir = path.dirname file
+
+      output = hercule.transcludeString input, dir, null, null
+      assert.equal output, 'Jackdaws love my big sphinx of quartz.\n'
+
+      done()
+
+  describe 'transcludeFile', ->
+
+    it 'should exit if a circular link exists', (done) ->
+      inputFile = __dirname + "/fixtures/test-circular/fox.md"
+
+      assert.throws () ->
+        output = hercule.transcludeFile inputFile, null, null, null
+      , Error, "Circular reference detected"
+
+      done()
+
+    it 'should not change a file without links', (done) ->
+      inputFile = __dirname + "/fixtures/test-base/fox.md"
+
+      output = hercule.transcludeFile inputFile, null, null, null
+      assert.equal output, 'The quick brown fox jumps over the lazy dog.\n'
+
+      done()
+
+    it 'should not change a file without valid links', (done) ->
+      inputFile = __dirname + "/fixtures/test-invalid/fox.md"
+
+      output = hercule.transcludeFile inputFile, null, null, null
+      assert.equal output, 'The quick brown fox {{jumps}} over the lazy dog.\n'
+
+      done()
+
+    it 'should transclude files with valid links', (done) ->
+      inputFile = __dirname + "/fixtures/test-basic/jackdaw.md"
+
+      output = hercule.transcludeFile inputFile, null, null, null
+      assert.equal output, 'Jackdaws love my big sphinx of quartz.\n'
+
+      done()
+
+    it 'should transclude files with valid links and respect leading whitespace', (done) ->
+      inputFile = __dirname + "/fixtures/test-whitespace/jackdaw.md"
+
+      output = hercule.transcludeFile inputFile, null, null, null
+      assert.equal output, 'Jackdaws love my\n  big\n  \n    sphinx of quartz.\n'
+
+      done()
+
+    it 'should transclude files with valid links and references', (done) ->
+      inputFile = __dirname + "/fixtures/test-extend/fox.md"
+
+      output = hercule.transcludeFile inputFile, null, null, null
+      assert.equal output, "The quick brown fox jumps over the lazy dog.\n"
+
+      done()
+
+    it 'should transclude files with valid links, references and string substitutions', (done) ->
+      inputFile = __dirname + "/fixtures/test-string-extend/fox.md"
+
+      output = hercule.transcludeFile inputFile, null, null, null
+      assert.equal output, "The quick brown fox jumps over the lazy dog.\n"
+
+      done()
diff --git a/test/index.coffee b/test/index.coffee
deleted file mode 100644
index 5a41e00..0000000
--- a/test/index.coffee
+++ /dev/null
@@ -1,326 +0,0 @@
-require 'coffee-script/register'
-assert = require 'assert-diff'
-hercule = require '../lib/transclude'
-fs = require 'fs'
-path = require 'path'
-
-describe 'hercule', ->
-  describe 'scan', ->
-    it 'should not detect non-existant placeholders', (done) ->
-      document = "Test document\nwith no placeholders."
-
-      references = hercule.scan document
-      assert.deepEqual references, []
-
-      done()
-
-    it 'should detect placeholders', (done) ->
-      document = "Test document\nwith :[number](one.md) placeholder."
-
-      references = hercule.scan document
-      assert.equal references.length, 1
-
-      done()
-
-    it 'should detect multiple placeholders', (done) ->
-      document = "Test :[document](test/doc.md) with :[number](three.md footer:common/footer.md) :[placeholders]()."
-
-      references = hercule.scan document
-      assert.equal references.length, 3
-
-      done()
-
-    it 'should not detect non-existant leading whitespace', (done) ->
-      document = "word :[word](test.md) word"
-
-      references = hercule.scan document
-      assert.equal references[0].whitespace, ""
-
-      done()
-
-    it 'should detect whitespace on the first line of a file', (done) ->
-      document = "\t:[](test)"
-
-      references = hercule.scan document
-      assert.equal references[0].whitespace, "\t"
-
-      done()
-
-    it 'should detect whitespace on following lines of a file', (done) ->
-      document = "\n :[](test)"
-
-      references = hercule.scan document
-      assert.equal references[0].whitespace, " "
-
-      done()
-
-    it 'should detect different types of leading whitespace', (done) ->
-      document = """# Heading 1
-      \t:[](tab)
-        :[](space)
-        \t :[](mixed)
-      """
-
-      references = hercule.scan document
-      assert.equal references[0].whitespace, "\t"
-      assert.equal references[1].whitespace, "  "
-      assert.equal references[2].whitespace, "  \t "
-
-      done()
-
-
-  describe 'parse', ->
-    it 'should parse a single file reference', (done) ->
-      reference =
-        placeholder: ":[name](file)"
-        relativePath: ""
-
-      parsed = hercule.parse reference
-      assert.deepEqual parsed, {
-        file: "file"
-        name: "name"
-        placeholder: reference.placeholder
-        references: []
-        relativePath: ""
-      }
-
-      done()
-
-    it 'should parse a single file reference with reference', (done) ->
-      reference =
-        placeholder: ":[name](file placeholder:filename.md)"
-        relativePath: ""
-
-      parsed = hercule.parse reference
-      assert.deepEqual parsed, {
-        file: "file"
-        name: "name"
-        placeholder: reference.placeholder
-        references: [
-          placeholder: "placeholder"
-          type: "file"
-          value: "filename.md"
-        ]
-        relativePath: ""
-      }
-
-      done()
-
-    it 'should parse a special reference', (done) ->
-      reference =
-        placeholder: ":[](file extend:)"
-        relativePath: ""
-
-      parsed = hercule.parse reference
-      assert.deepEqual parsed, {
-        file: "file"
-        name: null
-        placeholder: reference.placeholder
-        references: [
-          placeholder: "extend"
-          type: "string"
-          value: ""
-        ]
-        relativePath: ""
-      }
-
-      done()
-
-    it 'should parse multiples references', (done) ->
-      reference =
-        placeholder: ":[](file fruit:apple.md footer:../common/footer.md copyright:\"Copyright 2014 (c)\")"
-        relativePath: "customer/farmers-market"
-
-      parsed = hercule.parse reference
-      assert.deepEqual parsed, {
-        file: "file"
-        name: null
-        placeholder: reference.placeholder
-        references: [
-          placeholder: "fruit"
-          type: "file"
-          value: "customer/farmers-market/apple.md"
-        ,
-          placeholder: "footer"
-          type: "file"
-          value: "customer/common/footer.md"
-        ,
-          placeholder:"copyright"
-          type:"string"
-          value:"Copyright 2014 (c)"
-        ]
-        relativePath: "customer/farmers-market"
-      }
-
-      done()
-
-  describe 'linksFromInput', ->
-    it 'should return a parsed set of links for input', (done) ->
-      placeholder = ":[test](speed.md)"
-      input = "The #{placeholder} brown fox..."
-
-      links = hercule.linksFromInput input, [], ""
-      assert.deepEqual links, [
-        {
-          placeholder: placeholder
-          file: "speed.md"
-          name: "test"
-          whitespace: ""
-          references: []
-          relativePath: ""
-          parents: []
-        }
-      ]
-
-      done()
-
-  describe 'substitute', ->
-    it 'should not change the link when there are no references', (done) ->
-      file = "file.md"
-
-      expanded = hercule.substitute file, []
-      assert.deepEqual expanded, {file: file, type: "file"}
-
-      done()
-
-    it 'should not change the link when there are no matching references', (done) ->
-      file = "file.md"
-      references = [
-        placeholder: "footer"
-        type: "file"
-        value: "footer.md"
-      ]
-
-      expanded = hercule.substitute file, references
-      assert.deepEqual expanded, {file: file, type: "file"}
-
-      done()
-
-    it 'should change the link when there is a matching references', (done) ->
-      file = "footer"
-      references = [
-        placeholder: "footer"
-        type: "file"
-        value: "common/footer.md"
-      ]
-
-      expanded = hercule.substitute file, references
-      assert.deepEqual expanded, {file: "common/footer.md", type: "file"}
-
-      done()
-
-    it 'should change the link when there is a matching references', (done) ->
-      file = "canine"
-      references = [
-        placeholder: "canine"
-        type: "string"
-        value: "dog"
-      ]
-
-      expanded = hercule.substitute file, references
-      assert.deepEqual expanded, {file: "dog", type: "string"}
-
-      done()
-
-  describe 'readFile', ->
-    it 'should not throw an error for missing files', (done) ->
-      inputFile = __dirname + "/fixtures/missing.md"
-
-      content = hercule.readFile 'missing.md'
-      assert.equal content, null
-
-      done()
-
-    it 'should read files which exist', (done) ->
-      inputFile = __dirname + "/fixtures/test-base/fox.md"
-
-      content = hercule.readFile inputFile
-      assert.equal content, 'The quick brown fox jumps over the lazy dog.\n'
-
-      done()
-
-
-  describe 'transcludeString', ->
-    it 'should transclude files with valid links', (done) ->
-      input = "Jackdaws love my big sphinx of quartz."
-
-      hercule.transcludeString input, null, null, null, (err, document) ->
-        if err then return cb err
-        assert.equal document, 'Jackdaws love my big sphinx of quartz.'
-
-        done()
-
-    it 'should transclude files with valid links', (done) ->
-      file = __dirname + "/fixtures/test-basic/jackdaw.md"
-      input = (fs.readFileSync file).toString()
-      dir = path.dirname file
-
-      hercule.transcludeString input, dir, null, null, (err, document) ->
-        if err then return cb err
-        assert.equal document, 'Jackdaws love my big sphinx of quartz.\n'
-
-        done()
-
-  describe 'transclude', ->
-    it 'should exit if a circular link exists', (done) ->
-      inputFile = __dirname + "/fixtures/test-circular/fox.md"
-
-      hercule.transclude inputFile, null, null, null, (err, document) ->
-        assert.notEqual err, null
-
-        done()
-
-    it 'should not change a file without links', (done) ->
-      inputFile = __dirname + "/fixtures/test-base/fox.md"
-
-      hercule.transclude inputFile, null, null, null, (err, document) ->
-        if err then return cb err
-        assert.equal document, 'The quick brown fox jumps over the lazy dog.\n'
-
-        done()
-
-    it 'should not change a file without valid links', (done) ->
-      inputFile = __dirname + "/fixtures/test-invalid/fox.md"
-
-      hercule.transclude inputFile, null, null, null, (err, document) ->
-        if err then return cb err
-        assert.equal document, 'The quick brown fox {{jumps}} over the lazy dog.\n'
-
-        done()
-
-    it 'should transclude files with valid links', (done) ->
-      inputFile = __dirname + "/fixtures/test-basic/jackdaw.md"
-
-      hercule.transclude inputFile, null, null, null, (err, document) ->
-        if err then return cb err
-        assert.equal document, 'Jackdaws love my big sphinx of quartz.\n'
-
-        done()
-
-    it 'should transclude files with valid links and respect leading whitespace', (done) ->
-      inputFile = __dirname + "/fixtures/test-whitespace/jackdaw.md"
-
-      hercule.transclude inputFile, null, null, null, (err, document) ->
-        if err then return cb err
-        # TODO: Should there be the extra `\n `?
-        assert.equal document, 'Jackdaws love my\n  big\n  \n    sphinx of quartz.\n'
-
-        done()
-
-    it 'should transclude files with valid links and references', (done) ->
-      inputFile = __dirname + "/fixtures/test-extend/fox.md"
-
-      hercule.transclude inputFile, null, null, null, (err, document) ->
-        if err then return cb err
-        assert.equal document, "The quick brown fox jumps over the lazy dog.\n"
-
-        done()
-
-    it 'should transclude files with valid links, references and string substitutions', (done) ->
-      inputFile = __dirname + "/fixtures/test-string-extend/fox.md"
-
-      hercule.transclude inputFile, null, null, null, (err, document) ->
-        if err then return cb err
-        assert.equal document, "The quick brown fox jumps over the lazy dog.\n"
-
-        done()
diff --git a/test/utils.coffee b/test/utils.coffee
new file mode 100644
index 0000000..c83232f
--- /dev/null
+++ b/test/utils.coffee
@@ -0,0 +1,173 @@
+require 'coffee-script/register'
+assert = require 'assert-diff'
+utils = require '../src/utils'
+
+
+describe 'utils', ->
+
+  describe 'scan', ->
+    it 'should find zero links when there are none.', (done) ->
+      input = "Test document with no placeholders."
+
+      links = utils.scan input
+      assert.deepEqual links, []
+
+      done()
+
+    it 'should find links', (done) ->
+      input = "Test :[document](test/doc.md) with :[number](three.md footer:common/footer.md) :[placeholders]()."
+
+      links = utils.scan input
+      assert.equal links.length, 3
+
+      done()
+
+    it 'should ignore whitespace between words', (done) ->
+      input = "word :[word](test.md) word"
+
+      links = utils.scan input
+      assert.equal links[0].whitespace, ""
+
+      done()
+
+    it 'should find leading whitespace', (done) ->
+      input = """\t:[](tab)
+      \n :[](space)
+        \t :[](mixed)"""
+
+      links = utils.scan input
+      assert.equal links[0].whitespace, "\t"
+      assert.equal links[1].whitespace, " "
+      assert.equal links[2].whitespace, "  \t "
+
+      done()
+
+
+  describe 'parse', ->
+    it 'should parse a simple links', (done) ->
+      link =
+        placeholder: ":[name](file.md)"
+        relativePath: ""
+
+      parsedLink = utils.parse link
+
+      assert.deepEqual parsedLink, {
+        link: "file.md"
+        placeholder: link.placeholder
+        references: []
+        relativePath: ""
+      }
+
+      done()
+
+    it 'should parse complex links', (done) ->
+      link =
+        placeholder: ":[](file.md fruit:apple.md header: footer:../common/footer.md copyright:\"Copyright 2014 (c)\")"
+        relativePath: "customer/farmers-market"
+
+      parsedLink = utils.parse link
+
+      assert.deepEqual parsedLink, {
+        link: "file.md"
+        placeholder: link.placeholder
+        references: [
+          placeholder: "fruit"
+          type: "file"
+          value: "customer/farmers-market/apple.md"
+        ,
+          placeholder: "header"
+          type: "string"
+          value: ""
+        ,
+          placeholder: "footer"
+          type: "file"
+          value: "customer/common/footer.md"
+        ,
+          placeholder:"copyright"
+          type:"string"
+          value:"Copyright 2014 (c)"
+        ]
+        relativePath: "customer/farmers-market"
+      }
+
+      done()
+
+
+  describe 'find', ->
+    it 'should not find a match when no references provided', (done) ->
+      link = "file.md"
+      references = []
+
+      match = utils.find link, references
+      assert.deepEqual match, undefined
+
+      done()
+
+    it 'should not find a match when there are no matching references', (done) ->
+      link = "file.md"
+      references = [
+        placeholder: "footer"
+        type: "file"
+        value: "footer.md"
+      ]
+
+      match = utils.find link, references
+      assert.deepEqual match, undefined
+
+      done()
+
+    it 'should find matching references', (done) ->
+      link = "footer"
+      references = [
+        placeholder: "footer"
+        type: "file"
+        value: "common/footer.md"
+      ,
+        placeholder: "footer"
+        type: "file"
+        value: "other/footer.md"
+      ]
+
+      match = utils.find link, references
+      assert.deepEqual match, references[0]
+
+      done()
+
+
+  describe 'readFile', ->
+    it 'should return null for missing files', (done) ->
+      inputFile = __dirname + "/fixtures/missing.md"
+
+      content = utils.readFile 'missing.md'
+      assert.equal content, null
+
+      done()
+
+    it 'should read files which exist', (done) ->
+      inputFile = __dirname + "/fixtures/test-base/fox.md"
+
+      content = utils.readFile inputFile
+      assert.equal content, 'The quick brown fox jumps over the lazy dog.\n'
+
+      done()
+
+
+  describe 'inflate', ->
+    it 'should return strings', (done) ->
+      output = utils.inflate 'dog', 'string'
+      assert.equal output, 'dog'
+
+      done()
+
+    it 'should return contents of files', (done) ->
+      file = __dirname + "/fixtures/test-base/fox.md"
+      output = utils.inflate file, 'file'
+      assert.equal output, 'The quick brown fox jumps over the lazy dog.\n'
+
+      done()
+
+    it 'should return empty string for unsupported types', (done) ->
+      output = utils.inflate '', 'http'
+      assert.equal output, ''
+
+      done()