.gitignore
ew file mode 100644
@@ -0,0 +1 @@
web/dist/
netlify.toml
ew file mode 100644
@@ -0,0 +1,7 @@
[build]
command = "node web/build.js"
publish = "web/dist"
[build.environment]
GIT_DEPTH = "0"
NODE_VERSION = "20"
web/build.js
ew file mode 100644
@@ -0,0 +1,223 @@
#!/usr/bin/env node
/**
* build.js
*
* reads the git history of mturro/poem
* writes static HTML to web/dist/
*
* this file is part of the poem.
*/
'use strict';
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const DIST = path.join(__dirname, 'dist');
const REPO = 'https://github.com/mturro/poem';
// ─── helpers ──────────────────────────────────────────────────────────────────
function git(cmd) {
return execSync(`git -C "${ROOT}" ${cmd}`, { encoding: 'utf8' }).trim();
}
function esc(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function slug(filename) {
return filename.replace(/\.md$/, '');
}
// ─── git data ─────────────────────────────────────────────────────────────────
// ensure we have the full history (netlify does shallow clones by default)
try {
execSync(`git -C "${ROOT}" fetch --unshallow`, { encoding: 'utf8', stdio: 'pipe' });
} catch (_) { /* already full, or not shallow */ }
// all commits across the repo, newest first
function allCommits() {
const raw = git(`log --format="%H|%ad|%s" --date=short`);
return raw.split('\n').filter(Boolean).map(line => {
const [sha, date, ...rest] = line.split('|');
return { sha: sha.trim(), short: sha.trim().slice(0, 7), date, message: rest.join('|') };
});
}
// commits that touched a specific file, returned oldest-first
function fileCommits(filename) {
const raw = git(`log --follow --format="%H|%ad|%s" --date=short -- "${filename}"`);
if (!raw) return [];
return raw.split('\n').filter(Boolean).map(line => {
const [sha, date, ...rest] = line.split('|');
return { sha: sha.trim(), short: sha.trim().slice(0, 7), date, message: rest.join('|') };
}).reverse();
}
// unified diff for one commit's changes to one file
function fileDiff(sha, filename) {
const parents = git(`rev-list --parents -n 1 ${sha}`).split(' ').slice(1);
if (parents.length === 0) {
// root commit — render the whole file as additions
try {
const content = git(`show ${sha}:"${filename}"`);
return content.split('\n').map(l => '+' + l).join('\n');
} catch (_) { return ''; }
}
try {
return git(`diff ${parents[0]} ${sha} -- "${filename}"`);
} catch (_) { return ''; }
}
// ─── diff rendering ───────────────────────────────────────────────────────────
function renderDiff(raw) {
if (!raw || !raw.trim()) return '';
const lines = raw.split('\n');
const out = [];
for (const line of lines) {
if (
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('--- ') ||
line.startsWith('+++ ')
) continue;
if (line.startsWith('@@')) {
out.push(`<div class="diff-hunk">${esc(line)}</div>`);
} else if (line.startsWith('+')) {
out.push(`<div class="diff-add"><span aria-hidden="true">+</span>${esc(line.slice(1))}</div>`);
} else if (line.startsWith('-')) {
out.push(`<div class="diff-del"><span aria-hidden="true">-</span>${esc(line.slice(1))}</div>`);
} else {
out.push(`<div class="diff-ctx"><span aria-hidden="true"> </span>${esc(line.slice(1) ?? '')}</div>`);
}
}
return out.join('\n');
}
// ─── html templates ───────────────────────────────────────────────────────────
function page(title, body) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
${body}
</body>
</html>`;
}
function indexPage(poems, commits) {
const firstDate = commits[commits.length - 1]?.date ?? '';
const lastDate = commits[0]?.date ?? '';
const poemItems = poems.map(({ filename, commits: c }) => {
const n = c.length;
return ` <li>
<a href="${slug(filename)}.html">${esc(filename)}</a>
<span class="meta">${n} revision${n !== 1 ? 's' : ''}</span>
</li>`;
}).join('\n');
const logLines = commits.map(({ short, sha, date, message }) =>
` <div class="log-line">` +
`<a href="${REPO}/commit/${sha}" class="sha" rel="noopener">${short}</a>` +
`<span class="date">${date}</span>` +
`<span class="msg">${esc(message)}</span>` +
`</div>`
).join('\n');
return page('mturro/poem', `<header>
<h1><a href="${REPO}" rel="noopener">mturro/poem</a></h1>
<p class="subtitle">a poem in git · ${commits.length} commits · ${firstDate} – ${lastDate}</p>
</header>
<main>
<section>
<h2>poems</h2>
<ul class="poem-list">
${poemItems}
</ul>
</section>
<section>
<h2><code>$ git log --oneline</code></h2>
<div class="git-log" role="log" aria-label="full commit history">
${logLines}
</div>
</section>
</main>
<footer>
<a href="${REPO}" rel="noopener">view on github</a>
</footer>`);
}
function poemPage(filename, commits) {
const sections = commits.map(({ short, sha, date, message }) => {
const diff = fileDiff(sha, filename);
const diffHtml = renderDiff(diff);
return ` <section class="commit" aria-label="commit ${short}">
<div class="commit-meta">
<a href="${REPO}/commit/${sha}" class="sha" rel="noopener">${short}</a>
<span class="date">${date}</span>
<span class="msg">${esc(message)}</span>
</div>
${diffHtml ? `<div class="diff-block" role="region" aria-label="changes in this commit">\n${diffHtml}\n </div>` : ''}
</section>`;
}).join('\n\n');
return page(`${filename} — mturro/poem`, `<header>
<a href="index.html" class="back">← mturro/poem</a>
<h1>${esc(filename)}</h1>
<p class="cmd"><code>$ git log --follow --patch -- ${esc(filename)}</code></p>
</header>
<main>
${sections}
</main>
<footer>
<a href="${REPO}/commits/master/${filename}" rel="noopener">full history on github</a>
</footer>`);
}
// ─── build ────────────────────────────────────────────────────────────────────
console.log('building mturro/poem …');
const poemFiles = fs.readdirSync(ROOT)
.filter(f => f.endsWith('.md') && f !== 'README.md')
.sort();
const poems = poemFiles.map(filename => ({ filename, commits: fileCommits(filename) }));
const commits = allCommits();
fs.mkdirSync(DIST, { recursive: true });
fs.copyFileSync(path.join(__dirname, 'src', 'style.css'), path.join(DIST, 'style.css'));
fs.writeFileSync(path.join(DIST, 'index.html'), indexPage(poems, commits));
console.log(' index.html');
for (const { filename, commits: c } of poems) {
fs.writeFileSync(path.join(DIST, `${slug(filename)}.html`), poemPage(filename, c));
console.log(` ${slug(filename)}.html`);
}
console.log('done.');
web/package.json
ew file mode 100644
@@ -0,0 +1,7 @@
{
"name": "poem-web",
"private": true,
"scripts": {
"build": "node build.js"
}
}
web/src/style.css
ew file mode 100644
@@ -0,0 +1,197 @@
/* style.css — mturro/poem */
:root {
--bg: #111;
--fg: #c9d1d9;
--fg-dim: #6e7681;
--fg-bright: #e6edf3;
--add-bg: #0d2217;
--add-fg: #3fb950;
--del-bg: #2d1217;
--del-fg: #f85149;
--hunk-bg: #0d1b2e;
--hunk-fg: #6e7681;
--link: #58a6ff;
--border: #21262d;
--font: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo,
Consolas, 'DejaVu Sans Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
-webkit-text-size-adjust: 100%;
}
body {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.25rem;
}
/* ── links ── */
a { color: var(--link); text-decoration: none; }
a:hover, a:focus { text-decoration: underline; outline-offset: 2px; }
a:focus-visible { outline: 2px solid var(--link); border-radius: 2px; }
/* ── headings ── */
h1 {
font-size: 1.1rem;
font-weight: normal;
color: var(--fg-bright);
margin-bottom: 0.3rem;
}
h2 {
font-size: 0.8rem;
font-weight: normal;
color: var(--fg-dim);
margin: 2.5rem 0 0.75rem;
text-transform: lowercase;
letter-spacing: 0.06em;
}
/* ── layout ── */
header {
padding-bottom: 1.25rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.8rem;
color: var(--fg-dim);
}
footer a { color: var(--fg-dim); }
footer a:hover { color: var(--link); }
/* ── index: subtitle ── */
.subtitle {
font-size: 0.82rem;
color: var(--fg-dim);
margin-top: 0.2rem;
}
/* ── index: poem list ── */
.poem-list {
list-style: none;
border-top: 1px solid var(--border);
}
.poem-list li {
display: flex;
align-items: baseline;
gap: 1rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
.poem-list .meta {
margin-left: auto;
color: var(--fg-dim);
font-size: 0.78rem;
white-space: nowrap;
}
/* ── index: git log ── */
.git-log { border-top: 1px solid var(--border); }
.log-line {
display: flex;
gap: 1rem;
padding: 0.25rem 0;
border-bottom: 1px solid var(--border);
align-items: baseline;
flex-wrap: wrap;
}
.log-line .sha { color: var(--link); flex-shrink: 0; font-size: 0.85rem; }
.log-line .date { color: var(--fg-dim); flex-shrink: 0; font-size: 0.82rem; }
.log-line .msg { color: var(--fg); min-width: 0; }
/* ── poem page: header ── */
.back {
display: block;
font-size: 0.8rem;
color: var(--fg-dim);
margin-bottom: 0.6rem;
}
.cmd {
margin-top: 0.4rem;
font-size: 0.8rem;
color: var(--fg-dim);
}
/* ── poem page: commit sections ── */
.commit {
margin: 2.5rem 0;
}
.commit-meta {
display: flex;
gap: 1rem;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.commit-meta .sha { font-size: 0.82rem; }
.commit-meta .date { color: var(--fg-dim); font-size: 0.82rem; flex-shrink: 0; }
.commit-meta .msg { color: var(--fg-bright); font-style: italic; }
/* ── diff ── */
.diff-block {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
font-size: 0.82rem;
line-height: 1.5;
}
.diff-add,
.diff-del,
.diff-ctx,
.diff-hunk {
display: flex;
white-space: pre-wrap;
word-break: break-word;
min-height: 1.4em;
}
/* the +/- gutter character */
.diff-add > span,
.diff-del > span,
.diff-ctx > span,
.diff-hunk > span {
width: 1.75rem;
flex-shrink: 0;
text-align: center;
user-select: none;
opacity: 0.7;
}
.diff-add { background: var(--add-bg); color: var(--add-fg); }
.diff-del { background: var(--del-bg); color: var(--del-fg); }
.diff-ctx { color: var(--fg-dim); }
.diff-hunk { background: var(--hunk-bg); color: var(--hunk-fg); font-style: italic; }
/* ── responsive ── */
@media (max-width: 580px) {
html { font-size: 13px; }
.poem-list li { flex-direction: column; gap: 0.1rem; }
.poem-list .meta { margin-left: 0; }
.log-line { gap: 0.5rem; }
.commit-meta { gap: 0.5rem; }
}