<?php
error_reporting(E_ALL | E_STRICT);
/**
* Exception class for the XmlPrinter class.
* @author Peter Goodman
*/
class XmlPrinterException extends Exception { }
/**
* A simple stack-based XML string constructor for creating XML.
* @author Peter Goodman
*/
class XmlPrinter {
// what type of tag is this?
const TAG_CLOSING = 1,
TAG_NON_CLOSING = 0;
// exception error codes
const ERROR_UNRECOVERABLE = 1, // these are usually programmer errors that
ERROR_EMPTY_STACK = 2, // need to be fixed in the code
ERROR_UNEXPECTED_TAG = 4,
ERROR_UNFINISHED_DOCUMENT = 8;
// stack of tags and the current indent level
private $tags = array(),
$indent = -1;
/**
* Construct the class and put in the base <xml> tag. This tag is non-
* removable from the stack and isn't built in the usual way :) It's an
* exception to the rule.
*/
public function __construct() {
$this->open(' ');
$xml =& $this->tags[$this->indent];
$xml['children'] = -1;
$xml['content'] = '<?xml version="1.0"?>';
$this->indent = -1;
}
// destructor, obvious.
public function __destruct() {
unset($this->stack);
}
/**
* Add some content to the current tag on the top of the stack.
*/
public function hasContent($content = "") {
$tag =& $this->tags[$this->indent+1];
$nl = FALSE === strstr($content, "\n") ? '' : "\n";
// deal with ampersands
$content = preg_replace("~&(?!amp)~", "&", $content);
// wrap the content in a CDATA block if it contains tags
if(preg_match("~[<>]~i", $content))
$content = "<![CDATA[{$nl}{$content}{$nl}]]>";
// what should the indent prefix be?
$prefix = str_repeat("\t", $this->indent+1);
// prefix the content
$c = "";
foreach(explode("\n", $content) as $line) {
if(strlen($line) > 0) {
$tag['content_num_lines']++;
$c .= $nl . $prefix . $line;
}
}
// only one line, lets remove the prefix
if($tag['content_num_lines'] == 1)
$c = substr($c, strlen($prefix));
$tag['content'] .= $c;
return $this;
}
/**
* Push a tag onto the XML tag stack.
*/
public function open($name = '', $type = self::TAG_CLOSING) {
// we can't push no tag onto the tag stack
if(empty($name) || !is_string($name))
throw new XmlPrinterException("Tag name expected in push operation.",
self::ERROR_UNRECOVERABLE);
$this->indent++;
$this->tags[] = array('name' => $name,
'closing' => (bool)$type,
'attr' => '',
'content' => '',
'children' => 0,
'content_num_lines' => 0);
// get the parent tag and tell it that it has one more child. The
// children is also incremented in the pop method; however, these
// don't conflict because the lowest level child will have 0 children
// and this will have already been called. Thus, 1 + 0 accurately
// represents the number of children the node at the top of the stack
// has when a leaf node is popped, and so it all bubbles up nicely.
$parent =& $this->tags[$this->indent];
$parent['children']++;
return $this;
}
/**
* Pop the current tag off of the tag stack.
*/
public function close($expect = NULL) {
// we've closed all tags, this is a guard against popping the xml
// tag off the stack
if($this->indent < 0) {
$extra = !empty($expect) ? self::ERROR_UNRECOVERABLE : 0;
throw new XmlPrinterException("No tags left to close.",
self::ERROR_EMPTY_STACK | $extra);
}
// get the tag that we're popping off the stack
$tag = array_pop($this->tags);
// did we expect to close a certain tag name and it's not the same one
// that the class expects?
if($expect !== NULL && $tag['name'] != $expect)
throw new XmlPrinterException("Malformed XML. Expected [{$expect}] "
."but found [{$tag['name']}].",
self::ERROR_UNEXPECTED_TAG);
// this builds the output from the inside out by putting the content
// into the parent tag's content.
$prefix = $this->indent > 0 ? str_repeat("\t", $this->indent) : '';
$suffix = !$tag['closing'] ? ' /' : '';
$content = "\n{$prefix}<". $tag['name'] . $tag['attr'] . $suffix .">";
// do we need to build the contents of this tag?
if($tag['closing']) {
// write this tag over multiple lines
if($tag['content_num_lines'] > 1 || $tag['children'] > 0)
$content .= "{$prefix}". $tag['content'] ."\n{$prefix}";
// write this tag over a single line
else
$content .= $tag['content'];
$content .= '</'. $tag['name'] .">";
}
// add stuff into the parent and modify its content and children
// counter
$parent =& $this->tags[$this->indent];
$parent['content'] .= $content;
$parent['children'] += $tag['children'];
$this->indent--;
return $this;
}
/**
* Append an attribute to the top tag in the stack. This is really just a
* convenient shortcut.
*/
public function __call($attr, array $args = array()) {
if(empty($args))
$args = array("");
return $this->hasAttr($attr, (string)$args[0]);
}
/**
* Secondary function for attributes if an attribute, for example, has a
* colon in its name or has the name push, pop, or one of the other names
* of one of this class' functions.
*/
public function hasAttr($attr, $val = '') {
// unrecoverable programmer error, attributes need to have a name
if(empty($attr))
throw new XmlPrinterException("XML tag attribute must has a name.",
self::ERROR_UNRECOVERABLE);
$tag =& $this->tags[$this->indent+1];
// clean up any quotes in the attribute value so that they don't
// conflict with. Single quotes are allowed to pass because the
// attribute value is delimited with doubles
$attr = (string)$attr;
$val = preg_replace('~[\\\]*"~', '\"', (string)$val);
$tag['attr'] .= " {$attr}=\"{$val}\"";
return $this;
}
/**
* Do the header call.
*/
public function doHeader() {
header("Content-Type: text/xml");
}
/**
* Return the built XML string.
*/
public function __toString() {
// whoops, we're not done building the xml document yet!
if($this->indent > 0)
throw new XmlPrinterException("Cannot export malformed XML document.",
self::ERROR_UNFINISHED_DOCUMENT);
return (string)$this->tags[0]['content'];
}
}
/**
* RSS Feed Printer.
* @author Peter Goodman
*/
class RssFeedPrinter extends XmlPrinter {
// some other helpful stuff
const DATE_FORMAT = "D, d M Y H:i:s T";
private $done = FALSE, // are we done building the feed?
$context = 'rss', // what is the parent tag?
// obvious
$required_tags = array('rss' => array('channel' => FALSE),
'channel' => array('title' => FALSE,
'link' => FALSE,
'description' => FALSE),
'item' => array());
/**
* Construct and set up the rss top level tag.
*/
public function __construct() {
parent::__construct();
$this->open('rss')->version("2.0")
->hasAttr("xmlns:atom", "http://www.w3.org/2005/Atom");
$this->context = 'rss';
}
/**
* Open the <channel> tag, set up the atom:link, and set the context.
*/
public function open_channel() {
$this->open('channel');
$this->open('atom:link', XmlPrinter::TAG_NON_CLOSING)
->href(self::encodeURL())->rel("self")
->type("application/rss+xml")->close();
// if we have multiple channels in one feed
foreach($this->required_tags['channel'] as $key => $val)
$this->required_tags['channel'][$key] = FALSE;
// set the context
$this->context = 'channel';
return $this;
}
/**
* Create the </channel>
*/
public function close_channel() {
$this->close('channel');
$this->checkContext();
$this->context = 'rss';
return $this;
}
/**
* Create the <item> and set the context.
*/
public function open_item() {
$this->open('item');
$this->context = 'item';
return $this;
}
/**
* Create the </item>.
*/
public function close_item() {
$this->close('item');
$this->context = 'channel';
return $this;
}
/**
* Print out the RSS.
*/
public function __toString() {
if(!$this->done)
$this->close('rss');
$this->done = TRUE;
$this->context = 'rss';
$this->checkContext();
return parent::__toString();
}
/**
* Open a tag, if it's one of the required tags for this context then
* mark it off that we've met that requirement.
*/
public function open($name = '', $type = parent::TAG_CLOSING) {
// check off this required tag
if(isset($this->required_tags[$this->context][$name]))
$this->required_tags[$this->context][$name] = TRUE;
return parent::open($name, $type);
}
/**
* Check if all required tags have been used in this context and if not
* throw an exception.
*/
public function checkContext() {
$tags =& $this->required_tags[$this->context];
foreach($tags as $name => $used) {
if(!$used)
throw new XmlPrinterException("The tag [{$name}] is required ".
"in the [{$this->context}] section.");
// reset it
$tags[$name] = FALSE;
}
}
/**
* Helper function to make sure URLs are nicely encoded for RSS.
*/
static public function encodeURL($url = NULL) {
// default to the current url
if($url === NULL)
$url = "http://". $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"];
// split up the url into its parts
$url = parse_url($url);
$get = array();
parse_str(isset($url['query']) ? $url['query'] : '', $get);
// encode each GET parameter
foreach($get as $key => $val)
$get[$key] = rawurlencode($val);
// rebuild the query string
$url['query'] = http_build_query($get);
// rebuild the url
$ret = '';
$ret .= isset($url['scheme']) ? $url['scheme'] . '://' : '';
$ret .= isset($url['user']) ? $url['user'] : '';
$ret .= isset($url['pass']) ? ':'. $url['pass'] : '';
$ret .= isset($url['user']) ? '@' : '';
$ret .= isset($url['host']) ? $url['host'] : '';
$ret .= isset($url['path']) ? $url['path'] : '';
$ret .= isset($url['query']) ? $url['query'] : '';
$ret .= isset($url['fragment']) ? '#'. $url['fragment'] : '';
return $ret;
}
}
/**
* Function to create and output a basic RSS feed using a simple mapping
* notation.
* @author Peter Goodman
*/
function create_rss_feed(array $channel_map, $items, array $item_map = NULL) {
// make sure we're getting something that we can loop over for $items
if(!is_array($items) && !($items instanceof Traversable))
throw new UnexpectedValueException("Function [create_rss_feed] expected ".
"array or iterator for RSS items.");
// the default item mapping, includes all RSS 2.0 standard optional item
// tags
if(empty($item_map)) {
$tags = array('title','link','description','author','category',
'comments','enclosure','guid','pubDate','source');
$item_map = array_combine($tags, $tags); // yes I'm lazy.
}
// bring in the feed builder/printer
$rss = new RssFeedPrinter;
$rss->open_channel();
// deal with <channel> and the top level tags in it
foreach($channel_map as $tag => $value)
__rss_tag($rss, $tag, $value);
// deal with each <item> tag
foreach($items as $item) {
$rss->open_item();
// deal with tags within <item> by mapping properties/indexes in each
// $item to the tags that go within the <item>.
foreach($item_map as $tag => $index) {
$value = NULL;
// is this an object and does it have the property we're looking
// for?
if(is_object($item) && property_exists($item, $index))
$value = $item->$index;
// is this an array, or does it have array access?
if(NULL === $value && (is_array($item) || $item instanceof ArrayAccess))
if(isset($item[$index]))
$value = $item[$index];
// if the value is null it means that the mapping didn't find
// anything to map to in the item, so don't include this tag in
// the item
if(NULL === $value)
continue;
// deal with the tag and any special cases that need to be applied
// to its values
__rss_tag($rss, $tag, $value);
}
$rss->close_item();
}
$rss->close_channel();
// output the feed
$rss->doHeader();
echo $rss;
}
/**
* Create the internal tags of an RSS feed while being aware of special cases.
* @author Peter Goodman
* @internal
*/
function __rss_tag(RssFeedPrinter &$rss, $tag, $value = '') {
$rss->open($tag);
// deal with special cases
switch($tag) {
// make sure the link query parameters are escaped properly
case 'link':
$rss->hasContent(RssFeedPrinter::encodeURL($value));
break;
// make sure we format the date correctly
case 'pubDate':
if(is_string($value))
$value = strtotime($value);
$rss->hasContent(date(RssFeedPrinter::DATE_FORMAT,
(int)$value));
break;
// assumption: this is a permalink
case 'guid':
$rss->isPermaLink("true")
->hasContent(RssFeedPrinter::encodeURL($value));
break;
// everything else, flat assumption
default:
$rss->hasContent($value);
break;
}
$rss->close();
}