- Parser additions, pagination
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user