- new blog cms

This commit is contained in:
Ty Clifford
2026-07-04 19:41:44 -04:00
parent 64cb76a846
commit 782b983bf4
819 changed files with 3103 additions and 88735 deletions
+360
View File
@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
use Throwable;
final class App
{
private Config $config;
private ContentRepository $repository;
private CommentService $comments;
private Stats $stats;
public function __construct(Config $config, ContentRepository $repository, CommentService $comments, Stats $stats)
{
$this->config = $config;
$this->repository = $repository;
$this->comments = $comments;
$this->stats = $stats;
}
public function handle(): void
{
try {
$path = $this->path();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && preg_match('#^comment/([a-z0-9-]+)$#', $path, $matches)) {
$this->handleComment($matches[1]);
return;
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
$this->render('error', ['title' => 'Method not allowed', 'message' => 'This route only accepts GET requests.'], 405, 'error');
return;
}
$this->route($path);
} catch (Throwable $error) {
$this->render('error', [
'title' => 'Server error',
'message' => 'Something went wrong while rendering this page.',
'debug' => $error->getMessage(),
], 500, 'error');
}
}
public function repository(): ContentRepository
{
return $this->repository;
}
public function comments(): CommentService
{
return $this->comments;
}
public function stats(): Stats
{
return $this->stats;
}
public function config(): Config
{
return $this->config;
}
private function route(string $path): void
{
if ($path === '' || $path === 'home') {
$this->home(1);
return;
}
if (preg_match('#^page/(\d+)$#', $path, $matches)) {
$this->home((int) $matches[1]);
return;
}
if ($path === 'search') {
$this->search();
return;
}
if ($path === 'feed.xml') {
$this->feed();
return;
}
if ($path === 'sitemap.xml') {
$this->sitemap();
return;
}
if (preg_match('#^post/([a-z0-9-]+)$#', $path, $matches)) {
$this->single($matches[1], 'post');
return;
}
if (preg_match('#^category/([a-z0-9-]+)(?:/page/(\d+))?$#', $path, $matches)) {
$this->taxonomy('category', $matches[1], isset($matches[2]) ? (int) $matches[2] : 1);
return;
}
if (preg_match('#^tag/([a-z0-9-]+)(?:/page/(\d+))?$#', $path, $matches)) {
$this->taxonomy('tag', $matches[1], isset($matches[2]) ? (int) $matches[2] : 1);
return;
}
if (preg_match('#^archive/(\d{4})(?:/(\d{2}))?(?:/page/(\d+))?$#', $path, $matches)) {
$this->archive((int) $matches[1], isset($matches[2]) ? (int) $matches[2] : null, isset($matches[3]) ? (int) $matches[3] : 1);
return;
}
$item = $this->repository->find($path);
if ($item !== null) {
$this->single($path, (string) $item['type']);
return;
}
$this->render('error', ['title' => 'Not found', 'message' => 'That page does not exist.'], 404, 'not_found');
}
private function home(int $page): void
{
$perPage = (int) $this->config->get('posts_per_page', 6);
$result = $this->repository->paginate($this->repository->posts(false), $page, $perPage);
$this->render('listing', [
'title' => (string) $this->config->get('site.title'),
'heading' => (string) $this->config->get('site.tagline'),
'items' => $result['items'],
'pagination' => $result['pagination'],
'pagination_url' => static fn (int $target): string => $target === 1 ? View::url('') : View::url('page/' . $target),
], 200, 'home');
}
private function search(): void
{
$query = trim((string) ($_GET['q'] ?? ''));
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = (int) $this->config->get('posts_per_page', 6);
$result = $this->repository->search($query, $page, $perPage);
$this->render('listing', [
'title' => $query === '' ? 'Search' : 'Search: ' . $query,
'heading' => $query === '' ? 'Search the archive' : 'Search results for "' . $query . '"',
'items' => $result['items'],
'pagination' => $result['pagination'],
'search_query' => $query,
'empty_message' => $query === '' ? 'Enter a word or phrase to search posts and pages.' : 'No matching posts were found.',
'pagination_url' => static fn (int $target): string => View::url('search', ['q' => $query, 'page' => $target]),
], 200, 'search');
}
private function taxonomy(string $type, string $slug, int $page): void
{
$items = $type === 'category' ? $this->repository->byCategory($slug) : $this->repository->byTag($slug);
$label = $this->termLabel($items, $type, $slug);
$perPage = (int) $this->config->get('posts_per_page', 6);
$result = $this->repository->paginate($items, $page, $perPage);
$this->render('listing', [
'title' => ucfirst($type) . ': ' . $label,
'heading' => ucfirst($type) . ': ' . $label,
'items' => $result['items'],
'pagination' => $result['pagination'],
'empty_message' => 'No posts live here yet.',
'pagination_url' => static fn (int $target): string => $target === 1
? View::url($type . '/' . $slug)
: View::url($type . '/' . $slug . '/page/' . $target),
], 200, $type);
}
private function archive(int $year, ?int $month, int $page): void
{
$items = $this->repository->byArchive($year, $month);
$perPage = (int) $this->config->get('posts_per_page', 6);
$result = $this->repository->paginate($items, $page, $perPage);
$label = $month === null ? (string) $year : date('F Y', strtotime(sprintf('%04d-%02d-01', $year, $month)));
$base = $month === null ? 'archive/' . $year : sprintf('archive/%04d/%02d', $year, $month);
$this->render('listing', [
'title' => 'Archive: ' . $label,
'heading' => 'Archive: ' . $label,
'items' => $result['items'],
'pagination' => $result['pagination'],
'empty_message' => 'No posts were published in this window.',
'pagination_url' => static fn (int $target): string => $target === 1 ? View::url($base) : View::url($base . '/page/' . $target),
], 200, 'archive');
}
private function single(string $slug, string $type): void
{
$item = $this->repository->find($slug);
if ($item === null || $item['type'] !== $type) {
$this->render('error', ['title' => 'Not found', 'message' => 'That content is not published.'], 404, 'not_found');
return;
}
$approved = $this->comments->approvedFor((string) $item['slug']);
$captcha = $this->config->get('comments.captcha', true) ? Captcha::challenge() : ['token' => '', 'question' => ''];
$this->render('single', [
'title' => $item['title'],
'item' => $item,
'approved_comments' => $approved,
'captcha' => $captcha,
], 200, (string) $item['type'], (string) $item['slug']);
}
private function feed(): void
{
$base = $this->config->baseUrl();
$items = array_slice($this->repository->posts(false), 0, 20);
$xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0">',
'<channel>',
'<title>' . $this->xml((string) $this->config->get('site.title')) . '</title>',
'<link>' . $this->xml($base . View::url('')) . '</link>',
'<description>' . $this->xml((string) $this->config->get('site.description')) . '</description>',
];
foreach ($items as $item) {
$xml[] = '<item>';
$xml[] = '<title>' . $this->xml((string) $item['title']) . '</title>';
$xml[] = '<link>' . $this->xml($base . View::itemUrl($item)) . '</link>';
$xml[] = '<guid>' . $this->xml($base . View::itemUrl($item)) . '</guid>';
$xml[] = '<pubDate>' . gmdate(DATE_RSS, strtotime((string) $item['date']) ?: time()) . '</pubDate>';
$xml[] = '<description>' . $this->xml((string) $item['summary']) . '</description>';
$xml[] = '</item>';
}
$xml[] = '</channel>';
$xml[] = '</rss>';
$this->respond(implode("\n", $xml), 'application/rss+xml; charset=utf-8', 200, 'feed');
}
private function sitemap(): void
{
$base = $this->config->baseUrl();
$xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
'<url><loc>' . $this->xml($base . View::url('')) . '</loc></url>',
];
foreach ($this->repository->all(false) as $item) {
$xml[] = '<url>';
$xml[] = '<loc>' . $this->xml($base . View::itemUrl($item)) . '</loc>';
$xml[] = '<lastmod>' . $this->xml(date('Y-m-d', strtotime((string) $item['modified']) ?: time())) . '</lastmod>';
$xml[] = '</url>';
}
$xml[] = '</urlset>';
$this->respond(implode("\n", $xml), 'application/xml; charset=utf-8', 200, 'sitemap');
}
private function handleComment(string $slug): void
{
$item = $this->repository->find($slug);
if ($item === null || !$item['allow_comments'] || !$this->config->get('comments.enabled', true)) {
$this->flash('error', 'Comments are closed for that post.');
$this->redirect(View::url(''));
return;
}
if ($this->config->get('comments.captcha', true)) {
$valid = Captcha::verify((string) ($_POST['captcha_token'] ?? ''), (string) ($_POST['captcha_answer'] ?? ''));
if (!$valid) {
$this->flash('error', 'The captcha answer was not correct.');
$this->redirect(View::itemUrl($item) . '#comments');
return;
}
}
$result = $this->comments->add((string) $item['slug'], $_POST);
$this->flash($result['ok'] ? 'success' : 'error', $result['message']);
$this->redirect(View::itemUrl($item) . '#comments');
}
/** @param array<string, mixed> $data */
private function render(string $template, array $data, int $statusCode, string $routeType, string $slug = ''): void
{
http_response_code($statusCode);
header('Content-Type: text/html; charset=utf-8');
$theme = preg_replace('/[^a-z0-9_-]/', '', (string) $this->config->get('theme', 'neon')) ?: 'neon';
$layout = BLOG_ROOT . '/themes/' . $theme . '/layout.php';
$config = $this->config;
$repository = $this->repository;
$facets = $this->repository->facets();
$navPages = $this->repository->pages(false);
usort($navPages, static fn (array $a, array $b): int => ((int) $a['menu_order']) <=> ((int) $b['menu_order']));
$flash = $this->consumeFlash();
extract($data, EXTR_SKIP);
ob_start();
require $layout;
$body = (string) ob_get_clean();
$this->stats->record($routeType, $slug, $statusCode);
echo $body;
}
private function respond(string $body, string $contentType, int $statusCode, string $routeType): void
{
http_response_code($statusCode);
header('Content-Type: ' . $contentType);
$this->stats->record($routeType, '', $statusCode);
echo $body;
}
private function path(): string
{
$route = (string) ($_GET['route'] ?? '');
if ($route === '') {
$route = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
}
$scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '')));
$route = '/' . ltrim($route, '/');
if ($scriptDir !== '/' && str_starts_with($route, $scriptDir . '/')) {
$route = substr($route, strlen($scriptDir));
}
return trim($route, '/');
}
private function termLabel(array $items, string $type, string $slug): string
{
foreach ($items as $item) {
if ($type === 'category') {
return (string) $item['category'];
}
foreach ($item['tags'] as $tag) {
if (ContentRepository::slugify((string) $tag) === $slug) {
return (string) $tag;
}
}
}
return ucwords(str_replace('-', ' ', $slug));
}
private function flash(string $type, string $message): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
}
/** @return array{type: string, message: string}|null */
private function consumeFlash(): ?array
{
if (session_status() !== PHP_SESSION_ACTIVE || !isset($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return is_array($flash) ? $flash : null;
}
private function redirect(string $url): void
{
header('Location: ' . $url, true, 303);
}
private function xml(string $value): string
{
return htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
final class Captcha
{
/** @return array{token: string, question: string} */
public static function challenge(): array
{
if (session_status() !== PHP_SESSION_ACTIVE) {
return ['token' => '', 'question' => ''];
}
$a = random_int(2, 12);
$b = random_int(2, 12);
$token = bin2hex(random_bytes(12));
$_SESSION['captcha'][$token] = [
'answer' => (string) ($a + $b),
'expires' => time() + 900,
];
return [
'token' => $token,
'question' => 'What is ' . $a . ' + ' . $b . '?',
];
}
public static function verify(string $token, string $answer): bool
{
if (session_status() !== PHP_SESSION_ACTIVE || $token === '') {
return false;
}
$challenge = $_SESSION['captcha'][$token] ?? null;
unset($_SESSION['captcha'][$token]);
if (!is_array($challenge) || (int) ($challenge['expires'] ?? 0) < time()) {
return false;
}
return trim($answer) === (string) ($challenge['answer'] ?? '');
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
use PDO;
final class CommentService
{
private PDO $pdo;
private Config $config;
public function __construct(PDO $pdo, Config $config)
{
$this->pdo = $pdo;
$this->config = $config;
}
/** @return array<int, array<string, mixed>> */
public function approvedFor(string $slug): array
{
$statement = $this->pdo->prepare(
'SELECT * FROM comments WHERE slug = :slug AND status = "approved" ORDER BY created_at ASC'
);
$statement->execute(['slug' => $slug]);
return $statement->fetchAll();
}
/** @return array{ok: bool, stored: bool, message: string} */
public function add(string $slug, array $data): array
{
$honeypot = (string) $this->config->get('comments.honeypot_field', 'website_url');
if (trim((string) ($data[$honeypot] ?? '')) !== '') {
return [
'ok' => true,
'stored' => false,
'message' => 'Thanks. Your comment is awaiting review.',
];
}
$author = trim((string) ($data['author'] ?? ''));
$email = trim((string) ($data['email'] ?? ''));
$website = trim((string) ($data['site_url'] ?? ''));
$body = trim((string) ($data['body'] ?? ''));
if ($author === '' || $email === '' || $body === '') {
return ['ok' => false, 'stored' => false, 'message' => 'Name, email, and comment are required.'];
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'stored' => false, 'message' => 'Please enter a valid email address.'];
}
if ($website !== '' && !filter_var($website, FILTER_VALIDATE_URL)) {
return ['ok' => false, 'stored' => false, 'message' => 'Please enter a valid website URL or leave it blank.'];
}
if (mb_strlen($body) > 5000) {
return ['ok' => false, 'stored' => false, 'message' => 'Comments must stay under 5,000 characters.'];
}
$now = date('c');
$status = $this->config->get('comments.moderate', true) ? 'pending' : 'approved';
$statement = $this->pdo->prepare(
'INSERT INTO comments
(slug, author, email, website, body, status, ip_hash, user_agent, created_at, updated_at)
VALUES
(:slug, :author, :email, :website, :body, :status, :ip_hash, :user_agent, :created_at, :updated_at)'
);
$statement->execute([
'slug' => $slug,
'author' => $author,
'email' => $email,
'website' => $website,
'body' => $body,
'status' => $status,
'ip_hash' => $this->ipHash(),
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? 'cli'), 0, 300),
'created_at' => $now,
'updated_at' => $now,
]);
return [
'ok' => true,
'stored' => true,
'message' => $status === 'approved'
? 'Thanks. Your comment is live.'
: 'Thanks. Your comment is awaiting review.',
];
}
/** @return array<int, array<string, mixed>> */
public function list(?string $status = null, ?string $slug = null): array
{
$where = [];
$params = [];
if ($status !== null) {
$where[] = 'status = :status';
$params['status'] = $status;
}
if ($slug !== null) {
$where[] = 'slug = :slug';
$params['slug'] = $slug;
}
$sql = 'SELECT * FROM comments';
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY created_at DESC';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
return $statement->fetchAll();
}
public function setStatus(int $id, string $status): bool
{
if (!in_array($status, ['pending', 'approved', 'spam'], true)) {
return false;
}
$statement = $this->pdo->prepare('UPDATE comments SET status = :status, updated_at = :updated_at WHERE id = :id');
$statement->execute([
'id' => $id,
'status' => $status,
'updated_at' => date('c'),
]);
return $statement->rowCount() > 0;
}
public function delete(int $id): bool
{
$statement = $this->pdo->prepare('DELETE FROM comments WHERE id = :id');
$statement->execute(['id' => $id]);
return $statement->rowCount() > 0;
}
private function ipHash(): string
{
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'cli');
$salt = (string) $this->config->get('stats.hash_salt', 'change-me');
return hash_hmac('sha256', $ip, $salt);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
final class Config
{
/** @var array<string, mixed> */
private array $data;
private string $path;
/** @param array<string, mixed> $data */
private function __construct(string $path, array $data)
{
$this->path = $path;
$this->data = array_replace_recursive(self::defaults(), $data);
}
public static function load(string $path): self
{
$data = [];
if (is_file($path)) {
$decoded = json_decode((string) file_get_contents($path), true);
if (is_array($decoded)) {
$data = $decoded;
}
}
return new self($path, $data);
}
/** @return mixed */
public function get(string $key, mixed $default = null): mixed
{
$segments = explode('.', $key);
$value = $this->data;
foreach ($segments as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
public function set(string $key, mixed $value): void
{
$segments = explode('.', $key);
$cursor = &$this->data;
foreach ($segments as $segment) {
if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
$cursor[$segment] = [];
}
$cursor = &$cursor[$segment];
}
$cursor = $value;
}
/** @return array<string, mixed> */
public function all(): array
{
return $this->data;
}
public function save(): void
{
$dir = dirname($this->path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents(
$this->path,
json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
}
public function baseUrl(): string
{
$configured = trim((string) $this->get('base_url', ''));
if ($configured !== '') {
return rtrim($configured, '/');
}
if (PHP_SAPI === 'cli') {
return '';
}
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host;
}
/** @return array<string, mixed> */
private static function defaults(): array
{
return [
'site' => [
'title' => 'Neon Notes',
'tagline' => 'A fast file-first blog CMS',
'description' => 'A Markdown blog powered by PHP, SQLite, JSON, and plain files.',
'author' => 'Editor',
'language' => 'en',
],
'base_url' => '',
'timezone' => 'UTC',
'theme' => 'neon',
'posts_per_page' => 6,
'comments' => [
'enabled' => true,
'moderate' => true,
'captcha' => true,
'honeypot_field' => 'website_url',
],
'stats' => [
'enabled' => true,
'hash_salt' => 'change-me',
],
'social' => [],
];
}
}
+521
View File
@@ -0,0 +1,521 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
use RuntimeException;
final class ContentRepository
{
private string $pagesRoot;
private string $indexPath;
public function __construct(string $pagesRoot, string $indexPath)
{
$this->pagesRoot = $pagesRoot;
$this->indexPath = $indexPath;
}
/** @return array<int, array<string, mixed>> */
public function posts(bool $includeDrafts = false): array
{
return $this->filterByType('post', $includeDrafts);
}
/** @return array<int, array<string, mixed>> */
public function pages(bool $includeDrafts = false): array
{
return $this->filterByType('page', $includeDrafts);
}
/** @return array<int, array<string, mixed>> */
public function all(bool $includeDrafts = false): array
{
$items = [];
if (!is_dir($this->pagesRoot)) {
return $items;
}
$directories = glob($this->pagesRoot . '/*', GLOB_ONLYDIR) ?: [];
foreach ($directories as $directory) {
$slug = basename($directory);
$file = $directory . '/index.txt';
if (!is_file($file)) {
continue;
}
$item = $this->read($slug, $file);
if (!$includeDrafts && $item['status'] !== 'published') {
continue;
}
$items[] = $item;
}
usort($items, static function (array $a, array $b): int {
$dateCompare = strcmp((string) $b['date'], (string) $a['date']);
if ($dateCompare !== 0) {
return $dateCompare;
}
return strcmp((string) $a['title'], (string) $b['title']);
});
return $items;
}
public function find(string $slug, bool $includeDrafts = false): ?array
{
$slug = self::slugify($slug);
$file = $this->pagesRoot . '/' . $slug . '/index.txt';
if (!is_file($file)) {
return null;
}
$item = $this->read($slug, $file);
if (!$includeDrafts && $item['status'] !== 'published') {
return null;
}
return $item;
}
/** @return array{items: array<int, array<string, mixed>>, pagination: array<string, int|bool|null>} */
public function paginate(array $items, int $page, int $perPage): array
{
$total = count($items);
$pages = max(1, (int) ceil($total / max(1, $perPage)));
$page = max(1, min($page, $pages));
$offset = ($page - 1) * $perPage;
return [
'items' => array_slice($items, $offset, $perPage),
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'pages' => $pages,
'has_previous' => $page > 1,
'has_next' => $page < $pages,
'previous' => $page > 1 ? $page - 1 : null,
'next' => $page < $pages ? $page + 1 : null,
],
];
}
/** @return array{items: array<int, array<string, mixed>>, pagination: array<string, int|bool|null>} */
public function search(string $query, int $page, int $perPage): array
{
$query = trim(mb_strtolower($query));
if ($query === '') {
return $this->paginate([], $page, $perPage);
}
$terms = preg_split('/\s+/', $query) ?: [];
$scored = [];
foreach ($this->all(false) as $item) {
$haystack = mb_strtolower(
$item['title'] . ' ' .
$item['summary'] . ' ' .
$item['category'] . ' ' .
implode(' ', $item['tags']) . ' ' .
strip_tags((string) $item['markdown'])
);
$score = 0;
foreach ($terms as $term) {
if ($term === '') {
continue;
}
$score += substr_count($haystack, $term);
if (str_contains(mb_strtolower((string) $item['title']), $term)) {
$score += 4;
}
}
if ($score > 0) {
$item['search_score'] = $score;
$scored[] = $item;
}
}
usort($scored, static function (array $a, array $b): int {
$scoreCompare = ((int) $b['search_score']) <=> ((int) $a['search_score']);
if ($scoreCompare !== 0) {
return $scoreCompare;
}
return strcmp((string) $b['date'], (string) $a['date']);
});
return $this->paginate($scored, $page, $perPage);
}
/** @return array<int, array<string, mixed>> */
public function byCategory(string $categorySlug): array
{
return array_values(array_filter($this->posts(false), static function (array $item) use ($categorySlug): bool {
return self::slugify((string) $item['category']) === $categorySlug;
}));
}
/** @return array<int, array<string, mixed>> */
public function byTag(string $tagSlug): array
{
return array_values(array_filter($this->posts(false), static function (array $item) use ($tagSlug): bool {
foreach ($item['tags'] as $tag) {
if (self::slugify((string) $tag) === $tagSlug) {
return true;
}
}
return false;
}));
}
/** @return array<int, array<string, mixed>> */
public function byArchive(int $year, ?int $month = null): array
{
return array_values(array_filter($this->posts(false), static function (array $item) use ($year, $month): bool {
$timestamp = strtotime((string) $item['date']) ?: 0;
if ((int) date('Y', $timestamp) !== $year) {
return false;
}
return $month === null || (int) date('m', $timestamp) === $month;
}));
}
/** @return array{categories: array<string, int>, tags: array<string, int>, archives: array<string, int>} */
public function facets(): array
{
$categories = [];
$tags = [];
$archives = [];
foreach ($this->posts(false) as $item) {
$category = (string) $item['category'];
$categories[$category] = ($categories[$category] ?? 0) + 1;
foreach ($item['tags'] as $tag) {
$tags[(string) $tag] = ($tags[(string) $tag] ?? 0) + 1;
}
$archive = date('Y-m', strtotime((string) $item['date']) ?: time());
$archives[$archive] = ($archives[$archive] ?? 0) + 1;
}
ksort($categories);
ksort($tags);
krsort($archives);
return [
'categories' => $categories,
'tags' => $tags,
'archives' => $archives,
];
}
/** @param array<string, mixed> $metadata */
public function save(string $slug, array $metadata, string $markdown): array
{
$slug = self::slugify($slug !== '' ? $slug : (string) ($metadata['title'] ?? 'untitled'));
if ($slug === '') {
throw new RuntimeException('A title or slug is required.');
}
$directory = $this->pagesRoot . '/' . $slug;
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$existing = $this->find($slug, true);
$now = date('c');
$metadata = array_replace([
'title' => self::titleFromSlug($slug),
'type' => 'post',
'status' => 'draft',
'date' => $existing['date'] ?? $now,
'modified' => $now,
'category' => 'Notes',
'tags' => [],
'summary' => '',
'author' => 'Editor',
'cover' => '',
'allow_comments' => true,
'menu_order' => 0,
], $metadata);
$metadata['slug'] = $slug;
$metadata['tags'] = self::normalizeList($metadata['tags'] ?? []);
$metadata['allow_comments'] = self::toBool($metadata['allow_comments'] ?? true);
$metadata['modified'] = $now;
file_put_contents($directory . '/index.txt', $this->serialize($metadata, $markdown));
$this->rebuildIndex();
return $this->find($slug, true) ?? [];
}
public function delete(string $slug): bool
{
$slug = self::slugify($slug);
$directory = $this->pagesRoot . '/' . $slug;
if (!is_dir($directory)) {
return false;
}
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($directory);
$this->rebuildIndex();
return true;
}
public function rebuildIndex(): void
{
$dir = dirname($this->indexPath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$records = [];
foreach ($this->all(true) as $item) {
$copy = $item;
unset($copy['markdown'], $copy['html'], $copy['path']);
$records[] = $copy;
}
file_put_contents($this->indexPath, json_encode([
'generated_at' => date('c'),
'format' => 'neon-blog-pages-v1',
'items' => $records,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
public static function slugify(string $value): string
{
$value = trim(mb_strtolower($value));
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
$value = trim($value, '-');
return $value;
}
/** @return array<int, string> */
public static function normalizeList(mixed $value): array
{
if (is_string($value)) {
$value = preg_split('/\s*,\s*/', $value) ?: [];
}
if (!is_array($value)) {
return [];
}
$clean = [];
foreach ($value as $item) {
$item = trim((string) $item);
if ($item !== '') {
$clean[] = $item;
}
}
return array_values(array_unique($clean));
}
public static function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(mb_strtolower((string) $value), ['1', 'true', 'yes', 'on'], true);
}
/** @return array<int, array<string, mixed>> */
private function filterByType(string $type, bool $includeDrafts): array
{
return array_values(array_filter($this->all($includeDrafts), static function (array $item) use ($type): bool {
return $item['type'] === $type;
}));
}
/** @return array<string, mixed> */
private function read(string $slug, string $file): array
{
[$metadata, $markdown] = $this->parse((string) file_get_contents($file));
$modified = date('c', filemtime($file) ?: time());
$metadata = array_replace([
'slug' => $slug,
'title' => $this->firstHeading($markdown) ?: self::titleFromSlug($slug),
'type' => 'post',
'status' => 'published',
'date' => $modified,
'modified' => $modified,
'category' => 'Notes',
'tags' => [],
'summary' => '',
'author' => 'Editor',
'cover' => '',
'allow_comments' => true,
'menu_order' => 0,
], $metadata);
$metadata['slug'] = self::slugify((string) ($metadata['slug'] ?: $slug));
$metadata['tags'] = self::normalizeList($metadata['tags'] ?? []);
$metadata['allow_comments'] = self::toBool($metadata['allow_comments'] ?? true);
$metadata['menu_order'] = (int) ($metadata['menu_order'] ?? 0);
$metadata['date'] = date('c', strtotime((string) $metadata['date']) ?: time());
$metadata['modified'] = date('c', strtotime((string) $metadata['modified']) ?: filemtime($file) ?: time());
$metadata['summary'] = trim((string) ($metadata['summary'] ?: Markdown::excerpt($markdown)));
return $metadata + [
'path' => $file,
'markdown' => $markdown,
'html' => Markdown::toHtml($markdown),
'excerpt' => Markdown::excerpt($metadata['summary'] ?: $markdown, 34),
];
}
/** @return array{0: array<string, mixed>, 1: string} */
private function parse(string $raw): array
{
$raw = str_replace(["\r\n", "\r"], "\n", $raw);
if (str_starts_with($raw, "---\n")) {
$end = strpos($raw, "\n---\n", 4);
if ($end !== false) {
$frontMatter = substr($raw, 4, $end - 4);
$body = ltrim(substr($raw, $end + 5), "\n");
return [$this->parseKeyValueBlock($frontMatter), $body];
}
}
return $this->parseLegacyHeader($raw);
}
/** @return array<string, mixed> */
private function parseKeyValueBlock(string $block): array
{
$metadata = [];
foreach (explode("\n", $block) as $line) {
if (!str_contains($line, ':')) {
continue;
}
[$key, $value] = explode(':', $line, 2);
$metadata[trim($key)] = $this->parseValue(trim($value));
}
return $metadata;
}
/** @return array{0: array<string, mixed>, 1: string} */
private function parseLegacyHeader(string $raw): array
{
$known = [
'title', 'slug', 'date', 'modified', 'type', 'status', 'category',
'tags', 'summary', 'description', 'author', 'cover', 'allow_comments',
'menu_order',
];
$metadata = [];
$lines = explode("\n", $raw);
$cursor = 0;
while (isset($lines[$cursor])) {
$line = $lines[$cursor];
if (trim($line) === '') {
$cursor++;
break;
}
if (!preg_match('/^([A-Za-z_ -]+):\s*(.*)$/', $line, $matches)) {
break;
}
$key = mb_strtolower(str_replace([' ', '-'], '_', trim($matches[1])));
if (!in_array($key, $known, true)) {
break;
}
$metadata[$key] = $this->parseValue(trim($matches[2]));
$cursor++;
}
if ($metadata === []) {
return [[], $raw];
}
return [$metadata, implode("\n", array_slice($lines, $cursor))];
}
private function parseValue(string $value): mixed
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($value[0] === '[' && str_ends_with($value, ']')) {
$inside = trim(substr($value, 1, -1));
return $inside === '' ? [] : self::normalizeList($inside);
}
if (
(str_starts_with($value, '"') && str_ends_with($value, '"')) ||
(str_starts_with($value, "'") && str_ends_with($value, "'"))
) {
return substr($value, 1, -1);
}
$lower = mb_strtolower($value);
if (in_array($lower, ['true', 'false'], true)) {
return $lower === 'true';
}
if (is_numeric($value) && preg_match('/^\d+$/', $value)) {
return (int) $value;
}
return $value;
}
/** @param array<string, mixed> $metadata */
private function serialize(array $metadata, string $markdown): string
{
$order = [
'title', 'slug', 'type', 'status', 'date', 'modified', 'category',
'tags', 'summary', 'author', 'cover', 'allow_comments', 'menu_order',
];
$lines = ['---'];
foreach ($order as $key) {
if (!array_key_exists($key, $metadata)) {
continue;
}
$value = $metadata[$key];
if (is_array($value)) {
$value = '[' . implode(', ', array_map('strval', $value)) . ']';
} elseif (is_bool($value)) {
$value = $value ? 'true' : 'false';
}
$lines[] = $key . ': ' . str_replace(["\r", "\n"], ' ', (string) $value);
}
$lines[] = '---';
$lines[] = '';
$lines[] = rtrim($markdown) . PHP_EOL;
return implode(PHP_EOL, $lines);
}
private function firstHeading(string $markdown): string
{
foreach (explode("\n", $markdown) as $line) {
if (preg_match('/^#\s+(.+)$/', trim($line), $matches)) {
return trim($matches[1]);
}
}
return '';
}
private static function titleFromSlug(string $slug): string
{
return ucwords(str_replace('-', ' ', $slug));
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
use PDO;
final class Database
{
public static function connect(string $path): PDO
{
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$pdo = new PDO('sqlite:' . $path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA journal_mode = WAL');
self::migrate($pdo);
return $pdo;
}
private static function migrate(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
author TEXT NOT NULL,
email TEXT NOT NULL,
website TEXT DEFAULT "",
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT "pending",
ip_hash TEXT NOT NULL DEFAULT "",
user_agent TEXT NOT NULL DEFAULT "",
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)'
);
$pdo->exec('CREATE INDEX IF NOT EXISTS comments_slug_status_idx ON comments(slug, status)');
$pdo->exec('CREATE INDEX IF NOT EXISTS comments_status_created_idx ON comments(status, created_at)');
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
final class Markdown
{
public static function toHtml(string $markdown): string
{
$markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
if ($markdown === '') {
return '';
}
$lines = explode("\n", $markdown);
$html = [];
$count = count($lines);
$i = 0;
while ($i < $count) {
$line = rtrim($lines[$i]);
if (trim($line) === '') {
$i++;
continue;
}
if (preg_match('/^```([A-Za-z0-9_-]*)\s*$/', $line, $matches)) {
$language = $matches[1] !== '' ? ' class="language-' . self::e($matches[1]) . '"' : '';
$code = [];
$i++;
while ($i < $count && !preg_match('/^```\s*$/', rtrim($lines[$i]))) {
$code[] = $lines[$i];
$i++;
}
$html[] = '<pre><code' . $language . '>' . self::e(implode("\n", $code)) . '</code></pre>';
$i++;
continue;
}
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $matches)) {
$level = strlen($matches[1]);
$html[] = '<h' . $level . '>' . self::inline($matches[2]) . '</h' . $level . '>';
$i++;
continue;
}
if (preg_match('/^(-{3,}|\*{3,}|_{3,})$/', trim($line))) {
$html[] = '<hr>';
$i++;
continue;
}
if (preg_match('/^\s*>\s?(.*)$/', $line)) {
$quote = [];
while ($i < $count && preg_match('/^\s*>\s?(.*)$/', rtrim($lines[$i]), $matches)) {
$quote[] = $matches[1];
$i++;
}
$html[] = '<blockquote>' . self::toHtml(implode("\n", $quote)) . '</blockquote>';
continue;
}
if (preg_match('/^\s*[-*+]\s+(.+)$/', $line)) {
$items = [];
while ($i < $count && preg_match('/^\s*[-*+]\s+(.+)$/', rtrim($lines[$i]), $matches)) {
$items[] = '<li>' . self::inline($matches[1]) . '</li>';
$i++;
}
$html[] = '<ul>' . implode('', $items) . '</ul>';
continue;
}
if (preg_match('/^\s*\d+\.\s+(.+)$/', $line)) {
$items = [];
while ($i < $count && preg_match('/^\s*\d+\.\s+(.+)$/', rtrim($lines[$i]), $matches)) {
$items[] = '<li>' . self::inline($matches[1]) . '</li>';
$i++;
}
$html[] = '<ol>' . implode('', $items) . '</ol>';
continue;
}
$paragraph = [$line];
$i++;
while ($i < $count && trim($lines[$i]) !== '' && !self::startsBlock(rtrim($lines[$i]))) {
$paragraph[] = trim($lines[$i]);
$i++;
}
$html[] = '<p>' . self::inline(implode(' ', $paragraph)) . '</p>';
}
return implode("\n", $html);
}
public static function excerpt(string $markdown, int $words = 28): string
{
$text = trim(preg_replace('/\s+/', ' ', strip_tags(self::toHtml($markdown))) ?? '');
if ($text === '') {
return '';
}
$parts = preg_split('/\s+/', $text) ?: [];
if (count($parts) <= $words) {
return $text;
}
return implode(' ', array_slice($parts, 0, $words)) . '...';
}
private static function startsBlock(string $line): bool
{
return (bool) preg_match('/^(#{1,6}\s+|```|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s?|(-{3,}|\*{3,}|_{3,})$)/', $line);
}
private static function inline(string $text): string
{
$placeholders = [];
$text = preg_replace_callback('/`([^`]+)`/', static function (array $matches) use (&$placeholders): string {
$key = "\x1A" . count($placeholders) . "\x1A";
$placeholders[$key] = '<code>' . self::e($matches[1]) . '</code>';
return $key;
}, $text) ?? $text;
$text = self::e($text);
$text = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/', static function (array $matches): string {
$title = isset($matches[3]) ? ' title="' . self::e($matches[3]) . '"' : '';
return '<img src="' . self::e($matches[2]) . '" alt="' . self::e($matches[1]) . '"' . $title . '>';
}, $text) ?? $text;
$text = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/', static function (array $matches): string {
$title = isset($matches[3]) ? ' title="' . self::e($matches[3]) . '"' : '';
return '<a href="' . self::e($matches[2]) . '"' . $title . '>' . $matches[1] . '</a>';
}, $text) ?? $text;
$text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text) ?? $text;
$text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text) ?? $text;
return strtr($text, $placeholders);
}
private static function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
final class Stats
{
private string $path;
private Config $config;
public function __construct(string $path, Config $config)
{
$this->path = $path;
$this->config = $config;
}
public function record(string $routeType, string $slug, int $statusCode): void
{
if (!$this->config->get('stats.enabled', true) || PHP_SAPI === 'cli') {
return;
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
return;
}
$dir = dirname($this->path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$handle = fopen($this->path, 'ab');
if (!$handle) {
return;
}
flock($handle, LOCK_EX);
clearstatcache(true, $this->path);
if ((filesize($this->path) ?: 0) === 0) {
fputcsv($handle, [
'date',
'time',
'request_id',
'ip_hash',
'method',
'path',
'route_type',
'slug',
'query',
'referrer',
'user_agent',
'status_code',
], ',', '"', '', "\n");
}
$query = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_QUERY) ?: '';
fputcsv($handle, [
date('Y-m-d'),
date('H:i:s'),
bin2hex(random_bytes(6)),
$this->ipHash(),
$_SERVER['REQUEST_METHOD'] ?? 'GET',
parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/',
$routeType,
$slug,
$query,
$_SERVER['HTTP_REFERER'] ?? '',
substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 300),
$statusCode,
], ',', '"', '', "\n");
flock($handle, LOCK_UN);
fclose($handle);
}
/** @return array<string, mixed> */
public function summary(): array
{
if (!is_file($this->path)) {
return [
'visits' => 0,
'routes' => [],
'top_paths' => [],
];
}
$handle = fopen($this->path, 'rb');
if (!$handle) {
return ['visits' => 0, 'routes' => [], 'top_paths' => []];
}
$header = fgetcsv($handle, null, ',', '"', '') ?: [];
$visits = 0;
$routes = [];
$paths = [];
while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) {
if ($row === $header) {
continue;
}
$record = array_combine($header, $row);
if (!is_array($record)) {
continue;
}
$visits++;
$routes[$record['route_type']] = ($routes[$record['route_type']] ?? 0) + 1;
$paths[$record['path']] = ($paths[$record['path']] ?? 0) + 1;
}
fclose($handle);
arsort($routes);
arsort($paths);
return [
'visits' => $visits,
'routes' => $routes,
'top_paths' => array_slice($paths, 0, 10, true),
];
}
private function ipHash(): string
{
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'cli');
$salt = (string) $this->config->get('stats.hash_salt', 'change-me');
return hash_hmac('sha256', $ip, $salt);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace NeonBlog;
final class View
{
public static function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** @param array<string, mixed> $query */
public static function url(string $path = '', array $query = []): string
{
$path = '/' . ltrim($path, '/');
if ($path !== '/' && str_ends_with($path, '/')) {
$path = rtrim($path, '/');
}
return $query === [] ? $path : $path . '?' . http_build_query($query);
}
/** @param array<string, mixed> $item */
public static function itemUrl(array $item): string
{
$slug = (string) ($item['slug'] ?? '');
return ($item['type'] ?? 'post') === 'page'
? self::url($slug)
: self::url('post/' . $slug);
}
public static function asset(string $path): string
{
return self::url('themes/neon/assets/' . ltrim($path, '/'));
}
public static function date(string $iso, string $format = 'M j, Y'): string
{
$time = strtotime($iso);
return $time ? date($format, $time) : $iso;
}
public static function month(string $yearMonth): string
{
$time = strtotime($yearMonth . '-01');
return $time ? date('F Y', $time) : $yearMonth;
}
public static function classIf(bool $condition, string $class): string
{
return $condition ? ' ' . $class : '';
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use NeonBlog\App;
use NeonBlog\CommentService;
use NeonBlog\Config;
use NeonBlog\ContentRepository;
use NeonBlog\Database;
use NeonBlog\Stats;
define('BLOG_ROOT', dirname(__DIR__));
spl_autoload_register(static function (string $class): void {
$prefix = 'NeonBlog\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$relative = substr($class, strlen($prefix));
$path = BLOG_ROOT . '/app/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require $path;
}
});
if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
]);
}
$config = Config::load(BLOG_ROOT . '/config/site.json');
date_default_timezone_set((string) $config->get('timezone', 'UTC'));
$repository = new ContentRepository(
BLOG_ROOT . '/bl-content/pages',
BLOG_ROOT . '/bl-content/databases/pages.json'
);
$pdo = Database::connect(BLOG_ROOT . '/storage/database/blog.sqlite');
$comments = new CommentService($pdo, $config);
$stats = new Stats(BLOG_ROOT . '/storage/stats/visits.csv', $config);
return new App($config, $repository, $comments, $stats);