Sunday, September 11, 2011

My first knockout.js experience

Because I wanted to see what the hype is all about, I dove in the documentation and tutorials of knockout.js.

The tutorial site is great!
But the first thing that bothered me was the data-bind attribute everywhere.

So I pulled up my sleeves and pimped the 'templates and lists' tutorial

The easiest way to test the code is to copy and paste the gist content from the article in the html and javascript fields of the tutorial site.

The html

The final markup of the tutorial looks like this:

<h2>Your seat reservations (<span data-bind="text: seats().length"></span>)</h2>
<table>
<thead><tr>
<th>Passenger name</th><th>Meal</th><th>Surcharge</th><th></th>
</tr></thead>
<tbody data-bind="template: {name:'reservationTemplate', foreach: seats}"></tbody>
</table>
<script type="text/x-jquery-tmpl" id="reservationTemplate">
<tr>
<td><input data-bind="value: name" /></td>
<td><select data-bind="options: availableMeals, value: meal, optionsText: 'mealName'"></select></td>
<td data-bind="text: formattedPrice"></td>
<td><a href="#" data-bind="click: remove">Remove</a></td>
</tr>
</script>
<h3 data-bind="visible: totalSurcharge() > 0">
Total surcharge: $<span data-bind="text: totalSurcharge().toFixed(2)"></span>
</h3>
<button data-bind="click: addSeat, enable: seats().length < 5">Reserve another seat</button>
view raw default.html hosted with ❤ by GitHub

As you can see the data-bind attribute is all over the place.

And this is my markup:

<h2>Your seat reservations</h2>
<table>
<thead><tr>
<th>Passenger name</th><th>Meal</th><th>Surcharge</th><th></th>
</tr></thead>
<tbody id="seats"></tbody>
</table>
<p><button id="add-seat">Reserve another seat</button></p>
<div id="total-container">
<p>Total seats: <span id="total-seats"></span></p>
<p>Total surcharge: <span id="total-surcharge"></span></p>
</div>
<script type="text/x-jquery-tmpl" id="reservationTemplate">
{{each seats}}
<tr>
<td><input data-bind="value: name"></td>
<td><select data-bind="options: availableMeals, value: meal, optionsText: 'mealName'"></select></td>
<td data-bind="text: formattedPrice"></td>
<td><a href="#" data-bind="click: remove">Remove</a></td>
</tr>
{{/each}}
</script>
view raw pimped.html hosted with ❤ by GitHub

The data-bind attribute is only in the template.
The other bound elements have an id.

Instead of putting the seat count in the title, I put it together with the surcharge sum.
The tutorial code displays the surcharge sum when there actually is a surcharge, my code displays it from the moment a passenger is present.

The javascript code

The tutorial code looks like this:

// Raw catalog data - would come from the server
var availableMeals = [
{ mealName: "Standard (sandwich)", price: 0 },
{ mealName: "Premium (lobster)", price: 34.95 },
{ mealName: "Ultimate (whole zebra)", price: 290 }
];
// Class to represent a row in the reservations grid
var seatReservation = function(name) {
this.name = name;
this.availableMeals = availableMeals;
this.meal = ko.observable(availableMeals[0]);
this.formattedPrice = ko.dependentObservable(function() {
var price = this.meal().price;
return price ? "$" + price.toFixed(2) : "None";
}, this);
this.remove = function() { viewModel.seats.remove(this) }
}
// Overall viewmodel for this screen, along with initial state
var viewModel = {
seats: ko.observableArray([
new seatReservation("Steve"),
new seatReservation("Bert")
]),
addSeat: function() {
this.seats.push(new seatReservation());
}
};
viewModel.totalSurcharge = ko.dependentObservable(function() {
var total = 0;
for (var i = 0; i < this.seats().length; i++)
total += this.seats()[i].meal().price;
return total;
}, viewModel);
ko.applyBindings(viewModel);
view raw default.js hosted with ❤ by GitHub

Very readable if you are used to do all the legwork in jQuery.
I'm in the knockout camp if I need to create complicated interfaces from now on.

Before I show you my code I will do a blow-by-blow on what I did and what the hurdles were.

