commit 524796e43de7da7d89ecaa35fa629afa7bf19c30
Author: Starbeamrainbowlabs <sbrl@starbeamrainbowlabs.com>
Date:   Mon Mar 5 18:03:34 2018 +0000

    Erm I really think I ought to commit this to git naow :P

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..45d8b00
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# The application's data directory
+data/
+
+# Created by https://www.gitignore.io/api/composer,git
+
+### Composer ###
+composer.phar
+/vendor/
+
+# Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file
+# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
+# composer.lock
+
+### Git ###
+*.orig
+
+
+# End of https://www.gitignore.io/api/composer,git
diff --git a/AbstractAction.php b/AbstractAction.php
new file mode 100644
index 0000000..c6556f6
--- /dev/null
+++ b/AbstractAction.php
@@ -0,0 +1,18 @@
+<?php
+
+abstract class AbstractAction
+{
+    public abstract function handle();
+	
+	public function param_exists($key) {
+		return !empty($_GET[$key]);
+	}
+	
+	public function get_param($key, $default_value) {
+		return $_GET[$key] ?? $default_value;
+	}
+	
+	public get_post_body() {
+		return file_get_contents("php://input");
+	}
+}
diff --git a/Actions/Report.php b/Actions/Report.php
new file mode 100644
index 0000000..4dad26e
--- /dev/null
+++ b/Actions/Report.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Sandpiper\Actions;
+
+class Report extends \Sandpiper\AbstractAction
+{
+	
+	public function __construct()
+	{
+		
+	}
+	
+	public function handle() {
+		
+	}
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..1ae6b40
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,16 @@
+{
+    "name": "sbrl/wandering-sandpiper",
+    "description": "A simple error report / stack trace gathering system.",
+    "type": "project",
+    "require": {
+        "yosymfony/toml": "^1.0",
+        "aura/autoload": "^2.0"
+    },
+    "license": "MPL-2.0",
+    "authors": [
+        {
+            "name": "Starbeamrainbowlabs",
+            "email": "sbrl@starbeamrainbowlabs.com"
+        }
+    ]
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..9ba42f8
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,163 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "3f0d14de50f1944fa0995459b410e76c",
+    "packages": [
+        {
+            "name": "aura/autoload",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/auraphp/Aura.Autoload.git",
+                "reference": "306a7f8d3cb58fb6f94bcff1dddf20c543f68668"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/auraphp/Aura.Autoload/zipball/306a7f8d3cb58fb6f94bcff1dddf20c543f68668",
+                "reference": "306a7f8d3cb58fb6f94bcff1dddf20c543f68668",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "type": "library",
+            "extra": {
+                "aura": {
+                    "type": "library"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Aura\\Autoload\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Aura.Autoload Contributors",
+                    "homepage": "https://github.com/auraphp/Aura.Autoload/contributors"
+                }
+            ],
+            "description": "Provides a PSR-4 compliant autoloader implementation.",
+            "homepage": "https://github.com/auraphp/Aura.Autoload",
+            "keywords": [
+                "PSR-4",
+                "SPL autoloader",
+                "autoload",
+                "autoloader",
+                "class loader"
+            ],
+            "time": "2016-10-03T19:36:19+00:00"
+        },
+        {
+            "name": "yosymfony/parser-utils",
+            "version": "v1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/yosymfony/parser-utils.git",
+                "reference": "5376469d996a8f09139c0c795048b1140d143281"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/yosymfony/parser-utils/zipball/5376469d996a8f09139c0c795048b1140d143281",
+                "reference": "5376469d996a8f09139c0c795048b1140d143281",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Yosymfony\\ParserUtils\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Victor Puertas",
+                    "email": "vpgugr@gmail.com",
+                    "homepage": "http://yosymfony.com"
+                }
+            ],
+            "description": "Parser utilities",
+            "homepage": "http://github.com/yosymfony/toml",
+            "keywords": [
+                "lexer",
+                "parser"
+            ],
+            "time": "2017-11-18T22:30:41+00:00"
+        },
+        {
+            "name": "yosymfony/toml",
+            "version": "v1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/yosymfony/toml.git",
+                "reference": "8d67c2c9a5941d40a3a37ff42c3c3b72a0a09678"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/yosymfony/toml/zipball/8d67c2c9a5941d40a3a37ff42c3c3b72a0a09678",
+                "reference": "8d67c2c9a5941d40a3a37ff42c3c3b72a0a09678",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1",
+                "yosymfony/parser-utils": "^1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Yosymfony\\Toml\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Victor Puertas",
+                    "email": "vpgugr@gmail.com",
+                    "homepage": "http://yosymfony.com"
+                }
+            ],
+            "description": "A PHP parser for TOML compatible with specification 0.4.0",
+            "homepage": "http://github.com/yosymfony/toml",
+            "keywords": [
+                "mojombo",
+                "parser",
+                "toml"
+            ],
+            "time": "2018-02-05T22:43:21+00:00"
+        }
+    ],
+    "packages-dev": [],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": [],
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": [],
+    "platform-dev": []
+}
diff --git a/get-report-slot.php b/get-report-slot.php
new file mode 100644
index 0000000..3b0a6d7
--- /dev/null
+++ b/get-report-slot.php
@@ -0,0 +1,8 @@
+<?php
+
+require("settings.php");
+require("utilities/comment_key.php");
+
+header("content-type: text/plain");
+
+exit(key_generate($settings->comment_key_pass));
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..72497ed
--- /dev/null
+++ b/index.php
@@ -0,0 +1,30 @@
+<?php
+
+require("vendor/autoload.php");
+require("utilities/transform.php");
+
+// ---------------------------------------------------------------------------------
+
+$aura_loader = new \Aura\Autoload\Loader;
+$aura_loader->addPrefix('Sandpiper', '.');
+$aura_loader->addPrefix('SBRL\Utilities', './utilities');
+$aura_loader->register();
+
+// ---------------------------------------------------------------------------------
+
+
+
+function send_error($code, $message) {
+	http_response_code($code);
+	header("content-type: text/plain");
+	exit("$message\n");
+}
+
+if(empty($_GET["action"]))
+	send_error(400, "No action specified.");
+
+$action = $_GET["action"];
+
+$handler_name = "Sandpiper\\Actions\\$action";
+$handler = new $handler_name();
+$handler->handle();
diff --git a/report.php b/report.php
new file mode 100644
index 0000000..2978e20
--- /dev/null
+++ b/report.php
@@ -0,0 +1,106 @@
+<?php
+
+require("settings.php");
+require("utilities/comment_key.php");
+require("utilities/transform.php");
+require("utilities/simplexmlwriter.php");
+
+header("content-type: text/plain");
+
+if(empty($_GET["place_id"]))
+	exit("Error: No place id provided.\n");
+if(empty($_GET["summary"]))
+	exit("Error: No summary provided.\n");
+if(empty($_GET["version"]))
+	exit("No version provided.\n");
+
+/*if(empty($_GET["key"]))
+	exit("Error: No key provided.\n");
+
+if(!key_verify(
+	$_GET["key"],
+	$settings->comment_key_pass,
+	$settings->comment_key_min_age, $settings->comment_key_max_age
+	))
+	exit("Error: Invalid key.\n");
+*/
+/******************************************************/
+
+$places = json_decode(file_get_contents("$settings->data_dir/places.json"));
+
+$place = null;
+foreach($places as $current_place) {
+	if($place->key == $_GET["place_id"]) {
+		$place = $current_place;
+		break;
+	}
+}
+
+if($place == null)
+	exit("Error: Place id doesn't match.\n");
+
+$new_report = new stdClass();
+$new_report->version = escape4xml($_GET["version"]);
+$new_report->summary = escape4xml($_GET["summary"]);
+$new_report->stack_trace = escape4xml(file_get_contents('php://input'));
+
+$report_dir = "$settings->data_dir/places/$place->name";
+$report_filename = "$report_dir/" . slugify($new_report->summary) . ".xml";
+
+// Save the individual report
+
+if(!file_exists($report_filename)) {
+	$writer = new SimpleXmlWriter();
+	$writer->start();
+	$writer->add_xslt("../../theme/stack_traces.xslt");
+	$writer->open("error_info");
+	$writer->addtag("summary", [], $new_report->summary);
+	$writer->open("reports");
+	$writer->close();
+	$writer->close();
+	file_put_contents($report_filename, $writer->render());
+}
+
+$report_xml = simplexml_load_file($report_filename);
+
+if($report_xml === false)
+	exit("Error: invalid XML generated when creating a new report file.\n");
+
+$report_node = $report_xml->reports->addChild("report");
+$report_node->addChild("timestamp", date(DATE_ATOM));
+$report_node->addChild("version", $new_report->version);
+$report_node->addChild("summary", $new_report->summary);
+$report_node->addChild("stack_trace", $new_report->stack_trace);
+
+file_put_contents($report_filename, $report_xml->asXML());
+
+// Update the place index
+$place_index_filename = "$report_dir/index.xml";
+if(!file_exists($place_index_filename)) {
+	$writer = new SimpleXmlWriter();
+	$writer->start();
+	$writer->add_xslt("../../theme/place_index.xslt");
+	$writer->open("errors");
+	$writer->close();
+	file_put_contents($place_index_filename, $writer->render());
+}
+$index_xml = simplexml_load_file($place_index_filename);
+
+$index_entry = null;
+foreach($index_xml as $entry) {
+	if($entry->summary->__toString() != $new_report->summary)
+		continue;
+	$index_entry = $entry;
+	break;
+}
+
+if($index_entry == null) {
+	$index_entry = $index_xml->addChild("error");
+	$index_entry->summary = $new_report->summary;
+	$index_entry->last_report = date(DATE_ATOM);
+	$index_entry->report_count = 0;
+}
+
+$index_entry->report_count = intval($index_entry->report_count) + 1;
+
+file_put_contents($place_index_filename, $index_xml->asXML());
diff --git a/submit-report.sh b/submit-report.sh
new file mode 100644
index 0000000..e69de29
diff --git a/theme/place_index.xslt b/theme/place_index.xslt
new file mode 100644
index 0000000..5d03b56
--- /dev/null
+++ b/theme/place_index.xslt
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:transform version="1.0" xmlns:xsl="http://w3.org/1999/XSL/Transform">
+    <xsl:template match="/">
+        
+    </xsl:template>
+</xsl:transform>
diff --git a/theme/stack_traces.xslt b/theme/stack_traces.xslt
new file mode 100644
index 0000000..5d03b56
--- /dev/null
+++ b/theme/stack_traces.xslt
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:transform version="1.0" xmlns:xsl="http://w3.org/1999/XSL/Transform">
+    <xsl:template match="/">
+        
+    </xsl:template>
+</xsl:transform>
diff --git a/utilities/TomlConfig.php b/utilities/TomlConfig.php
new file mode 100644
index 0000000..9a8ac2d
--- /dev/null
+++ b/utilities/TomlConfig.php
@@ -0,0 +1,182 @@
+<?php 
+
+namespace SBRL\Utilities;
+
+use \stdClass;
+use \Yosymfony\Toml\Toml;
+use \Yosymfony\Toml\TomlBuilder;
+
+/**
+ * Handles the loading and saving of settings from a toml file.
+ * Supports loading default settings from a separate file.
+ * @author			Starbeamrainbowlabs
+ * @version			v0.2
+ * @lastModified	22nd March 2017
+ * @license			https://www.mozilla.org/en-US/MPL/2.0/	Mozilla Public License 2.0
+ * Changelog:
+	 * v0.3 - 5th March 2018
+		 * Forked to create a version for toml.
+	 * v0.2 - 22nd March 2017
+		 * Add license
+		 * Filled in some missing comments
+	 * v0.1: 28th February 2017
+		 * Initial release
+ */
+class TomlConfig
+{
+	private $defaultSettings;
+	
+	private $settingsFilePath = "";
+	/**
+	 * The custom settings object.
+	 * @var stdClass
+	 */
+	private $customSettings;
+	
+	/**
+	 * The banner to insert at the top of the custom settings file when saving it.
+	 * @var string
+	 */
+	private $customSettingsBanner;
+	
+	/**
+	 * Creates a new JsonConfig
+	 * @param string $settingsFilePath			The path to the customised settings file.
+	 * @param string $defaultSettingsFilePath	The path to the default settings file.
+	 */
+	public function __construct($settingsFilePath, $defaultSettingsFilePath) {
+		$this->customSettingsBanner = "-------[ Custom Settings File - Last updated " . date("Y-m-d") . " ]-------";
+		
+		$this->defaultSettings = Toml::ParseFile($defaultSettingsFilePath, true);
+		
+		$this->customSettings = new stdClass();
+		if(file_exists($settingsFilePath)) {
+			$this->customSettings = Toml::ParseFile($settingsFilePath, true);
+		}
+	}
+	/**
+	 * Gets the value identified by the specified property, in dotted notation (e.g. `Network.Firewalling.Ports.Http`)
+	 * @param	string	$key	The property, in dotted notation, to return.
+	 * @return	mixed			The value odentified by the given dotted property notation.
+	 */
+	public function get($key) {
+		if(static::hasPropertyByPath($this->customSettings, $key)) {
+			return static::getPropertyByPath($this->customSettings, $key);
+		}
+		return static::getPropertyByPath($this->defaultSettings, $key);
+	}
+	/**
+	 * Fetches the default value for the specified property, specified in dotted notation.
+	 * @param  string	$key	The key to fetch.
+	 * @return mixed	The default value of the property identified by the given dotted notation.
+	 */
+	public function getDefault($key) {
+		return static::getPropertyByPath($this->defaultSettings, $key);
+	}
+	/**
+	 * Determines whether the given property has been customised or explicitly specified in the customised settings file.
+	 * @param	string	$key	The property, in dotted notation, to check.
+	 * @return	boolean			Whether the specified properties file has been explicitly specified in the custom settings file.
+	 */
+	public function hasChanged($key) {
+		return static::hasPropertyByPath($this->customSettings, $key);
+	}
+	/**
+	 * Sets the property identified by the specified dotted path.
+	 * Does not re-save the properties back to disk!
+	 * @param	string	$key	The dotted path to update.
+	 * @param	mixed	$value	The value to set the property to.
+	 * @return	self			This ConfigFile instance, to aid method chaining.
+	 */
+	public function set($key, $value) {
+		static::setPropertyByPath($this->customSettings, $key, $value);
+		return $this;
+	}
+	
+	public function setCustomSettingsBanner($value) {
+		$this->customSettingsBanner = $value;
+		return $this;
+	}
+	
+	/**
+	 * Saves the customised settings back to the disk.
+	 * @return boolean Whether the save was successful or not.
+	 */
+	public function save() {
+		$output_builder = new TomlBuilder();
+		$toml_builder->addComment($this->customSettingsBanner);
+		
+		$this->build($toml_builder, $this->customSettings);
+		
+		return file_put_contents($settingsFilePath, $toml_builder->getTomlString());
+	}
+	
+	private function build($toml_builder, $customSettingsObject, $subobject_name = null) {
+		if($subobject_name !== null)
+			$toml_builder->addTable($subobject_name);
+		
+		foreach($customSettingsObject as $key => $value) {
+			if(!\is_object($value))
+				$toml_builder->addValue($key, $value);
+			else
+				$this->build($toml_builder, $value, "$subobject_name.$key");
+		}
+	}
+	
+	/**
+	 * Works out whether a given dotted path to a property exists on a given object.
+	 * @param	stdClass  	$obj	The object to search.
+	 * @param	string		$path	The dotted path to search with. e.g. `Network.Firewall.OpenPorts`
+	 * @return	boolean		Whether the given property is present in the given object.
+	 */
+	public static function hasPropertyByPath($obj, $path) {
+		$pathParts = explode(".", $path);
+		$subObj = $obj;
+		foreach($pathParts as $part) {
+			if(!isset($subObj->$part))
+				return false;
+			$subObj = $subObj->$part;
+		}
+		return true;
+	}
+	/**
+	 * Returns the property identified by the given dotted path.
+	 * If you're not sure whether a property exists or not, use static::hasPropertyByPath() first to check.
+	 * @param	stdClass	$obj	The object to fetch from.
+	 * @param	string		$path	The path to extract from the given object.
+	 * @return	mixed				The property identified by the given object.
+	 */
+	public static function getPropertyByPath($obj, $path) {
+		$pathParts = explode(".", $path);
+		$subObj = $obj;
+		foreach($pathParts as $part) {
+			$subObj = $subObj->$part;
+		}
+		return $subObj;
+	}
+	/**
+	 * Returns the property identified by the given dotted path.
+	 * If you're not sure whether a property exists or not, use static::hasPropertyByPath() first to check.
+	 * @param	stdClass	$obj	The object to set the property on.
+	 * @param	string		$path	The path to set on the given object.
+	 * @param	mixed		$value	The value to  set the given property to.
+	 */
+	public static function setPropertyByPath($obj, $path, $value) {
+		$pathParts = explode(".", $path);
+		$pathPartsCount = count($pathParts);
+		
+		$subObj = $obj;
+		for($i = 0; $i < $pathPartsCount; $i++) {
+			// Set the property on the last iteration
+			if($i + 1 == $pathPartsCount) {
+				$subObj->$part = $value;
+				break;
+			}
+			// Create sub-objects if they dobn't exist already
+			if(!isset($subObj->$part)) {
+				$subObj->$part = new stdClass();
+			}
+			$subObj = $subObj->$part;
+		}
+	}
+}
diff --git a/utilities/comment_key.php b/utilities/comment_key.php
new file mode 100644
index 0000000..a492d62
--- /dev/null
+++ b/utilities/comment_key.php
@@ -0,0 +1,68 @@
+<?php
+/***********************************************
+ * Comment Key Generation System
+ ***********************************************
+ * By Starbeamrainbowlabs <feedback@starbeamrainbowlabs.com>
+ *
+ * A set of functions to protect low-value comment boxes, contact us 
+ * forms, etc. from spam.
+ *
+ * It works by recording the time the page the form is on was loaded, 
+ * and then checking to make sure that it was loaded for at least 
+ * 10 - 15 seconds, and not more than 24 hours.
+ *
+ * This is done by taking the current UNIX timestamp, running it through 
+ * a number of (arbitrary) reversible transformations, and then 
+ * embedding it in the form as a hidden field. When the form is 
+ * submitted, the transformations are reversed and the time the page 
+ * was loaded checked.
+ *
+ * Note that this is not suitable for high-value targets, as it can very
+ * easily be bypassed and reverse-engineered by any determined human.
+ *
+ ***********************************************
+ * Changelog:
+	* 30th January 2018:
+		* Initial Release.
+ */
+
+/**
+ * Generates a new comment key.
+ * Note that the 'encryption' used is not secure - it's just simple XOR!
+ * It's mainly to better support verification in complex setups and 
+ * serve as a nother annoying hurdle for spammers.
+ * TODO: Swap out the XOR for openssl AES symmetric encryption
+ * @param  string $pass The password to transform it with. Should be a pre-selected random string.
+ * @return string       A new comment key stamped at the current time.
+ */
+function key_generate($pass) {
+	$new_key = strval(time());
+	// Repeat the key so that it's long enough to XOR the key with
+	$pass_enc = str_repeat($pass, (strlen($new_key) / strlen($pass)) + 1);
+	$new_key = $new_key ^ $pass_enc;
+	return base64_encode(strrev($new_key));
+}
+/**
+ * Decodes the given key.
+ * @param  string $key  The key to decode.
+ * @param  string $pass The password to decode it with.
+ * @return int			The time that the key was generated.
+ */
+function key_decode($key, $pass) {
+	$key_dec = strrev(base64_decode($key));
+	// Repeat the key so that it's long enough to XOR the key with
+	$pass_dec = str_repeat($pass, (strlen($key_dec) / strlen($pass)) + 1);
+	return intval($key_dec ^ $pass_dec);
+}
+/**
+ * Verifies a key.
+ * @param	string	$key		The key to decode and verify.
+ * @param	string	$pass		The password to decode the key with.
+ * @param	int		$min_age	The minimum required age for the key.
+ * @param	int		$max_age	The maximum allowed age for the key.
+ * @return	bool				Whether the key is valid or not.
+ */
+function key_verify($key, $pass, $min_age, $max_age) {
+	$age = time() - key_decode($key, $pass);
+	return $age >= $min_age && $age <= $max_age;
+}
diff --git a/utilities/simplexmlwriter.php b/utilities/simplexmlwriter.php
new file mode 100644
index 0000000..6b51f54
--- /dev/null
+++ b/utilities/simplexmlwriter.php
@@ -0,0 +1,197 @@
+<?php
+
+namespace Utilities;
+
+/****************
+ * Todo
+ ****************
+ * Test this code!
+ * 
+ * Add support of processing instructions (<?....?>, e.g. xslt support)
+ * 
+ * Allow customisation of the characters used for indentation
+ * Allow customisation of the newline character(s)
+ */
+
+class SimpleXmlWriter {
+	/*
+	 * 
+	 * Constants
+	 * 
+	 */
+	
+	//the XML doctype
+	const xml_doctype = '<?xml version="1.0" encoding="utf-8"?>';
+	
+	//the generator information
+	const generator_info = "<!-- Generated by simplexmlwriter.php, which was built by Starbeamrainbowlabs -->";
+	
+	/*
+	 * 
+	 * Properties
+	 * 
+	 */
+	
+	//whether we should prettyprint the xml - remember to set this first!
+	public $prettyprint = true;
+	
+	//the currently generated XML - don't reference this directly as it may have unclosed tags and therefore invalid XML
+	protected $xml = "";
+	
+	//the currently open tags
+	protected $opentags = [];
+	
+	/*
+	 * 
+	 * Functions
+	 * 
+	 */
+	
+	//begin constructing XMl
+	protected function start()
+	{
+		$this->add($this::xml_doctype);
+		$this->add($this::generator_info);
+	}
+	
+	public function add_xslt($uri = "")
+	{
+		$this->add("<?xml-stylesheet type=\"text/xsl\" href=\"$uri\"?>");
+	}
+	
+	//open a tag
+	public function open($tagname = "root", $attributes = [])
+	{
+		$attrtext = "";
+		foreach($attributes as $name => $value)
+			$attrtext .= "$name=\"$value\" ";
+		
+		$attrtext = " " . trim($attrtext);
+		
+		if(strlen($attrtext) === 1)
+			$attrtext = "";
+		
+		$this->add("<$tagname$attrtext>");
+		
+		$this->opentags[] = $tagname; //add the tag name to the list of currently open tags
+	}
+	
+	//add some plain text
+	public function addtext($text = "", $newline = true)
+	{
+		$this->add($text, $newline);
+	}
+	
+	//close a tag
+	public function close()
+	{
+		$this->add("</" . array_pop($this->opentags) . ">");
+	}
+	
+	//close all open tags
+	public function closeall()
+	{
+		while(count($this->opentags) > 0)
+		{
+			$this->close();
+		}
+	}
+	
+	//shortcut to add a whole tag
+	public function addtag($tagname = "root", $attributes = [], $text = "")
+	{
+		$attrtext = "";
+		foreach($attributes as $name => $value)
+			$attrtext .= "$name=\"$value\" ";
+		
+		$attrtext = " " . trim($attrtext);
+		
+		if(strlen($attrtext) === 1)
+			$attrtext = "";
+		
+		$this->add("<$tagname$attrtext>$text</$tagname>");
+	}
+	
+	//add a comment
+	public function addcomment($text = "")
+	{
+		$this->add("<!-- $text -->");
+	}
+	
+	//get a rendered version of the currently generated XML
+	public function render()
+	{
+		$result = $this->xml;
+		
+		$revopentags = array_reverse($this->opentags);
+		foreach($revopentags as $tagname)
+		{
+			if($this->prettyprint)
+				$result .= str_repeat("\t", count($revopentags)) . "</$tagname>\n";
+			else
+				$result .= "</$tagname>";
+				
+		}
+		
+		return $result;
+	}
+	
+	//get the appropriate number of tab characters for indentation purposes
+	protected function getindent()
+	{
+		return str_repeat("\t", count($this->opentags));
+	}
+	
+	//function to add to the rendered XML string
+	protected function add($text = "", $newline = true)
+	{
+		
+		if($newline and $this->prettyprint)
+			$this->xml .= $this->getindent();
+		
+		$this->xml .= $text;
+		
+		if($newline and $this->prettyprint)
+			$this->xml .= "\n";
+	}
+	
+	public function __toString() {
+		return $this->render();
+	}
+	
+	/*
+	 * 
+	 * Constuctor
+	 * 
+	 */
+	
+	function __construct() {
+		$this->start();
+	}
+}
+
+
+if(basename(__FILE__) == basename($_SERVER["SCRIPT_FILENAME"]))
+{
+	//we were not included
+	
+	$xml = new simplexmlwriter();
+	$xml->add_xslt("https://example.com/test.xslt");
+	$xml->open("root");
+	$xml->addcomment("- ------------------------------ -");
+	$xml->addcomment("- -- simplexmlwriter.php test -- -");
+	$xml->addcomment("- ------------------------------ -");
+	foreach(range(1, 10) as $i)
+	{
+		$xml->open("number");
+			$xml->addtag("type", [], "integer");
+			$xml->open("value");
+				$xml->addtext($i);
+			$xml->close();
+		$xml->close();
+	}
+	$xml->closeall();
+	header("content-type: application/xml");
+	echo($xml->render());
+}
+?>
diff --git a/utilities/transform.php b/utilities/transform.php
new file mode 100644
index 0000000..31940cb
--- /dev/null
+++ b/utilities/transform.php
@@ -0,0 +1,18 @@
+<?php
+
+function slugify($string, $maxlength = 50) {
+	return preg_replace([
+			'\s+',
+			'[^a-z0-9\-_]'
+		], [
+			'-',
+			''
+		],
+		strtolower(trim($string))
+	);
+}
+
+
+function escape4xml($string) {
+	return htmlspecialchars($string, ENT_QUOTES | ENT_XML1);
+}
