diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab17e5..1718bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,13 @@ All notable changes to Ty Clifford's Content Management System are documented he - Added human-readable CSV visit stats designed for spreadsheet import. - Added subdirectory install support through `base_path`, `base_url`, route parsing, and base-aware URL generation. - Added `cli/import-bludit.php` for importing Bludit content from a local server directory, preserving publish dates, modified dates, static pages, statuses, categories, tags, authors, cover metadata, page order, uploads, page-local files, and best-effort comments. +- Added trusted post/page rendering for raw HTML, iframes, JavaScript, and executable PHP inside Markdown `.txt` content. ### Changed - Replaced the previous Bludit application tree with a purpose-built CMS while keeping copy-friendly Bludit-style content storage. - Updated default branding to Ty Clifford's Content Management System. +- Expanded pagination controls to show Previous, up to 10 page numbers around the current page, Next, and a per-page summary. ### Verified diff --git a/README.md b/README.md index 25c06db..fceca8e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ bl-content/pages//index.txt Posts and static pages are Markdown `.txt` files with front matter. Comments are stored in SQLite, site configuration and generated indexes are JSON, and visit stats are CSV for easy Excel import. +Post and page bodies are treated as trusted author content. Markdown is rendered while allowing raw HTML, iframes, JavaScript, and executable PHP blocks inside the `.txt` files. + ## Requirements - PHP 8.1 or newer diff --git a/app/ContentRepository.php b/app/ContentRepository.php index c6bd378..2177c61 100644 --- a/app/ContentRepository.php +++ b/app/ContentRepository.php @@ -79,11 +79,12 @@ final class ContentRepository return $item; } - /** @return array{items: array>, pagination: array} */ + /** @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 / max(1, $perPage))); + $pages = max(1, (int) ceil($total / $perPage)); $page = max(1, min($page, $pages)); $offset = ($page - 1) * $perPage; @@ -98,11 +99,12 @@ final class ContentRepository '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} */ + /** @return array{items: array>, pagination: array} */ public function search(string $query, int $page, int $perPage): array { $query = trim(mb_strtolower($query)); @@ -150,6 +152,27 @@ final class ContentRepository 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 { diff --git a/app/Markdown.php b/app/Markdown.php index eda44e2..58eae96 100644 --- a/app/Markdown.php +++ b/app/Markdown.php @@ -5,9 +5,13 @@ namespace NeonBlog; final class Markdown { - public static function toHtml(string $markdown): string + public static function toHtml(string $markdown, bool $executePhp = true): string { - $markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown)); + $markdown = str_replace(["\r\n", "\r"], "\n", $markdown); + if ($executePhp) { + $markdown = self::executePhp($markdown); + } + $markdown = trim($markdown); if ($markdown === '') { return ''; } @@ -24,6 +28,12 @@ final class Markdown continue; } + if (self::startsHtmlBlock($line)) { + [$block, $i] = self::consumeHtmlBlock($lines, $i, $count); + $html[] = $block; + continue; + } + if (preg_match('/^```([A-Za-z0-9_-]*)\s*$/', $line, $matches)) { $language = $matches[1] !== '' ? ' class="language-' . self::e($matches[1]) . '"' : ''; $code = []; @@ -56,7 +66,7 @@ final class Markdown $quote[] = $matches[1]; $i++; } - $html[] = '
' . self::toHtml(implode("\n", $quote)) . '
'; + $html[] = '
' . self::toHtml(implode("\n", $quote), false) . '
'; continue; } @@ -94,7 +104,10 @@ final class Markdown public static function excerpt(string $markdown, int $words = 28): string { - $text = trim(preg_replace('/\s+/', ' ', strip_tags(self::toHtml($markdown))) ?? ''); + $markdown = preg_replace('/<\?(?:php|=)?[\s\S]*?\?>/i', ' ', $markdown) ?? $markdown; + $html = self::toHtml($markdown, false); + $html = preg_replace('#<(script|style)\b[^>]*>.*?#is', ' ', $html) ?? $html; + $text = trim(preg_replace('/\s+/', ' ', strip_tags($html)) ?? ''); if ($text === '') { return ''; } @@ -109,7 +122,8 @@ final class Markdown 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); + return self::startsHtmlBlock($line) + || (bool) preg_match('/^(#{1,6}\s+|```|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s?|(-{3,}|\*{3,}|_{3,})$)/', $line); } private static function inline(string $text): string @@ -121,6 +135,12 @@ final class Markdown return $key; }, $text) ?? $text; + $text = preg_replace_callback('/<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s+[^<>]*)?>||]*>/s', static function (array $matches) use (&$placeholders): string { + $key = "\x1A" . count($placeholders) . "\x1A"; + $placeholders[$key] = $matches[0]; + 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]) . '"' : ''; @@ -136,6 +156,81 @@ final class Markdown return strtr($text, $placeholders); } + private static function executePhp(string $source): string + { + if (!str_contains($source, '' . $source); + return strtr((string) ob_get_clean(), $placeholders); + } catch (\Throwable $error) { + ob_end_clean(); + return strtr($source, $placeholders) . "\n\n"; + } + } + + private static function startsHtmlBlock(string $line): bool + { + $line = trim($line); + return (bool) preg_match('/^(')) { + $block[] = rtrim($lines[$i]); + $i++; + } + return [implode("\n", $block), $i]; + } + + if (!preg_match('/^<([A-Za-z][A-Za-z0-9:-]*)\b/i', $trimmed, $matches)) { + return [implode("\n", $block), $i]; + } + + $tag = strtolower($matches[1]); + $voidTags = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']; + if (in_array($tag, $voidTags, true) || str_ends_with($trimmed, '/>') || stripos($trimmed, '') !== false) { + return [implode("\n", $block), $i]; + } + + while ($i < $count) { + $block[] = rtrim($lines[$i]); + if (stripos((string) end($block), '') !== false) { + $i++; + break; + } + if (trim($lines[$i]) === '' && !in_array($tag, ['article', 'aside', 'div', 'footer', 'form', 'header', 'iframe', 'main', 'nav', 'pre', 'script', 'section', 'style', 'table', 'textarea'], true)) { + $i++; + break; + } + $i++; + } + + return [implode("\n", $block), $i]; + } + private static function e(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); diff --git a/themes/neon/assets/style.css b/themes/neon/assets/style.css index 204de4c..6061635 100644 --- a/themes/neon/assets/style.css +++ b/themes/neon/assets/style.css @@ -440,7 +440,7 @@ h2 { .pagination { display: flex; flex-wrap: wrap; - gap: 0.8rem; + gap: 0.45rem; align-items: center; justify-content: center; margin: 1.5rem 0; @@ -448,7 +448,7 @@ h2 { } .pager { - min-width: 112px; + min-width: 44px; border: 1px solid var(--line); border-radius: var(--radius); padding: 0.6rem 0.9rem; @@ -457,6 +457,29 @@ h2 { background: var(--surface); } +.pager:first-child, +.pager:nth-last-child(2) { + min-width: 104px; +} + +.pager--current { + color: #06100b; + border-color: color-mix(in srgb, var(--accent) 70%, white); + background: linear-gradient(135deg, var(--accent), var(--accent-2)); +} + +.pager--disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.pagination__summary { + flex-basis: 100%; + margin-top: 0.25rem; + text-align: center; + font-size: 0.92rem; +} + .side-rail { display: grid; align-content: start; diff --git a/themes/neon/layout.php b/themes/neon/layout.php index afa603e..a7202fc 100644 --- a/themes/neon/layout.php +++ b/themes/neon/layout.php @@ -30,11 +30,22 @@ $renderPagination = static function (array $pagination, callable $url): void { ?