The first thing I wanted was to remove the data-bind attribute from the markup.
I knew someone wrote a jQuery plugin to make this possible.

It works fine until you want to use the plugin to bind the html elements in the template.
Because I didn't want to spend too much time looking for a way to make it work, I left the data-bind attributes.

Instead of using a ko.dependentObservable to make a sum of the surcharges I added it to the view model.
You should only use the method if the code depends on outside factors.

When I added the remove functionality it didn't work. I did some debugging and found out the this in the remove function was the view model object where it needs to be the seatReservation object.

I guess the unobtrusive plugin has to do something with it, but the quick solution is to bind the seatReservation object to the remove function.
Because jQuery is loaded I used $.proxy.

The tutorial adds an anonymous passenger but because that isn't allowed anymore, I added a prompt to the addSeat function.

So my code comes down to:

/**
* @preserve Unobtrusive Knockout support library for jQuery
*
* @author Joel Thoms
* @version 1.1
*/
(function($) {
if (!$ || !$['fn']) throw new Error('jQuery library is required.');
/**
* Private method to recursively render key value pairs into a string
*
* @param {Object} options Object to render into a string.
* @return {string} The string value of the object passed in.
*/
function render(options) {
var rendered = [];
for (var key in options) {
var val = options[key];
switch (typeof val) {
case 'string': rendered.push(key + ':' + val); break;
case 'object': rendered.push(key + ':{' + render(val) + '}'); break;
case 'function': rendered.push(key + ':' + val.toString()); break;
}
}
return rendered.join(',');
}
/**
* jQuery extension to handle unobtrusive Knockout data binding.
*
* @param {Object} options Object to render into a string.
* @return {Object} A jQuery object.
*/
$['fn']['dataBind'] = $['fn']['dataBind'] || function(options) {
return this['each'](function() {
var opts = $.extend({}, $['fn']['dataBind']['defaults'], options);
var attr = render(opts);
if (attr != null && attr != '') {
$(this)['attr']('data-bind', attr);
}
});
};
})(jQuery);
// Raw catalog data - would come from the server
var availableMeals = [
{ mealName: "Standard (sandwich)", price: 0 },
{ mealName: "Premium (lobster)", price: 34.95 },
{ mealName: "Ultimate (whole zebra)", price: 290 }
];
// Class to represent a row in the reservations grid
var seatReservation = function(name) {
this.name = name;
this.availableMeals = availableMeals;
this.meal = ko.observable(availableMeals[0]);
this.formattedPrice = ko.dependentObservable(function() {
var price = this.meal().price;
return price ? "$" + price.toFixed(2) : "None";
}, this);
var remove = function() {console.log(this.meal()); viewModel.seats.remove(this) };
this.remove = $.proxy(remove,this);
}
$('#seats').dataBind({ template: "'reservationTemplate'"});
$('#add-seat').dataBind({click: 'addSeat',enable: 'seats().length < 2'});
$('#total-container').dataBind({visible: 'seats().length > 0'});
$('#total-seats').dataBind({text: 'seats().length'});
$('#total-surcharge').dataBind({text: 'totalSurcharge() > 0 ? "$"+totalSurcharge().toFixed(2) : "None"'});
// Overall viewmodel for this screen, along with initial state
var viewModel = {
seats: ko.observableArray([
new seatReservation("Steve"),
new seatReservation("Bert")
]),
addSeat: function() {
var name = prompt("What is the passengers name?", "")
this.seats.push(new seatReservation(name));
},
totalSurcharge: function() {
var total = 0;
for (var i = 0; i < this.seats().length; i++)
total += this.seats()[i].meal().price;
return total;
}
};
ko.applyBindings(viewModel);
view raw pimped.js hosted with ❤ by GitHub

Conclusion

Amazing you can achieve so much with so little code. And the tutorial is still verbose because the availableMeals will be fetched using AJAX or generated by a server language, <?php echo json_encode($availableMeals); ?>

I grabbed it and I will not let it loose until something better comes along.

Tuesday, September 06, 2011

Ugling: the start of the template engine

The changes

