- new blog cms
This commit is contained in:
+360
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user