pagesRoot = $pagesRoot; $this->indexPath = $indexPath; } /** @return array> */ public function posts(bool $includeDrafts = false): array { return $this->filterByType('post', $includeDrafts); } /** @return array> */ public function pages(bool $includeDrafts = false): array { return $this->filterByType('page', $includeDrafts); } /** @return array> */ 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>, pagination: array} */ public function paginate(array $items, int $page, int $perPage): array { $perPage = max(1, $perPage); $total = count($items); $pages = max(1, (int) ceil($total / $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, 'numbers' => $this->paginationNumbers($page, $pages, 10), ], ]; } /** @return array{items: array>, pagination: array} */ 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 */ private function paginationNumbers(int $page, int $pages, int $window): array { if ($pages <= 1) { return [1]; } if ($pages <= $window) { return range(1, $pages); } $before = (int) floor(($window - 1) / 2); $start = max(1, $page - $before); $end = $start + $window - 1; if ($end > $pages) { $end = $pages; $start = max(1, $end - $window + 1); } return range($start, $end); } /** @return array> */ 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> */ 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> */ 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, tags: array, archives: array} */ 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 $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 $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 */ 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> */ 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 */ 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, 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 */ 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, 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 $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)); } }