- Parser additions, pagination

This commit is contained in:
Ty Clifford
2026-07-04 20:29:34 -04:00
parent c4c10c256c
commit bb93bf58fe
6 changed files with 167 additions and 11 deletions
+2
View File
@@ -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 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 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 `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 ### Changed
- Replaced the previous Bludit application tree with a purpose-built CMS while keeping copy-friendly Bludit-style content storage. - 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. - 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 ### Verified
+2
View File
@@ -8,6 +8,8 @@ bl-content/pages/<slug>/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. 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 ## Requirements
- PHP 8.1 or newer - PHP 8.1 or newer
+26 -3
View File
@@ -79,11 +79,12 @@ final class ContentRepository
return $item; return $item;
} }
/** @return array{items: array<int, array<string, mixed>>, pagination: array<string, int|bool|null>} */ /** @return array{items: array<int, array<string, mixed>>, pagination: array<string, mixed>} */
public function paginate(array $items, int $page, int $perPage): array public function paginate(array $items, int $page, int $perPage): array
{ {
$perPage = max(1, $perPage);
$total = count($items); $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)); $page = max(1, min($page, $pages));
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
@@ -98,11 +99,12 @@ final class ContentRepository
'has_next' => $page < $pages, 'has_next' => $page < $pages,
'previous' => $page > 1 ? $page - 1 : null, 'previous' => $page > 1 ? $page - 1 : null,
'next' => $page < $pages ? $page + 1 : null, 'next' => $page < $pages ? $page + 1 : null,
'numbers' => $this->paginationNumbers($page, $pages, 10),
], ],
]; ];
} }
/** @return array{items: array<int, array<string, mixed>>, pagination: array<string, int|bool|null>} */ /** @return array{items: array<int, array<string, mixed>>, pagination: array<string, mixed>} */
public function search(string $query, int $page, int $perPage): array public function search(string $query, int $page, int $perPage): array
{ {
$query = trim(mb_strtolower($query)); $query = trim(mb_strtolower($query));
@@ -150,6 +152,27 @@ final class ContentRepository
return $this->paginate($scored, $page, $perPage); return $this->paginate($scored, $page, $perPage);
} }
/** @return array<int, int> */
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<int, array<string, mixed>> */ /** @return array<int, array<string, mixed>> */
public function byCategory(string $categorySlug): array public function byCategory(string $categorySlug): array
{ {
+100 -5
View File
@@ -5,9 +5,13 @@ namespace NeonBlog;
final class Markdown 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 === '') { if ($markdown === '') {
return ''; return '';
} }
@@ -24,6 +28,12 @@ final class Markdown
continue; 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)) { if (preg_match('/^```([A-Za-z0-9_-]*)\s*$/', $line, $matches)) {
$language = $matches[1] !== '' ? ' class="language-' . self::e($matches[1]) . '"' : ''; $language = $matches[1] !== '' ? ' class="language-' . self::e($matches[1]) . '"' : '';
$code = []; $code = [];
@@ -56,7 +66,7 @@ final class Markdown
$quote[] = $matches[1]; $quote[] = $matches[1];
$i++; $i++;
} }
$html[] = '<blockquote>' . self::toHtml(implode("\n", $quote)) . '</blockquote>'; $html[] = '<blockquote>' . self::toHtml(implode("\n", $quote), false) . '</blockquote>';
continue; continue;
} }
@@ -94,7 +104,10 @@ final class Markdown
public static function excerpt(string $markdown, int $words = 28): string 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[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
$text = trim(preg_replace('/\s+/', ' ', strip_tags($html)) ?? '');
if ($text === '') { if ($text === '') {
return ''; return '';
} }
@@ -109,7 +122,8 @@ final class Markdown
private static function startsBlock(string $line): bool 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 private static function inline(string $text): string
@@ -121,6 +135,12 @@ final class Markdown
return $key; return $key;
}, $text) ?? $text; }, $text) ?? $text;
$text = preg_replace_callback('/<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s+[^<>]*)?>|<!--.*?-->|<![A-Z][^>]*>/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 = self::e($text);
$text = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/', static function (array $matches): string { $text = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/', static function (array $matches): string {
$title = isset($matches[3]) ? ' title="' . self::e($matches[3]) . '"' : ''; $title = isset($matches[3]) ? ' title="' . self::e($matches[3]) . '"' : '';
@@ -136,6 +156,81 @@ final class Markdown
return strtr($text, $placeholders); return strtr($text, $placeholders);
} }
private static function executePhp(string $source): string
{
if (!str_contains($source, '<?')) {
return $source;
}
$placeholders = [];
$source = preg_replace_callback('/```[\s\S]*?```/', static function (array $matches) use (&$placeholders): string {
$key = "\x1BPHPBLOCK" . count($placeholders) . "\x1B";
$placeholders[$key] = $matches[0];
return $key;
}, $source) ?? $source;
$source = preg_replace_callback('/`[^`\n]*<\?[^`\n]*`/', static function (array $matches) use (&$placeholders): string {
$key = "\x1BPHPBLOCK" . count($placeholders) . "\x1B";
$placeholders[$key] = $matches[0];
return $key;
}, $source) ?? $source;
ob_start();
try {
eval('?>' . $source);
return strtr((string) ob_get_clean(), $placeholders);
} catch (\Throwable $error) {
ob_end_clean();
return strtr($source, $placeholders) . "\n\n<!-- PHP content error: " . self::e($error->getMessage()) . " -->";
}
}
private static function startsHtmlBlock(string $line): bool
{
$line = trim($line);
return (bool) preg_match('/^(<!--|<![A-Z]+|<\/?[A-Za-z][A-Za-z0-9:-]*(\s|>|\/>))/i', $line);
}
/** @param array<int, string> $lines @return array{0: string, 1: int} */
private static function consumeHtmlBlock(array $lines, int $i, int $count): array
{
$block = [rtrim($lines[$i])];
$trimmed = trim($lines[$i]);
$i++;
if (str_starts_with($trimmed, '<!--')) {
while ($i < $count && !str_contains(end($block), '-->')) {
$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, '</' . $tag . '>') !== false) {
return [implode("\n", $block), $i];
}
while ($i < $count) {
$block[] = rtrim($lines[$i]);
if (stripos((string) end($block), '</' . $tag . '>') !== 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 private static function e(string $value): string
{ {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+25 -2
View File
@@ -440,7 +440,7 @@ h2 {
.pagination { .pagination {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.8rem; gap: 0.45rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 1.5rem 0; margin: 1.5rem 0;
@@ -448,7 +448,7 @@ h2 {
} }
.pager { .pager {
min-width: 112px; min-width: 44px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.6rem 0.9rem; padding: 0.6rem 0.9rem;
@@ -457,6 +457,29 @@ h2 {
background: var(--surface); 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 { .side-rail {
display: grid; display: grid;
align-content: start; align-content: start;
+12 -1
View File
@@ -30,11 +30,22 @@ $renderPagination = static function (array $pagination, callable $url): void { ?
<nav class="pagination" aria-label="Pagination"> <nav class="pagination" aria-label="Pagination">
<?php if ($pagination['has_previous']): ?> <?php if ($pagination['has_previous']): ?>
<a class="pager" href="<?= $url((int) $pagination['previous']) ?>">Previous</a> <a class="pager" href="<?= $url((int) $pagination['previous']) ?>">Previous</a>
<?php else: ?>
<span class="pager pager--disabled" aria-disabled="true">Previous</span>
<?php endif; ?> <?php endif; ?>
<span>Page <?= (int) $pagination['page'] ?> of <?= (int) $pagination['pages'] ?></span> <?php foreach (($pagination['numbers'] ?? []) as $number): ?>
<?php if ((int) $number === (int) $pagination['page']): ?>
<span class="pager pager--current" aria-current="page"><?= (int) $number ?></span>
<?php else: ?>
<a class="pager pager--number" href="<?= $url((int) $number) ?>"><?= (int) $number ?></a>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination['has_next']): ?> <?php if ($pagination['has_next']): ?>
<a class="pager" href="<?= $url((int) $pagination['next']) ?>">Next</a> <a class="pager" href="<?= $url((int) $pagination['next']) ?>">Next</a>
<?php else: ?>
<span class="pager pager--disabled" aria-disabled="true">Next</span>
<?php endif; ?> <?php endif; ?>
<span class="pagination__summary">Page <?= (int) $pagination['page'] ?> of <?= (int) $pagination['pages'] ?> · <?= (int) $pagination['per_page'] ?> per page</span>
</nav> </nav>
<?php endif; ?> <?php endif; ?>
<?php }; <?php };