If you look at the previous build.xml file you know processing content in phing will turn in spaghetti code very soon. So I moved it to the template task.

<?xml version="1.0" encoding="utf-8"?>
<project name="Ugling" basedir="." default="loop">
<!-- set variables to use in the tasks -->
<property name="dir.raw.content" value="content" />
<property name="dir.www.content" value="online/" />
<property name="dir.templates" value="templates" />
<taskdef name="markdown" classname="phing.tasks.ext.MarkdownTask" />
<taskdef name="template" classname="phing.tasks.ext.TemplateTask" />
<target name="loop">
<markdown destination="${dir.www.content}" removefilesetdir="${dir.raw.content}">
<fileset dir="${dir.raw.content}">
<include name="*.md" />
<include name="**/*.md" />
</fileset>
</markdown>
<template templatesdir="${dir.templates}">
<fileset dir="${dir.www.content}">
<include name="*.html" />
<include name="**/*.html" />
</fileset>
</template>
</target>
</project>
view raw build.xml hosted with ❤ by GitHub

The new build.xml file is easy to read because now you just have two tasks.

To get the build result I want i needed to add an attribute to the markdown task, removefilesetdir.
For me it was a cleaner way to process the markdown files and add the processed files to another directory than the way the rSTTask documentation shows.

<?php
require_once 'phing/Task.php';
require_once 'phing/util/FileUtils.php';
require_once 'System.php';
require_once "phing/tasks/ext/markdown/markdown.php";
class MarkdownTask extends Task {
/**
* @var string Taskname for logger
*/
protected $taskName = 'Markdown';
/**
* Result format, defaults to "html".
* @see $supportedFormats for all possible options
*
* @var string
*/
protected $format = 'html';
/**
* Input file in markdown format.
* Required
*
* @var string
*/
protected $file = null;
/**
* Output file or directory. May be omitted.
* When it ends with a slash, it is considered to be a directory
*
* @var string
*/
protected $destination = null;
protected $removefilesetdir = null;
protected $filesets = array(); // all fileset objects assigned to this task
protected $mapperElement = null;
/**
* all filterchains objects assigned to this task
*
* @var array
*/
protected $filterChains = array();
/**
* mode to create directories with
*
* @var integer
*/
protected $mode = 0755;
/**
* Only render files whole source files are newer than the
* target files
*
* @var boolean
*/
protected $uptodate = false;
/**
* The main entry point method.
*
* @return void
*/
public function main()
{
if (count($this->filterChains)) {
$this->fileUtils = new FileUtils();
}
if ($this->file != '') {
$file = $this->file;
$targetFile = $this->getTargetFile($file, $this->destination);
$this->render($file, $targetFile);
return;
}
if (!count($this->filesets)) {
throw new BuildException(
'"file" attribute or "fileset" subtag required'
);
}
// process filesets
$mapper = null;
if ($this->mapperElement !== null) {
$mapper = $this->mapperElement->getImplementation();
}
$project = $this->getProject();
foreach ($this->filesets as $fs) {
$ds = $fs->getDirectoryScanner($project);
$fromDir = $fs->getDir($project);
$srcFiles = $ds->getIncludedFiles();
foreach ($srcFiles as $src) {
$file = new PhingFile($fromDir, $src);
if ($mapper !== null) {
$results = $mapper->main($file);
if ($results === null) {
throw new BuildException(
sprintf(
'No filename mapper found for "%s"',
$file
)
);
}
$targetFile = reset($results);
} else {
$targetFile = $this->getTargetFile($file, $this->destination);
}
$this->render($file, $targetFile);
}
}
}
/**
* Renders a single file and applies filters on it
*
* @param string $tool conversion tool to use
* @param string $source markdown source file
* @param string $targetFile target file name
*
* @return void
*/
protected function render($source, $targetFile)
{
if (count($this->filterChains) == 0) {
return $this->renderFile($source, $targetFile);
}
$tmpTarget = tempnam(sys_get_temp_dir(), 'tmp-');
$this->renderFile($source, $tmpTarget);
$this->fileUtils->copyFile(
new PhingFile($tmpTarget),
new PhingFile($targetFile),
true, false, $this->filterChains,
$this->getProject(), $this->mode
);
unlink($tmpTarget);
}
/**
* Renders a single file with the markdown tool.
*
* @param string $tool conversion tool to use
* @param string $source markdown source file
* @param string $targetFile target file name
*
* @return void
*
* @throws BuildException When the conversion fails
*/
protected function renderFile($source, $targetFile)
{
if ($this->uptodate && file_exists($targetFile)
&& filemtime($source) <= filemtime($targetFile)
) {
//target is up to date
return;
}
//work around a bug in php by replacing /./ with /
$targetDir = str_replace('/./', '/', dirname($targetFile));
if (!is_dir($targetDir)) {
mkdir($targetDir, $this->mode, true);
}
$arOutput = Markdown(file_get_contents($source));
$retval = file_put_contents($targetFile,$arOutput);
if ( ! $retval) {
$this->log('File not rendered.', Project::MSG_INFO);
throw new BuildException('Rendering markdown failed');
}
$this->log('File rendered.', Project::MSG_DEBUG);
}
/**
* Determines and returns the target file name from the
* input file and the configured destination name.
*
* @param string $file Input file
* @param string $destination Destination file or directory name,
* may be null
*
* @return string Target file name
*
* @uses $format
* @uses $targetExt
*/
public function getTargetFile($file, $destination = null)
{
if ($destination != ''
&& substr($destination, -1) !== '/'
&& substr($destination, -1) !== '\\'
) {
return $destination;
}
if (strtolower(substr($file, -3)) == '.md') {
$file = substr($file, 0, -3);
}
if($this->removefilesetdir)
$file = str_replace($this->removefilesetdir.DIRECTORY_SEPARATOR,'',$file);
return $destination . $file . '.' . $this->format;
}
/**
* The setter for the attribute "file"
*
* @param string $file Path of file to render
*
* @return void
*/
public function setFile($file)
{
$this->file = $file;
}
/**
* The setter for the attribute "destination"
*
* @param string $destination Output file or directory. When it ends
* with a slash, it is taken as directory.
*
* @return void
*/
public function setDestination($destination)
{
$this->destination = $destination;
}
/**
* The setter for the attribute "destination"
*
* @param string $destination Output file or directory. When it ends
* with a slash, it is taken as directory.
*
* @return void
*/
public function setRemovefilesetdir($removefilesetdir)
{
$this->removefilesetdir = $removefilesetdir;
}
/**
* The setter for the attribute "uptodate"
*
* @param string $uptodate True/false
*
* @return void
*/
public function setUptodate($uptodate)
{
$this->uptodate = (boolean)$uptodate;
}
/**
* Nested creator, creates a FileSet for this task
*
* @return object The created fileset object
*/
public function createFileSet()
{
$num = array_push($this->filesets, new FileSet());
return $this->filesets[$num-1];
}
/**
* Nested creator, creates one Mapper for this task
*
* @return Mapper The created Mapper type object
*
* @throws BuildException
*/
public function createMapper()
{
if ($this->mapperElement !== null) {
throw new BuildException(
'Cannot define more than one mapper', $this->location
);
}
$this->mapperElement = new Mapper($this->project);
return $this->mapperElement;
}
/**
* Creates a filterchain, stores and returns it
*
* @return FilterChain The created filterchain object
*/
public function createFilterChain()
{
$num = array_push($this->filterChains, new FilterChain($this->project));
return $this->filterChains[$num-1];
}
}

