Files
tcms/app/ContentRepository.php
T
2026-07-04 19:41:44 -04:00

522 lines
17 KiB
PHP

<?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));
}
}