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.

No comments: