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.

Wednesday, August 17, 2011

My first phing buildscript

Because I get more and more components from version management tools it gets harder and harder to move from development to staging level. SVN repositories are the worst offenders, certainly if you see how clean the get and mercurial repositories are.

If you clone or checkout a repository most of the time it also includes test files, build files, and so on. It's stuff you don't need for your application.
Swiftmailer for example comes with a full blown test suite. What if hackers find a way to use it to their advantage?

The phing installation via pear is painless.

The way to delete a directory didn't appear in any of the searches i did. So i moved on to the documentation and I found what i needed.

<?xml version="1.0" encoding="UTF-8"?>
<project name="FooBar" default="dist">
<copy todir="c:\FooBarStage\vendor\swiftmailer">
<fileset dir=".\vendor\swiftmailer">
<exclude name=".git\**" /> <!-- exclude .git directory files because they can be locked -->
<exclude name=".gitignore" />
<exclude name="CHANGES" />
<exclude name="README" />
<exclude name="README.git" />
<exclude name="VERSION" />
<exclude name="build.xml" />
<exclude name="create_pear_package.php" />
<exclude name="package.xml.tpl" />
</fileset>
</copy>
<delete dir="c:\FooBarStage\vendor\swiftmailer\.git" />
<delete dir="c:\FooBarStage\vendor\swiftmailer\doc" />
<delete dir="c:\FooBarStage\vendor\swiftmailer\notes" />
<delete dir="c:\FooBarStage\vendor\swiftmailer\test-suite" />
<delete dir="c:\FooBarStage\vendor\swiftmailer\tests" />
</project>
view raw gistfile1.xml hosted with ❤ by GitHub


It's not very elegant but i'm taking babysteps at the moment.

Sunday, July 03, 2011

Being a good programmer

First of all this is not a post about best practices, being smart and all that jazz. It's about me exploring new areas to use my programming knowledge.


Update


I put the code on github


The problem


I'm a fan of the WFMU radiostation. They have a wide variety of music and they archive all their shows.
The downside is that after a month they delete the mp3 archive of the shows and because I don't listen all the time I'm missing episodes of my favorite shows.
That is why I started to download the mp3's but it takes too many steps and I needed to switch programms.



  1. Download the m3u file.

  2. Open the m3u file

  3. Copy the content

  4. Open a browsertab

  5. Paste the m3u content in the locationbar


So i went in developer-mode and started to work out a faster way to download the mp3's.


The brainstorm session


As i'm getting the mp3 archives as an rss feed and i'm using google reader to view it, the step to making it a browser add-on was taken quickly.


The easiest way to download the mp3 for me is to right click the link, click on a context item and the download starts.


The development process


I've done only a little chrome extension development, but the browser makes it a painless process with it's extension developers-mode.

This mode lets you choose a directory on your pc where the extension files are located and once you 'installed' the extension you can reload it when you changed the source code.


Because i never used contextmenu code i donwloaded the sample code from the google code site, which i also use to learn more about the api's.


The contextmenu item


The code is simple enough to understand.



  • Define when the menu item has to be added, the contexts.

  • Iterate over all the defined contexts to add the menu item and click handler, the genericOnClick.


The m3u content


From the sample I learned the first parameter of the click handler, info, holds the m3u url, linkUrl. Not a lot of brainpower needed here.


From my previous experience i know to get the file content i have to do an ajax request.

To make a cross-origin request possible you need to add the sites to the permissions variable of the manifest.json. This file contains all the metadata of the extension.


The download


Now we come to the head scratching part. As much as I searched I couldn't find a way to use the ajax responseText to automate the download.


I was trying to inject the mp3 url in the originating tab and then programmatically clicking on it. To do this i had to use the tabs.executeScript method but as i found out the code is executed in a sandbox so it can not use values from the click handler.


So i had to settle with opening a new tab which has as url the mp3 url.


The future


Now the number of actions has reduced to 3 and i can stay in the browser.



  1. Right click the link.

  2. Click the contextmenu item.

  3. Save file from tab.


I'm not going to show the code now because I want to make it more robust and universal.

There are no messages when something went wrong. I assume the content of the file only contains one mp3 link but i know it can contain multiple files.


Later i could support more metadata files and use a converting service like zamzar to make it an allround downloading extension.