The template engine vision

I want working with the generator to be as easy as possible so I try to stay away from code-like constructions as much as possible.
But at the same time it has to be as flexible as possible too.

The first problem I encounter is how can I provide a default template but allow specific templates at the same time.

Because i started to work with base template I got the idea that each directory could have a template.html that wraps all files in that directory and its subdirectories.
The only exception is the root template directory, it's required to have a template.html there.

To allow file specific templates it seemed the most logical to use the name of the file, so if your content file is called test.md and you want a file template you create a test.html file in the templates directory.

The template task today

I took my MarkdownTask.php code to start the TemplateTask.php code.
I concentrated on getting it to work so the code isn't that clean yet.

<?php
require_once 'phing/Task.php';
require_once 'phing/util/FileUtils.php';
require_once 'System.php';
require_once "phing/tasks/ext/markdown/markdown.php";
class TemplateTask extends Task {
/**
* @var string Taskname for logger
*/
protected $taskName = 'Template';
/**
* Input file in markdown format.
* Required
*
* @var string
*/
protected $file = null;
/**
* Output file or directory. May be omitted.
* When it ends with a slash, it is considered to be a directory
*
* @var string
*/
protected $destination = null;
/**
* templatefiles directory. required.
*
* @var string
*/
protected $templatesDir;
/**
* default template content. required.
*
* @var string
*/
protected $template_content;
/**
* default template placeholders. at least one required.
*
* @var array
*/
protected $template_placeholders = array();
protected $filesets = array(); // all fileset objects assigned to this task
protected $mapperElement = null;
/**
* all filterchains objects assigned to this task
*
* @var array
*/
protected $filterChains = array();
/**
* mode to create directories with
*
* @var integer
*/
protected $mode = 0755;
/**
* Only render files whole source files are newer than the
* target files
*
* @var boolean
*/
protected $uptodate = false;
/**
* The main entry point method.
*
* @return void
*/
public function main()
{
if( ! $this->templatesDir){
throw new BuildException('The templates directory is required');
}
$this->setTemplatePlaceholders('default');
if (count($this->filterChains)) {
$this->fileUtils = new FileUtils();
}
if ($this->file != '') {
$file = $this->file;
$targetFile = $this->getTargetFile($file, $this->destination);
$this->render($file, $targetFile);
return;
}
if (!count($this->filesets)) {
throw new BuildException(
'"file" attribute or "fileset" subtag required'
);
}
// process filesets
$mapper = null;
if ($this->mapperElement !== null) {
$mapper = $this->mapperElement->getImplementation();
}
$project = $this->getProject();
foreach ($this->filesets as $fs) {
$ds = $fs->getDirectoryScanner($project);
$fromDir = $fs->getDir($project);
$srcFiles = $ds->getIncludedFiles();
foreach ($srcFiles as $src) {
$file = new PhingFile($fromDir, $src);
if ($mapper !== null) {
$results = $mapper->main($file);
if ($results === null) {
throw new BuildException(
sprintf(
'No filename mapper found for "%s"',
$file
)
);
}
$targetFile = reset($results);
} else {
$targetFile = $this->getTargetFile($file, $this->destination);
}
$this->render($file, $targetFile);
}
}
}
/*
*
*/
protected function setTemplatePlaceholders($templateDir)
{
if($templateDir == 'default'){
if( ! file_exists($this->templatesDir.DIRECTORY_SEPARATOR.'template.html')){
throw new BuildException('A template.html file must in the templates directory');
}
$this->template_content = file_get_contents($this->templatesDir.DIRECTORY_SEPARATOR.'template.html');
preg_match_all('/\[[A-Z]+\]/',$this->template_content,$placeholders);
if( ! $placeholders){
throw new BuildException('At least a [CONTENT] placeholder is required');
}
$this->template_placeholders = $placeholders[0];
return true;
}else{
}
}
/**
* Renders a single file and applies filters on it
*
* @param string $tool conversion tool to use
* @param string $source markdown source file
* @param string $targetFile target file name
*
* @return void
*/
protected function render($source, $targetFile)
{
if (count($this->filterChains) == 0) {
return $this->renderFile($source, $targetFile);
}
$tmpTarget = tempnam(sys_get_temp_dir(), 'tmp-');
$this->renderFile($source, $tmpTarget);
$this->fileUtils->copyFile(
new PhingFile($tmpTarget),
new PhingFile($targetFile),
true, false, $this->filterChains,
$this->getProject(), $this->mode
);
unlink($tmpTarget);
}
/**
* Renders a single file with the markdown tool.
*
* @param string $tool conversion tool to use
* @param string $source markdown source file
* @param string $targetFile target file name
*
* @return void
*
* @throws BuildException When the conversion fails
*/
protected function renderFile($source, $targetFile)
{
if ($this->uptodate && file_exists($targetFile)
&& filemtime($source) <= filemtime($targetFile)
) {
//target is up to date
return;
}
//work around a bug in php by replacing /./ with /
$targetDir = str_replace('/./', '/', dirname($targetFile));
if (!is_dir($targetDir)) {
mkdir($targetDir, $this->mode, true);
}
$arOutput = $this->wrapTemplate($source);
$retval = file_put_contents($targetFile,$arOutput);
if ( ! $retval) {
$this->log('File not rendered.', Project::MSG_INFO);
throw new BuildException('Rendering template failed');
}
$this->log('File rendered.', Project::MSG_DEBUG);
}
protected function wrapTemplate($sourcefile)
{
$template_content = $this->template_content;
$template_placeholders = $this->template_placeholders;
if(strpos($sourcefile,DIRECTORY_SEPARATOR) !== false){
}
$placeholders = array_fill_keys($template_placeholders,'');
$placeholders['[CONTENT]'] = file_get_contents($sourcefile);
return strtr($template_content,$placeholders);
}
/**
* Determines and returns the target file name from the
* input file and the configured destination name.
*
* @param string $file Input file
* @param string $destination Destination file or directory name,
* may be null
*
* @return string Target file name
*
* @uses $format
* @uses $targetExt
*/
public function getTargetFile($file, $destination = null)
{
if ($destination != ''
&& substr($destination, -1) !== '/'
&& substr($destination, -1) !== '\\'
) {
return $destination;
}
return $destination . $file ;
}
/**
* The setter for the attribute "templatesdir"
*
* @param string $templatesDir directory of the templatefiles
*
* @return void
*/
public function setTemplatesDir($templatesDir)
{
$this->templatesDir = $templatesDir;
$this->template = $templatesDir.'/template.html';
}
/**
* The setter for the attribute "uptodate"
*
* @param string $uptodate True/false
*
* @return void
*/
public function setUptodate($uptodate)
{
$this->uptodate = (boolean)$uptodate;
}
/**
* Nested creator, creates a FileSet for this task
*
* @return object The created fileset object
*/
public function createFileSet()
{
$num = array_push($this->filesets, new FileSet());
return $this->filesets[$num-1];
}
/**
* Nested creator, creates one Mapper for this task
*
* @return Mapper The created Mapper type object
*
* @throws BuildException
*/
public function createMapper()
{
if ($this->mapperElement !== null) {
throw new BuildException(
'Cannot define more than one mapper', $this->location
);
}
$this->mapperElement = new Mapper($this->project);
return $this->mapperElement;
}
/**
* Creates a filterchain, stores and returns it
*
* @return FilterChain The created filterchain object
*/
public function createFilterChain()
{
$num = array_push($this->filterChains, new FilterChain($this->project));
return $this->filterChains[$num-1];
}
}

