]> git.bts.cx Git - cx.git/commitdiff
Initial commit
authorBen Sherratt <redacted>
Sat, 4 Feb 2023 15:19:45 +0000 (15:19 +0000)
committerBen Sherratt <redacted>
Thu, 4 May 2023 19:04:59 +0000 (20:04 +0100)
36 files changed:
.gitignore [new file with mode: 0644]
cx/cx.php [new file with mode: 0644]
cx/lib/admin.php [new file with mode: 0644]
cx/lib/db.php [new file with mode: 0644]
cx/lib/form.php [new file with mode: 0644]
cx/lib/http.php [new file with mode: 0644]
cx/lib/images.php [new file with mode: 0644]
cx/lib/posts.php [new file with mode: 0644]
cx/lib/sessions.php [new file with mode: 0644]
cx/lib/setup.php [new file with mode: 0644]
cx/lib/site.php [new file with mode: 0644]
cx/lib/system.php [new file with mode: 0644]
cx/lib/template.php [new file with mode: 0644]
cx/lib/url.php [new file with mode: 0644]
cx/lib/user_data.php [new file with mode: 0644]
cx/lib/users.php [new file with mode: 0644]
cx/templates/admin/base.php [new file with mode: 0644]
cx/templates/admin/image.php [new file with mode: 0644]
cx/templates/admin/login.php [new file with mode: 0644]
cx/templates/admin/main.php [new file with mode: 0644]
cx/templates/admin/post.php [new file with mode: 0644]
cx/templates/admin/skeleton.php [new file with mode: 0644]
cx/templates/public/atom.php [new file with mode: 0644]
cx/templates/public/base.php [new file with mode: 0644]
cx/templates/public/list.php [new file with mode: 0644]
cx/templates/public/post.php [new file with mode: 0644]
cx/third_party/parsedown/LICENSE.txt [new file with mode: 0644]
cx/third_party/parsedown/Parsedown.php [new file with mode: 0644]
public/.htaccess [new file with mode: 0644]
public/design/css/style.css [new file with mode: 0644]
public/design/fonts/fanwood/fanwood-webfont.woff [new file with mode: 0755]
public/design/fonts/fanwood/fanwood_italic-webfont.woff [new file with mode: 0755]
public/design/fonts/fanwood/fanwood_text-webfont.woff [new file with mode: 0755]
public/design/fonts/fanwood/fanwood_text_italic-webfont.woff [new file with mode: 0755]
public/index.php [new file with mode: 0644]
setup.template.php [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..7fe3f81
--- /dev/null
@@ -0,0 +1,7 @@
+# Secret files...
+setup.php
+
+# User data
+db/
+data/
+public/data/
diff --git a/cx/cx.php b/cx/cx.php
new file mode 100644 (file)
index 0000000..e1f8daf
--- /dev/null
+++ b/cx/cx.php
@@ -0,0 +1,177 @@
+<?php
+
+function cx_require(...$segments) {
+       array_unshift($segments, CX_PATH);
+       require_once(join(DIRECTORY_SEPARATOR, $segments));
+}
+
+define('CX_PATH', __DIR__);
+
+cx_require('lib', 'admin.php');
+cx_require('lib', 'form.php');
+cx_require('lib', 'http.php');
+cx_require('lib', 'images.php');
+cx_require('lib', 'posts.php');
+cx_require('lib', 'sessions.php');
+cx_require('lib', 'setup.php');
+cx_require('lib', 'site.php');
+cx_require('lib', 'system.php');
+cx_require('lib', 'template.php');
+cx_require('lib', 'url.php');
+cx_require('lib', 'user_data.php');
+cx_require('lib', 'users.php');
+
+function cx($db_path, $data_folder_path, $public_data_folder_path) {
+       define('CX_DATABASE_FILE', $db_path);
+       define('CX_USER_DATA_PATH', $data_folder_path);
+       define('CX_PUBLIC_USER_DATA_PATH', $public_data_folder_path);
+
+       if (cx_setup_required()) {
+               cx_setup_run();
+
+               require('../setup.php');
+               $new_author = cx_users_add_user(CX_SETUP_USER, CX_SETUP_PASSWORD);
+               $new_site = cx_sites_add_site(CX_SETUP_URL, CX_SETUP_TITLE, CX_SETUP_BYLINE, CX_SETUP_COPYRIGHT);
+               cx_sites_site_add_user($new_site, $new_author, true);
+
+               exit;
+       }
+
+       $path = '/';
+
+       if (isset($_SERVER['REQUEST_URI'])) {
+               $route_details = parse_url($_SERVER['REQUEST_URI']);
+               if (isset($route_details['path'])) {
+                       $path = $route_details['path'];
+               }
+       }
+
+       $script_name = $_SERVER['SCRIPT_NAME'];
+       $script_name_len = strlen($script_name);
+       if (substr_compare($path, $script_name, 0, $script_name_len) == 0) {
+               $path = substr($path, $script_name_len);
+       }
+
+       cx_route($path);
+}
+
+function cx_route($path) {
+       $path_components = explode('/', $path, 10);
+       $path_components = array_filter($path_components);
+       $path_components = array_values($path_components); // re-index
+
+       $template = null;
+       $template_class = 'public';
+       $template_variables = [];
+
+       if (count($path_components) == 0) {
+               $template = 'list';
+       } else if (count($path_components) >= 1 && $path_components[0] == 'feed') {
+               header('Content-type: application/atom+xml;');
+               $template = 'atom';
+       } else if (count($path_components) >= 1 && $path_components[0] == 'cx') {
+               if (count($path_components) >= 2 && $path_components[1] == 'login') {
+                       if (cx_admin_logged_in()) {
+                               cx_http_redirect(cx_url_admin('/'));
+                               exit(0);
+                       } else {
+                               $username = cx_form_input_sanitized('id');
+                               $password = cx_form_input_sanitized('password');
+       
+                               if ($username != null && $password != null && cx_admin_login($username, $password)) {
+                                       cx_http_redirect(cx_url_admin('/'));
+                                       exit(0);
+                               }
+       
+                               $template_class = 'admin';
+                               $template = 'login';
+                       }
+               } else {
+                       if (cx_admin_logged_in() == false) {
+                               cx_http_redirect(cx_url_admin('/login/'));
+                               exit(0);
+                       } else {
+                               if (count($path_components) >= 2 && $path_components[1] == 'logout') {
+                                       cx_admin_logout();
+                                       cx_http_redirect(cx_url_admin('/'));
+                                       exit(0);
+                               } else if (count($path_components) >= 3 && $path_components[1] == 'posts' && $path_components[2] == 'add') {
+                                       $template_variables['post_id'] = '0';
+                                       $template_variables['post_title'] = '';
+                                       $template_variables['post_slug'] = '';
+                                       $template_variables['post_date'] = '';
+                                       $template_variables['post_data'] = '';
+
+                                       $template_class = 'admin';
+                                       $template = 'post';
+                               } else if (count($path_components) >= 3 && $path_components[1] == 'posts' && $path_components[2] == 'edit') {
+                                       $post = cx_posts_find_post($_GET['id']);
+
+                                       $template_variables['post_id'] = $post->id;
+                                       $template_variables['post_title'] = $post->title;
+                                       $template_variables['post_slug'] = $post->slug;
+                                       $template_variables['post_date'] = $post->date;
+                                       $template_variables['post_data'] = $post->data;
+
+                                       $template_class = 'admin';
+                                       $template = 'post';
+                               } else if (count($path_components) >= 3 && $path_components[1] == 'posts' && $path_components[2] == 'update') {
+                                       $title = cx_form_input_sanitized('post_title');
+                                       $slug = cx_form_input_sanitized('post_slug');
+                                       if (isset($slug) == false) $slug = null;
+                                       $date = cx_form_input_sanitized_date_time('post_date');
+                                       if (isset($date) == false) $date = null;
+                                       $data = cx_form_input_sanitized('post_data');
+                                       
+                                       if (isset($_GET['id']) == false or $_GET['id'] == 0) {
+                                               cx_posts_add_post(1, $title, $slug, $date, $data);
+                                       } else {
+                                               $id = $_GET['id'];
+                                               cx_posts_update_post($id, $title, $slug, $date, $data);
+                                       }
+                                       
+                                       cx_http_redirect(cx_url_admin('/'));
+                                       exit(0);
+                               } else if (count($path_components) >= 3 && $path_components[1] == 'posts' && $path_components[2] == 'delete') {
+                                       cx_posts_delete_post($_GET['id']);
+                                       cx_http_redirect(cx_url_admin('/'));
+                                       exit(0);
+                               } else if (count($path_components) >= 3 && $path_components[1] == 'images' && $path_components[2] == 'add') {
+                                       $template_variables['image_id'] = '0';
+                                       $template_variables['image_alt_text'] = '';
+                                       
+                                       $template_class = 'admin';
+                                       $template = 'image';
+                               } else if (count($path_components) >= 3 && $path_components[1] == 'images' && $path_components[2] == 'update') {
+                                       $alt_text = cx_form_input_sanitized('image_alt_text');
+
+                                       $filename = $_FILES['image_file']['tmp_name'];
+                                       $original_filename = $_FILES['image_file']['name'];
+
+                                       cx_images_add_image(1, $alt_text, $filename, $original_filename);
+                                       
+                                       cx_http_redirect(cx_url_admin('/'));
+                                       exit(0);
+                               } else {
+                                       $template_class = 'admin';
+                                       $template = 'main';
+                               }
+                       }
+               }
+       } else if (count($path_components) >= 3) { // FIXME sometime, needs more flexibility...
+               $year = $path_components[0];
+               $month = $path_components[1];
+               $slug = $path_components[2];
+
+               $template = 'post';
+               $template_variables['post_id'] = cx_posts_find_post_id($slug);
+       }
+       
+       if ($template != null) {
+               $output = cx_template_render($template_class, $template, $template_variables);
+               echo($output);
+       } else {
+               http_response_code(404);
+               exit(0);
+       }
+}
diff --git a/cx/lib/admin.php b/cx/lib/admin.php
new file mode 100644 (file)
index 0000000..dd375ee
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+cx_require('lib', 'sessions.php');
+cx_require('lib', 'users.php');
+
+define('CX_ADMIN_SESSION_LIFETIME', (86400 * 1)); // 86400 = 1 day
+
+function cx_admin_logged_in() {
+       return isset($_COOKIE['cx_session']) && cx_sessions_active_session_user($_COOKIE['cx_session'], CX_ADMIN_SESSION_LIFETIME) != null;
+}
+
+function cx_admin_login($username, $password) {
+       $password_hash = cx_users_hash_password_for_user($username, $password);
+
+       $user = cx_users_find_user(name: $username, password_hash: $password_hash);
+       if ($user == null) {
+               return false;
+       } else {
+               $session_uid = cx_sessions_create_session($user);
+               setcookie('cx_session', $session_uid, time() + CX_ADMIN_SESSION_LIFETIME, "/");
+               return true;
+       }
+}
+
+function cx_admin_logout() {
+       if (isset($_COOKIE['cx_session'])) {
+               cx_sessions_close_session($_COOKIE['cx_session']);
+       }
+       setcookie('cx_session', null, time(), "/");
+}
diff --git a/cx/lib/db.php b/cx/lib/db.php
new file mode 100644 (file)
index 0000000..13808c5
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+function _cx_db() {
+       static $db = new SQLite3(CX_DATABASE_FILE);
+       return $db;
+}
+
+function _cx_db_sql_exec($sql, ...$args) {
+       $db = _cx_db();
+       $statement = $db->prepare($sql);
+
+       foreach ($args as $i => $arg) {
+               $idx = $i + 1;
+               $statement->bindValue($idx, $arg);
+       }
+
+       return $statement->execute();
+}
+
+function cx_db_exec($sql, ...$args) {
+       _cx_db_sql_exec($sql, ...$args);
+       return _cx_db()->lastInsertRowID();
+}
+
+function cx_db_query($sql, ...$args) {
+       $result_set = _cx_db_sql_exec($sql, ...$args);
+       while ($result = $result_set->fetchArray()) {
+               yield $result;
+       }
+}
diff --git a/cx/lib/form.php b/cx/lib/form.php
new file mode 100644 (file)
index 0000000..d5e4e56
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+function cx_form_input_sanitized($name) {
+       if (array_key_exists($name, $_POST)) {
+               $insecure_input = $_POST[$name];
+               $tagless_input = strip_tags($insecure_input);
+               return $tagless_input;
+       } else {
+               return null;
+       }
+}
+
+function cx_form_input_sanitized_date_time($name) {
+       $sanitised_string = cx_form_input_sanitized($name);
+
+       if ($sanitised_string != null) {
+               $date = date_parse_from_format('Y-m-d H:i:s', $sanitised_string);
+               if ($date['error_count'] > 0) $date = date_parse_from_format('Y-m-d', $sanitised_string);
+               if ($date['error_count'] == 0) {
+                       $timestamp = mktime($date['hour'], $date['minute'], $date['second'], $date['month'], $date['day'], $date['year']);
+                       if ($timestamp != false) {
+                               return $timestamp;
+                       }
+               }
+       }
+
+       return null;
+}
diff --git a/cx/lib/http.php b/cx/lib/http.php
new file mode 100644 (file)
index 0000000..ac1aed2
--- /dev/null
@@ -0,0 +1,5 @@
+<?php
+
+function cx_http_redirect($url) {
+       header('Location: ' . $url, true, 302);
+}
diff --git a/cx/lib/images.php b/cx/lib/images.php
new file mode 100644 (file)
index 0000000..fd6d622
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+
+cx_require('lib', 'db.php');
+cx_require('lib', 'setup.php');
+cx_require('lib', 'user_data.php');
+
+
+class Image {
+       public $id;
+       public $uid;
+       public $alt_text;
+       public $url;
+
+       public function __construct($dict) {
+               $this->id = $dict['image_id']; // FIXME, hide when not used?
+               $this->uid = $dict['image_uid'];
+               $this->alt_text = $dict['image_alt_text'];
+               $this->url = $this->uid . '.' . $dict['image_type'];
+       }
+}
+
+function cx_images_add_image($site_id, $alt_text, $image_path, $image_original_filename) {
+       $path_parts = pathinfo($image_original_filename);
+
+       $uid = hash_file("sha256", $image_path);
+
+       $target_name = $uid . "." . $path_parts['extension'];
+       $path = cx_user_data_path('images', $target_name);
+
+       move_uploaded_file($image_path, $path);
+
+       $creation_time = $update_time = time();
+       
+       $sql = 'INSERT INTO images (
+                       image_site_id,
+                       image_creation_time,
+                       image_update_time,
+                       image_uid,
+                       image_type,
+                       image_alt_text)
+               VALUES (?, ?, ?, ?, ?, ?);';
+       cx_db_exec($sql, $site_id, $creation_time, $update_time, $uid, $path_parts['extension'], $alt_text);
+}
+
+function cx_images_get(int $limit = 0) {
+       $sql = 'SELECT
+               image_id,
+               image_uid,
+               image_type,
+               image_alt_text
+               FROM images
+               ORDER BY image_creation_time DESC';
+
+       if ($limit > 0) {
+               $sql .= ' LIMIT ' . $limit;
+       }
+
+       $sql .= ';';
+
+       foreach (cx_db_query($sql) as $image) {
+               $p = new Image($image);
+               yield $p;
+       }
+}
+
+cx_setup_register(1, function() {
+       cx_db_exec('CREATE TABLE images (
+                       image_id INTEGER PRIMARY KEY,
+                       image_site_id INTEGER,
+                       image_creation_time INTEGER,
+                       image_update_time INTEGER,
+                       image_uid STRING,
+                       image_type STRING,
+                       image_alt_text STRING,
+
+                       FOREIGN KEY(image_site_id) REFERENCES sites(site_id)
+               );');
+
+       mkdir(cx_user_data_path('images'), recursive: true);
+});
diff --git a/cx/lib/posts.php b/cx/lib/posts.php
new file mode 100644 (file)
index 0000000..d07f999
--- /dev/null
@@ -0,0 +1,195 @@
+<?php
+
+cx_require('lib', 'db.php');
+cx_require('lib', 'setup.php');
+cx_require('third_party', 'parsedown', 'Parsedown.php');
+
+function mk_markdown($markdown) {
+       static $Parsedown = new Parsedown();
+
+       return $Parsedown->text($markdown);
+}
+
+class Post {
+       public $id;
+       public $title;
+       public $slug;
+       public $date;
+       public $data;
+       public $html_content;
+       public $html_excerpt;
+
+       public function __construct($dict) {
+               $this->id = $dict['post_id']; // FIXME, hide when not used?
+               $this->title = $dict['post_title'];
+               $this->slug = $dict['post_slug'];
+               $this->date = $dict['post_date'];
+               $this->data = $dict['post_data'];
+               $this->html_content = mk_markdown($this->data);
+               $this->html_excerpt = null;
+
+               // Read more...
+               $segments = explode('---', $this->data, 2);
+               if (count($segments) > 1) {
+                       $this->html_excerpt = mk_markdown($segments[0]);
+               }
+       }
+}
+
+function cx_post_make_slug($title) {
+       $alnum_title = preg_replace('/[^A-Za-z0-9 ]?/', '', $title);
+       
+       $slug_components = explode(' ', $alnum_title, 10);
+       $slug_components = array_filter($slug_components);
+       $slug_components = array_values($slug_components); // re-index
+
+       $slug = join('-', $slug_components);
+       $slug = strtolower($slug);
+
+       return $slug;
+}
+
+function cx_posts_add_post($site_id, $title, $slug, $date, $data) {
+       $creation_time = $update_time = time();
+       
+       if ($slug == null) {
+               $slug = cx_post_make_slug($title);
+       }
+
+       if ($date == null) {
+               $date = $update_time;
+       }
+
+       $sql = 'INSERT INTO posts (
+                       post_site_id,
+                       post_creation_time,
+                       post_update_time,
+                       post_slug,
+                       post_date,
+                       post_is_page,
+                       post_title,
+                       post_data,
+                       post_data_version)
+               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);';
+       cx_db_exec($sql, $site_id, $creation_time, $update_time, $slug, $date, false, $title, $data, 1);
+}
+
+function cx_posts_update_post($post_id, $title, $slug, $date, $data) {
+       $update_time = time();
+       
+       if ($slug == null) {
+               $slug = cx_post_make_slug($title);
+       }
+
+       if ($date == null) {
+               $date = $update_time;
+       }
+
+       $sql = 'UPDATE posts
+               SET post_update_time = ?,
+               post_slug = ?,
+               post_date = ?,
+               post_title = ?,
+               post_data = ?
+               WHERE post_id == ?;';
+               //LIMIT 1;';
+       cx_db_exec($sql, $update_time, $slug, $date , $title, $data, $post_id);
+}
+
+function cx_posts_delete_post($post_id) {
+       $sql = 'DELETE FROM posts
+               WHERE post_id == ?;';
+               //LIMIT 1;';
+       cx_db_exec($sql, $post_id);
+}
+
+function cx_posts_get(int $limit = 0) {
+       $sql = 'SELECT
+               post_id,
+               post_slug,
+               post_date,
+               post_title,
+               post_data
+               FROM posts
+               WHERE post_is_page==FALSE
+               ORDER BY post_date DESC';
+       
+       if ($limit > 0) {
+               $sql .= ' LIMIT ' . $limit;
+       }
+
+       $sql .= ';';
+
+       foreach (cx_db_query($sql) as $post) {
+               $p = new Post($post);
+               yield $p;
+       }
+}
+
+function cx_posts_find_post($post_id) {
+       $sql = 'SELECT
+               post_id,
+               post_slug,
+               post_date,
+               post_title,
+               post_data
+               FROM posts
+               WHERE post_is_page == FALSE
+               AND post_id == ?
+               LIMIT 1;';
+
+       foreach (cx_db_query($sql, $post_id) as $post) {
+               $p = new Post($post);
+               return $p;
+       }
+
+       return null;
+}
+
+function cx_posts_find_post_id($post_slug) {
+       $sql = 'SELECT
+               post_id
+               FROM posts
+               WHERE post_slug == ?
+               LIMIT 1;';
+
+       foreach (cx_db_query($sql, $post_slug) as $post) {
+               return $post['post_id'];
+       }
+
+       return null;
+}
+
+function cx_pages_get() {
+       $sql = 'SELECT
+               post_id,
+               post_slug,
+               post_date,
+               post_title,
+               post_data
+               FROM posts
+               WHERE post_is_page == TRUE
+               ORDER BY post_creation_time DESC;';
+
+       foreach (cx_db_query($sql) as $post) {
+               $p = new Post($post);
+               yield $p;
+       }
+}
+
+cx_setup_register(1, function() {
+       cx_db_exec('CREATE TABLE posts (
+                       post_id INTEGER PRIMARY KEY,
+                       post_site_id INTEGER,
+                       post_creation_time INTEGER,
+                       post_update_time INTEGER,
+                       post_slug STRING,
+                       post_date INTEGER,
+                       post_is_page BOOLEAN,
+                       post_title STRING,
+                       post_data BLOB,
+                       post_data_version INTEGER,
+
+                       FOREIGN KEY(post_site_id) REFERENCES sites(site_id)
+               );');
+});
diff --git a/cx/lib/sessions.php b/cx/lib/sessions.php
new file mode 100644 (file)
index 0000000..7057324
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+cx_require('lib', 'db.php');
+cx_require('lib', 'setup.php');
+
+function cx_sessions_create_session($user) {
+       $uid = sha1(random_bytes(100));
+       $update_time = $creation_time = time();
+
+       $sql = 'INSERT INTO sessions (
+               session_uid,
+               session_user_id,
+               session_creation_time,
+               session_update_time,
+               session_closed_time)
+       VALUES (?, ?, ?, ?, ?);';
+       cx_db_exec($sql, $uid, $user, $creation_time, $update_time, -1);
+
+       return $uid;
+}
+
+function cx_sessions_close_session($uid) {
+       $close_time = time();
+
+       $sql = 'UPDATE sessions
+               SET session_closed_time = ?
+               WHERE session_uid == ?;';
+               //LIMIT 1;';
+
+       cx_db_exec($sql, $close_time, $uid);
+}
+
+function cx_sessions_active_session_user($uid, $lifetime) {
+       $sql = 'SELECT
+               session_user_id
+               FROM sessions
+               WHERE session_uid == ?
+               AND session_update_time >= ?
+               AND session_closed_time == -1
+               LIMIT 1;';
+
+       $update_time = time() - $lifetime;
+
+       foreach (cx_db_query($sql, $uid, $update_time) as $session) {
+               return $session['session_user_id'];
+       }
+
+       return null;
+}
+
+cx_setup_register(1, function() {
+       cx_db_exec('CREATE TABLE sessions (
+               session_id INTEGER PRIMARY KEY,
+               session_uid STRING,
+               session_user_id INTEGER,
+               session_creation_time INTEGER,
+               session_update_time INTEGER,
+               session_closed_time INTEGER,
+               
+               FOREIGN KEY(session_user_id) REFERENCES users(user_id))');
+});
+
+
diff --git a/cx/lib/setup.php b/cx/lib/setup.php
new file mode 100644 (file)
index 0000000..2478f9e
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+$cx_setup_functions = [];
+
+function cx_setup_register(int $version, $function) {
+       global $cx_setup_functions;
+
+       if (isset($version, $cx_setup_functions) == false) {
+               $cx_setup_functions[$version] = [];
+       }
+
+       $cx_setup_functions[$version][] = $function;
+}
+
+function cx_setup_required() {
+       return file_exists(CX_DATABASE_FILE) == false;
+}
+
+function cx_setup_run() {
+       global $cx_setup_functions;
+       foreach ($cx_setup_functions as $version => $functions) {
+               foreach ($functions as $function) {
+                       $function();
+               }
+       }
+}
diff --git a/cx/lib/site.php b/cx/lib/site.php
new file mode 100644 (file)
index 0000000..4a80b60
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+cx_require('lib', 'db.php');
+cx_require('lib', 'setup.php');
+
+function _cx_default_site_details(...$args) {
+       $sql = 'SELECT '. join(',', $args) .'
+               FROM sites
+               LIMIT 1;';
+
+       foreach (cx_db_query($sql) as $site) {
+               return $site;
+       }
+}
+
+function cx_sites_add_site($url, $title, $byline, $copyright) {
+       $sql = 'INSERT INTO sites (
+                       site_url,
+                       site_title,
+                       site_byline,
+                       site_copyright
+               )
+               VALUES (?, ?, ?, ?);';
+       $site_id = cx_db_exec($sql, $url, $title, $byline, $copyright);
+       return $site_id;
+}
+
+function cx_sites_site_add_user($site_id, $user_id, $owner = false) {
+       $sql = 'INSERT INTO site_authorship (
+                       site_authorship_site_id,
+                       site_authorship_user_id,
+                       site_authorship_owner
+               )
+               VALUES (?, ?, ?);';
+       cx_db_exec($sql, $site_id, $user_id, $owner);
+}
+
+function cx_site_name() {
+       $details = _cx_default_site_details('site_title');
+       return $details['site_title'];
+}
+
+function cx_site_byline() {
+       $details = _cx_default_site_details('site_byline');
+       return $details['site_byline'];
+}
+
+function cx_site_author() {
+       $details = _cx_default_site_details('site_copyright');
+       return $details['site_copyright'];
+}
+
+function cx_site_url() {
+       $details = _cx_default_site_details('site_url');
+       return $details['site_url'];
+}
+
+cx_setup_register(1, function() {
+       cx_db_exec('CREATE TABLE sites (
+                       site_id INTEGER PRIMARY KEY,
+                       site_url STRING,
+                       site_title STRING,
+                       site_byline STRING,
+                       site_copyright STRING
+               );');
+       
+       cx_db_exec('CREATE TABLE site_metadata (
+                       site_metadata_site_id INTEGER,
+                       site_metadata_key STRING,
+                       site_metadata_value STRING,
+
+                       PRIMARY KEY (site_metadata_site_id, site_metadata_key),
+                       FOREIGN KEY (site_metadata_site_id) REFERENCES sites(site_id)
+               );');
+
+       cx_db_exec('CREATE TABLE site_authorship (
+                       site_authorship_id INTEGER PRIMARY KEY,
+                       site_authorship_site_id INTEGER,
+                       site_authorship_user_id INTEGER,
+                       site_authorship_owner BOOLEAN,
+
+                       FOREIGN KEY(site_authorship_site_id) REFERENCES sites(site_id),
+                       FOREIGN KEY(site_authorship_user_id) REFERENCES users(user_id)
+               );');
+});
diff --git a/cx/lib/system.php b/cx/lib/system.php
new file mode 100644 (file)
index 0000000..c8f23aa
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+cx_require('lib', 'db.php');
+cx_require('lib', 'setup.php');
+
+function cx_system_get_metadata($key, $default_value = null) {
+
+}
+
+function cx_system_set_metadata($key, $value) {
+       
+}
+
+cx_setup_register(1, function() {
+       cx_db_exec('CREATE TABLE system_metadata (
+                       system_metadata_key STRING PRIMARY KEY,
+                       system_metadata_value STRING
+               );');
+});
diff --git a/cx/lib/template.php b/cx/lib/template.php
new file mode 100644 (file)
index 0000000..e368a2e
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+function cx_template_render($class, $name, $variables = []) {
+       global $cx_template_base, $cx_template_content;
+       
+       $base_template = null;
+
+       $output = '';
+       while ($name != null) {
+               $segments = array(CX_PATH, 'templates', $class, $name . '.php');
+               $path = join(DIRECTORY_SEPARATOR, $segments);
+               
+               $base_template = null;
+
+               $cx_template_base_previous = $cx_template_base;
+               $cx_template_base = function($name) use (&$base_template ) {
+                       $base_template = $name;
+               };
+
+               $cx_template_content_previous = $cx_template_content;
+               $cx_template_content = function() use ($output) {
+                       return $output;
+               };
+
+               cx_require('lib', 'url.php'); // For templates
+
+               extract($variables);
+               ob_start();
+               include($path);
+               $output = ob_get_contents();
+               ob_end_clean();
+
+               $cx_template_base = $cx_template_base_previous;
+               $cx_template_content = $cx_template_content_previous;
+
+               $name = $base_template;
+       }
+
+       return $output;
+}
+
+function cx_template_base($name) {
+       global $cx_template_base;
+       $cx_template_base($name);
+}
+
+function cx_template_content() {
+       global $cx_template_content;
+       return $cx_template_content();
+}
diff --git a/cx/lib/url.php b/cx/lib/url.php
new file mode 100644 (file)
index 0000000..1ace539
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+cx_require('lib', 'site.php');
+
+function cx_url($path) {
+       return $path;
+}
+
+function cx_url_admin($path) {
+       return cx_url('/cx' . $path);
+}
+
+function cx_url_site($path) {
+       return cx_site_url() . cx_url($path);
+}
diff --git a/cx/lib/user_data.php b/cx/lib/user_data.php
new file mode 100644 (file)
index 0000000..c8a3023
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+cx_require('lib', 'setup.php');
+
+function cx_user_data_find_all($type) {
+       $data_directory = join(DIRECTORY_SEPARATOR, array(CX_PUBLIC_USER_DATA_PATH, $type));
+
+       if (file_exists($data_directory)) {
+               foreach (scandir($data_directory) as $path) {
+                       yield $path;
+               }
+       }
+}
+
+function cx_user_data_path($type, $filename = null) {
+       $path = join(DIRECTORY_SEPARATOR, array(CX_PUBLIC_USER_DATA_PATH, $type));
+       if ($filename != null) $path = join(DIRECTORY_SEPARATOR, array($path, $filename));
+       return $path;
+}
+
+//     define('CX_USER_DATA_PATH', $data_folder_path);
+//     define('CX_PUBLIC_USER_DATA_PATH', $public_data_folder_path);
+
+//cx_user_data_register_type($type) {
+//
+//}
+
+//cx_setup_register(2, function() {
+//});
diff --git a/cx/lib/users.php b/cx/lib/users.php
new file mode 100644 (file)
index 0000000..5ff0b57
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+cx_require('lib', 'db.php');
+cx_require('lib', 'setup.php');
+
+function cx_users_find_user($id = null, $name = null, $password_hash = null) {
+       if ($id != null) {
+               $sql = 'SELECT
+                       user_id
+                       FROM users
+                       WHERE user_id == ?
+                       LIMIT 1;';
+
+               foreach (cx_db_query($sql, $id) as $user) {
+                       return $user['user_id'];
+               }
+       } else if ($name != null && $password_hash != null) {
+               $sql = 'SELECT
+                       user_id
+                       FROM users
+                       WHERE user_name == ?
+                       AND user_password_hash == ?
+                       LIMIT 1;';
+
+               foreach (cx_db_query($sql, $name, $password_hash) as $user) {
+                       return $user['user_id'];
+               }
+       }
+
+       return null;
+}
+
+function cx_users_hash_password_for_user($username, $password) {
+       $sql = 'SELECT
+               user_salt
+               FROM users
+               WHERE user_name == ?
+               LIMIT 1;';
+
+       $salt = '';
+       foreach (cx_db_query($sql, $username) as $user) {
+               $salt = $user['user_salt'];
+       }
+
+       $password_hash = sha1($salt . $password);
+       return $password_hash;
+}
+
+function cx_users_add_user($name, $password) {
+       $creation_time = time();//
+       $salt = sha1(random_bytes(100));
+       $password_hash = sha1($salt . $password);
+
+       $sql = 'INSERT INTO users (
+                       user_creation_time,
+                       user_name,
+                       user_salt,
+                       user_password_hash
+               )
+               VALUES (?, ?, ?, ?);';
+       $user_id = cx_db_exec($sql, $creation_time, $name, $salt, $password_hash);
+       return $user_id;
+}
+
+cx_setup_register(1, function() {
+       cx_db_exec('CREATE TABLE users (
+                       user_id INTEGER PRIMARY KEY,
+                       user_creation_time INTEGER,
+                       user_name STRING,
+                       user_salt STRING,
+                       user_password_hash STRING
+               );');
+});
diff --git a/cx/templates/admin/base.php b/cx/templates/admin/base.php
new file mode 100644 (file)
index 0000000..b1ae079
--- /dev/null
@@ -0,0 +1,14 @@
+<?php cx_template_base('skeleton'); ?>
+
+<nav>
+<ul role="list">
+<li><a href="<?= cx_url_admin('/'); ?>">admin</a></li>
+<li><a href="<?= cx_url_admin('/posts/add/'); ?>">add post/page</a></li>
+<li><a href="<?= cx_url_admin('/images/add/'); ?>">add image</a></li>
+<li><a href="<?= cx_url_admin('/logout/'); ?>">logout</a></li>
+</ul>
+</nav>
+
+<main>
+<?= cx_template_content(); ?>
+</main>
diff --git a/cx/templates/admin/image.php b/cx/templates/admin/image.php
new file mode 100644 (file)
index 0000000..1d84e1c
--- /dev/null
@@ -0,0 +1,14 @@
+<?php cx_template_base('base'); ?>
+
+<form action="<?= cx_url_admin('/images/update?id=' . $image_id); ?>" method="post" enctype="multipart/form-data">
+
+<p>alt-text: <input name="image_alt_text" type="text" value="<?= $image_alt_text ?>"></p>
+
+<?php if ($image_id == 0): ?>
+<p>file: <input name="image_file" type="file"></p>
+<?php else: ?>
+<p></p>
+<?php endif; ?>
+
+<p><input type="submit" value="update"></p>
+</form>
diff --git a/cx/templates/admin/login.php b/cx/templates/admin/login.php
new file mode 100644 (file)
index 0000000..7c490ca
--- /dev/null
@@ -0,0 +1,6 @@
+<?php cx_template_base('skeleton'); ?>
+<form action="<?= cx_url_admin('/login/'); ?>" method="post">
+<input name="id" type="text">
+<input name="password" type="password">
+<input type="submit">
+</form>
diff --git a/cx/templates/admin/main.php b/cx/templates/admin/main.php
new file mode 100644 (file)
index 0000000..43f2454
--- /dev/null
@@ -0,0 +1,22 @@
+<?php cx_template_base('base'); ?>
+
+<h2>Posts</h2>
+<ul role="list">
+<?php foreach (cx_posts_get() as $post): ?>
+<li><a href="<?= cx_url_admin('/posts/edit?id=' . $post->id); ?>"><?= $post->title ?></a> <a href="<?= cx_url_admin('/posts/delete?id=' . $post->id); ?>">🚮</a></li>
+<?php endforeach; ?>
+</ul>
+
+<h2>Pages</h2>
+<ul role="list">
+<?php foreach (cx_pages_get() as $post): ?>
+<li><a href="<?= cx_url_admin('/posts/edit?id=' . $post->id); ?>"><?= $post->title ?></a> <a href="<?= cx_url_admin('/posts/delete?id=' . $post->id); ?>">🚮</a></li>
+<?php endforeach; ?>
+</ul>
+
+<h2>Images</h2>
+<ul role="list">
+<?php foreach (cx_images_get() as $image): ?>
+<li><a href="<?= cx_url_admin('/images/edit?id=' . $image->id); ?>"><?= $image->url ?></a> <a href="<?= cx_url_admin('/images/delete?id=' . $image->id); ?>">🚮</a></li>
+<?php endforeach; ?>
+</ul>
diff --git a/cx/templates/admin/post.php b/cx/templates/admin/post.php
new file mode 100644 (file)
index 0000000..b7d380a
--- /dev/null
@@ -0,0 +1,11 @@
+<?php cx_template_base('base'); ?>
+
+<form action="<?= cx_url_admin('/posts/update?id=' . $post_id); ?>" method="post">
+
+<p>title: <input name="post_title" type="text" value="<?= $post_title ?>"></p>
+<p>slug: <input name="post_slug" type="text" value="<?= $post_slug ?>"></p>
+<p>date: <input name="post_date" type="text" value="<?= $post_date ? date('Y-m-d H:i:s', $post_date) : "" ?>"></p>
+<p><textarea name="post_data"  cols="60" rows="40"><?= $post_data ?></textarea></p>
+
+<p><input type="submit" value="update"></p>
+</form>
diff --git a/cx/templates/admin/skeleton.php b/cx/templates/admin/skeleton.php
new file mode 100644 (file)
index 0000000..532942b
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+       <meta charset="UTF-8">
+       <meta http-equiv="X-UA-Compatible" content="IE=edge">
+       <meta name="viewport" content="width=device-width, initial-scale=1.0">
+       <title>CX Admin Panel</title>
+       <link rel="stylesheet" href="<?= cx_url('/design/css/style.css') ?>">
+</head>
+
+<body>
+<?= cx_template_content(); ?>
+</body>
+</html>
diff --git a/cx/templates/public/atom.php b/cx/templates/public/atom.php
new file mode 100644 (file)
index 0000000..398443d
--- /dev/null
@@ -0,0 +1,34 @@
+<?= '<?xml version="1.0" encoding="utf-8"?>'."\n" /* So some installs of PHP don't get confused... */ ?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+       <id><?= cx_url_site('/'); ?></id>
+
+       <title><?= cx_site_name(); ?></title>
+       <subtitle><?= cx_site_byline(); ?></subtitle>
+
+       <?php foreach (cx_posts_get(limit: 1) as $post): ?>
+       <updated><?= date(DATE_RFC3339, $post->date) ?></updated>
+       <?php endforeach; ?>
+
+       <link href="<?= cx_url_site('/feed/'); ?>" rel="self" type="application/atom+xml" />
+       <link href="<?= cx_url('/'); ?>" rel="alternate" type="text/html" />
+
+       <author>
+               <name><?= cx_site_author() ?></name>
+       </author>
+
+<?php foreach (cx_posts_get() as $post): ?>
+<?php $post_permalink = '/' . date('Y', $post->date) . '/' . date('m', $post->date) . '/' . $post->slug; ?>
+       <entry>
+               <id><?= cx_url_site($post_permalink); ?></id>
+               <title><?= $post->title ?></title>
+               <updated><?= date(DATE_RFC3339, $post->date) ?></updated>
+               <link href="<?= cx_url($post_permalink) ?>" rel="alternate" type="text/html" />
+               <content type="xhtml">
+                       <div xmlns="http://www.w3.org/1999/xhtml">
+                               <?= $post->html_content ?>
+                       </div>
+               </content>
+       </entry>
+<?php endforeach; ?>
+
+</feed>
\ No newline at end of file
diff --git a/cx/templates/public/base.php b/cx/templates/public/base.php
new file mode 100644 (file)
index 0000000..8c3794d
--- /dev/null
@@ -0,0 +1,40 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+       <meta charset="UTF-8">
+       <meta http-equiv="X-UA-Compatible" content="IE=edge">
+       <meta name="viewport" content="width=device-width, initial-scale=1.0">
+       <title><?= cx_site_name(); ?></title>
+       <link rel="stylesheet" href="<?= cx_url('/design/css/style.css') ?>">
+       <link rel="alternate" type="application/rss+xml" title="bts.cx" href="<?= cx_url('/feed/') ?>" />
+</head>
+
+<body>
+<header>
+  <h1><?= cx_site_name(); ?></h1>
+</header>
+
+<nav>
+
+<ul role="list">
+<li><a href="<?= cx_url('/'); ?>">Home</a></li>
+
+<?php foreach (cx_pages_get() as $page): ?>
+<li><a href="<?= cx_url($page->url); ?>"><?= $page->title ?></a></li>
+<?php endforeach; ?>
+</ul>
+</nav>
+
+<?= cx_template_content(); ?>
+
+<footer>
+
+<div>
+<p>&copy; <?= date('Y'); ?> <?= cx_site_name(); ?> &middot; <?= cx_site_author(); ?></p>
+</div>
+
+</footer>
+
+</body>
+</html>
diff --git a/cx/templates/public/list.php b/cx/templates/public/list.php
new file mode 100644 (file)
index 0000000..3b4cee2
--- /dev/null
@@ -0,0 +1,16 @@
+<?php cx_template_base('base'); ?>
+<main>
+<?php foreach (cx_posts_get() as $post): ?>
+<?php $post_permalink = '/' . date('Y', $post->date) . '/' . date('m', $post->date) . '/' . $post->slug; ?>
+       <article>
+               <h1><a href="<?= cx_url($post_permalink) ?>"><?= $post->title ?></a></h1>
+               <p class="updated"><?= date('l, F jS, Y',$post->date) ?></p>
+               <?php if ($post->html_excerpt): ?>
+               <?= $post->html_excerpt ?>
+               <p><a href="<?= cx_url($post_permalink) ?>">Read more...</a></p>
+               <?php else: ?>
+               <?= $post->html_content ?>
+               <?php endif; ?>
+       </article>
+<?php endforeach; ?>
+</main>
diff --git a/cx/templates/public/post.php b/cx/templates/public/post.php
new file mode 100644 (file)
index 0000000..9fb9fce
--- /dev/null
@@ -0,0 +1,15 @@
+<?php cx_template_base('base'); ?>
+<main>
+<?php $post = cx_posts_find_post($post_id); ?>
+<?php if ($post): ?>
+<?php $post_permalink = '/' . date('Y', $post->date) . '/' . date('m', $post->date) . '/' . $post->slug; ?>
+       <article>
+               <h1><a href="<?= cx_url($post_permalink) ?>"><?= $post->title ?></a></h1>
+               <p class="updated"><?= date('l, F jS, Y',$post->date) ?></p>
+               <?= $post->html_content ?>
+       </article>
+<?php else: ?>
+       <p>Nothing found.</p>
+<?php endif; ?>
+
+</main>
diff --git a/cx/third_party/parsedown/LICENSE.txt b/cx/third_party/parsedown/LICENSE.txt
new file mode 100644 (file)
index 0000000..8e7c764
--- /dev/null
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2013-2018 Emanuil Rusev, erusev.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/cx/third_party/parsedown/Parsedown.php b/cx/third_party/parsedown/Parsedown.php
new file mode 100644 (file)
index 0000000..1b9d6d5
--- /dev/null
@@ -0,0 +1,1712 @@
+<?php
+
+#
+#
+# Parsedown
+# http://parsedown.org
+#
+# (c) Emanuil Rusev
+# http://erusev.com
+#
+# For the full license information, view the LICENSE file that was distributed
+# with this source code.
+#
+#
+
+class Parsedown
+{
+    # ~
+
+    const version = '1.7.4';
+
+    # ~
+
+    function text($text)
+    {
+        # make sure no definitions are set
+        $this->DefinitionData = array();
+
+        # standardize line breaks
+        $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+        # remove surrounding line breaks
+        $text = trim($text, "\n");
+
+        # split text into lines
+        $lines = explode("\n", $text);
+
+        # iterate through lines to identify blocks
+        $markup = $this->lines($lines);
+
+        # trim line breaks
+        $markup = trim($markup, "\n");
+
+        return $markup;
+    }
+
+    #
+    # Setters
+    #
+
+    function setBreaksEnabled($breaksEnabled)
+    {
+        $this->breaksEnabled = $breaksEnabled;
+
+        return $this;
+    }
+
+    protected $breaksEnabled;
+
+    function setMarkupEscaped($markupEscaped)
+    {
+        $this->markupEscaped = $markupEscaped;
+
+        return $this;
+    }
+
+    protected $markupEscaped;
+
+    function setUrlsLinked($urlsLinked)
+    {
+        $this->urlsLinked = $urlsLinked;
+
+        return $this;
+    }
+
+    protected $urlsLinked = true;
+
+    function setSafeMode($safeMode)
+    {
+        $this->safeMode = (bool) $safeMode;
+
+        return $this;
+    }
+
+    protected $safeMode;
+
+    protected $safeLinksWhitelist = array(
+        'http://',
+        'https://',
+        'ftp://',
+        'ftps://',
+        'mailto:',
+        'data:image/png;base64,',
+        'data:image/gif;base64,',
+        'data:image/jpeg;base64,',
+        'irc:',
+        'ircs:',
+        'git:',
+        'ssh:',
+        'news:',
+        'steam:',
+    );
+
+    #
+    # Lines
+    #
+
+    protected $BlockTypes = array(
+        '#' => array('Header'),
+        '*' => array('Rule', 'List'),
+        '+' => array('List'),
+        '-' => array('SetextHeader', 'Table', 'Rule', 'List'),
+        '0' => array('List'),
+        '1' => array('List'),
+        '2' => array('List'),
+        '3' => array('List'),
+        '4' => array('List'),
+        '5' => array('List'),
+        '6' => array('List'),
+        '7' => array('List'),
+        '8' => array('List'),
+        '9' => array('List'),
+        ':' => array('Table'),
+        '<' => array('Comment', 'Markup'),
+        '=' => array('SetextHeader'),
+        '>' => array('Quote'),
+        '[' => array('Reference'),
+        '_' => array('Rule'),
+        '`' => array('FencedCode'),
+        '|' => array('Table'),
+        '~' => array('FencedCode'),
+    );
+
+    # ~
+
+    protected $unmarkedBlockTypes = array(
+        'Code',
+    );
+
+    #
+    # Blocks
+    #
+
+    protected function lines(array $lines)
+    {
+        $CurrentBlock = null;
+
+        foreach ($lines as $line)
+        {
+            if (chop($line) === '')
+            {
+                if (isset($CurrentBlock))
+                {
+                    $CurrentBlock['interrupted'] = true;
+                }
+
+                continue;
+            }
+
+            if (strpos($line, "\t") !== false)
+            {
+                $parts = explode("\t", $line);
+
+                $line = $parts[0];
+
+                unset($parts[0]);
+
+                foreach ($parts as $part)
+                {
+                    $shortage = 4 - mb_strlen($line, 'utf-8') % 4;
+
+                    $line .= str_repeat(' ', $shortage);
+                    $line .= $part;
+                }
+            }
+
+            $indent = 0;
+
+            while (isset($line[$indent]) and $line[$indent] === ' ')
+            {
+                $indent ++;
+            }
+
+            $text = $indent > 0 ? substr($line, $indent) : $line;
+
+            # ~
+
+            $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
+
+            # ~
+
+            if (isset($CurrentBlock['continuable']))
+            {
+                $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $CurrentBlock = $Block;
+
+                    continue;
+                }
+                else
+                {
+                    if ($this->isBlockCompletable($CurrentBlock['type']))
+                    {
+                        $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);
+                    }
+                }
+            }
+
+            # ~
+
+            $marker = $text[0];
+
+            # ~
+
+            $blockTypes = $this->unmarkedBlockTypes;
+
+            if (isset($this->BlockTypes[$marker]))
+            {
+                foreach ($this->BlockTypes[$marker] as $blockType)
+                {
+                    $blockTypes []= $blockType;
+                }
+            }
+
+            #
+            # ~
+
+            foreach ($blockTypes as $blockType)
+            {
+                $Block = $this->{'block'.$blockType}($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $Block['type'] = $blockType;
+
+                    if ( ! isset($Block['identified']))
+                    {
+                        $Blocks []= $CurrentBlock;
+
+                        $Block['identified'] = true;
+                    }
+
+                    if ($this->isBlockContinuable($blockType))
+                    {
+                        $Block['continuable'] = true;
+                    }
+
+                    $CurrentBlock = $Block;
+
+                    continue 2;
+                }
+            }
+
+            # ~
+
+            if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted']))
+            {
+                $CurrentBlock['element']['text'] .= "\n".$text;
+            }
+            else
+            {
+                $Blocks []= $CurrentBlock;
+
+                $CurrentBlock = $this->paragraph($Line);
+
+                $CurrentBlock['identified'] = true;
+            }
+        }
+
+        # ~
+
+        if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
+        {
+            $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);
+        }
+
+        # ~
+
+        $Blocks []= $CurrentBlock;
+
+        unset($Blocks[0]);
+
+        # ~
+
+        $markup = '';
+
+        foreach ($Blocks as $Block)
+        {
+            if (isset($Block['hidden']))
+            {
+                continue;
+            }
+
+            $markup .= "\n";
+            $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']);
+        }
+
+        $markup .= "\n";
+
+        # ~
+
+        return $markup;
+    }
+
+    protected function isBlockContinuable($Type)
+    {
+        return method_exists($this, 'block'.$Type.'Continue');
+    }
+
+    protected function isBlockCompletable($Type)
+    {
+        return method_exists($this, 'block'.$Type.'Complete');
+    }
+
+    #
+    # Code
+
+    protected function blockCode($Line, $Block = null)
+    {
+        if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['indent'] >= 4)
+        {
+            $text = substr($Line['body'], 4);
+
+            $Block = array(
+                'element' => array(
+                    'name' => 'pre',
+                    'handler' => 'element',
+                    'text' => array(
+                        'name' => 'code',
+                        'text' => $text,
+                    ),
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeContinue($Line, $Block)
+    {
+        if ($Line['indent'] >= 4)
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['element']['text']['text'] .= "\n";
+
+                unset($Block['interrupted']);
+            }
+
+            $Block['element']['text']['text'] .= "\n";
+
+            $text = substr($Line['body'], 4);
+
+            $Block['element']['text']['text'] .= $text;
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeComplete($Block)
+    {
+        $text = $Block['element']['text']['text'];
+
+        $Block['element']['text']['text'] = $text;
+
+        return $Block;
+    }
+
+    #
+    # Comment
+
+    protected function blockComment($Line)
+    {
+        if ($this->markupEscaped or $this->safeMode)
+        {
+            return;
+        }
+
+        if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!')
+        {
+            $Block = array(
+                'markup' => $Line['body'],
+            );
+
+            if (preg_match('/-->$/', $Line['text']))
+            {
+                $Block['closed'] = true;
+            }
+
+            return $Block;
+        }
+    }
+
+    protected function blockCommentContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']))
+        {
+            return;
+        }
+
+        $Block['markup'] .= "\n" . $Line['body'];
+
+        if (preg_match('/-->$/', $Line['text']))
+        {
+            $Block['closed'] = true;
+        }
+
+        return $Block;
+    }
+
+    #
+    # Fenced Code
+
+    protected function blockFencedCode($Line)
+    {
+        if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches))
+        {
+            $Element = array(
+                'name' => 'code',
+                'text' => '',
+            );
+
+            if (isset($matches[1]))
+            {
+                /**
+                 * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes
+                 * Every HTML element may have a class attribute specified.
+                 * The attribute, if specified, must have a value that is a set
+                 * of space-separated tokens representing the various classes
+                 * that the element belongs to.
+                 * [...]
+                 * The space characters, for the purposes of this specification,
+                 * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab),
+                 * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and
+                 * U+000D CARRIAGE RETURN (CR).
+                 */
+                $language = substr($matches[1], 0, strcspn($matches[1], " \t\n\f\r"));
+
+                $class = 'language-'.$language;
+
+                $Element['attributes'] = array(
+                    'class' => $class,
+                );
+            }
+
+            $Block = array(
+                'char' => $Line['text'][0],
+                'element' => array(
+                    'name' => 'pre',
+                    'handler' => 'element',
+                    'text' => $Element,
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockFencedCodeContinue($Line, $Block)
+    {
+        if (isset($Block['complete']))
+        {
+            return;
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['element']['text']['text'] .= "\n";
+
+            unset($Block['interrupted']);
+        }
+
+        if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text']))
+        {
+            $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1);
+
+            $Block['complete'] = true;
+
+            return $Block;
+        }
+
+        $Block['element']['text']['text'] .= "\n".$Line['body'];
+
+        return $Block;
+    }
+
+    protected function blockFencedCodeComplete($Block)
+    {
+        $text = $Block['element']['text']['text'];
+
+        $Block['element']['text']['text'] = $text;
+
+        return $Block;
+    }
+
+    #
+    # Header
+
+    protected function blockHeader($Line)
+    {
+        if (isset($Line['text'][1]))
+        {
+            $level = 1;
+
+            while (isset($Line['text'][$level]) and $Line['text'][$level] === '#')
+            {
+                $level ++;
+            }
+
+            if ($level > 6)
+            {
+                return;
+            }
+
+            $text = trim($Line['text'], '# ');
+
+            $Block = array(
+                'element' => array(
+                    'name' => 'h' . min(6, $level),
+                    'text' => $text,
+                    'handler' => 'line',
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # List
+
+    protected function blockList($Line)
+    {
+        list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]');
+
+        if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'indent' => $Line['indent'],
+                'pattern' => $pattern,
+                'element' => array(
+                    'name' => $name,
+                    'handler' => 'elements',
+                ),
+            );
+
+            if($name === 'ol')
+            {
+                $listStart = stristr($matches[0], '.', true);
+
+                if($listStart !== '1')
+                {
+                    $Block['element']['attributes'] = array('start' => $listStart);
+                }
+            }
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => 'li',
+                'text' => array(
+                    $matches[2],
+                ),
+            );
+
+            $Block['element']['text'] []= & $Block['li'];
+
+            return $Block;
+        }
+    }
+
+    protected function blockListContinue($Line, array $Block)
+    {
+        if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches))
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['li']['text'] []= '';
+
+                $Block['loose'] = true;
+
+                unset($Block['interrupted']);
+            }
+
+            unset($Block['li']);
+
+            $text = isset($matches[1]) ? $matches[1] : '';
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => 'li',
+                'text' => array(
+                    $text,
+                ),
+            );
+
+            $Block['element']['text'] []= & $Block['li'];
+
+            return $Block;
+        }
+
+        if ($Line['text'][0] === '[' and $this->blockReference($Line))
+        {
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);
+
+            $Block['li']['text'] []= $text;
+
+            return $Block;
+        }
+
+        if ($Line['indent'] > 0)
+        {
+            $Block['li']['text'] []= '';
+
+            $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);
+
+            $Block['li']['text'] []= $text;
+
+            unset($Block['interrupted']);
+
+            return $Block;
+        }
+    }
+
+    protected function blockListComplete(array $Block)
+    {
+        if (isset($Block['loose']))
+        {
+            foreach ($Block['element']['text'] as &$li)
+            {
+                if (end($li['text']) !== '')
+                {
+                    $li['text'] []= '';
+                }
+            }
+        }
+
+        return $Block;
+    }
+
+    #
+    # Quote
+
+    protected function blockQuote($Line)
+    {
+        if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'blockquote',
+                    'handler' => 'lines',
+                    'text' => (array) $matches[1],
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockQuoteContinue($Line, array $Block)
+    {
+        if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['element']['text'] []= '';
+
+                unset($Block['interrupted']);
+            }
+
+            $Block['element']['text'] []= $matches[1];
+
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $Block['element']['text'] []= $Line['text'];
+
+            return $Block;
+        }
+    }
+
+    #
+    # Rule
+
+    protected function blockRule($Line)
+    {
+        if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text']))
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'hr'
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Setext
+
+    protected function blockSetextHeader($Line, array $Block = null)
+    {
+        if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (chop($Line['text'], $Line['text'][0]) === '')
+        {
+            $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
+
+            return $Block;
+        }
+    }
+
+    #
+    # Markup
+
+    protected function blockMarkup($Line)
+    {
+        if ($this->markupEscaped or $this->safeMode)
+        {
+            return;
+        }
+
+        if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches))
+        {
+            $element = strtolower($matches[1]);
+
+            if (in_array($element, $this->textLevelElements))
+            {
+                return;
+            }
+
+            $Block = array(
+                'name' => $matches[1],
+                'depth' => 0,
+                'markup' => $Line['text'],
+            );
+
+            $length = strlen($matches[0]);
+
+            $remainder = substr($Line['text'], $length);
+
+            if (trim($remainder) === '')
+            {
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
+                {
+                    $Block['closed'] = true;
+
+                    $Block['void'] = true;
+                }
+            }
+            else
+            {
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
+                {
+                    return;
+                }
+
+                if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder))
+                {
+                    $Block['closed'] = true;
+                }
+            }
+
+            return $Block;
+        }
+    }
+
+    protected function blockMarkupContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']))
+        {
+            return;
+        }
+
+        if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open
+        {
+            $Block['depth'] ++;
+        }
+
+        if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close
+        {
+            if ($Block['depth'] > 0)
+            {
+                $Block['depth'] --;
+            }
+            else
+            {
+                $Block['closed'] = true;
+            }
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['markup'] .= "\n";
+
+            unset($Block['interrupted']);
+        }
+
+        $Block['markup'] .= "\n".$Line['body'];
+
+        return $Block;
+    }
+
+    #
+    # Reference
+
+    protected function blockReference($Line)
+    {
+        if (preg_match('/^\[(.+?)\]:[ ]*<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches))
+        {
+            $id = strtolower($matches[1]);
+
+            $Data = array(
+                'url' => $matches[2],
+                'title' => null,
+            );
+
+            if (isset($matches[3]))
+            {
+                $Data['title'] = $matches[3];
+            }
+
+            $this->DefinitionData['Reference'][$id] = $Data;
+
+            $Block = array(
+                'hidden' => true,
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Table
+
+    protected function blockTable($Line, array $Block = null)
+    {
+        if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '')
+        {
+            $alignments = array();
+
+            $divider = $Line['text'];
+
+            $divider = trim($divider);
+            $divider = trim($divider, '|');
+
+            $dividerCells = explode('|', $divider);
+
+            foreach ($dividerCells as $dividerCell)
+            {
+                $dividerCell = trim($dividerCell);
+
+                if ($dividerCell === '')
+                {
+                    continue;
+                }
+
+                $alignment = null;
+
+                if ($dividerCell[0] === ':')
+                {
+                    $alignment = 'left';
+                }
+
+                if (substr($dividerCell, - 1) === ':')
+                {
+                    $alignment = $alignment === 'left' ? 'center' : 'right';
+                }
+
+                $alignments []= $alignment;
+            }
+
+            # ~
+
+            $HeaderElements = array();
+
+            $header = $Block['element']['text'];
+
+            $header = trim($header);
+            $header = trim($header, '|');
+
+            $headerCells = explode('|', $header);
+
+            foreach ($headerCells as $index => $headerCell)
+            {
+                $headerCell = trim($headerCell);
+
+                $HeaderElement = array(
+                    'name' => 'th',
+                    'text' => $headerCell,
+                    'handler' => 'line',
+                );
+
+                if (isset($alignments[$index]))
+                {
+                    $alignment = $alignments[$index];
+
+                    $HeaderElement['attributes'] = array(
+                        'style' => 'text-align: '.$alignment.';',
+                    );
+                }
+
+                $HeaderElements []= $HeaderElement;
+            }
+
+            # ~
+
+            $Block = array(
+                'alignments' => $alignments,
+                'identified' => true,
+                'element' => array(
+                    'name' => 'table',
+                    'handler' => 'elements',
+                ),
+            );
+
+            $Block['element']['text'] []= array(
+                'name' => 'thead',
+                'handler' => 'elements',
+            );
+
+            $Block['element']['text'] []= array(
+                'name' => 'tbody',
+                'handler' => 'elements',
+                'text' => array(),
+            );
+
+            $Block['element']['text'][0]['text'] []= array(
+                'name' => 'tr',
+                'handler' => 'elements',
+                'text' => $HeaderElements,
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockTableContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['text'][0] === '|' or strpos($Line['text'], '|'))
+        {
+            $Elements = array();
+
+            $row = $Line['text'];
+
+            $row = trim($row);
+            $row = trim($row, '|');
+
+            preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches);
+
+            foreach ($matches[0] as $index => $cell)
+            {
+                $cell = trim($cell);
+
+                $Element = array(
+                    'name' => 'td',
+                    'handler' => 'line',
+                    'text' => $cell,
+                );
+
+                if (isset($Block['alignments'][$index]))
+                {
+                    $Element['attributes'] = array(
+                        'style' => 'text-align: '.$Block['alignments'][$index].';',
+                    );
+                }
+
+                $Elements []= $Element;
+            }
+
+            $Element = array(
+                'name' => 'tr',
+                'handler' => 'elements',
+                'text' => $Elements,
+            );
+
+            $Block['element']['text'][1]['text'] []= $Element;
+
+            return $Block;
+        }
+    }
+
+    #
+    # ~
+    #
+
+    protected function paragraph($Line)
+    {
+        $Block = array(
+            'element' => array(
+                'name' => 'p',
+                'text' => $Line['text'],
+                'handler' => 'line',
+            ),
+        );
+
+        return $Block;
+    }
+
+    #
+    # Inline Elements
+    #
+
+    protected $InlineTypes = array(
+        '"' => array('SpecialCharacter'),
+        '!' => array('Image'),
+        '&' => array('SpecialCharacter'),
+        '*' => array('Emphasis'),
+        ':' => array('Url'),
+        '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'),
+        '>' => array('SpecialCharacter'),
+        '[' => array('Link'),
+        '_' => array('Emphasis'),
+        '`' => array('Code'),
+        '~' => array('Strikethrough'),
+        '\\' => array('EscapeSequence'),
+    );
+
+    # ~
+
+    protected $inlineMarkerList = '!"*_&[:<>`~\\';
+
+    #
+    # ~
+    #
+
+    public function line($text, $nonNestables=array())
+    {
+        $markup = '';
+
+        # $excerpt is based on the first occurrence of a marker
+
+        while ($excerpt = strpbrk($text, $this->inlineMarkerList))
+        {
+            $marker = $excerpt[0];
+
+            $markerPosition = strpos($text, $marker);
+
+            $Excerpt = array('text' => $excerpt, 'context' => $text);
+
+            foreach ($this->InlineTypes[$marker] as $inlineType)
+            {
+                # check to see if the current inline type is nestable in the current context
+
+                if ( ! empty($nonNestables) and in_array($inlineType, $nonNestables))
+                {
+                    continue;
+                }
+
+                $Inline = $this->{'inline'.$inlineType}($Excerpt);
+
+                if ( ! isset($Inline))
+                {
+                    continue;
+                }
+
+                # makes sure that the inline belongs to "our" marker
+
+                if (isset($Inline['position']) and $Inline['position'] > $markerPosition)
+                {
+                    continue;
+                }
+
+                # sets a default inline position
+
+                if ( ! isset($Inline['position']))
+                {
+                    $Inline['position'] = $markerPosition;
+                }
+
+                # cause the new element to 'inherit' our non nestables
+
+                foreach ($nonNestables as $non_nestable)
+                {
+                    $Inline['element']['nonNestables'][] = $non_nestable;
+                }
+
+                # the text that comes before the inline
+                $unmarkedText = substr($text, 0, $Inline['position']);
+
+                # compile the unmarked text
+                $markup .= $this->unmarkedText($unmarkedText);
+
+                # compile the inline
+                $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']);
+
+                # remove the examined text
+                $text = substr($text, $Inline['position'] + $Inline['extent']);
+
+                continue 2;
+            }
+
+            # the marker does not belong to an inline
+
+            $unmarkedText = substr($text, 0, $markerPosition + 1);
+
+            $markup .= $this->unmarkedText($unmarkedText);
+
+            $text = substr($text, $markerPosition + 1);
+        }
+
+        $markup .= $this->unmarkedText($text);
+
+        return $markup;
+    }
+
+    #
+    # ~
+    #
+
+    protected function inlineCode($Excerpt)
+    {
+        $marker = $Excerpt['text'][0];
+
+        if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(?<!'.$marker.')\1(?!'.$marker.')/s', $Excerpt['text'], $matches))
+        {
+            $text = $matches[2];
+            $text = preg_replace("/[ ]*\n/", ' ', $text);
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'code',
+                    'text' => $text,
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmailTag($Excerpt)
+    {
+        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches))
+        {
+            $url = $matches[1];
+
+            if ( ! isset($matches[2]))
+            {
+                $url = 'mailto:' . $url;
+            }
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $matches[1],
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmphasis($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        $marker = $Excerpt['text'][0];
+
+        if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'strong';
+        }
+        elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'em';
+        }
+        else
+        {
+            return;
+        }
+
+        return array(
+            'extent' => strlen($matches[0]),
+            'element' => array(
+                'name' => $emphasis,
+                'handler' => 'line',
+                'text' => $matches[1],
+            ),
+        );
+    }
+
+    protected function inlineEscapeSequence($Excerpt)
+    {
+        if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
+        {
+            return array(
+                'markup' => $Excerpt['text'][1],
+                'extent' => 2,
+            );
+        }
+    }
+
+    protected function inlineImage($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')
+        {
+            return;
+        }
+
+        $Excerpt['text']= substr($Excerpt['text'], 1);
+
+        $Link = $this->inlineLink($Excerpt);
+
+        if ($Link === null)
+        {
+            return;
+        }
+
+        $Inline = array(
+            'extent' => $Link['extent'] + 1,
+            'element' => array(
+                'name' => 'img',
+                'attributes' => array(
+                    'src' => $Link['element']['attributes']['href'],
+                    'alt' => $Link['element']['text'],
+                ),
+            ),
+        );
+
+        $Inline['element']['attributes'] += $Link['element']['attributes'];
+
+        unset($Inline['element']['attributes']['href']);
+
+        return $Inline;
+    }
+
+    protected function inlineLink($Excerpt)
+    {
+        $Element = array(
+            'name' => 'a',
+            'handler' => 'line',
+            'nonNestables' => array('Url', 'Link'),
+            'text' => null,
+            'attributes' => array(
+                'href' => null,
+                'title' => null,
+            ),
+        );
+
+        $extent = 0;
+
+        $remainder = $Excerpt['text'];
+
+        if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
+        {
+            $Element['text'] = $matches[1];
+
+            $extent += strlen($matches[0]);
+
+            $remainder = substr($remainder, $extent);
+        }
+        else
+        {
+            return;
+        }
+
+        if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches))
+        {
+            $Element['attributes']['href'] = $matches[1];
+
+            if (isset($matches[2]))
+            {
+                $Element['attributes']['title'] = substr($matches[2], 1, - 1);
+            }
+
+            $extent += strlen($matches[0]);
+        }
+        else
+        {
+            if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
+            {
+                $definition = strlen($matches[1]) ? $matches[1] : $Element['text'];
+                $definition = strtolower($definition);
+
+                $extent += strlen($matches[0]);
+            }
+            else
+            {
+                $definition = strtolower($Element['text']);
+            }
+
+            if ( ! isset($this->DefinitionData['Reference'][$definition]))
+            {
+                return;
+            }
+
+            $Definition = $this->DefinitionData['Reference'][$definition];
+
+            $Element['attributes']['href'] = $Definition['url'];
+            $Element['attributes']['title'] = $Definition['title'];
+        }
+
+        return array(
+            'extent' => $extent,
+            'element' => $Element,
+        );
+    }
+
+    protected function inlineMarkup($Excerpt)
+    {
+        if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false)
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*[ ]*>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'markup' => $matches[0],
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?[^-])*-->/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'markup' => $matches[0],
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'markup' => $matches[0],
+                'extent' => strlen($matches[0]),
+            );
+        }
+    }
+
+    protected function inlineSpecialCharacter($Excerpt)
+    {
+        if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text']))
+        {
+            return array(
+                'markup' => '&amp;',
+                'extent' => 1,
+            );
+        }
+
+        $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot');
+
+        if (isset($SpecialCharacter[$Excerpt['text'][0]]))
+        {
+            return array(
+                'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';',
+                'extent' => 1,
+            );
+        }
+    }
+
+    protected function inlineStrikethrough($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches))
+        {
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'del',
+                    'text' => $matches[1],
+                    'handler' => 'line',
+                ),
+            );
+        }
+    }
+
+    protected function inlineUrl($Excerpt)
+    {
+        if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
+        {
+            return;
+        }
+
+        if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE))
+        {
+            $url = $matches[0][0];
+
+            $Inline = array(
+                'extent' => strlen($matches[0][0]),
+                'position' => $matches[0][1],
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $url,
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+
+            return $Inline;
+        }
+    }
+
+    protected function inlineUrlTag($Excerpt)
+    {
+        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches))
+        {
+            $url = $matches[1];
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $url,
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    # ~
+
+    protected function unmarkedText($text)
+    {
+        if ($this->breaksEnabled)
+        {
+            $text = preg_replace('/[ ]*\n/', "<br />\n", $text);
+        }
+        else
+        {
+            $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "<br />\n", $text);
+            $text = str_replace(" \n", "\n", $text);
+        }
+
+        return $text;
+    }
+
+    #
+    # Handlers
+    #
+
+    protected function element(array $Element)
+    {
+        if ($this->safeMode)
+        {
+            $Element = $this->sanitiseElement($Element);
+        }
+
+        $markup = '<'.$Element['name'];
+
+        if (isset($Element['attributes']))
+        {
+            foreach ($Element['attributes'] as $name => $value)
+            {
+                if ($value === null)
+                {
+                    continue;
+                }
+
+                $markup .= ' '.$name.'="'.self::escape($value).'"';
+            }
+        }
+
+        $permitRawHtml = false;
+
+        if (isset($Element['text']))
+        {
+            $text = $Element['text'];
+        }
+        // very strongly consider an alternative if you're writing an
+        // extension
+        elseif (isset($Element['rawHtml']))
+        {
+            $text = $Element['rawHtml'];
+            $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];
+            $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode;
+        }
+
+        if (isset($text))
+        {
+            $markup .= '>';
+
+            if (!isset($Element['nonNestables']))
+            {
+                $Element['nonNestables'] = array();
+            }
+
+            if (isset($Element['handler']))
+            {
+                $markup .= $this->{$Element['handler']}($text, $Element['nonNestables']);
+            }
+            elseif (!$permitRawHtml)
+            {
+                $markup .= self::escape($text, true);
+            }
+            else
+            {
+                $markup .= $text;
+            }
+
+            $markup .= '</'.$Element['name'].'>';
+        }
+        else
+        {
+            $markup .= ' />';
+        }
+
+        return $markup;
+    }
+
+    protected function elements(array $Elements)
+    {
+        $markup = '';
+
+        foreach ($Elements as $Element)
+        {
+            $markup .= "\n" . $this->element($Element);
+        }
+
+        $markup .= "\n";
+
+        return $markup;
+    }
+
+    # ~
+
+    protected function li($lines)
+    {
+        $markup = $this->lines($lines);
+
+        $trimmedMarkup = trim($markup);
+
+        if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '<p>')
+        {
+            $markup = $trimmedMarkup;
+            $markup = substr($markup, 3);
+
+            $position = strpos($markup, "</p>");
+
+            $markup = substr_replace($markup, '', $position, 4);
+        }
+
+        return $markup;
+    }
+
+    #
+    # Deprecated Methods
+    #
+
+    function parse($text)
+    {
+        $markup = $this->text($text);
+
+        return $markup;
+    }
+
+    protected function sanitiseElement(array $Element)
+    {
+        static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
+        static $safeUrlNameToAtt  = array(
+            'a'   => 'href',
+            'img' => 'src',
+        );
+
+        if (isset($safeUrlNameToAtt[$Element['name']]))
+        {
+            $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);
+        }
+
+        if ( ! empty($Element['attributes']))
+        {
+            foreach ($Element['attributes'] as $att => $val)
+            {
+                # filter out badly parsed attribute
+                if ( ! preg_match($goodAttribute, $att))
+                {
+                    unset($Element['attributes'][$att]);
+                }
+                # dump onevent attribute
+                elseif (self::striAtStart($att, 'on'))
+                {
+                    unset($Element['attributes'][$att]);
+                }
+            }
+        }
+
+        return $Element;
+    }
+
+    protected function filterUnsafeUrlInAttribute(array $Element, $attribute)
+    {
+        foreach ($this->safeLinksWhitelist as $scheme)
+        {
+            if (self::striAtStart($Element['attributes'][$attribute], $scheme))
+            {
+                return $Element;
+            }
+        }
+
+        $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]);
+
+        return $Element;
+    }
+
+    #
+    # Static Methods
+    #
+
+    protected static function escape($text, $allowQuotes = false)
+    {
+        return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8');
+    }
+
+    protected static function striAtStart($string, $needle)
+    {
+        $len = strlen($needle);
+
+        if ($len > strlen($string))
+        {
+            return false;
+        }
+        else
+        {
+            return strtolower(substr($string, 0, $len)) === strtolower($needle);
+        }
+    }
+
+    static function instance($name = 'default')
+    {
+        if (isset(self::$instances[$name]))
+        {
+            return self::$instances[$name];
+        }
+
+        $instance = new static();
+
+        self::$instances[$name] = $instance;
+
+        return $instance;
+    }
+
+    private static $instances = array();
+
+    #
+    # Fields
+    #
+
+    protected $DefinitionData;
+
+    #
+    # Read-Only
+
+    protected $specialCharacters = array(
+        '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|',
+    );
+
+    protected $StrongRegex = array(
+        '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s',
+        '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us',
+    );
+
+    protected $EmRegex = array(
+        '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
+        '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
+    );
+
+    protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?';
+
+    protected $voidElements = array(
+        'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
+    );
+
+    protected $textLevelElements = array(
+        'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
+        'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
+        'i', 'rp', 'del', 'code',          'strike', 'marquee',
+        'q', 'rt', 'ins', 'font',          'strong',
+        's', 'tt', 'kbd', 'mark',
+        'u', 'xm', 'sub', 'nobr',
+                   'sup', 'ruby',
+                   'var', 'span',
+                   'wbr', 'time',
+    );
+}
diff --git a/public/.htaccess b/public/.htaccess
new file mode 100644 (file)
index 0000000..3089e13
--- /dev/null
@@ -0,0 +1,11 @@
+# BEGIN CX 
+
+RewriteEngine On 
+RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 
+RewriteBase / 
+RewriteRule ^index\.php$ - [L] 
+RewriteCond %{REQUEST_FILENAME} !-f 
+RewriteCond %{REQUEST_FILENAME} !-d 
+RewriteRule . /index.php [L] 
+
+# END CX
\ No newline at end of file
diff --git a/public/design/css/style.css b/public/design/css/style.css
new file mode 100644 (file)
index 0000000..7d21a2b
--- /dev/null
@@ -0,0 +1,176 @@
+/* Very Smol Reset */
+* {
+  box-sizing: border-box;
+  margin: 0;
+}
+
+@font-face {
+  font-family: Fanwood;
+  src: url(/design/fonts/fanwood/fanwood-webfont.woff);
+}
+
+@font-face {
+  font-family: Fanwood;
+  src: url(/design/fonts/fanwood/fanwood_italic-webfont.woff);
+  font-style: italic;
+}
+
+@font-face {
+  font-family: "Fanwood Text";
+  src: url(/design/fonts/fanwood/fanwood_text-webfont.woff);
+}
+
+@font-face {
+  font-family: "Fanwood Text";
+  src: url(/design/fonts/fanwood/fanwood_text_italic-webfont.woff);
+  font-style: italic;
+}
+
+/* Layout */
+
+body {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  padding: 5vh clamp(1rem, 5vw, 3rem) 1rem;
+  font-family: Fanwood, serif;
+  font-size: 1.4em;
+  line-height: 1.2em;
+  color: #222;
+}
+
+body > * {
+  --layout-spacing: max(8vh, 3rem);
+  --max-width: 60ch;
+  width: min(100%, var(--max-width));
+  margin-left: auto;
+  margin-right: auto;
+}
+
+header h1 {
+  font-size: 4em;
+}
+
+nav ul {
+  list-style: none;
+  display: flex;
+  flex-wrap: wrap;
+  padding: 0;
+  margin-left: -1rem;
+  margin-right: -1rem;
+  vertical-align: bottom;
+}
+
+nav ul li {
+  padding: 0.5rem 1rem;
+}
+
+nav [aria-current="page"] {
+  font-weight: bold;
+}
+
+main,
+main > * + * {
+  margin-top: var(--layout-spacing);
+}
+
+body > footer {
+  margin-top: auto;
+  padding-top: var(--layout-spacing);
+}
+
+body > footer div {
+  border-top: 1px solid #ccc;
+  padding-top: 0.5em;
+  font-size: 0.9rem;
+  color: #767676;
+}
+
+article * + * {
+  margin-top: 1em;
+}
+
+article > h1 {
+  margin-top: 0;
+}
+
+article > h1 a {
+  text-decoration: none;
+  color: #000;
+}
+
+article > h1 + .updated {
+  margin-top: 0;
+}
+
+
+/* Typography */
+:is(h1, h2, h3) {
+  line-height: 1.2;
+}
+
+:is(h1, h2) {
+  max-width: 40ch;
+}
+
+
+
+.updated {
+  font-size: 0.8em;
+  color: #aaa;
+}
+
+:is(h2, h3):not(:first-child) {
+  margin-top: 2em;
+}
+
+a {
+  color: navy;
+  text-underline-offset: 0.08em;
+}
+
+a:focus {
+  outline: 1px solid currentColor;
+  outline-offset: 0.2em;
+}
+
+/* Media */
+article + article {
+  border-top: 1px dashed #ccc;
+  padding-top: var(--layout-spacing);
+}
+
+article img {
+  display: block;
+  width: 100%;
+  min-height: 20rem;
+  max-height: 40vh;
+  object-fit: scale-down; /*cover;*/
+  margin: 2rem auto;
+}
+
+@supports (aspect-ratio: 1) {
+  article img {
+    max-height: unset;
+    aspect-ratio: 3/2;
+  }
+}
+
+/* Supporting Content */
+
+code:not([class*="language"]) {
+  font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+  font-size: 1.75ex;
+  color: #444;
+  background-color: rgba(0, 0, 0, 0.1);
+  padding-right: 0.15em;
+  padding-left: 0.15em;
+}
+
+blockquote {
+  margin: 2rem 0;
+  padding: 0.5em 1rem;
+  border-left: 3px solid rgba(0, 0, 0, 0.35);
+  background-color: rgba(0, 0, 0, 0.05);
+  border-radius: 0 0.25rem 0.25rem 0;
+}
diff --git a/public/design/fonts/fanwood/fanwood-webfont.woff b/public/design/fonts/fanwood/fanwood-webfont.woff
new file mode 100755 (executable)
index 0000000..ede3aa2
Binary files /dev/null and b/public/design/fonts/fanwood/fanwood-webfont.woff differ
diff --git a/public/design/fonts/fanwood/fanwood_italic-webfont.woff b/public/design/fonts/fanwood/fanwood_italic-webfont.woff
new file mode 100755 (executable)
index 0000000..53156d2
Binary files /dev/null and b/public/design/fonts/fanwood/fanwood_italic-webfont.woff differ
diff --git a/public/design/fonts/fanwood/fanwood_text-webfont.woff b/public/design/fonts/fanwood/fanwood_text-webfont.woff
new file mode 100755 (executable)
index 0000000..8b7fc93
Binary files /dev/null and b/public/design/fonts/fanwood/fanwood_text-webfont.woff differ
diff --git a/public/design/fonts/fanwood/fanwood_text_italic-webfont.woff b/public/design/fonts/fanwood/fanwood_text_italic-webfont.woff
new file mode 100755 (executable)
index 0000000..bcd089b
Binary files /dev/null and b/public/design/fonts/fanwood/fanwood_text_italic-webfont.woff differ
diff --git a/public/index.php b/public/index.php
new file mode 100644 (file)
index 0000000..79e00a4
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+
+/*
+ ,-----,--.   ,--.    ,-----. ,--.
+'  .--./\  `.'  /     |  |) /_|  |,---. ,---.,--.   ,--.,--,--,--.--.,---.
+|  |     .'    \      |  .-.  |  | .-. | .-. |  |.'.|  ' ,-.  |  .--| .-. :
+'  '--'\/  .'.  \     |  '--' |  ' '-' ' '-' |   .'.   \ '-'  |  |  \   --.
+ `-----'--'   '--'    `------'`--'`---'.`-  /'--'   '--'`--`--`--'   `----'
+                                       `---'
+ */
+
+$main_file = join(DIRECTORY_SEPARATOR, array(__DIR__, "..", "cx", "cx.php"));
+$db_file = join(DIRECTORY_SEPARATOR, array(__DIR__, "..", "db", "btscx.db"));
+$data_folder = join(DIRECTORY_SEPARATOR, array(__DIR__, "..", "data"));
+$public_data_folder = join(DIRECTORY_SEPARATOR, array(__DIR__, "data"));
+require_once($main_file);
+cx($db_file, $data_folder, $public_data_folder);
diff --git a/setup.template.php b/setup.template.php
new file mode 100644 (file)
index 0000000..e2da459
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+define('CX_SETUP_USER', 'admin');
+define('CX_SETUP_PASSWORD', 'password');
+
+define('CX_SETUP_URL', 'https://blog.blog');
+define('CX_SETUP_TITLE', 'Basic Blog');
+define('CX_SETUP_BYLINE', 'Just a basic blog');
+define('CX_SETUP_COPYRIGHT', 'Me');
\ No newline at end of file