= $ttl) return null; if ($invalidate_before > 0 && $mt < $invalidate_before) return null; $raw = @file_get_contents($f); if ($raw === false) return null; $data = json_decode($raw, true); return is_array($data) ? $data : null; } function _recent_changelog_source_mtime(): int { $latest = 0; foreach (glob(__DIR__ . '/../changelog/*-changelog.txt') ?: [] as $f) { $m = @filemtime($f); if ($m && $m > $latest) $latest = $m; } return $latest; } function _recent_cache_put(string $key, array $data): void { $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json'; @file_put_contents($f, json_encode($data), LOCK_EX); } // Last-resort fallback: return whatever is cached regardless of age, so an // upstream hiccup never blanks the list (stale-on-error). function _recent_cache_get_stale(string $key): ?array { $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json'; if (!is_file($f)) return null; $raw = @file_get_contents($f); if ($raw === false) return null; $data = json_decode($raw, true); return is_array($data) ? $data : null; } const BLOG_SQLITE = '/mnt/slab/blog.sillylaird.ca/blog.sqlite'; function _recent_fetch(string $url, float $timeout = 1.5): ?string { $ctx = stream_context_create(['http' => [ 'timeout' => $timeout, 'ignore_errors' => true, 'header' => "User-Agent: sillylaird-static-render/1.0\r\n", ]]); $body = @file_get_contents($url, false, $ctx); return $body === false ? null : $body; } function recent_blog_posts(int $limit = 2): array { // Invalidate cache when the blog DB changes so new posts show within the // window without waiting out the full TTL. $src_mtime = @filemtime(BLOG_SQLITE) ?: 0; $cached = _recent_cache_get('blog', RECENT_CACHE_TTL, $src_mtime); if ($cached !== null) return array_slice($cached, 0, $limit); // Blog runs on the same box; read its SQLite directly instead of an HTTP // round-trip to blog.sillylaird.ca (the old fetch blocked ~1.5s on every // cache miss and stalled when the blog vhost was slow/down). $posts = null; if (is_file(BLOG_SQLITE)) { try { $db = new PDO('sqlite:' . BLOG_SQLITE, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 2, ]); $stmt = $db->query('SELECT title, path, year, month, day FROM posts ORDER BY id DESC'); $posts = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable $e) { $posts = null; } } if (!is_array($posts)) { // DB unavailable — serve the last good list rather than blanking. $stale = _recent_cache_get_stale('blog'); return $stale !== null ? array_slice($stale, 0, $limit) : []; } _recent_cache_put('blog', $posts); return array_slice($posts, 0, $limit); } function recent_changelog_entries(int $limit = 2): array { $cached = _recent_cache_get('changelog', RECENT_CACHE_TTL, _recent_changelog_source_mtime()); if ($cached !== null) return array_slice($cached, 0, $limit); $html = _recent_fetch('https://www.sillylaird.ca/changelog/latest.php'); if ($html === null) { $stale = _recent_cache_get_stale('changelog'); return $stale !== null ? array_slice($stale, 0, $limit) : []; } $entries = []; $doc = new DOMDocument(); libxml_use_internal_errors(true); if (!$doc->loadHTML('' . $html)) { libxml_clear_errors(); $stale = _recent_cache_get_stale('changelog'); return $stale !== null ? array_slice($stale, 0, $limit) : []; } libxml_clear_errors(); $xp = new DOMXPath($doc); foreach ($xp->query('//div[contains(@class,"changelog-entry")]') as $node) { /** @var DOMElement $node */ $title = ''; foreach (['b', 'strong'] as $tag) { $el = $node->getElementsByTagName($tag)->item(0); if ($el) { $title = trim($el->textContent); break; } } $timeEl = $node->getElementsByTagName('time')->item(0); $iso = $timeEl ? trim($timeEl->getAttribute('datetime')) : ''; $when = $timeEl ? trim($timeEl->textContent) : ''; $body = ''; foreach ($xp->query('.//span[contains(@class,"text")]', $node) as $b) { $body = trim($b->textContent); break; } $entries[] = compact('title', 'iso', 'when', 'body'); } _recent_cache_put('changelog', $entries); return array_slice($entries, 0, $limit); } function render_recent_blog(int $limit = 2): void { $posts = recent_blog_posts($limit); if (!$posts) { echo '
No posts to show right now.
'; return; } $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); echo '
Posts (all)
'; echo '
    '; foreach ($posts as $p) { $path = ltrim((string)($p['path'] ?? ''), '/'); $iso = sprintf('%04d-%02d-%02d', (int)($p['year'] ?? 0), (int)($p['month'] ?? 0), (int)($p['day'] ?? 0)); $iso = $iso === '0000-00-00' ? '' : $iso; echo '
  1. ' . $h($p['title'] ?? 'Untitled') . ''; if ($iso) echo '
    '; echo '
  2. '; } echo '
'; } /** Latest changelog row for sidebar widgets (reads local TXT, no HTTP). */ function latest_changelog_entry(): ?array { static $entry = null; static $loaded = false; if ($loaded) return $entry; $loaded = true; $parser = __DIR__ . '/../changelog/api/parser.php'; if (!is_file($parser)) return null; require_once $parser; $rows = get_changelog_entries(1); $entry = $rows[0] ?? null; return $entry; } function render_recent_changelog(int $limit = 2): void { $entries = recent_changelog_entries($limit); if (!$entries) { echo '
No entries to show right now.
'; return; } $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); echo '
Entries (all)
'; echo '
    '; foreach ($entries as $e) { echo '
  1. '; if ($e['title']) echo '' . $h($e['title']) . ''; if ($e['iso'] || $e['when']) { echo '
    ' . $h($e['when'] ?: $e['iso']) . '
    '; } if ($e['body']) echo '
    ' . $h($e['body']) . '
    '; $yr = $e['iso'] ? substr((string)$e['iso'], 0, 4) : ''; if ($yr) echo 'more (' . $h($yr) . ') →'; echo '
  2. '; } echo '
'; }