The default template code is working.

To prevent placeholders showing up in the online files they are set to an empty string before the actual content is added or generated.

Next time

The next blog post I will have the template vision working and have come up with a plan to add generated navigation content.

Saturday, September 03, 2011

Ugling: the phing powered static site generator (the begin)

Preface


I was looking for a project to use phing in a way that isn't expected.

Using it to remove version software directories or run tests is done so many times before.


The cool programmers started using static site generators like jekyll and petrify.

Why would you need a database if most of your content is static.


So why not use phing to do the same thing.


I also want to make the threshold as low as possible.
Markdown is one thing but other elements like the navigation are going to be not that easy.


Transforming the markdown


Creating a task in phing is easy, certainly if someone already did a similar task.


I just copied the code from the rSTTask, removed the parts i didn't need and changed the code to use the markdown class.


<?php
require_once 'phing/Task.php';
require_once 'phing/util/FileUtils.php';
require_once 'System.php';
require_once "phing/tasks/ext/markdown/markdown.php";
class MarkdownTask extends Task {
/**
* @var string Taskname for logger
*/
protected $taskName = 'Markdown';
/**
* Result format, defaults to "html".
* @see $supportedFormats for all possible options
*
* @var string
*/
protected $format = 'html';
/**
* Input file in markdown format.
* Required
*
* @var string
*/
protected $file = null;
/**
* Output file or directory. May be omitted.
* When it ends with a slash, it is considered to be a directory
*
* @var string
*/
protected $destination = null;
protected $filesets = array(); // all fileset objects assigned to this task
protected $mapperElement = null;
/**
* all filterchains objects assigned to this task
*
* @var array
*/
protected $filterChains = array();
/**
* mode to create directories with
*
* @var integer
*/
protected $mode = 0755;
/**
* Only render files whole source files are newer than the
* target files
*
* @var boolean
*/
protected $uptodate = false;
/**
* The main entry point method.
*
* @return void
*/
public function main()
{
if (count($this->filterChains)) {
$this->fileUtils = new FileUtils();
}
if ($this->file != '') {
$file = $this->file;
$targetFile = $this->getTargetFile($file, $this->destination);
$this->render($file, $targetFile);
return;
}
if (!count($this->filesets)) {
throw new BuildException(
'"file" attribute or "fileset" subtag required'
);
}
// process filesets
$mapper = null;
if ($this->mapperElement !== null) {
$mapper = $this->mapperElement->getImplementation();
}
$project = $this->getProject();
foreach ($this->filesets as $fs) {
$ds = $fs->getDirectoryScanner($project);
$fromDir = $fs->getDir($project);
$srcFiles = $ds->getIncludedFiles();
foreach ($srcFiles as $src) {
$file = new PhingFile($fromDir, $src);
if ($mapper !== null) {
$results = $mapper->main($file);
if ($results === null) {
throw new BuildException(
sprintf(
'No filename mapper found for "%s"',
$file
)
);
}
$targetFile = reset($results);
} else {
$targetFile = $this->getTargetFile($file, $this->destination);
}
$this->render($file, $targetFile);
}
}
}
/**
* Renders a single file and applies filters on it
*
* @param string $tool conversion tool to use
* @param string $source markdown source file
* @param string $targetFile target file name
*
* @return void
*/
protected function render($source, $targetFile)
{
if (count($this->filterChains) == 0) {
return $this->renderFile($source, $targetFile);
}
$tmpTarget = tempnam(sys_get_temp_dir(), 'tmp-');
$this->renderFile($source, $tmpTarget);
$this->fileUtils->copyFile(
new PhingFile($tmpTarget),
new PhingFile($targetFile),
true, false, $this->filterChains,
$this->getProject(), $this->mode
);
unlink($tmpTarget);
}
/**
* Renders a single file with the markdown tool.
*
* @param string $tool conversion tool to use
* @param string $source markdown source file
* @param string $targetFile target file name
*
* @return void
*
* @throws BuildException When the conversion fails
*/
protected function renderFile($source, $targetFile)
{
if ($this->uptodate && file_exists($targetFile)
&& filemtime($source) <= filemtime($targetFile)
) {
//target is up to date
return;
}
//work around a bug in php by replacing /./ with /
$targetDir = str_replace('/./', '/', dirname($targetFile));
if (!is_dir($targetDir)) {
mkdir($targetDir, $this->mode, true);
}
$arOutput = Markdown(file_get_contents($source));
$retval = file_put_contents($targetFile,$arOutput);
if ( ! $retval) {
$this->log('File not rendered.', Project::MSG_INFO);
throw new BuildException('Rendering markdown failed');
}
$this->log('File rendered.', Project::MSG_DEBUG);
}
/**
* Determines and returns the target file name from the
* input file and the configured destination name.
*
* @param string $file Input file
* @param string $destination Destination file or directory name,
* may be null
*
* @return string Target file name
*
* @uses $format
* @uses $targetExt
*/
public function getTargetFile($file, $destination = null)
{
if ($destination != ''
&& substr($destination, -1) !== '/'
&& substr($destination, -1) !== '\\'
) {
return $destination;
}
if (strtolower(substr($file, -3)) == '.md') {
$file = substr($file, 0, -3);
}
return $destination . $file . '.' . $this->format;
}
/**
* The setter for the attribute "file"
*
* @param string $file Path of file to render
*
* @return void
*/
public function setFile($file)
{
$this->file = $file;
}
/**
* The setter for the attribute "destination"
*
* @param string $destination Output file or directory. When it ends
* with a slash, it is taken as directory.
*
* @return void
*/
public function setDestination($destination)
{
$this->destination = $destination;
}
/**
* The setter for the attribute "uptodate"
*
* @param string $uptodate True/false
*
* @return void
*/
public function setUptodate($uptodate)
{
$this->uptodate = (boolean)$uptodate;
}
/**
* Nested creator, creates a FileSet for this task
*
* @return object The created fileset object
*/
public function createFileSet()
{
$num = array_push($this->filesets, new FileSet());
return $this->filesets[$num-1];
}
/**
* Nested creator, creates one Mapper for this task
*
* @return Mapper The created Mapper type object
*
* @throws BuildException
*/
public function createMapper()
{
if ($this->mapperElement !== null) {
throw new BuildException(
'Cannot define more than one mapper', $this->location
);
}
$this->mapperElement = new Mapper($this->project);
return $this->mapperElement;
}
/**
* Creates a filterchain, stores and returns it
*
* @return FilterChain The created filterchain object
*/
public function createFilterChain()
{
$num = array_push($this->filterChains, new FilterChain($this->project));
return $this->filterChains[$num-1];
}
}

