summaryrefslogtreecommitdiff
path: root/src/web
diff options
context:
space:
mode:
authorFreya Murphy <freya@freyacat.org>2024-05-24 09:05:42 -0400
committerFreya Murphy <freya@freyacat.org>2024-05-24 09:05:42 -0400
commitc5f39ea2cd7cf02246705ea8872d3b350526165c (patch)
tree2694f9fdc5d83b529a01f2997c1d89c271c86592 /src/web
downloadwebsite-c5f39ea2cd7cf02246705ea8872d3b350526165c.tar.gz
website-c5f39ea2cd7cf02246705ea8872d3b350526165c.tar.bz2
website-c5f39ea2cd7cf02246705ea8872d3b350526165c.zip
initial
Diffstat (limited to 'src/web')
-rw-r--r--src/web/_controller/_comments.php87
-rw-r--r--src/web/_controller/_meta.php76
-rw-r--r--src/web/_controller/blog.php74
-rw-r--r--src/web/_controller/bucket.php22
-rw-r--r--src/web/_controller/error.php21
-rw-r--r--src/web/_controller/home.php17
-rw-r--r--src/web/_controller/projects.php21
-rw-r--r--src/web/_model/_comments.php66
-rw-r--r--src/web/_model/blog.php80
-rw-r--r--src/web/_model/bucket.php26
-rw-r--r--src/web/_model/error.php31
-rw-r--r--src/web/_model/main.php97
-rw-r--r--src/web/_model/projects.php36
-rw-r--r--src/web/_views/apps/blog.php13
-rw-r--r--src/web/_views/apps/blog_post.php6
-rw-r--r--src/web/_views/apps/blog_rss.php20
-rw-r--r--src/web/_views/apps/blog_writeup.php5
-rw-r--r--src/web/_views/apps/bucket.php41
-rw-r--r--src/web/_views/apps/error.php6
-rw-r--r--src/web/_views/apps/home.php55
-rw-r--r--src/web/_views/apps/projects.php11
-rw-r--r--src/web/_views/comments.php51
-rw-r--r--src/web/_views/footer.php51
-rw-r--r--src/web/_views/head.php29
-rw-r--r--src/web/_views/header.php43
-rw-r--r--src/web/config/routes.php10
-rw-r--r--src/web/config/style.php7
-rw-r--r--src/web/core/_controller.php51
-rw-r--r--src/web/core/_model.php50
-rw-r--r--src/web/core/loader.php111
-rw-r--r--src/web/core/router.php248
-rw-r--r--src/web/helpers/aria.php16
-rw-r--r--src/web/helpers/database.php282
-rw-r--r--src/web/helpers/image.php94
-rw-r--r--src/web/helpers/lang.php79
-rw-r--r--src/web/helpers/markdown.php33
-rw-r--r--src/web/helpers/sanitize.php8
-rw-r--r--src/web/index.php38
-rw-r--r--src/web/lang/apps/blog.php16
-rw-r--r--src/web/lang/apps/error.php13
-rw-r--r--src/web/lang/apps/home.php35
-rw-r--r--src/web/lang/apps/projects.php4
-rw-r--r--src/web/lang/common.php60
-rw-r--r--src/web/third_party/parsedown.php1995
-rw-r--r--src/web/third_party/parsedown_extra.php686
45 files changed, 4821 insertions, 0 deletions
diff --git a/src/web/_controller/_comments.php b/src/web/_controller/_comments.php
new file mode 100644
index 0000000..4b87a94
--- /dev/null
+++ b/src/web/_controller/_comments.php
@@ -0,0 +1,87 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class _comments_controller extends Controller {
+
+ private $comments_model;
+
+ function __construct($load) {
+ parent::__construct($load);
+ $this->comments_model = $this->load->model('_comments');
+ }
+
+
+ public function comments($page, $ref): void {
+ $data = $this->comments_model->get_comments($page);
+ $this->view('comments', array(
+ 'comments' => $data,
+ 'ref' => $ref,
+ 'page' => $page
+ ));
+ }
+
+ public function post(): void {
+ $author = ''; $content = ''; $ref = '';
+ if (
+ !array_key_exists('author', $_GET) ||
+ !array_key_exists('content', $_GET) ||
+ !array_key_exists('ref', $_GET) ||
+ !array_key_exists('page', $_GET)
+ ) {
+ $this->error(400); return;
+ }
+
+ $author = trim($_GET['author']);
+ $content = trim($_GET['content']);
+ $page = $_GET['page'];
+ $ref = $_GET['ref'];
+ $url = NULL;
+
+ $author_len = strlen($author);
+ $content_len = strlen($content);
+
+ if ($author_len < 1 || $content_len < 1) {
+ $this->error(400);
+ return;
+ }
+
+ if ($author_len > 30 || $content_len > 500) {
+ $this->error(413);
+ return;
+ }
+
+ if (base64_encode(base64_decode($ref)) !== $ref) {
+ // invalid base64
+ $this->error(400);
+ return;
+ }
+
+ try {
+ $ref = base64_decode($ref);
+ $url = parse_url($ref);
+ if (!$url && array_key_exists('host', $url)) {
+ // dont allow redirects off this site
+ $this->error(400);
+ return;
+ }
+ } catch (Exception $e) {
+ $this->error(400);
+ return;
+ }
+
+ $vulgar = 'false';
+ if (
+ $this->comments_model->is_vulgar($author) ||
+ $this->comments_model->is_vulgar($content)
+ ) {
+ $vulgar = 'true';
+ }
+
+ $result = $this->comments_model
+ ->post_comment($author, $content, $page, $vulgar);
+
+ if ($result) {
+ header('Location: ' . $this->main->get_url($ref) . '#comments');
+ } else {
+ $this->error(500);
+ }
+ }
+}
diff --git a/src/web/_controller/_meta.php b/src/web/_controller/_meta.php
new file mode 100644
index 0000000..801d254
--- /dev/null
+++ b/src/web/_controller/_meta.php
@@ -0,0 +1,76 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class _meta_controller extends Controller {
+
+ function __construct($load) {
+ parent::__construct($load);
+ }
+
+ public function robots() {
+ header("Content-Type: text/plain");
+ $sitemap = $this->main->get_url_full('sitemap.xml');
+
+ echo "User-agent: *\n";
+ echo "Disallow:\n";
+ echo "Crawl-delay: 5\n";
+ echo "Disallow: /_comments/\n";
+ echo "Disallow: /pacbattle/\n";
+ echo "Disallow: /bucket/\n";
+ echo "Sitemap: {$sitemap}\n";
+ }
+
+ private function sitemap_page($url, $priority) {
+ echo "<url>\n";
+ echo "<loc>{$this->main->get_url_full($url)}</loc>\n";
+ echo "<priority>{$priority}</priority>\n";
+ echo "</url>";
+ }
+
+ public function sitemap() {
+ header("Content-Type: application/xml");
+
+ echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
+ echo "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
+
+ $this->sitemap_page('home', 1);
+ $this->sitemap_page('projects', 0.8);
+ $this->sitemap_page('blog', 0.8);
+
+ $this->load->app_lang('blog');
+ $blog_modal = $this->load->model('blog');
+ $blog = $blog_modal->get_data()['blog'];
+
+ foreach ($blog as $name => $_) {
+ $this->sitemap_page("blog/post?name={$name}", 0.5);
+ }
+
+ echo "</urlset>\n";
+ }
+
+ public function manifest() {
+ $json = array(
+ 'short_name' => lang('domain'),
+ 'name' => lang('domain'),
+ 'icons' => [
+ array(
+ 'src' => $this->main->get_url('public/icons/logo512.png'),
+ 'type' => 'image/png',
+ 'sizes' => '512x512',
+ 'purpose' => 'any maskable'
+ )
+ ],
+ 'id' => $this->main->get_url('home'),
+ 'start_url' => $this->main->get_url('home'),
+ 'background_color' => lang('theme_color'),
+ 'display' => 'standalone',
+ 'scope' => lang('base_path'),
+ 'theme_color' => lang('theme_color'),
+ 'shortcuts' => [],
+ 'description' => lang('default_short_desc'),
+ 'screenshots' => []
+ );
+
+ header('Content-type: application/json');
+ echo json_encode($json);
+ }
+
+}
diff --git a/src/web/_controller/blog.php b/src/web/_controller/blog.php
new file mode 100644
index 0000000..f13ffd1
--- /dev/null
+++ b/src/web/_controller/blog.php
@@ -0,0 +1,74 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Blog_controller extends Controller {
+
+ public $comments_controller;
+ private $blog_model;
+
+ function __construct($load) {
+ parent::__construct($load);
+ $this->blog_model = $this->load->model('blog');
+ $this->comments_controller = $this->load->controller('_comments');
+ }
+
+ public function index(): void {
+ parent::index();
+ $data = $this->blog_model->get_data();
+ $this->view('header', $data);
+ $this->view('apps/blog', $data);
+ $this->view('footer', $data);
+ }
+
+ private function protect($folder) {
+ if (!array_key_exists('name', $_GET)) {
+ $this->error(400);
+ }
+
+ $basepath = $GLOBALS['assetroot'] . '/' . $folder . '/';
+ $realBase = realpath($basepath);
+
+ $userpath = $basepath . $_GET['name'];
+ $realUserPath = realpath($userpath);
+
+ if ($realUserPath === false || strpos($realUserPath, $realBase) !== 0) {
+ $this->error(404);
+ }
+ }
+
+ public function post(): void {
+ $this->protect('blog');
+ parent::index();
+ $data = $this->blog_model->get_post($_GET['name']);
+ if ($data === FALSE) {
+ $this->error(404);
+ }
+ $this->view('header', $data);
+ $this->view('apps/blog_post', $data);
+ $ref = 'blog/post?name=' . $_GET['name'];
+ $this->comments_controller->comments($data['post']['meta']['name'], $ref);
+ $this->view('footer', $data);
+ }
+
+ public function writeup(): void {
+ $this->protect('writeup');
+ parent::index();
+ $data = $this->blog_model->get_writeup($_GET['name']);
+ if ($data === FALSE) {
+ $this->error(404);
+ }
+ $this->view('header', $data);
+ $this->view('apps/blog_writeup', $data);
+ $ref = 'blog/writeup?name=' . $_GET['name'];
+ $this->comments_controller->comments($data['post']['meta']['name'], $ref);
+ $this->view('footer', $data);
+ }
+
+ public function rss() {
+ $data = $this->blog_model->get_data();
+ header('Content-Type: application/xml');
+ $this->view('apps/blog_rss', $data);
+ die();
+ }
+
+}
+
+?>
diff --git a/src/web/_controller/bucket.php b/src/web/_controller/bucket.php
new file mode 100644
index 0000000..ed15ef8
--- /dev/null
+++ b/src/web/_controller/bucket.php
@@ -0,0 +1,22 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Bucket_controller extends Controller {
+
+ private $bucket_model;
+
+ function __construct($load) {
+ parent::__construct($load);
+ $this->bucket_model = $this->load->model('bucket');
+ }
+
+ public function index(): void {
+ parent::index();
+ $data = $this->bucket_model->get_data();
+ if ($data === NULL) {
+ $this->error(400);
+ return;
+ }
+ $this->view('apps/bucket', $data);
+ }
+}
+
+?>
diff --git a/src/web/_controller/error.php b/src/web/_controller/error.php
new file mode 100644
index 0000000..d24308b
--- /dev/null
+++ b/src/web/_controller/error.php
@@ -0,0 +1,21 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Error_controller extends Controller {
+
+ private $error_model;
+
+ function __construct($load) {
+ parent::__construct($load);
+ $this->error_model = $this->load->model('error');
+ }
+
+ public function index(): void {
+ parent::index();
+ $data = $this->error_model->get_data();
+ $this->view('header', $data);
+ $this->view('apps/error', $data);
+ $this->view('footer', $data);
+ }
+
+}
+
+?>
diff --git a/src/web/_controller/home.php b/src/web/_controller/home.php
new file mode 100644
index 0000000..12dff64
--- /dev/null
+++ b/src/web/_controller/home.php
@@ -0,0 +1,17 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Home_controller extends Controller {
+ function __construct($load) {
+ parent::__construct($load);
+ }
+
+ public function index(): void {
+ parent::index();
+ $data = $this->main->get_data();
+ $this->view('header', $data);
+ $this->view('apps/home', $data);
+ $this->view('footer', $data);
+ }
+
+}
+
+?>
diff --git a/src/web/_controller/projects.php b/src/web/_controller/projects.php
new file mode 100644
index 0000000..9ee2136
--- /dev/null
+++ b/src/web/_controller/projects.php
@@ -0,0 +1,21 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Projects_controller extends Controller {
+
+ private $projects_model;
+
+ function __construct($load) {
+ parent::__construct($load);
+ $this->projects_model = $this->load->model('projects');
+ }
+
+ public function index(): void {
+ parent::index();
+ $data = $this->projects_model->get_data();
+ $this->view('header', $data);
+ $this->view('apps/projects', $data);
+ $this->view('footer', $data);
+ }
+
+}
+
+?>
diff --git a/src/web/_model/_comments.php b/src/web/_model/_comments.php
new file mode 100644
index 0000000..73c1fc7
--- /dev/null
+++ b/src/web/_model/_comments.php
@@ -0,0 +1,66 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class _comments_model extends Model {
+
+ function __construct($load) {
+ parent::__construct($load);
+ }
+
+ private function load_profanity() {
+ $path = $GLOBALS['assetroot'] . '/profanity.txt';
+ $str = file_get_contents($path);
+ $lines = explode("\n", $str);
+
+ $regex = '/(';
+ foreach ($lines as $idx => $line) {
+ if ($line == '') {
+ continue;
+ }
+ if ($idx != 0) {
+ $regex .= '|';
+ }
+ $regex .= $line;
+ }
+ $regex .= ')/';
+
+ return $regex;
+ }
+
+ public function is_vulgar($text) {
+ $profanity = $this->load_profanity();
+ return preg_match($profanity, $text);
+ }
+
+ public function get_comments($page) {
+ $ip = $this->main->info['ip'];
+ $query = $this->db
+ ->select('*')
+ ->from('admin.comment c')
+ ->where('c.page')
+ ->eq($page)
+ ->query('AND (
+ (c.vulgar IS FALSE) OR
+ (c.vulgar IS TRUE and c.ip = ?)
+ )')
+ ->order_by('c.id', 'DESC');
+ $result = $query->rows($ip);
+ return $result;
+ }
+
+ public function ban_user() {
+ $ip = $this->main->info['ip'];
+ $this->db
+ ->insert_into('admin.banned', 'ip', 'reason')
+ ->values($ip, 'vulgar language')
+ ->execute();
+ }
+
+ public function post_comment($author, $content, $page, $vulgar) {
+ $ip = $this->main->info['ip'];
+ return $this->db
+ ->insert_into('admin.comment',
+ 'author', 'content', 'page', 'ip', 'vulgar')
+ ->values($author, $content, $page, $ip, $vulgar)
+ ->execute();
+ }
+
+}
diff --git a/src/web/_model/blog.php b/src/web/_model/blog.php
new file mode 100644
index 0000000..42cee97
--- /dev/null
+++ b/src/web/_model/blog.php
@@ -0,0 +1,80 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Blog_model extends Model {
+
+ private $markdown;
+
+ function __construct($load) {
+ parent::__construct($load);
+ $this->markdown = new MarkdownParser();
+ }
+
+ private function load_blog(&$data) {
+ $blog = array();
+ $dir = $GLOBALS['assetroot'] . '/blog';
+ if ($handle = opendir($dir)) {
+ while (false !== ($entry = readdir($handle))) {
+ if (str_starts_with($entry, ".")) {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ $md = $this->markdown->parse($path);
+ $blog[$entry] = $md;
+ }
+ }
+ krsort($blog);
+ $data['blog'] = $blog;
+ }
+
+ public function get_data(): ?array {
+ $data = parent::get_data();
+ $this->load_blog($data);
+ $data['title'] = lang('title');
+ $data['desc'] = lang('blog_short_desc');
+ return $data;
+ }
+
+ private function load_post($name) {
+ $dir = $GLOBALS['assetroot'] . '/blog';
+ $path = $dir . '/' . $name;
+ if(!file_exists($path)) {
+ return FALSE;
+ }
+ $md = $this->markdown->parse($path);
+ return $md;
+ }
+
+ public function get_post($name) {
+ $data = parent::get_data();
+ $post = $this->load_post($name);
+ if (!$post) {
+ return FALSE;
+ }
+ $data['title'] = $post['meta']['name'];
+ $data['desc'] = $post['meta']['desc'];
+ $data['post'] = $post;
+ return $data;
+ }
+
+ private function load_writeup($name) {
+ $dir = $GLOBALS['assetroot'] . '/writeup';
+ $path = $dir . '/' . $name;
+ if(!file_exists($path)) {
+ return FALSE;
+ }
+ $md = $this->markdown->parse($path);
+ return $md;
+ }
+
+ public function get_writeup($name) {
+ $data = parent::get_data();
+ $writeup = $this->load_writeup($name);
+ if (!$writeup) {
+ return FALSE;
+ }
+ $data['title'] = $writeup['meta']['name'];
+ $data['desc'] = $writeup['meta']['desc'];
+ $data['post'] = $writeup;
+ return $data;
+ }
+}
+?>
diff --git a/src/web/_model/bucket.php b/src/web/_model/bucket.php
new file mode 100644
index 0000000..f38bebe
--- /dev/null
+++ b/src/web/_model/bucket.php
@@ -0,0 +1,26 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Bucket_model extends Model {
+
+ function __construct($load) {
+ parent::__construct($load);
+ }
+
+ public function get_data(): ?array {
+ $data = parent::get_data();
+
+ if (array_key_exists('name', $_GET)) {
+ $data['name'] = $_GET['name'];
+ } else {
+ return NULL;
+ }
+
+ if (array_key_exists('lightmode', $_GET)) {
+ $data['lightmode'] = $_GET['lightmode'];
+ } else {
+ $data['lightmode'] = 'false';
+ }
+
+ return $data;
+ }
+}
+?>
diff --git a/src/web/_model/error.php b/src/web/_model/error.php
new file mode 100644
index 0000000..0a08fdd
--- /dev/null
+++ b/src/web/_model/error.php
@@ -0,0 +1,31 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Error_model extends Model {
+
+ function __construct($load) {
+ parent::__construct($load);
+ }
+
+ private function get_msg(&$data) {
+ if (!array_key_exists('code', $_GET)) {
+ http_response_code(500);
+ $data['msg'] = ucfirst(lang('error'));
+ $data['title'] = '500';
+ } else {
+ $code = $_GET['code'];
+ http_response_code($code);
+ $data['title'] = $code;
+ $msg = ucfirst(lang('error_' . $code, FALSE));
+ if (!$msg) {
+ $msg = ucfirst(lang('error'));
+ }
+ $data['msg'] = $msg;
+ }
+ }
+
+ public function get_data(): ?array {
+ $data = parent::get_data();
+ $this->get_msg($data);
+ return $data;
+ }
+}
+?>
diff --git a/src/web/_model/main.php b/src/web/_model/main.php
new file mode 100644
index 0000000..6767010
--- /dev/null
+++ b/src/web/_model/main.php
@@ -0,0 +1,97 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Main_model extends Model {
+
+ // stores the current request info
+ public $info;
+
+ // the main loader
+ public $load;
+
+ /**
+ * Loads the main model
+ * @param Loader $load - the main loader object
+ */
+ function __construct($load) {
+ parent::__construct($load, TRUE);
+ $GLOBALS['main_model'] = $this;
+ }
+
+ /**
+ * Gets the stamp for a asset path
+ * @param string $path
+ */
+ private function asset_stamp($path): int {
+ $root = $GLOBALS['webroot'];
+ $path = $root . '/../public/' . $path;
+ return filemtime($path);
+ }
+
+ /**
+ * Get the current IE version
+ * @returns the IE version if valid IE user agent, INT_MAX if not
+ */
+ public function get_ie_version(): int {
+ if (preg_match('/MSIE\s(?P<v>\d+)/i', @$_SERVER['HTTP_USER_AGENT'], $B)) {
+ return $B['v'];
+ } else {
+ return PHP_INT_MAX;
+ }
+ }
+
+ /**
+ * Gets the full url including the http scheme and host part
+ * Needed for IE 6 & 7 need.
+ */
+ public function get_url_full($path): string {
+ $host = $_SERVER['HTTP_HOST'];
+ $base = lang('base_path');
+ $url = "http://{$host}{$base}{$path}";
+ return $url;
+ }
+
+ /**
+ * Gets a full path url from a relative path
+ */
+ public function get_url($path): string {
+ if ($this->get_ie_version() <= 7) {
+ return $this->get_url_full($path);
+ }
+ $base = lang('base_path');
+ $url = "{$base}{$path}";
+ return $url;
+ }
+
+ /**
+ * Loads a css html link
+ * @param string $path - the path to the css file
+ */
+ public function link_css($path): string {
+ $stamp = $this->asset_stamp($path);
+ $href = $this->get_url("public/{$path}?stamp={$stamp}");
+ return '<link rel="stylesheet" href="'. $href .'">';
+ }
+
+ /**
+ * Loads a css html link
+ * @param string $path - the path to the css file
+ */
+ public function embed_css($path): string {
+ $file = $GLOBALS['publicroot'] . '/' . $path;
+ if (file_exists($file)) {
+ $text = file_get_contents($file);
+ return "<style>{$text}</style>";
+ } else {
+ return "";
+ }
+ }
+
+ /**
+ * Formats a ISO date
+ * @param $iso_date the ISO date
+ */
+ public function format_date($iso_date): string {
+ return date("Y-m-d D H:m", strtotime($iso_date));
+ }
+}
+
+?>
diff --git a/src/web/_model/projects.php b/src/web/_model/projects.php
new file mode 100644
index 0000000..5373a78
--- /dev/null
+++ b/src/web/_model/projects.php
@@ -0,0 +1,36 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Projects_model extends Model {
+
+ private $markdown;
+
+ function __construct($load) {
+ parent::__construct($load);
+ $this->markdown = new MarkdownParser();
+ }
+
+ private function load_projects(&$data) {
+ $projects = array();
+ $dir = $GLOBALS['assetroot'] . '/projects';
+ if ($handle = opendir($dir)) {
+ while (false !== ($entry = readdir($handle))) {
+ if (str_starts_with($entry, ".")) {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ $md = $this->markdown->parse($path);
+ $projects[$entry] = $md;
+ }
+ }
+ krsort($projects);
+ $data['projects'] = $projects;
+ }
+
+ public function get_data(): ?array {
+ $data = parent::get_data();
+ $this->load_projects($data);
+ $data['title'] = lang('title');
+ $data['desc'] = lang('short_desc');
+ return $data;
+ }
+}
+?>
diff --git a/src/web/_views/apps/blog.php b/src/web/_views/apps/blog.php
new file mode 100644
index 0000000..e8908e6
--- /dev/null
+++ b/src/web/_views/apps/blog.php
@@ -0,0 +1,13 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<?=aria_section('blog', lang('title'))?>
+ <p><?=lang('blog_desc')?></p>
+ <?php
+ foreach($blog as $name => $post) {
+ $meta = $post['meta'];
+ $link = $this->main->get_url('blog/post?name=' . $name);
+ echo '<a href="' . $link . '"><h3>' . $meta['name'] . '</h3></a>';
+ echo '<span>' . $meta['desc'] . '</span><br>';
+ echo '<span><time>' . $this->main->format_date($meta['date']) . '</time></span>';
+ }
+ ?>
+</div>
diff --git a/src/web/_views/apps/blog_post.php b/src/web/_views/apps/blog_post.php
new file mode 100644
index 0000000..d5ad255
--- /dev/null
+++ b/src/web/_views/apps/blog_post.php
@@ -0,0 +1,6 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<?=aria_section('post', $post['meta']['name'])?>
+ <span><?=ucfirst(lang('posted'))?>: <time><?=$this->main->format_date($post['meta']['date'])?></time></span>
+ <br>
+ <?=$post['content']?>
+</div>
diff --git a/src/web/_views/apps/blog_rss.php b/src/web/_views/apps/blog_rss.php
new file mode 100644
index 0000000..1d0dcd4
--- /dev/null
+++ b/src/web/_views/apps/blog_rss.php
@@ -0,0 +1,20 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<rss version="2.0">
+ <channel>
+ <title><?=lang('title')?></title>
+ <link><?=lang('root_url') . '/blog'?></link>
+ <description><?=lang('blog_short_desc')?></description>
+ <language><?=lang('lang_short')?></language>
+ <?php
+ foreach ($blog as $name => $post) {
+ echo '<item>';
+ echo '<title>' . $post['meta']['name'] . '</title>';
+ echo '<description>' . $post['meta']['desc'] . '</description>';
+ echo '<pubDate>' . $post['meta']['date'] . '</pubDate>';
+ echo '<link>' . lang('root_url') . '/blog/post?name=' . $name . '</link>';
+ echo '<guid>' . lang('root_url') . '/blog/post?name=' . $name . '</guid>';
+ echo '</item>';
+ }
+ ?>
+ </channel>
+</rss>
diff --git a/src/web/_views/apps/blog_writeup.php b/src/web/_views/apps/blog_writeup.php
new file mode 100644
index 0000000..b0f18e7
--- /dev/null
+++ b/src/web/_views/apps/blog_writeup.php
@@ -0,0 +1,5 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<?=aria_section('writeup', $post['meta']['name'])?>
+ <br>
+ <?=$post['content']?>
+</div>
diff --git a/src/web/_views/apps/bucket.php b/src/web/_views/apps/bucket.php
new file mode 100644
index 0000000..58925f1
--- /dev/null
+++ b/src/web/_views/apps/bucket.php
@@ -0,0 +1,41 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+ <?php
+ $root='https://webring.bucketfish.me/redirect.html?to=%s&name=' . $name;
+ $this->view('head', $data);
+ if ($lightmode === 'true') {
+ echo $this->main->link_css('css/bucket_light.css');
+ } else {
+ echo $this->main->link_css('css/bucket.css');
+ }
+ ?>
+ </head>
+ <body>
+ <div id="webring">
+ <center>
+ <span class="center">
+ ๐Ÿณ๏ธโ€๐ŸŒˆ
+ <a href="https://webring.bucketfish.me" class="header">
+ <span class="e0">b</span><!--
+ --><span class="e1">u</span><!--
+ --><span class="e2">c</span><!--
+ --><span class="e3">k</span><!--
+ --><span class="e4">e</span><!--
+ --><span class="e5">t</span><!--
+ --> <!--
+ --><span class="e6">w</span><!--
+ --><span class="e7">e</span><!--
+ --><span class="e8">b</span><!--
+ --><span class="e9">r</span><!--
+ --><span class="e10">i</span><!--
+ --><span class="e11">n</span><!--
+ --><span class="e12">g</span>
+ </a>
+ ๐Ÿณ๏ธโ€๐ŸŒˆ
+ </span>
+ </center>
+ <span class="left">โฅผ <a href="<?=sprintf($root, 'prev')?>" class="prev">prev</a></span>
+ <span class="right"><a href="<?=sprintf($root, 'next')?>" class="next">next</a> โฅฝ</span>
+ </div>
+ </body>
+</html>
+
diff --git a/src/web/_views/apps/error.php b/src/web/_views/apps/error.php
new file mode 100644
index 0000000..efe7546
--- /dev/null
+++ b/src/web/_views/apps/error.php
@@ -0,0 +1,6 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<?=aria_section('error')?>
+ <h2><?=lang('haa_haa_hee_hee_hoo_hoo')?></h2>
+ <h1><?=$title?></h1>
+ <h2><?=ucfirst($msg)?></h2>
+</div>
diff --git a/src/web/_views/apps/home.php b/src/web/_views/apps/home.php
new file mode 100644
index 0000000..8e59423
--- /dev/null
+++ b/src/web/_views/apps/home.php
@@ -0,0 +1,55 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<div class="col left">
+ <?=aria_section('about', lang('about'))?>
+ <p><?=lang('section_about')?></p>
+ </div>
+
+ <?=aria_section('whats_new', lang('whats_new'))?>
+ <p><?=lang('section_whats_new')?></p>
+ </div>
+</div>
+
+<div class="col right">
+ <?=aria_section('interests', lang('interests'))?>
+ <table>
+ <tbody>
+ <tr>
+ <th><?=lang('interests_general')?></th>
+ <td><?=lang('interests_general_value')?></td>
+ </tr>
+ <tr>
+ <th><?=lang('interests_music')?></th>
+ <td><?=lang('interests_music_value')?></td>
+ </tr>
+ <tr>
+ <th><?=lang('interests_comics')?></th>
+ <td><?=lang('interests_comics_value')?></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <?=aria_section('contact', lang('contact'))?>
+ <table>
+ <tbody>
+ <tr>
+ <th><?=lang('contact_email')?></th>
+ <td><a href="mailto:contact@freyacat.org">contact@freyacat.org</a></td>
+ </tr>
+ <tr>
+ <th><?=lang('contact_matrix')?></th>
+ <td><a href="https://matrix.to/#/@freya:freya.cat">@freya:freya.cat</a></td>
+ </tr>
+ <tr>
+ <th><?=lang('contact_xmpp')?></th>
+ <td><a href="xmpp:freya@freya.cat">freya@freya.cat</a></td>
+ </tr>
+ <tr>
+ <th><?=lang('contact_mastodon')?></th>
+ <td><a href="https://social.freya.cat/@freya">@freya@freya.cat</a></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</div>
+
diff --git a/src/web/_views/apps/projects.php b/src/web/_views/apps/projects.php
new file mode 100644
index 0000000..bac1004
--- /dev/null
+++ b/src/web/_views/apps/projects.php
@@ -0,0 +1,11 @@
+<?=aria_section('projects', lang('title'))?>
+ <?php
+ foreach($projects as $project) {
+ $meta = $project['meta'];
+ $content = $project['content'];
+ $link = lang('git_url') . '/' . $meta['repo'];
+ echo '<a href="' . $link . '"><h3>' . $meta['name'] . '</h3></a>';
+ echo $content;
+ }
+ ?>
+</div>
diff --git a/src/web/_views/comments.php b/src/web/_views/comments.php
new file mode 100644
index 0000000..d72afd6
--- /dev/null
+++ b/src/web/_views/comments.php
@@ -0,0 +1,51 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<?=aria_section('comments', lang('comments'))?>
+ <?php
+ foreach($comments as $comment) {
+ $date = date_create($comment['created']);
+ $date = date_format($date, "Y-m-d H:i");
+
+ echo '<div class="comment">';
+ echo '<h3 class="header">' . esc($comment['author']) . '</h3>';
+ echo '<span class="date">' . $date . '</span>';
+ echo '<p class="content">' . esc($comment['content']) . '</p>';
+ echo '</div>';
+ }
+ if (!count($comments)) {
+ echo '<span>'. lang('no_comments') .'</span>';
+ }
+ ?>
+ <div class="new">
+ <h3><?=lang('new_comment_title')?></h3>
+ <form id="new_comment" method="get" action="<?=lang('base_path') . '_comments/post'?>">
+ <input
+ type="text"
+ name="author"
+ id="author"
+ aria-label="<?=lang('new_comment_author_label')?>"
+ placeholder="<?=lang('new_comment_author_ph')?>">
+ <input
+ type="text"
+ name="content"
+ id="content"
+ aria-label="<?=lang('new_comment_content_label')?>"
+ placeholder="<?=lang('new_comment_content_ph')?>">
+ <input
+ type="hidden"
+ class="hidden"
+ name="ref"
+ value="<?=base64_encode($ref)?>">
+ <input
+ type="hidden"
+ class="hidden"
+ name="page"
+ value="<?=$page?>">
+ <input
+ type="submit"
+ role="button"
+ id="submit"
+ value="<?=lang('new_comment_submit_text')?>">
+ </form>
+ </div>
+</div>
+
diff --git a/src/web/_views/footer.php b/src/web/_views/footer.php
new file mode 100644
index 0000000..1eac625
--- /dev/null
+++ b/src/web/_views/footer.php
@@ -0,0 +1,51 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<?php
+ $footer_text = lang('footer_text', NULL);
+ if ($footer_text) {
+ $footer_text = $footer_text[array_rand($footer_text)];
+ } else {
+ $footer_text = '';
+ }
+
+ $legacy = $this->main->get_ie_version() <= 7;
+?>
+ </div>
+ </div>
+ <div id="footer" role="contentinfo" aria-label="footer">
+ <?=lang('license_pre')?>
+ <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>
+ <br>
+ <?=lang('copyright')?> <?=lang('first_name')?> <?=lang('last_name')?> <?=date('Y')?>
+ <br>
+ <span class="footer-text">
+ <?=$footer_text?>
+ </span>
+ <div class="buttons">
+ <?=image('buttons/eyes', 'alt_button_eyes', animated: TRUE, width: '88', height: '30')?>
+ <?=image('buttons/vim', 'alt_button_vim', animated: TRUE, width: '88', height: '30')?>
+ <?=image('buttons/gnu-linux', 'alt_button_gnu_linux', width: '88', height: '30')?>
+ <a href="https://citrons.xyz/a/memetic-apioform-page.html">
+ <?=image('buttons/apiopage', 'alt_button_apiopage', width: '81', height: '30')?>
+ </a>
+ </div>
+ <br>
+ <iframe
+ height="94"
+ class="john"
+ title="<?=lang('john_title')?>"
+ src="https://john.citrons.xyz/embed?ref=freya.cat"
+ ></iframe>
+ <iframe
+ height="40"
+ class="bucket"
+ title="<?=lang('bucket_title')?>"
+ src="<?=$this->main->get_url('bucket?name=freya')?>"
+ ></iframe>
+ </div>
+<?php if($legacy): ?>
+ </center>
+<?php else: ?>
+ </div>
+<?php endif; ?>
+ </body>
+</html>
diff --git a/src/web/_views/head.php b/src/web/_views/head.php
new file mode 100644
index 0000000..e30e05c
--- /dev/null
+++ b/src/web/_views/head.php
@@ -0,0 +1,29 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<!DOCTYPE html>
+<html lang="<?=lang('lang_short')?>">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="author" content="freya">
+ <meta name="description" content="<?=$desc?>">
+ <meta name="theme-color" content="<?=lang('theme_color')?>">
+ <meta name="referrer" content="origin">
+ <meta name="color-scheme" content="none">
+ <meta property="og:description" content="<?=$desc?>">
+ <meta property="og:title" content="<?=$title?>">
+ <meta property="og:site_name" content="<?=lang('domain')?>">
+ <meta property="og:image" content="<?=$this->main->get_url_full('public/icons/logo640.png')?>">
+ <title><?=$title?></title>
+ <link rel="icon" type="image/png" sizes="16x16" href="/public/icons/logo16.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/public/icons/logo32.png">
+ <link rel="icon" type="image/png" sizes="64x64" href="/public/icons/logo64.png">
+ <link rel="icon" type="image/png" sizes="320x320" href="/public/icons/logo320.png">
+ <link rel="icon" type="image/png" sizes="512x512" href="/public/icons/logo512.png">
+ <link rel="icon" type="image/png" sizes="640x640" href="/public/icons/logo640.png">
+ <link rel="manifest" href="/manifest.json">
+ <?php if($this->main->get_ie_version() <= 7)
+ echo $this->main->link_css('css/legacy.css');
+ ?>
+ <?php foreach($css as $file)
+ echo $this->main->embed_css($file);
+ ?>
diff --git a/src/web/_views/header.php b/src/web/_views/header.php
new file mode 100644
index 0000000..b037038
--- /dev/null
+++ b/src/web/_views/header.php
@@ -0,0 +1,43 @@
+<?php /* Copyright (c) 2024 Freya Murphy */ ?>
+<?php
+ $this->view('head', $data);
+ echo $this->main->link_css('css/main.css');
+ $legacy = $this->main->get_ie_version() <= 7;
+?>
+</head>
+<body>
+<?php if($legacy): ?>
+<center>
+<?php else: ?>
+<div class="center">
+<?php endif; ?>
+ <div id="header" role="banner" aria-label="banner">
+ <?=image('img/headerLogo', 'alt_website_logo', size: '200')?>
+ <div class="content">
+ <h1 class="logo-text">
+ <?=lang('first_name')?>
+ </h1>
+ <div role="navigation">
+ <ul id="nav">
+ <li><?=ilang('action_home',
+ href: $this->main->get_url('home'),
+ container: 'h2'
+ )?></li>
+ <li><?=ilang('action_projects',
+ href: $this->main->get_url('projects'),
+ container: 'h2'
+ )?></li>
+ <li><?=ilang('action_blog',
+ href: $this->main->get_url('blog'),
+ container: 'h2'
+ )?></li>
+ </ul>
+ </div>
+ </div>
+ </div>
+<?php if($legacy): ?>
+ <div id="main" class="legacy" role="main">
+<?php else: ?>
+ <div id="main" role="main" aria-label="main">
+<?php endif; ?>
+ <div id="container">
diff --git a/src/web/config/routes.php b/src/web/config/routes.php
new file mode 100644
index 0000000..cb78a72
--- /dev/null
+++ b/src/web/config/routes.php
@@ -0,0 +1,10 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+$routes = array();
+$routes[''] = 'home';
+
+$routes['robots.txt'] = '_meta/robots';
+$routes['sitemap.xml'] = '_meta/sitemap';
+$routes['manifest.json'] = '_meta/manifest';
+
+$serviceable = array('bucket');
diff --git a/src/web/config/style.php b/src/web/config/style.php
new file mode 100644
index 0000000..6b29fab
--- /dev/null
+++ b/src/web/config/style.php
@@ -0,0 +1,7 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+$style = array();
+
+$style['home'] = 'css/home.css';
+$style['blog'] = 'css/blog.css';
+$style['error'] = 'css/error.css';
diff --git a/src/web/core/_controller.php b/src/web/core/_controller.php
new file mode 100644
index 0000000..0dbb5b8
--- /dev/null
+++ b/src/web/core/_controller.php
@@ -0,0 +1,51 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+abstract class Controller {
+
+ // the main model
+ public $main;
+
+ // the loader
+ public $load;
+
+ /**
+ * Creates a constructor
+ * @param Loader $load - the website loaded object
+ */
+ function __construct($load) {
+ $this->load = $load;
+ $this->main = $this->load->model('main');
+
+ $this->load->lang();
+ $info = $this->main->info;
+ $app = $info['app'];
+ if ($app) {
+ $this->load->app_lang($app);
+ }
+ }
+
+ public function index() {}
+
+ public function redirect($link) {
+ header('Location: '. $link, true, 301);
+ die();
+ }
+
+ protected function view($__name, $data = array()) {
+ $__root = $GLOBALS['webroot'];
+ $__path = $__root . '/_views/' . $__name . '.php';
+ if (is_file($__path)) {
+ extract($data);
+ require($__path);
+ return;
+ }
+ }
+
+ protected function error($code): void {
+ $_GET['code'] = $code;
+ $this->main->info['app'] = 'error';
+ $error_controller = $this->load->controller('error');
+ $error_controller->index();
+ die();
+ }
+
+}
diff --git a/src/web/core/_model.php b/src/web/core/_model.php
new file mode 100644
index 0000000..4c27b1b
--- /dev/null
+++ b/src/web/core/_model.php
@@ -0,0 +1,50 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+abstract class Model {
+ // the main model
+ // shared by all controllers and models
+ public $main;
+ public $load;
+
+ // the database
+ public $db;
+
+ private $config;
+
+ /**
+ * Creates a model
+ * @param Loader $load - the main loader object
+ */
+ function __construct($load, $main = FALSE) {
+ $this->load = $load;
+ if ($main) {
+ $this->main = $this;
+ } else {
+ $this->main = $this->load->model('main');
+ }
+ $this->db = $this->load->db();
+ }
+
+ /**
+ * @returns the base model data
+ */
+ public function get_data(): ?array {
+ $data = array();
+
+ $info = $this->main->info;
+ $app = $info['app'];
+
+ $data['title'] = lang('first_name');
+ $data['desc'] = lang('default_short_desc');
+ $data['css'] = array();
+
+ $style = $GLOBALS['style'];
+ if (isset($style[$app])) {
+ $css = $style[$app];
+ if (!is_array($css))
+ $css = array($css);
+ $data['css'] = $css;
+ }
+
+ return $data;
+ }
+}
diff --git a/src/web/core/loader.php b/src/web/core/loader.php
new file mode 100644
index 0000000..b0a1cbd
--- /dev/null
+++ b/src/web/core/loader.php
@@ -0,0 +1,111 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Loader {
+
+ // keep track of what has been loaded
+ private $loaded;
+
+ // the database
+ private $db;
+
+ function __construct() {
+ $this->loaded = array();
+ }
+
+ /**
+ * Loads a $type of object from a $dir with a given $name
+ * @param string $name - the name of the object to load
+ * @param string $dir - the directory theese objects are stored in
+ * @param string $type - the type of the object
+ */
+ private function load_type($name, $dir, $type): object|NULL {
+ $path = $dir . '/' . $name . '.php';
+ if (array_key_exists($path, $this->loaded)) {
+ return $this->loaded[$path];
+ }
+
+ if (!file_exists($path)) {
+ return NULL;
+ }
+
+ $parts = explode('/', $name);
+ $part = end($parts);
+ $class = ucfirst($part) . '_' . $type;
+ require($path);
+
+ $ref = NULL;
+ try {
+ $ref = new ReflectionClass($class);
+ } catch (Exception $_e) {}
+
+ if ($ref === NULL) {
+ return NULL;
+ }
+
+ $obj = $ref->newInstance($this);
+ $this->loaded[$path] = $obj;
+
+ return $obj;
+ }
+
+ /**
+ * Loads a model
+ * @param string $name - the name of the model to load
+ */
+ public function model($name): object|NULL {
+ $root = $GLOBALS['webroot'];
+ $dir = $root . '/_model';
+ return $this->load_type($name, $dir, 'model');
+ }
+
+ /**
+ * Loads a controller
+ * @param string $name - the name of the controller to load
+ */
+ public function controller($name): Controller|NULL {
+ $root = $GLOBALS['webroot'];
+ $dir = $root . '/_controller';
+ return $this->load_type($name, $dir, 'controller');
+ }
+
+ /**
+ * Loads the given common lang
+ */
+ public function lang(): void {
+ $dir = $GLOBALS['webroot'] . '/lang/';
+ $lang = $GLOBALS['lang'];
+ if ($handle = opendir($dir)) {
+ while (false !== ($entry = readdir($handle))) {
+ if ($entry === '.' || $entry === '..' || $entry === 'apps') {
+ continue;
+ }
+ $path = $dir . $entry;
+ require($path);
+ }
+ }
+ $GLOBALS['lang'] = $lang;
+ }
+
+ /**
+ * Loads a given app specific lang
+ * @param string $name - the name of the app
+ */
+ public function app_lang($name): void {
+ $dir = $GLOBALS['webroot'] . '/lang/apps/';
+ $file = $dir . $name . '.php';
+ if (file_exists($file)) {
+ $lang = $GLOBALS['lang'];
+ require($dir . $name . '.php');
+ $GLOBALS['lang'] = $lang;
+ }
+ }
+
+ public function db() {
+ if ($this->db) {
+ return $this->db;
+ } else {
+ $this->db = new DatabaseHelper();
+ return $this->db;
+ }
+ }
+
+}
diff --git a/src/web/core/router.php b/src/web/core/router.php
new file mode 100644
index 0000000..c8fb142
--- /dev/null
+++ b/src/web/core/router.php
@@ -0,0 +1,248 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+class Router {
+
+ // the loader
+ private $load;
+
+ // the main model
+ private $main;
+
+ // the database
+ private $db;
+
+ private $db_ready;
+
+ /**
+ * Creates a router
+ * @param Loader $load - the main laoder object
+ */
+ function __construct($load) {
+ $this->load = $load;
+ $this->db = $load->db();
+ $this->main = $this->load->model('main');
+ $this->db_ready = file_exists('/status/ready');
+ }
+
+ /**
+ * @param string $path - the current request path
+ * Gets the current route
+ * @return array<string,mixed>
+ */
+ private function get_req_route($path): array {
+ // trim the path
+ $path = trim($path);
+ // remove first '/'
+ $path = substr($path, 1);
+ // get path parts
+ $parts = explode('/', $path);
+
+ $len = count($parts);
+
+ // get route info
+ $route = array();
+ // e.g. /
+ if ($path === '') {
+ $route = array(
+ 'app' => '',
+ 'slug' => 'index',
+ );
+ // e.g. /home /login
+ } else if ($len === 1) {
+ $route = array(
+ 'app' => $parts[0],
+ 'slug' => 'index',
+ );
+ // e.g. /home/posts
+ } else {
+ $route = array (
+ 'app' => implode('/', array_slice($parts, 0, -1)),
+ 'slug' => end($parts)
+ );
+ };
+
+ $routes = $GLOBALS['routes'];
+ if (array_key_exists($route['app'], $routes)) {
+ $parts = explode('/', $routes[$route['app']]);
+ if (count($parts) == 1) {
+ $route['app'] = $parts[0];
+ } else {
+ $route['app'] = $parts[0];
+ $route['slug'] = $parts[1];
+ }
+ }
+
+ return $route;
+ }
+
+ /**
+ * Gets the users ip
+ */
+ private function get_ip(): string {
+ $ip = '';
+ if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
+ $ip = $_SERVER['HTTP_CLIENT_IP'];
+ } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+ $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
+ } else {
+ $ip = $_SERVER['REMOTE_ADDR'];
+ }
+ return $ip;
+ }
+
+ /**
+ * Gets the curret request info
+ * @return array<string,mixed>
+ */
+ private function get_req(): array|bool {
+ $method = $_SERVER['REQUEST_METHOD'];
+
+ $uri_str = $_SERVER['REQUEST_URI'];
+ $uri = parse_url($uri_str);
+ if (!$uri) {
+ return FALSE;
+ }
+
+ $path = '';
+ if (array_key_exists('path', $uri)) {
+ $path = $uri['path'];
+ }
+
+ return array_merge(
+ array(
+ 'uri' => $uri,
+ 'uri_str' => $uri_str,
+ 'method' => $method,
+ 'ip' => $this->get_ip()
+ ),
+ $this->get_req_route($path),
+ );
+ }
+
+ /**
+ * Handles a router error code
+ * @param int $code - the http error code
+ * @param bool $recursed
+ */
+ private function handle_error($code, $recursed): void {
+ if ($recursed) {
+ die($code . ' (recursed)');
+ }
+ $uri_str = $_SERVER['REQUEST_URI'];
+ $req = array();
+ $req['slug'] = 'index';
+ $req['app'] = 'error';
+ $req['uri_str'] = $uri_str;
+ $this->main->info = $req;
+ $_GET['code'] = $code;
+ $this->handle_req($req, TRUE);
+ }
+
+ private function load_htc($req, $recursed): void {
+ $parts = explode('/', $req['uri_str']);
+ $file = end($parts);
+ $path = $GLOBALS['publicroot'] . '/polyfills/' . $file;
+
+ if (file_exists($path)) {
+ header('Content-type: text/x-component');
+ include($path);
+ } else {
+ $this->handle_error(400, $recursed);
+ }
+ }
+
+ /**
+ * @param array $req
+ * @param bool $recursed
+ */
+ private function handle_req($req, $recursed = FALSE): void {
+
+ if ($recursed === false) {
+ if (
+ $this->db_ready === false &&
+ in_array($req['app'], $GLOBALS['serviceable']) === false
+ ) {
+ $this->handle_error(503, $recursed);
+ return;
+ }
+
+ if ($this->check_banned($req)) {
+ $this->handle_error(401, $recursed);
+ return;
+ }
+ }
+
+ if (!$req) {
+ $this->handle_error(500, $recursed);
+ return;
+ }
+
+ if (str_ends_with($req['uri_str'], '.htc')) {
+ $this->load_htc($req, $recursed);
+ return;
+ }
+
+ $controller = $this->load->controller($req['app']);
+
+ if ($controller === NULL) {
+ $this->handle_error(404, $recursed);
+ return;
+ }
+
+ $ref = NULL;
+ try {
+ $ref = new ReflectionMethod($controller, $req['slug']);
+ } catch (Exception $_e) {}
+
+ if ($ref === NULL || !$ref->isPublic()) {
+ $this->handle_error(404, $recursed);
+ return;
+
+ }
+
+ $ref->invoke($controller);
+ }
+
+ private function log_request($req): void {
+ if (
+ $req === FALSE ||
+ $this->db_ready === FALSE ||
+ in_array($req['app'], $GLOBALS['serviceable'])
+ ) {
+ return;
+ }
+
+ $query = $this->db
+ ->insert_into('admin.request_log',
+ 'ip', 'method', 'uri')
+ ->values(
+ $req['ip'], $req['method'], $req['uri_str']);
+
+ $query->execute();
+ }
+
+ private function check_banned($req) {
+ $ip = FALSE;
+ if ($req) {
+ $ip = $req['ip'];
+ } else {
+ $ip = $this->get_ip();
+ }
+ $query = $this->db
+ ->select('TRUE')
+ ->from('admin.banned')
+ ->where('ip')->eq($ip);
+
+ return !!($query->row());
+ }
+
+ /**
+ * Handels the incomming reuqest
+ */
+ public function handle_request(): void {
+ $req = $this->get_req();
+ $this->log_request($req);
+ $this->main->info = $req;
+ $this->handle_req($req);
+ }
+
+}
diff --git a/src/web/helpers/aria.php b/src/web/helpers/aria.php
new file mode 100644
index 0000000..8ebfcc5
--- /dev/null
+++ b/src/web/helpers/aria.php
@@ -0,0 +1,16 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+function aria_section($id, $title = NULL): string {
+ $out = '';
+ if ($title) {
+ $idh = $id . '_heading';
+ $out .= sprintf('<div id="%s" class="section" role="region" aria-labelledby="%s">',
+ $id, $idh);
+ $out .= sprintf('<h2 class="heading" id="%s">%s</h2>',
+ $idh, $title);
+ } else {
+ $out .= sprintf('<div id="%s" class="section" role="region">',
+ $id);
+ }
+ return $out;
+}
diff --git a/src/web/helpers/database.php b/src/web/helpers/database.php
new file mode 100644
index 0000000..25cb5ba
--- /dev/null
+++ b/src/web/helpers/database.php
@@ -0,0 +1,282 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+class DatabaseQuery {
+
+ private $conn;
+ private $query;
+
+ private $where;
+ private $set;
+
+ private $param;
+
+ function __construct($conn) {
+ $this->conn = $conn;
+ $this->query = '';
+
+ $this->set = FALSE;
+ $this->where = FALSE;
+ $this->param = array();
+ }
+
+ ///
+ /// ARBITRARY QUERY
+ ///
+
+ public function query($query) {
+ $this->query .= $query;
+ return $this;
+ }
+
+ ///
+ /// SELECT
+ ///
+
+ public function select($select) {
+ $this->query .= "SELECT $select\n";
+ return $this;
+ }
+
+ public function from($from) {
+ $this->query .= "FROM $from\n";
+ return $this;
+ }
+
+ ///
+ /// INSERT
+ ///
+
+ public function insert_into($insert, ...$columns) {
+ $this->query .= "INSERT INTO $insert\n (";
+ foreach ($columns as $idx => $column) {
+ if ($idx !== 0) {
+ $this->query .= ",";
+ }
+ $this->query .= $column;
+ }
+ $this->query .= ")\n";
+ return $this;
+ }
+
+ public function values(...$values) {
+ $this->query .= "VALUES (";
+ foreach ($values as $idx => $value) {
+ if ($idx !== 0) {
+ $this->query .= ",";
+ }
+ $this->query .= "?";
+ array_push($this->param, $value);
+ }
+ $this->query .= ")\n";
+ return $this;
+ }
+
+ ///
+ /// WHERE
+ ///
+
+ public function where($cond) {
+ if (!$this->where) {
+ $this->where = TRUE;
+ $this->query .= "WHERE ";
+ } else {
+ $this->query .= "AND ";
+ }
+ $this->query .= "$cond ";
+ return $this;
+ }
+
+ public function where_in($column, $array) {
+ if (!$this->where) {
+ $this->where = TRUE;
+ $this->query .= "WHERE ";
+ } else {
+ $this->query .= "AND ";
+ }
+ if (empty($array)) {
+ $this->query .= "FALSE\n";
+ return $this;
+ }
+ $in = $this->in($array);
+ $this->query .= "$column $in\n";
+ return $this;
+ }
+
+ private function in($array) {
+ $in = 'IN (';
+ foreach ($array as $idx => $item) {
+ if ($idx != 0) {
+ $in .= ",";
+ }
+ $in .= "?";
+ array_push($this->param, $item);
+ }
+ $in .= ")";
+ return $in;
+ }
+
+ ///
+ /// OPERATORS
+ ///
+
+ public function like($item) {
+ $this->query .= "LIKE ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function eq($item) {
+ $this->query .= "= ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function ne($item) {
+ $this->query .= "<> ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function lt($item) {
+ $this->query .= "< ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function le($item) {
+ $this->query .= "<= ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ ///
+ /// JOINS
+ ///
+
+ public function join($table, $on, $type = 'LEFT') {
+ $this->query .= "$type JOIN $table ON $on\n";
+ return $this;
+ }
+
+ ///
+ /// LIMIT, OFFSET, ORDER
+ ///
+
+ public function limit($limit) {
+ $this->query .= "LIMIT ?\n";
+ array_push($this->param, $limit);
+ return $this;
+ }
+
+ public function offset($offset) {
+ $this->query .= "OFFSET ?\n";
+ array_push($this->param, $offset);
+ return $this;
+ }
+
+ public function order_by($column, $order = 'ASC') {
+ $this->query .= "ORDER BY " . $column . ' ' . $order . ' ';
+ return $this;
+ }
+
+ ///
+ /// COLLECT
+ ///
+
+ public function rows(...$params) {
+ $args = $this->param;
+ foreach ($params as $param) {
+ array_push($args, $param);
+ }
+ $stmt = $this->conn->prepare($this->query);
+ try {
+ $stmt->execute($args);
+ } catch (Exception $ex) {
+ echo $ex;
+ echo '<br> >> caused by <<<br>';
+ echo str_replace("\n", "<br>", $this->query);
+ }
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ public function row(...$params) {
+ $args = $this->param;
+ foreach ($params as $param) {
+ array_push($args, $param);
+ }
+ $stmt = $this->conn->prepare($this->query);
+ $stmt->execute($args);
+ return $stmt->fetch(PDO::FETCH_ASSOC);
+ }
+
+ public function execute(...$params) {
+ $args = $this->param;
+ foreach ($params as $param) {
+ array_push($args, $param);
+ }
+ $stmt = $this->conn->prepare($this->query);
+ try {
+ $stmt->execute($args);
+ return TRUE;
+ } catch (Exception $_e) {
+ echo $_e;
+ echo '<br> >> caused by <<<br>';
+ echo str_replace("\n", "<br>", $this->query);
+ return FALSE;
+ }
+ }
+}
+
+/**
+ * DatabaseHelper
+ * allows queries on the
+ * postgres database
+ */
+class DatabaseHelper {
+
+ private $conn;
+
+ function __construct() {
+ $this->conn = NULL;
+ }
+
+ private function connect() {
+ if ($this->conn === NULL) {
+ $user = getenv("POSTGRES_USER");
+ $pass = getenv("POSTGRES_PASSWORD");
+ $db = getenv("POSTGRES_DB");
+ $host = 'db';
+ $port = '5432';
+
+ $conn_str = sprintf("pgsql:host=%s;port=%d;dbname=%s;user=%s;password=%s",
+ $host,
+ $port,
+ $db,
+ $user,
+ $pass
+ );
+ $this->conn = new \PDO($conn_str);
+ $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
+ }
+ return $this->conn;
+ }
+
+ public function select($select) {
+ $conn = $this->connect();
+ $query = new DatabaseQuery($conn);
+ return $query->select($select);
+ }
+
+ public function insert_into($insert, ...$columns) {
+ $conn = $this->connect();
+ $query = new DatabaseQuery($conn);
+ return $query->insert_into($insert, ...$columns);
+ }
+
+ public function query($query_str) {
+ $conn = $this->connect();
+ $query = new DatabaseQuery($conn);
+ return $query->query($query_str);
+ }
+}
+
diff --git a/src/web/helpers/image.php b/src/web/helpers/image.php
new file mode 100644
index 0000000..c18154a
--- /dev/null
+++ b/src/web/helpers/image.php
@@ -0,0 +1,94 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+function __get_mime($type) {
+ switch ($type) {
+ case 'mp4':
+ return 'video/mp4';
+ case 'webm':
+ return 'video/webm';
+ case 'gif':
+ return 'image/gif';
+ case 'png':
+ return 'image/png';
+ case 'jpg':
+ return 'image/jpeg';
+ case 'webp':
+ return 'image/webp';
+ default:
+ return NULL;
+ }
+}
+
+function __make_source(
+ $name,
+ $format,
+ $media
+) {
+ if ($media) {
+ $media = "media=\"$media\"";
+ } else {
+ $media = '';
+ }
+ $main = $GLOBALS['main_model'];
+ $path = $main->get_url('public/' . $name . '.' . $format);
+ $mime = __get_mime($format);
+ return sprintf('<source type="%s" srcset="%s" %s>',
+ $mime, $path, $media);
+}
+
+function image(
+ $name,
+ $alt,
+ $formats = array('webp', 'png'),
+ $animated = FALSE,
+ $attrs = array(),
+
+ $height = NULL,
+ $width = NULL,
+ $size = NULL,
+) :string {
+
+ if ($animated === TRUE) {
+ $animated = array('gif');
+ }
+
+ if (!$animated) {
+ $animated = array();
+ }
+
+ $out = "<picture>";
+
+ foreach ($formats as $format) {
+ $media = count($animated) ? '(prefers-reduced-motion: reduce)' : NULL;
+ $out .= __make_source($name, $format, $media);
+ }
+
+ foreach ($animated as $format) {
+ $out .= __make_source($name, $format, NULL);
+ }
+
+ $format = end($formats);
+ $main = $GLOBALS['main_model'];
+ $path = $main->get_url('public/' . $name . '.' . $format);
+ $out .= "<img src=\"$path\"";
+ if ($alt) {
+ $alt = lang($alt);
+ $attrs['alt'] = $alt;
+ }
+ if ($width) {
+ $attrs['width'] = $width;
+ }
+ if ($height) {
+ $attrs['height'] = $height;
+ }
+ if ($size) {
+ $attrs['width'] = $size;
+ $attrs['height'] = $size;
+ }
+ foreach ($attrs as $key => $value) {
+ $out .= " $key=\"$value\"";
+ }
+ $out .= '></picture>';
+
+ return $out;
+}
diff --git a/src/web/helpers/lang.php b/src/web/helpers/lang.php
new file mode 100644
index 0000000..e8fa29e
--- /dev/null
+++ b/src/web/helpers/lang.php
@@ -0,0 +1,79 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+$lang = array();
+
+function lang($key, $default = NULL, $sub = NULL) {
+ $lang = $GLOBALS['lang'];
+ if(array_key_exists($key, $lang)) {
+ if ($sub) {
+ return sprintf($lang[$key], ...$sub);
+ } else {
+ return $lang[$key];
+ }
+ } else if ($default !== NULL) {
+ return $default;
+ } else {
+ trigger_error('Undefined lang string: ' . $key, E_USER_WARNING);
+ return $key;
+ }
+}
+
+function ilang($key,
+ $class = NULL,
+ $id = NULL,
+ $href = NULL,
+ $click = NULL,
+ $attrs = array(),
+ $sub = NULL,
+ $button = FALSE,
+ $container = 'span'
+) {
+ $text = ucfirst(lang($key . "_text", FALSE, sub: $sub));
+ $tip = lang($key . "_tip", FALSE, sub: $sub);
+ $icon = lang($key . "_icon", FALSE);
+ $content = lang($key . "_content", FALSE);
+
+ if ($click || $button) {
+ echo '<button ';
+ } else {
+ echo '<a ';
+ }
+ if ($tip) {
+ echo 'title="' . $tip . '" ';
+ echo 'aria-label="' . $tip . '" ';
+ }
+ if ($class) {
+ echo 'class="' . $class . '" ';
+ }
+ if ($id) {
+ echo 'id="' . $id . '" ';
+ }
+ if ($click) {
+ echo 'onclick="' . $click . '" ';
+ }
+ if ($href) {
+ echo 'href="' . $href . '" ';
+ }
+ foreach ($attrs as $key => $attr) {
+ echo $key . '="' . $attr . '" ';
+ }
+ echo '> ';
+ if ($icon) {
+ echo '<i class="' . $icon . '">';
+ if ($content) {
+ echo $content;
+ }
+ echo '</i>';
+ }
+ if ($text) {
+ echo '<' . $container;
+ if ($icon) {
+ echo ' class="ml-sm"';
+ }
+ echo '>' . $text . '</' . $container . '>';
+ }
+ if ($click || $button) {
+ echo '</button>';
+ } else {
+ echo '</a>';
+ }
+}
diff --git a/src/web/helpers/markdown.php b/src/web/helpers/markdown.php
new file mode 100644
index 0000000..39b430c
--- /dev/null
+++ b/src/web/helpers/markdown.php
@@ -0,0 +1,33 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+class MarkdownParser {
+
+ private $parsedown;
+
+ function __construct() {
+ $this->parsedown = new ParsedownExtra();
+ }
+
+ function parse($path) {
+ $content = file_get_contents($path);
+ $data = array(
+ 'meta' => array(),
+ 'content' => $content
+ );
+ if (str_starts_with($content, '---')) {
+ $parts = explode('---', $content);
+ $data['content'] = trim(implode('---', array_slice($parts, 2)));
+ $meta = array_filter(explode("\n", $parts[1]), fn($x) => $x != '');
+ foreach ($meta as $set) {
+ $parts = explode(": ", $set);
+ $key = trim($parts[0]);
+ $value = trim($parts[1]);
+ $data['meta'][$key] = $value;
+ }
+
+ }
+ $data['content'] = $this->parsedown->text($data['content']);
+ return $data;
+ }
+
+}
diff --git a/src/web/helpers/sanitize.php b/src/web/helpers/sanitize.php
new file mode 100644
index 0000000..5d37852
--- /dev/null
+++ b/src/web/helpers/sanitize.php
@@ -0,0 +1,8 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+function esc($data) {
+ $data = str_replace('&', '&amp;', $data);
+ $data = str_replace('<', '&lt;', $data);
+ $data = str_replace('>', '&gt;', $data);
+ return $data;
+}
diff --git a/src/web/index.php b/src/web/index.php
new file mode 100644
index 0000000..e33e750
--- /dev/null
+++ b/src/web/index.php
@@ -0,0 +1,38 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+ini_set('html_errors', '1');
+
+$webroot = dirname(__FILE__);
+$assetroot = realpath(dirname(__FILE__) . '/../assets');
+$publicroot = realpath(dirname(__FILE__) . '/../public');
+$main_model = NULL;
+
+// loadd all third party
+require($webroot . '/third_party/parsedown.php');
+require($webroot . '/third_party/parsedown_extra.php');
+
+// load all the config files
+require($webroot . '/config/routes.php');
+require($webroot . '/config/style.php');
+
+// load all the helpers
+require($webroot . '/helpers/lang.php');
+require($webroot . '/helpers/aria.php');
+require($webroot . '/helpers/image.php');
+require($webroot . '/helpers/markdown.php');
+require($webroot . '/helpers/database.php');
+require($webroot . '/helpers/sanitize.php');
+
+// load all core files
+require($webroot . '/core/_controller.php');
+require($webroot . '/core/_model.php');
+require($webroot . '/core/loader.php');
+require($webroot . '/core/router.php');
+
+function __init() {
+ $load = new Loader();
+ $router = new Router($load);
+ $router->handle_request();
+};
+
+__init();
diff --git a/src/web/lang/apps/blog.php b/src/web/lang/apps/blog.php
new file mode 100644
index 0000000..d860a8a
--- /dev/null
+++ b/src/web/lang/apps/blog.php
@@ -0,0 +1,16 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+$lang['title'] = 'Blog';
+$lang['posted'] = 'posted';
+
+$lang['blog_short_desc'] = 'I post things here sometimes';
+$lang['blog_desc'] = 'This is my blog! You can read the posts listed here, or you can find them on my <a href="blog/rss">RSS</a> feed.';
+
+$lang['comments'] = 'Comments';
+$lang['no_comments'] = 'No comments';
+$lang['new_comment_title'] = 'New comment:';
+$lang['new_comment_author_ph'] = 'Identify yourself';
+$lang['new_comment_author_label'] = 'Name';
+$lang['new_comment_content_ph'] = 'Write the stuff';
+$lang['new_comment_content_label'] = 'Content';
+$lang['new_comment_submit_text'] = 'Post Comment';
diff --git a/src/web/lang/apps/error.php b/src/web/lang/apps/error.php
new file mode 100644
index 0000000..79574b5
--- /dev/null
+++ b/src/web/lang/apps/error.php
@@ -0,0 +1,13 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+$lang['error_400'] = 'Bad request';
+$lang['error_401'] = 'Forbidden';
+$lang['error_404'] = 'Resource not found';
+$lang['error_413'] = 'Request too large';
+$lang['error_500'] = 'Whoops! Server error :(';
+$lang['error_503'] = 'Service unavailable';
+$lang['error'] = 'An unknown error has occoured';
+
+$lang['haa_haa_hee_hee_hoo_hoo'] = 'Haa Haa. Hee Hee. Hoo Hoo.';
+
+?>
diff --git a/src/web/lang/apps/home.php b/src/web/lang/apps/home.php
new file mode 100644
index 0000000..b3e3a16
--- /dev/null
+++ b/src/web/lang/apps/home.php
@@ -0,0 +1,35 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+$lang['title'] = $lang['first_name'];
+
+# sections
+$lang['about'] = 'About';
+$lang['section_about'] = '
+Hello! My name is Freya, and I am a person on the internet.
+I like to make projects that involve many things, though some of my
+favorites are operating systems, programming languages, and networking
+infrastructure.
+';
+
+$lang['whats_new'] = 'Whats New?';
+$lang['section_whats_new'] = '
+Every so often I post ideas, thoughts, or
+revelations that I have on my blog.
+Feel free to check it out at any time. (โ—•โ€ฟโ—•)
+';
+
+# interests
+$lang['interests'] = 'Interests';
+$lang['interests_general'] = 'General';
+$lang['interests_general_value'] = 'Computing, Anime, FNaF';
+$lang['interests_music'] = 'Music';
+$lang['interests_music_value'] = 'Billy Joel, Linkin Park, Vocaloid, Neil Cicierega';
+$lang['interests_comics'] = 'Comics';
+$lang['interests_comics_value'] = 'Homestuck';
+
+# contact
+$lang['contact'] = 'Contact Me';
+$lang['contact_email'] = 'Email';
+$lang['contact_matrix'] = 'Matrix';
+$lang['contact_xmpp'] = 'XMPP';
+$lang['contact_mastodon'] = 'Mastodon';
diff --git a/src/web/lang/apps/projects.php b/src/web/lang/apps/projects.php
new file mode 100644
index 0000000..8302691
--- /dev/null
+++ b/src/web/lang/apps/projects.php
@@ -0,0 +1,4 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+$lang['title'] = 'Projects';
+$lang['short_desc'] = 'Things that I have made';
diff --git a/src/web/lang/common.php b/src/web/lang/common.php
new file mode 100644
index 0000000..442e4c1
--- /dev/null
+++ b/src/web/lang/common.php
@@ -0,0 +1,60 @@
+<?php /* Copyright (c) 2024 Freya Murphy */
+
+# things
+$lang['lang_short'] = 'en';
+$lang['lang_full'] = 'en_US';
+
+$lang['domain'] = 'freya.cat';
+$lang['base_path'] = '/';
+$lang['root_url'] = sprintf("https://%s%s", $lang['domain'], $lang['base_path']);
+$lang['git_url'] = 'https://g.freya.cat/freya';
+$lang['theme_color'] = '#181818';
+
+# names
+$lang['first_name'] = 'Freya';
+$lang['last_name'] = 'Murphy';
+
+$lang['default_short_desc'] = 'Hi I exist';
+
+# common actions
+$lang['action_home_text'] = 'Home';
+$lang['action_home_tip'] = 'View my home page.';
+$lang['action_projects_text'] = 'Projects';
+$lang['action_projects_tip'] = 'View my projects.';
+$lang['action_blog_text'] = 'Blog';
+$lang['action_blog_tip'] = 'View my blog';
+
+# common alt text
+$lang['alt_button_eyes'] = 'Best viewed with eyes';
+$lang['alt_button_vim'] = 'Edited with VIM';
+$lang['alt_button_gnu_linux'] = 'Made with GNU/Linux';
+$lang['alt_button_apiopage'] = 'Memetic apiopage';
+$lang['alt_website_logo'] = 'Website Logo';
+
+# misc
+$lang['john_title'] = 'Johnvertisement';
+$lang['bucket_title'] = 'Bucket Webring';
+$lang['license_pre'] = 'This site is licensed under the';
+$lang['copyright'] = 'Copyright (c)';
+
+# footer_text
+$lang ['footer_text'] = [
+ "Always look on the bright side of life",
+ "๐Ÿ powder",
+ "Submit to john",
+ "Make sure to feed your computer three meals a day",
+ "curl https://f.freya.cat/rick/roll.sh | bash",
+ "medium rare chocolate",
+ "Incorporated by bees",
+ "[redacted]",
+ "Certified <a href=\"https://datatracker.ietf.org/doc/html/rfc9225\">RFC 9225</a> compliant.",
+ "JS free since always",
+ "Thank you for visiting the",
+ "footer text",
+ "If problems occur, stop doing the bad",
+ ":(){ :|: & };:",
+ "Approved by russian hackers",
+ "Shake well",
+ "Help im stuck inside footer text!",
+ "abort: no x11 display server found",
+];
diff --git a/src/web/third_party/parsedown.php b/src/web/third_party/parsedown.php
new file mode 100644
index 0000000..4d60658
--- /dev/null
+++ b/src/web/third_party/parsedown.php
@@ -0,0 +1,1995 @@
+<?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.8.0-beta-7';
+
+ # ~
+
+ function text($text)
+ {
+ $Elements = $this->textElements($text);
+
+ # convert to markup
+ $markup = $this->elements($Elements);
+
+ # trim line breaks
+ $markup = trim($markup, "\n");
+
+ return $markup;
+ }
+
+ protected function textElements($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
+ return $this->linesElements($lines);
+ }
+
+ #
+ # 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;
+
+ function setStrictMode($strictMode)
+ {
+ $this->strictMode = (bool) $strictMode;
+
+ return $this;
+ }
+
+ protected $strictMode;
+
+ protected $safeLinksWhitelist = array(
+ 'http://',
+ 'https://',
+ 'ftp://',
+ 'ftps://',
+ 'mailto:',
+ 'tel:',
+ '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)
+ {
+ return $this->elements($this->linesElements($lines));
+ }
+
+ protected function linesElements(array $lines)
+ {
+ $Elements = array();
+ $CurrentBlock = null;
+
+ foreach ($lines as $line)
+ {
+ if (chop($line) === '')
+ {
+ if (isset($CurrentBlock))
+ {
+ $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted'])
+ ? $CurrentBlock['interrupted'] + 1 : 1
+ );
+ }
+
+ continue;
+ }
+
+ while (($beforeTab = strstr($line, "\t", true)) !== false)
+ {
+ $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4;
+
+ $line = $beforeTab
+ . str_repeat(' ', $shortage)
+ . substr($line, strlen($beforeTab) + 1)
+ ;
+ }
+
+ $indent = strspn($line, ' ');
+
+ $text = $indent > 0 ? substr($line, $indent) : $line;
+
+ # ~
+
+ $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
+
+ # ~
+
+ if (isset($CurrentBlock['continuable']))
+ {
+ $methodName = 'block' . $CurrentBlock['type'] . 'Continue';
+ $Block = $this->$methodName($Line, $CurrentBlock);
+
+ if (isset($Block))
+ {
+ $CurrentBlock = $Block;
+
+ continue;
+ }
+ else
+ {
+ if ($this->isBlockCompletable($CurrentBlock['type']))
+ {
+ $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+ $CurrentBlock = $this->$methodName($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']))
+ {
+ if (isset($CurrentBlock))
+ {
+ $Elements[] = $this->extractElement($CurrentBlock);
+ }
+
+ $Block['identified'] = true;
+ }
+
+ if ($this->isBlockContinuable($blockType))
+ {
+ $Block['continuable'] = true;
+ }
+
+ $CurrentBlock = $Block;
+
+ continue 2;
+ }
+ }
+
+ # ~
+
+ if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph')
+ {
+ $Block = $this->paragraphContinue($Line, $CurrentBlock);
+ }
+
+ if (isset($Block))
+ {
+ $CurrentBlock = $Block;
+ }
+ else
+ {
+ if (isset($CurrentBlock))
+ {
+ $Elements[] = $this->extractElement($CurrentBlock);
+ }
+
+ $CurrentBlock = $this->paragraph($Line);
+
+ $CurrentBlock['identified'] = true;
+ }
+ }
+
+ # ~
+
+ if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
+ {
+ $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+ $CurrentBlock = $this->$methodName($CurrentBlock);
+ }
+
+ # ~
+
+ if (isset($CurrentBlock))
+ {
+ $Elements[] = $this->extractElement($CurrentBlock);
+ }
+
+ # ~
+
+ return $Elements;
+ }
+
+ protected function extractElement(array $Component)
+ {
+ if ( ! isset($Component['element']))
+ {
+ if (isset($Component['markup']))
+ {
+ $Component['element'] = array('rawHtml' => $Component['markup']);
+ }
+ elseif (isset($Component['hidden']))
+ {
+ $Component['element'] = array();
+ }
+ }
+
+ return $Component['element'];
+ }
+
+ 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 $Block['type'] === 'Paragraph' and ! isset($Block['interrupted']))
+ {
+ return;
+ }
+
+ if ($Line['indent'] >= 4)
+ {
+ $text = substr($Line['body'], 4);
+
+ $Block = array(
+ 'element' => array(
+ 'name' => 'pre',
+ 'element' => array(
+ 'name' => 'code',
+ 'text' => $text,
+ ),
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockCodeContinue($Line, $Block)
+ {
+ if ($Line['indent'] >= 4)
+ {
+ if (isset($Block['interrupted']))
+ {
+ $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+ unset($Block['interrupted']);
+ }
+
+ $Block['element']['element']['text'] .= "\n";
+
+ $text = substr($Line['body'], 4);
+
+ $Block['element']['element']['text'] .= $text;
+
+ return $Block;
+ }
+ }
+
+ protected function blockCodeComplete($Block)
+ {
+ return $Block;
+ }
+
+ #
+ # Comment
+
+ protected function blockComment($Line)
+ {
+ if ($this->markupEscaped or $this->safeMode)
+ {
+ return;
+ }
+
+ if (strpos($Line['text'], '<!--') === 0)
+ {
+ $Block = array(
+ 'element' => array(
+ 'rawHtml' => $Line['body'],
+ 'autobreak' => true,
+ ),
+ );
+
+ if (strpos($Line['text'], '-->') !== false)
+ {
+ $Block['closed'] = true;
+ }
+
+ return $Block;
+ }
+ }
+
+ protected function blockCommentContinue($Line, array $Block)
+ {
+ if (isset($Block['closed']))
+ {
+ return;
+ }
+
+ $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+ if (strpos($Line['text'], '-->') !== false)
+ {
+ $Block['closed'] = true;
+ }
+
+ return $Block;
+ }
+
+ #
+ # Fenced Code
+
+ protected function blockFencedCode($Line)
+ {
+ $marker = $Line['text'][0];
+
+ $openerLength = strspn($Line['text'], $marker);
+
+ if ($openerLength < 3)
+ {
+ return;
+ }
+
+ $infostring = trim(substr($Line['text'], $openerLength), "\t ");
+
+ if (strpos($infostring, '`') !== false)
+ {
+ return;
+ }
+
+ $Element = array(
+ 'name' => 'code',
+ 'text' => '',
+ );
+
+ if ($infostring !== '')
+ {
+ /**
+ * 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($infostring, 0, strcspn($infostring, " \t\n\f\r"));
+
+ $Element['attributes'] = array('class' => "language-$language");
+ }
+
+ $Block = array(
+ 'char' => $marker,
+ 'openerLength' => $openerLength,
+ 'element' => array(
+ 'name' => 'pre',
+ 'element' => $Element,
+ ),
+ );
+
+ return $Block;
+ }
+
+ protected function blockFencedCodeContinue($Line, $Block)
+ {
+ if (isset($Block['complete']))
+ {
+ return;
+ }
+
+ if (isset($Block['interrupted']))
+ {
+ $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+ unset($Block['interrupted']);
+ }
+
+ if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength']
+ and chop(substr($Line['text'], $len), ' ') === ''
+ ) {
+ $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1);
+
+ $Block['complete'] = true;
+
+ return $Block;
+ }
+
+ $Block['element']['element']['text'] .= "\n" . $Line['body'];
+
+ return $Block;
+ }
+
+ protected function blockFencedCodeComplete($Block)
+ {
+ return $Block;
+ }
+
+ #
+ # Header
+
+ protected function blockHeader($Line)
+ {
+ $level = strspn($Line['text'], '#');
+
+ if ($level > 6)
+ {
+ return;
+ }
+
+ $text = trim($Line['text'], '#');
+
+ if ($this->strictMode and isset($text[0]) and $text[0] !== ' ')
+ {
+ return;
+ }
+
+ $text = trim($text, ' ');
+
+ $Block = array(
+ 'element' => array(
+ 'name' => 'h' . $level,
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $text,
+ 'destination' => 'elements',
+ )
+ ),
+ );
+
+ return $Block;
+ }
+
+ #
+ # List
+
+ protected function blockList($Line, array $CurrentBlock = null)
+ {
+ list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]');
+
+ if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches))
+ {
+ $contentIndent = strlen($matches[2]);
+
+ if ($contentIndent >= 5)
+ {
+ $contentIndent -= 1;
+ $matches[1] = substr($matches[1], 0, -$contentIndent);
+ $matches[3] = str_repeat(' ', $contentIndent) . $matches[3];
+ }
+ elseif ($contentIndent === 0)
+ {
+ $matches[1] .= ' ';
+ }
+
+ $markerWithoutWhitespace = strstr($matches[1], ' ', true);
+
+ $Block = array(
+ 'indent' => $Line['indent'],
+ 'pattern' => $pattern,
+ 'data' => array(
+ 'type' => $name,
+ 'marker' => $matches[1],
+ 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)),
+ ),
+ 'element' => array(
+ 'name' => $name,
+ 'elements' => array(),
+ ),
+ );
+ $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/');
+
+ if ($name === 'ol')
+ {
+ $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0';
+
+ if ($listStart !== '1')
+ {
+ if (
+ isset($CurrentBlock)
+ and $CurrentBlock['type'] === 'Paragraph'
+ and ! isset($CurrentBlock['interrupted'])
+ ) {
+ return;
+ }
+
+ $Block['element']['attributes'] = array('start' => $listStart);
+ }
+ }
+
+ $Block['li'] = array(
+ 'name' => 'li',
+ 'handler' => array(
+ 'function' => 'li',
+ 'argument' => !empty($matches[3]) ? array($matches[3]) : array(),
+ 'destination' => 'elements'
+ )
+ );
+
+ $Block['element']['elements'] []= & $Block['li'];
+
+ return $Block;
+ }
+ }
+
+ protected function blockListContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument']))
+ {
+ return null;
+ }
+
+ $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker']));
+
+ if ($Line['indent'] < $requiredIndent
+ and (
+ (
+ $Block['data']['type'] === 'ol'
+ and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+ ) or (
+ $Block['data']['type'] === 'ul'
+ and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+ )
+ )
+ ) {
+ if (isset($Block['interrupted']))
+ {
+ $Block['li']['handler']['argument'] []= '';
+
+ $Block['loose'] = true;
+
+ unset($Block['interrupted']);
+ }
+
+ unset($Block['li']);
+
+ $text = isset($matches[1]) ? $matches[1] : '';
+
+ $Block['indent'] = $Line['indent'];
+
+ $Block['li'] = array(
+ 'name' => 'li',
+ 'handler' => array(
+ 'function' => 'li',
+ 'argument' => array($text),
+ 'destination' => 'elements'
+ )
+ );
+
+ $Block['element']['elements'] []= & $Block['li'];
+
+ return $Block;
+ }
+ elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line))
+ {
+ return null;
+ }
+
+ if ($Line['text'][0] === '[' and $this->blockReference($Line))
+ {
+ return $Block;
+ }
+
+ if ($Line['indent'] >= $requiredIndent)
+ {
+ if (isset($Block['interrupted']))
+ {
+ $Block['li']['handler']['argument'] []= '';
+
+ $Block['loose'] = true;
+
+ unset($Block['interrupted']);
+ }
+
+ $text = substr($Line['body'], $requiredIndent);
+
+ $Block['li']['handler']['argument'] []= $text;
+
+ return $Block;
+ }
+
+ if ( ! isset($Block['interrupted']))
+ {
+ $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']);
+
+ $Block['li']['handler']['argument'] []= $text;
+
+ return $Block;
+ }
+ }
+
+ protected function blockListComplete(array $Block)
+ {
+ if (isset($Block['loose']))
+ {
+ foreach ($Block['element']['elements'] as &$li)
+ {
+ if (end($li['handler']['argument']) !== '')
+ {
+ $li['handler']['argument'] []= '';
+ }
+ }
+ }
+
+ return $Block;
+ }
+
+ #
+ # Quote
+
+ protected function blockQuote($Line)
+ {
+ if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
+ {
+ $Block = array(
+ 'element' => array(
+ 'name' => 'blockquote',
+ 'handler' => array(
+ 'function' => 'linesElements',
+ 'argument' => (array) $matches[1],
+ 'destination' => 'elements',
+ )
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockQuoteContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted']))
+ {
+ return;
+ }
+
+ if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
+ {
+ $Block['element']['handler']['argument'] []= $matches[1];
+
+ return $Block;
+ }
+
+ if ( ! isset($Block['interrupted']))
+ {
+ $Block['element']['handler']['argument'] []= $Line['text'];
+
+ return $Block;
+ }
+ }
+
+ #
+ # Rule
+
+ protected function blockRule($Line)
+ {
+ $marker = $Line['text'][0];
+
+ if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '')
+ {
+ $Block = array(
+ 'element' => array(
+ 'name' => 'hr',
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ #
+ # Setext
+
+ protected function blockSetextHeader($Line, array $Block = null)
+ {
+ if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
+ {
+ return;
+ }
+
+ if ($Line['indent'] < 4 and chop(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*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches))
+ {
+ $element = strtolower($matches[1]);
+
+ if (in_array($element, $this->textLevelElements))
+ {
+ return;
+ }
+
+ $Block = array(
+ 'name' => $matches[1],
+ 'element' => array(
+ 'rawHtml' => $Line['text'],
+ 'autobreak' => true,
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockMarkupContinue($Line, array $Block)
+ {
+ if (isset($Block['closed']) or isset($Block['interrupted']))
+ {
+ return;
+ }
+
+ $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+ return $Block;
+ }
+
+ #
+ # Reference
+
+ protected function blockReference($Line)
+ {
+ if (strpos($Line['text'], ']') !== false
+ and preg_match('/^\[(.+?)\]:[ ]*+<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches)
+ ) {
+ $id = strtolower($matches[1]);
+
+ $Data = array(
+ 'url' => $matches[2],
+ 'title' => isset($matches[3]) ? $matches[3] : null,
+ );
+
+ $this->DefinitionData['Reference'][$id] = $Data;
+
+ $Block = array(
+ 'element' => array(),
+ );
+
+ return $Block;
+ }
+ }
+
+ #
+ # Table
+
+ protected function blockTable($Line, array $Block = null)
+ {
+ if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
+ {
+ return;
+ }
+
+ if (
+ strpos($Block['element']['handler']['argument'], '|') === false
+ and strpos($Line['text'], '|') === false
+ and strpos($Line['text'], ':') === false
+ or strpos($Block['element']['handler']['argument'], "\n") !== false
+ ) {
+ return;
+ }
+
+ if (chop($Line['text'], ' -:|') !== '')
+ {
+ return;
+ }
+
+ $alignments = array();
+
+ $divider = $Line['text'];
+
+ $divider = trim($divider);
+ $divider = trim($divider, '|');
+
+ $dividerCells = explode('|', $divider);
+
+ foreach ($dividerCells as $dividerCell)
+ {
+ $dividerCell = trim($dividerCell);
+
+ if ($dividerCell === '')
+ {
+ return;
+ }
+
+ $alignment = null;
+
+ if ($dividerCell[0] === ':')
+ {
+ $alignment = 'left';
+ }
+
+ if (substr($dividerCell, - 1) === ':')
+ {
+ $alignment = $alignment === 'left' ? 'center' : 'right';
+ }
+
+ $alignments []= $alignment;
+ }
+
+ # ~
+
+ $HeaderElements = array();
+
+ $header = $Block['element']['handler']['argument'];
+
+ $header = trim($header);
+ $header = trim($header, '|');
+
+ $headerCells = explode('|', $header);
+
+ if (count($headerCells) !== count($alignments))
+ {
+ return;
+ }
+
+ foreach ($headerCells as $index => $headerCell)
+ {
+ $headerCell = trim($headerCell);
+
+ $HeaderElement = array(
+ 'name' => 'th',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $headerCell,
+ 'destination' => 'elements',
+ )
+ );
+
+ 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',
+ 'elements' => array(),
+ ),
+ );
+
+ $Block['element']['elements'] []= array(
+ 'name' => 'thead',
+ );
+
+ $Block['element']['elements'] []= array(
+ 'name' => 'tbody',
+ 'elements' => array(),
+ );
+
+ $Block['element']['elements'][0]['elements'] []= array(
+ 'name' => 'tr',
+ 'elements' => $HeaderElements,
+ );
+
+ return $Block;
+ }
+
+ protected function blockTableContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted']))
+ {
+ return;
+ }
+
+ if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|'))
+ {
+ $Elements = array();
+
+ $row = $Line['text'];
+
+ $row = trim($row);
+ $row = trim($row, '|');
+
+ preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches);
+
+ $cells = array_slice($matches[0], 0, count($Block['alignments']));
+
+ foreach ($cells as $index => $cell)
+ {
+ $cell = trim($cell);
+
+ $Element = array(
+ 'name' => 'td',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $cell,
+ 'destination' => 'elements',
+ )
+ );
+
+ if (isset($Block['alignments'][$index]))
+ {
+ $Element['attributes'] = array(
+ 'style' => 'text-align: ' . $Block['alignments'][$index] . ';',
+ );
+ }
+
+ $Elements []= $Element;
+ }
+
+ $Element = array(
+ 'name' => 'tr',
+ 'elements' => $Elements,
+ );
+
+ $Block['element']['elements'][1]['elements'] []= $Element;
+
+ return $Block;
+ }
+ }
+
+ #
+ # ~
+ #
+
+ protected function paragraph($Line)
+ {
+ return array(
+ 'type' => 'Paragraph',
+ 'element' => array(
+ 'name' => 'p',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $Line['text'],
+ 'destination' => 'elements',
+ ),
+ ),
+ );
+ }
+
+ protected function paragraphContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted']))
+ {
+ return;
+ }
+
+ $Block['element']['handler']['argument'] .= "\n".$Line['text'];
+
+ return $Block;
+ }
+
+ #
+ # Inline Elements
+ #
+
+ protected $InlineTypes = array(
+ '!' => array('Image'),
+ '&' => array('SpecialCharacter'),
+ '*' => array('Emphasis'),
+ ':' => array('Url'),
+ '<' => array('UrlTag', 'EmailTag', 'Markup'),
+ '[' => array('Link'),
+ '_' => array('Emphasis'),
+ '`' => array('Code'),
+ '~' => array('Strikethrough'),
+ '\\' => array('EscapeSequence'),
+ );
+
+ # ~
+
+ protected $inlineMarkerList = '!*_&[:<`~\\';
+
+ #
+ # ~
+ #
+
+ public function line($text, $nonNestables = array())
+ {
+ return $this->elements($this->lineElements($text, $nonNestables));
+ }
+
+ protected function lineElements($text, $nonNestables = array())
+ {
+ # standardize line breaks
+ $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+ $Elements = array();
+
+ $nonNestables = (empty($nonNestables)
+ ? array()
+ : array_combine($nonNestables, $nonNestables)
+ );
+
+ # $excerpt is based on the first occurrence of a marker
+
+ while ($excerpt = strpbrk($text, $this->inlineMarkerList))
+ {
+ $marker = $excerpt[0];
+
+ $markerPosition = strlen($text) - strlen($excerpt);
+
+ $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 (isset($nonNestables[$inlineType]))
+ {
+ 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
+
+
+ $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables'])
+ ? array_merge($Inline['element']['nonNestables'], $nonNestables)
+ : $nonNestables
+ ;
+
+ # the text that comes before the inline
+ $unmarkedText = substr($text, 0, $Inline['position']);
+
+ # compile the unmarked text
+ $InlineText = $this->inlineText($unmarkedText);
+ $Elements[] = $InlineText['element'];
+
+ # compile the inline
+ $Elements[] = $this->extractElement($Inline);
+
+ # 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);
+
+ $InlineText = $this->inlineText($unmarkedText);
+ $Elements[] = $InlineText['element'];
+
+ $text = substr($text, $markerPosition + 1);
+ }
+
+ $InlineText = $this->inlineText($text);
+ $Elements[] = $InlineText['element'];
+
+ foreach ($Elements as &$Element)
+ {
+ if ( ! isset($Element['autobreak']))
+ {
+ $Element['autobreak'] = false;
+ }
+ }
+
+ return $Elements;
+ }
+
+ #
+ # ~
+ #
+
+ protected function inlineText($text)
+ {
+ $Inline = array(
+ 'extent' => strlen($text),
+ 'element' => array(),
+ );
+
+ $Inline['element']['elements'] = self::pregReplaceElements(
+ $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
+ array(
+ array('name' => 'br'),
+ array('text' => "\n"),
+ ),
+ $text
+ );
+
+ return $Inline;
+ }
+
+ 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)
+ {
+ $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
+
+ $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@'
+ . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*';
+
+ if (strpos($Excerpt['text'], '>') !== false
+ and preg_match("/^<((mailto:)?$commonMarkEmail)>/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' => array(
+ 'function' => 'lineElements',
+ 'argument' => $matches[1],
+ 'destination' => 'elements',
+ )
+ ),
+ );
+ }
+
+ protected function inlineEscapeSequence($Excerpt)
+ {
+ if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
+ {
+ return array(
+ 'element' => array('rawHtml' => $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']['handler']['argument'],
+ ),
+ 'autobreak' => true,
+ ),
+ );
+
+ $Inline['element']['attributes'] += $Link['element']['attributes'];
+
+ unset($Inline['element']['attributes']['href']);
+
+ return $Inline;
+ }
+
+ protected function inlineLink($Excerpt)
+ {
+ $Element = array(
+ 'name' => 'a',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => null,
+ 'destination' => 'elements',
+ ),
+ 'nonNestables' => array('Url', 'Link'),
+ 'attributes' => array(
+ 'href' => null,
+ 'title' => null,
+ ),
+ );
+
+ $extent = 0;
+
+ $remainder = $Excerpt['text'];
+
+ if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
+ {
+ $Element['handler']['argument'] = $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['handler']['argument'];
+ $definition = strtolower($definition);
+
+ $extent += strlen($matches[0]);
+ }
+ else
+ {
+ $definition = strtolower($Element['handler']['argument']);
+ }
+
+ 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(
+ 'element' => array('rawHtml' => $matches[0]),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+
+ if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?+[^-])*-->/s', $Excerpt['text'], $matches))
+ {
+ return array(
+ 'element' => array('rawHtml' => $matches[0]),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+
+ if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches))
+ {
+ return array(
+ 'element' => array('rawHtml' => $matches[0]),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+ }
+
+ protected function inlineSpecialCharacter($Excerpt)
+ {
+ if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false
+ and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches)
+ ) {
+ return array(
+ 'element' => array('rawHtml' => '&' . $matches[1] . ';'),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+
+ return;
+ }
+
+ 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',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $matches[1],
+ 'destination' => 'elements',
+ )
+ ),
+ );
+ }
+ }
+
+ protected function inlineUrl($Excerpt)
+ {
+ if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
+ {
+ return;
+ }
+
+ if (strpos($Excerpt['context'], 'http') !== false
+ and 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)
+ {
+ $Inline = $this->inlineText($text);
+ return $this->element($Inline['element']);
+ }
+
+ #
+ # Handlers
+ #
+
+ protected function handle(array $Element)
+ {
+ if (isset($Element['handler']))
+ {
+ if (!isset($Element['nonNestables']))
+ {
+ $Element['nonNestables'] = array();
+ }
+
+ if (is_string($Element['handler']))
+ {
+ $function = $Element['handler'];
+ $argument = $Element['text'];
+ unset($Element['text']);
+ $destination = 'rawHtml';
+ }
+ else
+ {
+ $function = $Element['handler']['function'];
+ $argument = $Element['handler']['argument'];
+ $destination = $Element['handler']['destination'];
+ }
+
+ $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']);
+
+ if ($destination === 'handler')
+ {
+ $Element = $this->handle($Element);
+ }
+
+ unset($Element['handler']);
+ }
+
+ return $Element;
+ }
+
+ protected function handleElementRecursive(array $Element)
+ {
+ return $this->elementApplyRecursive(array($this, 'handle'), $Element);
+ }
+
+ protected function handleElementsRecursive(array $Elements)
+ {
+ return $this->elementsApplyRecursive(array($this, 'handle'), $Elements);
+ }
+
+ protected function elementApplyRecursive($closure, array $Element)
+ {
+ $Element = call_user_func($closure, $Element);
+
+ if (isset($Element['elements']))
+ {
+ $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']);
+ }
+ elseif (isset($Element['element']))
+ {
+ $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']);
+ }
+
+ return $Element;
+ }
+
+ protected function elementApplyRecursiveDepthFirst($closure, array $Element)
+ {
+ if (isset($Element['elements']))
+ {
+ $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']);
+ }
+ elseif (isset($Element['element']))
+ {
+ $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']);
+ }
+
+ $Element = call_user_func($closure, $Element);
+
+ return $Element;
+ }
+
+ protected function elementsApplyRecursive($closure, array $Elements)
+ {
+ foreach ($Elements as &$Element)
+ {
+ $Element = $this->elementApplyRecursive($closure, $Element);
+ }
+
+ return $Elements;
+ }
+
+ protected function elementsApplyRecursiveDepthFirst($closure, array $Elements)
+ {
+ foreach ($Elements as &$Element)
+ {
+ $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element);
+ }
+
+ return $Elements;
+ }
+
+ protected function element(array $Element)
+ {
+ if ($this->safeMode)
+ {
+ $Element = $this->sanitiseElement($Element);
+ }
+
+ # identity map if element has no handler
+ $Element = $this->handle($Element);
+
+ $hasName = isset($Element['name']);
+
+ $markup = '';
+
+ if ($hasName)
+ {
+ $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;
+ }
+
+ $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']);
+
+ if ($hasContent)
+ {
+ $markup .= $hasName ? '>' : '';
+
+ if (isset($Element['elements']))
+ {
+ $markup .= $this->elements($Element['elements']);
+ }
+ elseif (isset($Element['element']))
+ {
+ $markup .= $this->element($Element['element']);
+ }
+ else
+ {
+ if (!$permitRawHtml)
+ {
+ $markup .= self::escape($text, true);
+ }
+ else
+ {
+ $markup .= $text;
+ }
+ }
+
+ $markup .= $hasName ? '</' . $Element['name'] . '>' : '';
+ }
+ elseif ($hasName)
+ {
+ $markup .= ' />';
+ }
+
+ return $markup;
+ }
+
+ protected function elements(array $Elements)
+ {
+ $markup = '';
+
+ $autoBreak = true;
+
+ foreach ($Elements as $Element)
+ {
+ if (empty($Element))
+ {
+ continue;
+ }
+
+ $autoBreakNext = (isset($Element['autobreak'])
+ ? $Element['autobreak'] : isset($Element['name'])
+ );
+ // (autobreak === false) covers both sides of an element
+ $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext;
+
+ $markup .= ($autoBreak ? "\n" : '') . $this->element($Element);
+ $autoBreak = $autoBreakNext;
+ }
+
+ $markup .= $autoBreak ? "\n" : '';
+
+ return $markup;
+ }
+
+ # ~
+
+ protected function li($lines)
+ {
+ $Elements = $this->linesElements($lines);
+
+ if ( ! in_array('', $lines)
+ and isset($Elements[0]) and isset($Elements[0]['name'])
+ and $Elements[0]['name'] === 'p'
+ ) {
+ unset($Elements[0]['name']);
+ }
+
+ return $Elements;
+ }
+
+ #
+ # AST Convenience
+ #
+
+ /**
+ * Replace occurrences $regexp with $Elements in $text. Return an array of
+ * elements representing the replacement.
+ */
+ protected static function pregReplaceElements($regexp, $Elements, $text)
+ {
+ $newElements = array();
+
+ while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE))
+ {
+ $offset = $matches[0][1];
+ $before = substr($text, 0, $offset);
+ $after = substr($text, $offset + strlen($matches[0][0]));
+
+ $newElements[] = array('text' => $before);
+
+ foreach ($Elements as $Element)
+ {
+ $newElements[] = $Element;
+ }
+
+ $text = $after;
+ }
+
+ $newElements[] = array('text' => $text);
+
+ return $newElements;
+ }
+
+ #
+ # 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($Element['name']))
+ {
+ unset($Element['attributes']);
+ return $Element;
+ }
+
+ 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/src/web/third_party/parsedown_extra.php b/src/web/third_party/parsedown_extra.php
new file mode 100644
index 0000000..8cdb5d2
--- /dev/null
+++ b/src/web/third_party/parsedown_extra.php
@@ -0,0 +1,686 @@
+<?php
+
+#
+#
+# Parsedown Extra
+# https://github.com/erusev/parsedown-extra
+#
+# (c) Emanuil Rusev
+# http://erusev.com
+#
+# For the full license information, view the LICENSE file that was distributed
+# with this source code.
+#
+#
+
+class ParsedownExtra extends Parsedown
+{
+ # ~
+
+ const version = '0.8.0';
+
+ # ~
+
+ function __construct()
+ {
+ if (version_compare(parent::version, '1.7.1') < 0)
+ {
+ throw new Exception('ParsedownExtra requires a later version of Parsedown');
+ }
+
+ $this->BlockTypes[':'] []= 'DefinitionList';
+ $this->BlockTypes['*'] []= 'Abbreviation';
+
+ # identify footnote definitions before reference definitions
+ array_unshift($this->BlockTypes['['], 'Footnote');
+
+ # identify footnote markers before before links
+ array_unshift($this->InlineTypes['['], 'FootnoteMarker');
+ }
+
+ #
+ # ~
+
+ function text($text)
+ {
+ $Elements = $this->textElements($text);
+
+ # convert to markup
+ $markup = $this->elements($Elements);
+
+ # trim line breaks
+ $markup = trim($markup, "\n");
+
+ # merge consecutive dl elements
+
+ $markup = preg_replace('/<\/dl>\s+<dl>\s+/', '', $markup);
+
+ # add footnotes
+
+ if (isset($this->DefinitionData['Footnote']))
+ {
+ $Element = $this->buildFootnoteElement();
+
+ $markup .= "\n" . $this->element($Element);
+ }
+
+ return $markup;
+ }
+
+ #
+ # Blocks
+ #
+
+ #
+ # Abbreviation
+
+ protected function blockAbbreviation($Line)
+ {
+ if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches))
+ {
+ $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2];
+
+ $Block = array(
+ 'hidden' => true,
+ );
+
+ return $Block;
+ }
+ }
+
+ #
+ # Footnote
+
+ protected function blockFootnote($Line)
+ {
+ if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches))
+ {
+ $Block = array(
+ 'label' => $matches[1],
+ 'text' => $matches[2],
+ 'hidden' => true,
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockFootnoteContinue($Line, $Block)
+ {
+ if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text']))
+ {
+ return;
+ }
+
+ if (isset($Block['interrupted']))
+ {
+ if ($Line['indent'] >= 4)
+ {
+ $Block['text'] .= "\n\n" . $Line['text'];
+
+ return $Block;
+ }
+ }
+ else
+ {
+ $Block['text'] .= "\n" . $Line['text'];
+
+ return $Block;
+ }
+ }
+
+ protected function blockFootnoteComplete($Block)
+ {
+ $this->DefinitionData['Footnote'][$Block['label']] = array(
+ 'text' => $Block['text'],
+ 'count' => null,
+ 'number' => null,
+ );
+
+ return $Block;
+ }
+
+ #
+ # Definition List
+
+ protected function blockDefinitionList($Line, $Block)
+ {
+ if ( ! isset($Block) or $Block['type'] !== 'Paragraph')
+ {
+ return;
+ }
+
+ $Element = array(
+ 'name' => 'dl',
+ 'elements' => array(),
+ );
+
+ $terms = explode("\n", $Block['element']['handler']['argument']);
+
+ foreach ($terms as $term)
+ {
+ $Element['elements'] []= array(
+ 'name' => 'dt',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $term,
+ 'destination' => 'elements'
+ ),
+ );
+ }
+
+ $Block['element'] = $Element;
+
+ $Block = $this->addDdElement($Line, $Block);
+
+ return $Block;
+ }
+
+ protected function blockDefinitionListContinue($Line, array $Block)
+ {
+ if ($Line['text'][0] === ':')
+ {
+ $Block = $this->addDdElement($Line, $Block);
+
+ return $Block;
+ }
+ else
+ {
+ if (isset($Block['interrupted']) and $Line['indent'] === 0)
+ {
+ return;
+ }
+
+ if (isset($Block['interrupted']))
+ {
+ $Block['dd']['handler']['function'] = 'textElements';
+ $Block['dd']['handler']['argument'] .= "\n\n";
+
+ $Block['dd']['handler']['destination'] = 'elements';
+
+ unset($Block['interrupted']);
+ }
+
+ $text = substr($Line['body'], min($Line['indent'], 4));
+
+ $Block['dd']['handler']['argument'] .= "\n" . $text;
+
+ return $Block;
+ }
+ }
+
+ #
+ # Header
+
+ protected function blockHeader($Line)
+ {
+ $Block = parent::blockHeader($Line);
+
+ if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE))
+ {
+ $attributeString = $matches[1][0];
+
+ $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+ $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
+ }
+
+ 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,
+ 'element' => array(
+ 'rawHtml' => $Line['text'],
+ 'autobreak' => true,
+ ),
+ );
+
+ $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['element']['rawHtml'] .= "\n";
+ unset($Block['interrupted']);
+ }
+
+ $Block['element']['rawHtml'] .= "\n".$Line['body'];
+
+ return $Block;
+ }
+
+ protected function blockMarkupComplete($Block)
+ {
+ if ( ! isset($Block['void']))
+ {
+ $Block['element']['rawHtml'] = $this->processTag($Block['element']['rawHtml']);
+ }
+
+ return $Block;
+ }
+
+ #
+ # Setext
+
+ protected function blockSetextHeader($Line, array $Block = null)
+ {
+ $Block = parent::blockSetextHeader($Line, $Block);
+
+ if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE))
+ {
+ $attributeString = $matches[1][0];
+
+ $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+ $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
+ }
+
+ return $Block;
+ }
+
+ #
+ # Inline Elements
+ #
+
+ #
+ # Footnote Marker
+
+ protected function inlineFootnoteMarker($Excerpt)
+ {
+ if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches))
+ {
+ $name = $matches[1];
+
+ if ( ! isset($this->DefinitionData['Footnote'][$name]))
+ {
+ return;
+ }
+
+ $this->DefinitionData['Footnote'][$name]['count'] ++;
+
+ if ( ! isset($this->DefinitionData['Footnote'][$name]['number']))
+ {
+ $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # ยป &
+ }
+
+ $Element = array(
+ 'name' => 'sup',
+ 'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name),
+ 'element' => array(
+ 'name' => 'a',
+ 'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'),
+ 'text' => $this->DefinitionData['Footnote'][$name]['number'],
+ ),
+ );
+
+ return array(
+ 'extent' => strlen($matches[0]),
+ 'element' => $Element,
+ );
+ }
+ }
+
+ private $footnoteCount = 0;
+
+ #
+ # Link
+
+ protected function inlineLink($Excerpt)
+ {
+ $Link = parent::inlineLink($Excerpt);
+
+ $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : '';
+
+ if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches))
+ {
+ $Link['element']['attributes'] += $this->parseAttributeData($matches[1]);
+
+ $Link['extent'] += strlen($matches[0]);
+ }
+
+ return $Link;
+ }
+
+ #
+ # ~
+ #
+
+ private $currentAbreviation;
+ private $currentMeaning;
+
+ protected function insertAbreviation(array $Element)
+ {
+ if (isset($Element['text']))
+ {
+ $Element['elements'] = self::pregReplaceElements(
+ '/\b'.preg_quote($this->currentAbreviation, '/').'\b/',
+ array(
+ array(
+ 'name' => 'abbr',
+ 'attributes' => array(
+ 'title' => $this->currentMeaning,
+ ),
+ 'text' => $this->currentAbreviation,
+ )
+ ),
+ $Element['text']
+ );
+
+ unset($Element['text']);
+ }
+
+ return $Element;
+ }
+
+ protected function inlineText($text)
+ {
+ $Inline = parent::inlineText($text);
+
+ if (isset($this->DefinitionData['Abbreviation']))
+ {
+ foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning)
+ {
+ $this->currentAbreviation = $abbreviation;
+ $this->currentMeaning = $meaning;
+
+ $Inline['element'] = $this->elementApplyRecursiveDepthFirst(
+ array($this, 'insertAbreviation'),
+ $Inline['element']
+ );
+ }
+ }
+
+ return $Inline;
+ }
+
+ #
+ # Util Methods
+ #
+
+ protected function addDdElement(array $Line, array $Block)
+ {
+ $text = substr($Line['text'], 1);
+ $text = trim($text);
+
+ unset($Block['dd']);
+
+ $Block['dd'] = array(
+ 'name' => 'dd',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $text,
+ 'destination' => 'elements'
+ ),
+ );
+
+ if (isset($Block['interrupted']))
+ {
+ $Block['dd']['handler']['function'] = 'textElements';
+
+ unset($Block['interrupted']);
+ }
+
+ $Block['element']['elements'] []= & $Block['dd'];
+
+ return $Block;
+ }
+
+ protected function buildFootnoteElement()
+ {
+ $Element = array(
+ 'name' => 'div',
+ 'attributes' => array('class' => 'footnotes'),
+ 'elements' => array(
+ array('name' => 'hr'),
+ array(
+ 'name' => 'ol',
+ 'elements' => array(),
+ ),
+ ),
+ );
+
+ uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes');
+
+ foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData)
+ {
+ if ( ! isset($DefinitionData['number']))
+ {
+ continue;
+ }
+
+ $text = $DefinitionData['text'];
+
+ $textElements = parent::textElements($text);
+
+ $numbers = range(1, $DefinitionData['count']);
+
+ $backLinkElements = array();
+
+ foreach ($numbers as $number)
+ {
+ $backLinkElements[] = array('text' => ' ');
+ $backLinkElements[] = array(
+ 'name' => 'a',
+ 'attributes' => array(
+ 'href' => "#fnref$number:$definitionId",
+ 'rev' => 'footnote',
+ 'class' => 'footnote-backref',
+ ),
+ 'rawHtml' => '&#8617;',
+ 'allowRawHtmlInSafeMode' => true,
+ 'autobreak' => false,
+ );
+ }
+
+ unset($backLinkElements[0]);
+
+ $n = count($textElements) -1;
+
+ if ($textElements[$n]['name'] === 'p')
+ {
+ $backLinkElements = array_merge(
+ array(
+ array(
+ 'rawHtml' => '&#160;',
+ 'allowRawHtmlInSafeMode' => true,
+ ),
+ ),
+ $backLinkElements
+ );
+
+ unset($textElements[$n]['name']);
+
+ $textElements[$n] = array(
+ 'name' => 'p',
+ 'elements' => array_merge(
+ array($textElements[$n]),
+ $backLinkElements
+ ),
+ );
+ }
+ else
+ {
+ $textElements[] = array(
+ 'name' => 'p',
+ 'elements' => $backLinkElements
+ );
+ }
+
+ $Element['elements'][1]['elements'] []= array(
+ 'name' => 'li',
+ 'attributes' => array('id' => 'fn:'.$definitionId),
+ 'elements' => array_merge(
+ $textElements
+ ),
+ );
+ }
+
+ return $Element;
+ }
+
+ # ~
+
+ protected function parseAttributeData($attributeString)
+ {
+ $Data = array();
+
+ $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY);
+
+ foreach ($attributes as $attribute)
+ {
+ if ($attribute[0] === '#')
+ {
+ $Data['id'] = substr($attribute, 1);
+ }
+ else # "."
+ {
+ $classes []= substr($attribute, 1);
+ }
+ }
+
+ if (isset($classes))
+ {
+ $Data['class'] = implode(' ', $classes);
+ }
+
+ return $Data;
+ }
+
+ # ~
+
+ protected function processTag($elementMarkup) # recursive
+ {
+ # http://stackoverflow.com/q/1148928/200145
+ libxml_use_internal_errors(true);
+
+ $DOMDocument = new DOMDocument;
+
+ # http://stackoverflow.com/q/11309194/200145
+ $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8');
+
+ # http://stackoverflow.com/q/4879946/200145
+ $DOMDocument->loadHTML($elementMarkup);
+ $DOMDocument->removeChild($DOMDocument->doctype);
+ $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild);
+
+ $elementText = '';
+
+ if ($DOMDocument->documentElement->getAttribute('markdown') === '1')
+ {
+ foreach ($DOMDocument->documentElement->childNodes as $Node)
+ {
+ $elementText .= $DOMDocument->saveHTML($Node);
+ }
+
+ $DOMDocument->documentElement->removeAttribute('markdown');
+
+ $elementText = "\n".$this->text($elementText)."\n";
+ }
+ else
+ {
+ foreach ($DOMDocument->documentElement->childNodes as $Node)
+ {
+ $nodeMarkup = $DOMDocument->saveHTML($Node);
+
+ if ($Node instanceof DOMElement and ! in_array($Node->nodeName, $this->textLevelElements))
+ {
+ $elementText .= $this->processTag($nodeMarkup);
+ }
+ else
+ {
+ $elementText .= $nodeMarkup;
+ }
+ }
+ }
+
+ # because we don't want for markup to get encoded
+ $DOMDocument->documentElement->nodeValue = 'placeholder\x1A';
+
+ $markup = $DOMDocument->saveHTML($DOMDocument->documentElement);
+ $markup = str_replace('placeholder\x1A', $elementText, $markup);
+
+ return $markup;
+ }
+
+ # ~
+
+ protected function sortFootnotes($A, $B) # callback
+ {
+ return $A['number'] - $B['number'];
+ }
+
+ #
+ # Fields
+ #
+
+ protected $regexAttribute = '(?:[#.][-\w]+[ ]*)';
+}