279 lines
7.3 KiB
HTML
279 lines
7.3 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Post</title>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js/styles/github.min.css">
|
|
<script src="https://cdn.jsdelivr.net/npm/highlight.js/lib/common.min.js"></script>
|
|
|
|
<style>
|
|
:root {
|
|
--ink: #26241f;
|
|
--ink-soft: #6b6a63;
|
|
--paper: #fbfaf7;
|
|
--rule: #e4e1d8;
|
|
--accent: #3a5a40;
|
|
--code-bg: #f1efe8;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html { -webkit-text-size-adjust: 100%; }
|
|
|
|
body {
|
|
margin: 0;
|
|
background: var(--paper);
|
|
color: var(--ink);
|
|
font-family: Georgia, 'Iowan Old Style', 'Palatino Linotype', serif;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.wrap {
|
|
max-width: 680px;
|
|
margin: 0 auto;
|
|
padding: 4rem 1.5rem 6rem;
|
|
}
|
|
|
|
.back-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
font-size: 0.85rem;
|
|
letter-spacing: 0.02em;
|
|
text-transform: uppercase;
|
|
color: var(--ink-soft);
|
|
text-decoration: none;
|
|
margin-bottom: 3rem;
|
|
border-bottom: 1px solid transparent;
|
|
transition: border-color 0.15s ease, color 0.15s ease;
|
|
}
|
|
.back-link:hover {
|
|
color: var(--accent);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.meta {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
font-size: 0.85rem;
|
|
color: var(--ink-soft);
|
|
margin: 0 0 0.5rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
#content h1:first-child {
|
|
font-size: 2.1rem;
|
|
line-height: 1.25;
|
|
margin: 0 0 0.75rem;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
#content h1, #content h2, #content h3 {
|
|
font-family: Georgia, serif;
|
|
color: var(--ink);
|
|
margin-top: 2.2em;
|
|
margin-bottom: 0.6em;
|
|
}
|
|
|
|
#content h2 { font-size: 1.5rem; border-bottom: 1px solid var(--rule); padding-bottom: 0.3em; }
|
|
#content h3 { font-size: 1.2rem; }
|
|
|
|
#content p { margin: 1.1em 0; }
|
|
|
|
#content a { color: var(--accent); text-decoration-color: rgba(58,90,64,0.35); }
|
|
#content a:hover { text-decoration-color: var(--accent); }
|
|
|
|
#content img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
border-radius: 4px;
|
|
margin: 1.5em 0;
|
|
}
|
|
|
|
#content blockquote {
|
|
margin: 1.5em 0;
|
|
padding: 0.2em 1.2em;
|
|
border-left: 3px solid var(--accent);
|
|
color: var(--ink-soft);
|
|
font-style: italic;
|
|
}
|
|
|
|
#content ul, #content ol {
|
|
padding-left: 1.4em;
|
|
}
|
|
#content li { margin: 0.4em 0; }
|
|
|
|
#content hr {
|
|
border: none;
|
|
border-top: 1px solid var(--rule);
|
|
margin: 2.5em 0;
|
|
}
|
|
|
|
#content code {
|
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
|
font-size: 0.85em;
|
|
background: var(--code-bg);
|
|
padding: 0.15em 0.4em;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
#content pre {
|
|
background: var(--code-bg);
|
|
padding: 1em 1.2em;
|
|
border-radius: 6px;
|
|
overflow-x: auto;
|
|
line-height: 1.5;
|
|
}
|
|
#content pre code {
|
|
background: none;
|
|
padding: 0;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
#content table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 1.5em 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
font-size: 0.9rem;
|
|
}
|
|
#content th, #content td {
|
|
text-align: left;
|
|
padding: 0.5em 0.8em;
|
|
border-bottom: 1px solid var(--rule);
|
|
}
|
|
#content th { color: var(--ink-soft); font-weight: 600; }
|
|
|
|
.state {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
color: var(--ink-soft);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.state.error { color: #a3413a; }
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
.wrap { animation: rise 0.35s ease-out both; }
|
|
@keyframes rise {
|
|
from { opacity: 0; transform: translateY(6px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="wrap">
|
|
<a class="back-link" href="index.html">← All posts</a>
|
|
<p class="meta" id="meta" hidden></p>
|
|
<div id="content"><p class="state">Loading post…</p></div>
|
|
</div>
|
|
|
|
<script>
|
|
function parseFrontMatter(raw) {
|
|
const match = raw.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n([\s\S]*)$/);
|
|
if (!match) return { meta: {}, body: raw };
|
|
|
|
const meta = {};
|
|
match[1].split(/\r?\n/).forEach(line => {
|
|
const idx = line.indexOf(':');
|
|
if (idx === -1) return;
|
|
const key = line.slice(0, idx).trim();
|
|
let value = line.slice(idx + 1).trim();
|
|
// strip optional surrounding quotes: title: "My Post"
|
|
value = value.replace(/^["'](.*)["']$/, '$1');
|
|
meta[key] = value;
|
|
});
|
|
|
|
return { meta, body: match[2] };
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '';
|
|
const d = new Date(dateStr);
|
|
if (isNaN(d)) return dateStr; // fall back to raw string if unparseable
|
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
|
}
|
|
|
|
function showError(message) {
|
|
document.getElementById('content').innerHTML =
|
|
`<p class="state error">${message}</p>`;
|
|
}
|
|
|
|
// ---- main ------------------------------------------------------------
|
|
|
|
marked.setOptions({
|
|
highlight: (code, lang) => {
|
|
try {
|
|
return lang
|
|
? hljs.highlight(code, { language: lang }).value
|
|
: hljs.highlightAuto(code).value;
|
|
} catch (e) {
|
|
return code;
|
|
}
|
|
}
|
|
});
|
|
|
|
async function loadPost() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const file = params.get('file');
|
|
|
|
if (!file) {
|
|
showError('No post specified. Link to this page as <code>post.html?file=your-post.md</code>.');
|
|
return;
|
|
}
|
|
|
|
// Guard against path traversal — keep the fetch confined to posts/
|
|
if (file.includes('..') || file.startsWith('/')) {
|
|
showError('Invalid post path.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('posts/' + file);
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
const raw = await res.text();
|
|
|
|
const { meta, body } = parseFrontMatter(raw);
|
|
const html = marked.parse(body);
|
|
|
|
document.getElementById('content').innerHTML = html;
|
|
|
|
// Title: front matter > first H1 in the doc > filename
|
|
let title = meta.title;
|
|
if (!title) {
|
|
const h1 = document.querySelector('#content h1');
|
|
title = h1 ? h1.textContent : file.replace(/\.md$/, '');
|
|
}
|
|
document.title = title;
|
|
|
|
// Inject an H1 if front matter supplied a title but the body didn't have one
|
|
if (meta.title && !document.querySelector('#content h1:first-child')) {
|
|
const h1 = document.createElement('h1');
|
|
h1.textContent = meta.title;
|
|
document.getElementById('content').prepend(h1);
|
|
}
|
|
|
|
// Meta line: date (and tags, if present)
|
|
const metaEl = document.getElementById('meta');
|
|
const parts = [];
|
|
if (meta.date) parts.push(formatDate(meta.date));
|
|
if (meta.tags) parts.push(meta.tags.split(',').map(t => t.trim()).join(' · '));
|
|
if (parts.length) {
|
|
metaEl.innerHTML = parts.join(' / ');
|
|
metaEl.hidden = false;
|
|
}
|
|
} catch (err) {
|
|
showError('Could not load this post. It may have been moved or deleted.');
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', loadPost);
|
|
</script>
|
|
|
|
</body>
|
|
</html> |