566 lines
18 KiB
PHP
566 lines
18 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) ?? [];
|
|
}
|
|
|
|
/** @param array<string, mixed> $metadata */
|
|
public function saveImported(string $slug, array $metadata, string $markdown, bool $rebuildIndex = true): 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);
|
|
}
|
|
|
|
$date = (string) ($metadata['date'] ?? date('c'));
|
|
$metadata = array_replace([
|
|
'title' => self::titleFromSlug($slug),
|
|
'type' => 'post',
|
|
'status' => 'published',
|
|
'date' => $date,
|
|
'modified' => $metadata['modified'] ?? $date,
|
|
'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);
|
|
|
|
file_put_contents($directory . '/index.txt', $this->serialize($metadata, $markdown));
|
|
$timestamp = strtotime((string) ($metadata['modified'] ?? $metadata['date'])) ?: time();
|
|
touch($directory . '/index.txt', $timestamp);
|
|
|
|
if ($rebuildIndex) {
|
|
$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));
|
|
}
|
|
}
|