Added tag filtering and RSS feed
This commit is contained in:
90
index.html
90
index.html
@@ -3,6 +3,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Hex Studios</title>
|
<title>Hex Studios</title>
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Hex Studios RSS Feed" href="posts/feed.php">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
@@ -32,17 +33,18 @@
|
|||||||
|
|
||||||
<div id="links-section">
|
<div id="links-section">
|
||||||
<h2>Links</h2>
|
<h2>Links</h2>
|
||||||
<ul>
|
<ul class="custom-list">
|
||||||
<li><a href="https://git.bellsworne.tech/">Git repos</a></li>
|
<li><a href="https://git.bellsworne.tech/">Git repos</a></li>
|
||||||
<li><a href="https://youtube.com/c/hexstudios">YouTube</a></li>
|
<li><a href="https://youtube.com/c/hexstudios">YouTube</a></li>
|
||||||
<li><a href="https://bellsworne.com">My company - Bellsworne</a></li>
|
<li><a href="https://bellsworne.com">My company - Bellsworne</a></li>
|
||||||
<li><a href="https://git.bellsworne.tech/chrisbell/hexstudios-co">Page source code (git)</a></li>
|
<li><a href="https://git.bellsworne.tech/chrisbell/hexstudios-co">Page source code (git)</a></li>
|
||||||
|
<li><a href="posts/feed.php">RSS feed</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="sticky-posts-section">
|
<div id="sticky-posts-section">
|
||||||
<h2>Pinned posts</h2>
|
<h2>Pinned posts</h2>
|
||||||
<ul>
|
<ul class="custom-list">
|
||||||
<li><a href="post.html?file=about.md">About <i class="post-link-date">August 13, 2026</i></a></li>
|
<li><a href="post.html?file=about.md">About <i class="post-link-date">August 13, 2026</i></a></li>
|
||||||
<li><a href="post.html?file=projects.md">Projects <i class="post-link-date">August 13, 2026</i></a></li>
|
<li><a href="post.html?file=projects.md">Projects <i class="post-link-date">August 13, 2026</i></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -50,40 +52,80 @@
|
|||||||
|
|
||||||
<div id="posts-section">
|
<div id="posts-section">
|
||||||
<h2>Posts</h2>
|
<h2>Posts</h2>
|
||||||
<ul id="posts-list"></ul>
|
<ul id="tags-list"></ul>
|
||||||
|
<ul class="custom-list" id="posts-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
let allPosts = [];
|
||||||
|
let activeTag = null;
|
||||||
|
|
||||||
async function loadPosts() {
|
async function loadPosts() {
|
||||||
const list = document.getElementById('posts-list');
|
const list = document.getElementById('posts-list');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('posts/list.php');
|
const res = await fetch('posts/list.php');
|
||||||
const posts = await res.json();
|
allPosts = await res.json();
|
||||||
|
renderTags();
|
||||||
if (!posts.length) {
|
renderPosts();
|
||||||
list.innerHTML = '<li>No posts yet.</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
posts.forEach(post => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
const a = document.createElement('a');
|
|
||||||
const i = document.createElement('i');
|
|
||||||
a.textContent = post.title;
|
|
||||||
i.textContent = post.date;
|
|
||||||
a.href = post.type === 'html'
|
|
||||||
? 'posts/' + post.file
|
|
||||||
: 'post.html?file=' + encodeURIComponent(post.file);
|
|
||||||
li.appendChild(a);
|
|
||||||
a.appendChild(i);
|
|
||||||
i.classList.add("post-link-date");
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
list.innerHTML = '<li>Couldn\u2019t load posts.</li>';
|
list.innerHTML = '<li>Couldn\u2019t load posts.</li>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
const tagsList = document.getElementById('tags-list');
|
||||||
|
const tags = [...new Set(allPosts.flatMap(p => p.tags || []))].sort();
|
||||||
|
|
||||||
|
tagsList.innerHTML = '';
|
||||||
|
if (!tags.length) return;
|
||||||
|
|
||||||
|
const makeTagButton = (label, tagValue) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.className = 'tag-btn' + (activeTag === tagValue ? ' active' : '');
|
||||||
|
btn.onclick = () => {
|
||||||
|
activeTag = tagValue;
|
||||||
|
renderTags();
|
||||||
|
renderPosts();
|
||||||
|
};
|
||||||
|
li.appendChild(btn);
|
||||||
|
tagsList.appendChild(li);
|
||||||
|
};
|
||||||
|
|
||||||
|
makeTagButton('all', null);
|
||||||
|
tags.forEach(tag => makeTagButton(tag, tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPosts() {
|
||||||
|
const list = document.getElementById('posts-list');
|
||||||
|
const posts = activeTag
|
||||||
|
? allPosts.filter(p => (p.tags || []).includes(activeTag))
|
||||||
|
: allPosts;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (!posts.length) {
|
||||||
|
list.innerHTML = '<li>No posts' + (activeTag ? ' tagged \u201c' + activeTag + '\u201d' : '') + '.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
posts.forEach(post => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
const i = document.createElement('i');
|
||||||
|
a.textContent = post.title;
|
||||||
|
i.textContent = post.date;
|
||||||
|
a.href = post.type === 'html'
|
||||||
|
? 'posts/' + post.file
|
||||||
|
: 'post.html?file=' + encodeURIComponent(post.file);
|
||||||
|
li.appendChild(a);
|
||||||
|
a.appendChild(i);
|
||||||
|
i.classList.add("post-link-date");
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadPosts);
|
document.addEventListener('DOMContentLoaded', loadPosts);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
41
posts/feed.php
Normal file
41
posts/feed.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/posts.php';
|
||||||
|
|
||||||
|
$siteUrl = 'https://hexstudios.co';
|
||||||
|
$siteTitle = 'Hex Studios';
|
||||||
|
$siteDesc = 'Developer, biker, musician, engineer, and an unapologetic centrist in the deep red south.';
|
||||||
|
|
||||||
|
$posts = getPosts(__DIR__);
|
||||||
|
|
||||||
|
function xmlEscape($str) {
|
||||||
|
return htmlspecialchars($str, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/rss+xml; charset=UTF-8');
|
||||||
|
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title><?= xmlEscape($siteTitle) ?></title>
|
||||||
|
<link><?= xmlEscape($siteUrl) ?></link>
|
||||||
|
<description><?= xmlEscape($siteDesc) ?></description>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate><?= date(DATE_RSS) ?></lastBuildDate>
|
||||||
|
<?php foreach ($posts as $post):
|
||||||
|
$postUrl = $post['type'] === 'html'
|
||||||
|
? $siteUrl . '/posts/' . $post['file']
|
||||||
|
: $siteUrl . '/post.html?file=' . urlencode($post['file']);
|
||||||
|
?>
|
||||||
|
<item>
|
||||||
|
<title><?= xmlEscape($post['title']) ?></title>
|
||||||
|
<link><?= xmlEscape($postUrl) ?></link>
|
||||||
|
<guid isPermaLink="true"><?= xmlEscape($postUrl) ?></guid>
|
||||||
|
<pubDate><?= date(DATE_RSS, $post['created']) ?></pubDate>
|
||||||
|
<description><?= xmlEscape($post['excerpt']) ?></description>
|
||||||
|
<?php foreach ($post['tags'] as $tag): ?>
|
||||||
|
<category><?= xmlEscape($tag) ?></category>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</item>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
@@ -1,49 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/posts.php';
|
||||||
|
|
||||||
$dir = __DIR__;
|
$posts = getPosts(__DIR__);
|
||||||
$files = array_diff(scandir($dir), ['.', '..', 'list.php']);
|
|
||||||
|
|
||||||
function parseFrontMatter($raw) {
|
$posts = array_map(function ($p) {
|
||||||
if (preg_match('/^---\r?\n(.+?)\r?\n---\r?\n(.*)$/s', $raw, $m)) {
|
unset($p['excerpt']);
|
||||||
$meta = [];
|
return $p;
|
||||||
foreach (preg_split('/\r?\n/', $m[1]) as $line) {
|
}, $posts);
|
||||||
if (strpos($line, ':') === false) continue;
|
|
||||||
[$key, $value] = explode(':', $line, 2);
|
|
||||||
$meta[trim($key)] = trim($value, " \t\"'");
|
|
||||||
}
|
|
||||||
return $meta;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$posts = [];
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
|
||||||
if (!in_array($ext, ['html', 'md'])) continue;
|
|
||||||
|
|
||||||
$path = $dir . '/' . $file;
|
|
||||||
$fallbackTitle = ucwords(str_replace(['-', '_'], ' ', pathinfo($file, PATHINFO_FILENAME)));
|
|
||||||
$created = filectime($path); // fallback if no front matter date
|
|
||||||
|
|
||||||
$meta = [];
|
|
||||||
if ($ext === 'md') {
|
|
||||||
$raw = file_get_contents($path, false, null, 0, 4096); // just need the header
|
|
||||||
$meta = parseFrontMatter($raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = $meta['title'] ?? $fallbackTitle;
|
|
||||||
$date = isset($meta['date']) ? strtotime($meta['date']) : $created;
|
|
||||||
|
|
||||||
$posts[] = [
|
|
||||||
'file' => $file,
|
|
||||||
'title' => $title,
|
|
||||||
'type' => $ext,
|
|
||||||
'created' => $date ?: $created,
|
|
||||||
'date' => date('F j, Y', $date ?: $created),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($posts, fn($a, $b) => $b['created'] - $a['created']); // newest first
|
|
||||||
|
|
||||||
echo json_encode($posts);
|
echo json_encode($posts);
|
||||||
68
posts/posts.php
Normal file
68
posts/posts.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function parseFrontMatter($raw) {
|
||||||
|
if (preg_match('/^---\r?\n(.+?)\r?\n---\r?\n(.*)$/s', $raw, $m)) {
|
||||||
|
$meta = [];
|
||||||
|
foreach (preg_split('/\r?\n/', $m[1]) as $line) {
|
||||||
|
if (strpos($line, ':') === false) continue;
|
||||||
|
[$key, $value] = explode(':', $line, 2);
|
||||||
|
$meta[trim($key)] = trim($value, " \t\"'");
|
||||||
|
}
|
||||||
|
return [$meta, $m[2]];
|
||||||
|
}
|
||||||
|
return [[], $raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExcerpt($body, $length = 220) {
|
||||||
|
$text = preg_replace('/```.*?```/s', '', $body); // code blocks
|
||||||
|
$text = preg_replace('/[#>*_`~\[\]]/', '', $text); // markdown symbols
|
||||||
|
$text = preg_replace('/\(.*?\)/', '', $text); // link targets
|
||||||
|
$text = preg_replace('/\s+/', ' ', $text); // collapse whitespace
|
||||||
|
$text = trim($text);
|
||||||
|
if (mb_strlen($text) > $length) {
|
||||||
|
$text = mb_substr($text, 0, $length) . '…';
|
||||||
|
}
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPosts($dir) {
|
||||||
|
$skip = ['.', '..', 'list.php', 'feed.php', 'posts.php'];
|
||||||
|
$files = array_diff(scandir($dir), $skip);
|
||||||
|
|
||||||
|
$posts = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ['html', 'md'])) continue;
|
||||||
|
|
||||||
|
$path = $dir . '/' . $file;
|
||||||
|
$fallbackTitle = ucwords(str_replace(['-', '_'], ' ', pathinfo($file, PATHINFO_FILENAME)));
|
||||||
|
$created = filectime($path);
|
||||||
|
|
||||||
|
$meta = [];
|
||||||
|
$body = '';
|
||||||
|
if ($ext === 'md') {
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
[$meta, $body] = parseFrontMatter($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $meta['title'] ?? $fallbackTitle;
|
||||||
|
$date = isset($meta['date']) ? strtotime($meta['date']) : $created;
|
||||||
|
$tags = isset($meta['tags']) && trim($meta['tags']) !== ''
|
||||||
|
? array_map('trim', explode(',', $meta['tags']))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$posts[] = [
|
||||||
|
'file' => $file,
|
||||||
|
'title' => $title,
|
||||||
|
'type' => $ext,
|
||||||
|
'created' => $date ?: $created,
|
||||||
|
'date' => date('F j, Y', $date ?: $created),
|
||||||
|
'tags' => $tags,
|
||||||
|
'excerpt' => $body ? makeExcerpt($body) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($posts, fn($a, $b) => $b['created'] - $a['created']); // newest first
|
||||||
|
|
||||||
|
return $posts;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
--background-color: #1a1a1a;
|
--background-color: #1a1a1a;
|
||||||
--foreground-color: #f0f0ea;
|
--foreground-color: #f0f0ea;
|
||||||
--soft-color: #8a8a82;
|
--soft-color: #8a8a82;
|
||||||
--accent-color: #a5453a;
|
--accent-color: #197aa7;
|
||||||
--rule-color: #333330;
|
--rule-color: #333330;
|
||||||
--code-bg: #242422;
|
--code-bg: #242422;
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
--background-color: #f2f0ea;
|
--background-color: #f2f0ea;
|
||||||
--foreground-color: #1a1a1a;
|
--foreground-color: #1a1a1a;
|
||||||
--soft-color: #6b6a63;
|
--soft-color: #6b6a63;
|
||||||
--accent-color: #921b0e;
|
--accent-color: #197aa7;
|
||||||
--rule-color: #d8d5cb;
|
--rule-color: #d8d5cb;
|
||||||
--code-bg: #e8e5da;
|
--code-bg: #e8e5da;
|
||||||
}
|
}
|
||||||
@@ -60,28 +60,48 @@ hr {
|
|||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* tag filter row */
|
||||||
|
#tags-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1em;
|
||||||
|
margin: 0.5em 0 1.5em;
|
||||||
|
}
|
||||||
|
.tag-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--soft-color);
|
||||||
|
font-size: 11pt;
|
||||||
|
font-family: 'Times New Roman', Times, serif;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tag-btn:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
.tag-btn.active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* post lists on the main page */
|
/* post lists on the main page */
|
||||||
#posts-section ul,
|
.custom-list {
|
||||||
#sticky-posts-section ul,
|
|
||||||
#links-section ul {
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
#posts-section li,
|
|
||||||
#sticky-posts-section li,
|
.custom-list li {
|
||||||
#links-section li {
|
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
.custom-list li::before {
|
||||||
#posts-section li::before,
|
content: "> ";
|
||||||
#sticky-posts-section li::before,
|
|
||||||
#links-section li::before {
|
|
||||||
content: "> ";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
i.post-link-date {
|
i.post-link-date {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #6b6b65;
|
color: var(--soft-color);
|
||||||
font-size: 11pt;
|
font-size: 11pt;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@@ -89,6 +109,7 @@ i.post-link-date::before {
|
|||||||
content: " // ";
|
content: " // ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* post.html */
|
/* post.html */
|
||||||
.back-link {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Reference in New Issue
Block a user