The source layout



  • content

    • index.md



  • media

  • online

  • templates

    • base.html



  • build.xml


As you can see the content directory holds the markdown files.


You can add as many subdirectories as needed. And use index.md files to display content for an url without the html extension.


The media directory will hold css files, js files, and other public content.

I'm not sure i'm going to keep it because the only phing action will coppy the files to the online directory.


The online directory holds all the files you can place online.


The templates directory holds html files that have placeholders.
This will be for the 'expert' users.


The build.xml file is the phing hook which will take care of all the actions.


Later there can come a build.properties file to move the user accessible properties outside the build script.


Build.xml content


<?xml version="1.0" encoding="utf-8"?>
<project name="Ugling" basedir="." default="loop">
<!-- set variables to use in the tasks -->
<property name="dir.raw.content" value="content" />
<property name="dir.www.content" value="online" />
<property name="template.base" value="templates/base.html" />
<taskdef name="markdown" classname="phing.tasks.ext.MarkdownTask" />
<!-- phing gotcha: if you don't put a foreach in a target you get an error -->
<target name="loop">
<foreach param="filename" absparam="absfilename" target="single">
<fileset dir="${dir.raw.content}">
<include name="*.md"/>
<include name="**/*.md"/>
</fileset>
</foreach>
</target>
<target name="single" description="render a single markdown file to HTML and add template">
<php function="str_replace" returnProperty="file">
<param value=".md"/>
<param value=""/>
<param value="${filename}"/>
</php>
<markdown file="${absfilename}" destination="${dir.www.content}/${file}.html" />
<copy file="${dir.www.content}/${file}.html" tofile="${dir.www.content}/${file}2.html"/>
<!-- phing gotcha: expression attribute doen't process the variables -->
<php function="file_get_contents" returnProperty="pagecontent">
<param value="${dir.www.content}/${file}.html"/>
</php>
<php function="file_get_contents" returnProperty="rawtemplate">
<param value="${template.base}"/>
</php>
<php function="str_replace" returnProperty="templatecontent">
<param value="[CONTENT]"/>
<param value="${pagecontent}"/>
<param value="${rawtemplate}"/>
</php>
<php function="file_put_contents">
<param value="${dir.www.content}/${file}2.html"/>
<param value="${templatecontent}"/>
</php>
<move file="${dir.www.content}/${file}2.html" tofile="${dir.www.content}/${file}.html" overwrite="true"/>
</target>
</project>
view raw build.xml hosted with ❤ by GitHub

I think it's a readable file.



  1. Set the start target: default="loop"

  2. Add properties

  3. Add markdown task

  4. Find all markdown files and send the file names to the single target

  5. Process the markdown file and move it to the online directory


The future


Now that the easy part is over I can go to work on the necessary plugins.
Navigation is the first that needs attention.