krz/hn

A brutalist web client for Hacker News.

clone: git clone https://gitbay.org/krz/hn.git

01bd81cf3f943b4aff4e79f93910ab764a873548

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-01-08T02:18:27Z

MASSIVE UPDATE TO PYTHON STATIC SERVICE
 .gitignore                         |   2 +-
 CONTRIBUTING.md                    |   2 +-
 hn.py                              | 109 ++++++++++++++
 index.php                          |  10 --
 requirements.txt                   |   4 +
 src/Controller/FeedController.php  |  47 ------
 src/Controller/RouteController.php | 170 ----------------------
 src/Model/ApiService.php           | 286 -------------------------------------
 src/Model/CacheService.php         |  10 --
 src/View/BaseTemplate.php          |  35 -----
 static/styles.css                  | 184 ------------------------
 static/styles.min.css              |   1 -
 templates/base.html                |  26 ++++
 13 files changed, 141 insertions(+), 745 deletions(-)

diff --git a/.gitignore b/.gitignore
index 9f11b75..9b1960e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1 @@
-.idea/
+output/
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d03b13c..695e170 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,7 +6,7 @@ This project really only has a few guidelines to follow:
 
 - Focus on the end-user's privacy and experience.
 - Keep the codebase minimal and, ideally, package-free.
-- Follow the [PHP Style Guide](https://gist.github.com/ryansechrest/8138375).
+- Follow the [PEP 8](https://peps.python.org/pep-0008/) style guide.
 
 ## Reporting Bugs
 
diff --git a/hn.py b/hn.py
new file mode 100644
index 0000000..1a9f880
--- /dev/null
+++ b/hn.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""
+Static Hacker News page generator.
+
+* Top stories → output/index.html (site root)
+* Best, New, Ask, Show, Job → output/<section>/index.html
+"""
+
+import json
+import urllib.request
+from pathlib import Path
+from datetime import datetime
+from typing import List, Dict
+
+BASE_URL = "https://hacker-news.firebaseio.com/v0"
+OUTPUT_DIR = Path(__file__).parent / "output"
+TEMPLATE_PATH = Path(__file__).parent / "templates" / "base.html"
+
+
+# ----------------------------------------------------------------------
+# Helper functions
+# ----------------------------------------------------------------------
+def fetch_json(url: str) -> dict:
+    """GET a JSON endpoint and return the parsed object."""
+    with urllib.request.urlopen(url) as resp:
+        return json.load(resp)
+
+
+def get_story_ids(endpoint: str, limit: int = 10) -> List[int]:
+    """Return the first ``limit`` IDs for a given endpoint."""
+    url = f"{BASE_URL}/{endpoint}.json"
+    all_ids = fetch_json(url)
+    return all_ids[:limit]
+
+
+def get_item(item_id: int) -> Dict:
+    """Fetch a single Hacker News item."""
+    url = f"{BASE_URL}/item/{item_id}.json"
+    return fetch_json(url)
+
+
+def render_page(title: str, items_html: str, build_time: str) -> str:
+    """
+    Insert title, items, and the build timestamp into the base template.
+    """
+    template = TEMPLATE_PATH.read_text(encoding="utf-8")
+    rendered = (
+        template
+        .replace("{{title}}", title)
+        .replace("{{items}}", items_html)
+        .replace("{{build}}", build_time)
+    )
+    return rendered
+
+
+def build_list_item(story: Dict) -> str:
+    """Turn a story dict into a single <li> element."""
+    url = story.get("url") or f"https://news.ycombinator.com/item?id={story['id']}"
+    title = story.get("title", "(no title)")
+    score = story.get("score", 0)
+    by = story.get("by", "unknown")
+    return f'<li><a href="{url}">{title}</a> ({score} points) by {by}</li>'
+
+
+# ----------------------------------------------------------------------
+# Main generation logic
+# ----------------------------------------------------------------------
+def generate_static_pages():
+    # Make sure the top‑level output folder exists
+    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+    # One timestamp for the whole run
+    build_timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
+
+    # Mapping: endpoint → (human‑readable title, sub‑folder name or None for root)
+    sections = {
+        "topstories": ("Top Stories", None),      # None → write to root index.html
+        "beststories": ("Best Stories", "best"),
+        "newstories": ("New Stories", "new"),
+        "askstories": ("Ask HN", "ask"),
+        "showstories": ("Show HN", "show"),
+        "jobstories": ("Jobs", "job"),
+    }
+
+    for endpoint, (title, subdir) in sections.items():
+        print(f"Fetching {title}…")
+        ids = get_story_ids(endpoint, limit=10)
+        stories = [get_item(i) for i in ids]
+
+        items_html = "\n".join(build_list_item(s) for s in stories)
+
+        page_html = render_page(title, items_html, build_timestamp)
+
+        # Determine where to write the file
+        if subdir is None:
+            target_path = OUTPUT_DIR / "index.html"
+        else:
+            target_dir = OUTPUT_DIR / subdir
+            target_dir.mkdir(parents=True, exist_ok=True)
+            target_path = target_dir / "index.html"
+
+        target_path.write_text(page_html, encoding="utf-8")
+        print(f" → wrote {target_path}")
+
+    print("All pages generated (built at", build_timestamp, ")")
+
+
+if __name__ == "__main__":
+    generate_static_pages()
\ No newline at end of file
diff --git a/index.php b/index.php
deleted file mode 100644
index 163b112..0000000
--- a/index.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-require_once 'src/Controller/RouteController.php';
-
-$GLOBALS['full_domain'] = 'https://hn.cleberg.net';
-$GLOBALS['author_name'] = 'Christian Cleberg';
-$GLOBALS['site_title'] = 'hn';
-
-$route = new HN\Controllers\RouteController($_SERVER['REQUEST_URI']);
-$route->routeUser();
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..3364031
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+json
+urllib.request
+pathlib
+typing
\ No newline at end of file
diff --git a/src/Controller/FeedController.php b/src/Controller/FeedController.php
deleted file mode 100644
index 8bba37f..0000000
--- a/src/Controller/FeedController.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-namespace HN\Controllers;
-
-class FeedController
-{
-    /**
-     * @var string
-     */
-    private string $canonical_url;
-    /**
-     * @var string
-     */
-    private string $description;
-    /**
-     * @var string
-     */
-    private string $title;
-    /**
-     * @var string
-     */
-    private string $content;
-    /**
-     * @var false|string
-     */
-    private mixed $current_year;
-
-    public function __construct(string $canonical_url, string $description, string $title, string $content)
-    {
-        $this->canonical_url = $canonical_url;
-        $this->description = $description;
-        $this->title = $title;
-        $this->content = $content;
-        $this->current_year = date("Y");
-    }
-
-    /**
-     * Request template to be presented to the user
-     *
-     * @access public
-     * @author Christian Cleberg <hello@cleberg.net>
-     */
-    public function render(): void
-    {
-        include_once 'src/View/BaseTemplate.php';
-    }
-}
diff --git a/src/Controller/RouteController.php b/src/Controller/RouteController.php
deleted file mode 100644
index e3b2236..0000000
--- a/src/Controller/RouteController.php
+++ /dev/null
@@ -1,170 +0,0 @@
-<?php
-
-namespace HN\Controllers;
-
-require_once 'src/Controller/FeedController.php';
-
-use function HN\Models\GetApiResults;
-use function HN\Models\ParseItem;
-use function HN\Models\ParseStories;
-use function HN\Models\ParseUser;
-
-class RouteController
-{
-    /**
-     * @var string
-     */
-    private string $request;
-
-    public function __construct(string $request)
-    {
-        $this->request = $request;
-    }
-
-    /**
-     * Route the user to the appropriate function, based on the URL
-     *
-     * @access public
-     * @return void No return type; send user to FeedController->render() or a 404 error
-     * @author Christian Cleberg <hello@cleberg.net>
-     */
-    public function routeUser(): void
-    {
-        include_once 'src/Model/ApiService.php';
-        $path = ltrim($this->request, '/');
-        $elements = explode('/', $path);
-
-        switch (array_shift($elements)) {
-            case '':
-            case 'top':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'],
-                    'The top stories from Hacker News, proxied by hn.',
-                    'hn',
-                    ParseStories(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"'
-                        ),
-                        'Top'
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            case 'best':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'] . '/Best/',
-                    'The best stories from Hacker News, proxied by hn.',
-                    'hn ~ best',
-                    ParseStories(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/beststories.json?limitToFirst=10&orderBy="$key"'
-                        ),
-                        'Best'
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            case 'new':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'] . '/new/',
-                    'The newest stories from Hacker News, proxied by hn.',
-                    'hn ~ new',
-                    ParseStories(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/newstories.json?limitToFirst=10&orderBy="$key"'
-                        ),
-                        'New'
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            case 'ask':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'] . '/ask/',
-                    'The top asks from Hacker News, proxied by hn.',
-                    'hn ~ ask',
-                    ParseStories(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/askstories.json?limitToFirst=10&orderBy="$key"'
-                        ),
-                        'Ask'
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            case 'show':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'] . '/show/',
-                    'The latest showcases from Hacker News, proxied by hn.',
-                    'hn ~ show',
-                    ParseStories(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/showstories.json?limitToFirst=10&orderBy="$key"'
-                        ),
-                        'Show'
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            case 'job':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'] . '/job/',
-                    'The latest jobs from Hacker News, proxied by hn.',
-                    'hn ~ jobs',
-                    ParseStories(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/jobstories.json?limitToFirst=10&orderBy="$key"'
-                        ),
-                        'Job'
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            case 'user':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'] . '/user/' . $elements[0],
-                    'The Hacker News profile for ' . $elements[0] . ', proxied by hn.',
-                    'hn ~ ' . $elements[0],
-                    ParseUser(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/user/' . $elements[0] . '.json'
-                        ),
-                        'User: ' . $elements[0]
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            case 'item':
-                $feed = new FeedController(
-                    $GLOBALS['full_domain'] . '/item/' . $elements[0],
-                    'Hacker News story ' . $elements[0] . ', proxied by hn.',
-                    'hn ~ ' . $elements[0],
-                    ParseItem(
-                        GetApiResults(
-                            'https://hacker-news.firebaseio.com/v0/item/' . $elements[0] . '.json'
-                        )
-                    )
-                );
-
-                $feed->render();
-                break;
-
-            default:
-                header('HTTP/1.1 404 Not Found');
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Model/ApiService.php b/src/Model/ApiService.php
deleted file mode 100644
index dce65e5..0000000
--- a/src/Model/ApiService.php
+++ /dev/null
@@ -1,286 +0,0 @@
-<?php
-
-namespace HN\Models;
-
-/**
- * Extract a set of stories from the Hacker News API
- *
- * @access public
- * @param string $api_url The API endpoint to use for extraction
- * @return mixed The API results formatted into an HTML section
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function GetApiResults(string $api_url): mixed
-{
-    $response = file_get_contents($api_url);
-    return json_decode($response, true);
-}
-
-/**
- * Formats a given set of API results into an HTML section
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @param string $inline_title The <h1> title to use in the HTML
- * @return string $html_output The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ParseStories(mixed $api_results, string $inline_title): string
-{
-    if ($api_results == "null") {
-        return '<p>ERROR: Stories not found. API returned `null`.</p>';
-    } else {
-        $html_output = '<h1>' . $inline_title . '</h1>';
-        for ($i = 0; $i < count($api_results); $i++) {
-            $story_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results[$i] . '.json');
-            $html_output .= ConstructStory($story_api_results);
-        }
-
-        return $html_output;
-    }
-}
-
-/**
- *Extract a user's profile from Hacker News API and format in HTML
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @param string $inline_title The <h1> title to use in the HTML
- * @return string $html_output The formatted HTML result of stories from the API
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ParseUser(mixed $api_results, string $inline_title): string
-{
-    if ($api_results == "null") {
-        return '<p>ERROR: User not found.</p>';
-    } else {
-        // TODO: Create function to format $about using the following guidelines
-        //     : https://news.ycombinator.com/formatdoc
-        //     : hint: nl2br() will solve the first formatting requirement
-        $about = $api_results['about'];
-        $karma = $api_results['karma'];
-        $created = date('Y-m-d h:m:s', $api_results['created']);
-
-        $html_output = <<<EOT
-            <div class="user-details">
-                <h1>$inline_title</h1>
-                <p>About: $about</p>
-                <p>Karma: $karma</p>
-                <p>Created: <time datetime="$created">$created</time></p>
-                <br>
-                <h2>Recently Submitted</h2>
-            </div>
-        EOT;
-
-        $limit = (count($api_results['submitted']) > 10) ? 10 : count($api_results['submitted']);
-        if (count($api_results['submitted']) > 0) {
-            for ($i = 0; $i < $limit; $i++) {
-                $user_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['submitted'][$i] . '.json');
-                $html_output .= GetItem($user_api_results);
-            }
-        } else {
-            $html_output .= '<p>User has no submissions.</p>';
-        }
-
-        return $html_output;
-    }
-}
-
-
-/**
- * Formats one specific item requested by the user
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @param string $inline_title The <h1> title to use in the HTML
- * @return string $html_output The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ParseItem(mixed $api_results): string
-{
-    if ($api_results == "null") {
-        return '<p>ERROR: User not found.</p>';
-    } else {
-        $html_output = '';
-
-        if (in_array($api_results['type'], array("job", "story", "poll", "pollopt"))) {
-            $html_output .= ConstructStoryDiscussion($api_results);
-        } else {
-            if (array_key_exists('parent', $api_results)) {
-                $parent_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['parent'] . '.json');
-                $html_output .= GetItem($parent_api_results);
-                $html_output .= '<hr>';
-            }
-
-            $html_output .= GetItem($api_results);
-
-            if ($api_results['descendants'] != 0) {
-                $html_output .= '<hr>';
-                $child_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['kids'][0] . '.json');
-                $html_output .= GetItem($child_api_results);
-            }
-        }
-
-        return $html_output;
-    }
-}
-
-
-/**
- * Formats one item from the API to HTML
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @return string The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function GetItem(mixed $api_results): string
-{
-    $type = $api_results['type'];
-
-    return match ($type) {
-        'story', 'job' => ConstructStory($api_results),
-        'comment' => ConstructComment($api_results),
-        'poll' => ConstructPoll($api_results),
-        'pollopt' => ConstructPollOpt($api_results),
-        default => '[ERROR] Item type not found: ' . $type,
-    };
-}
-
-
-/**
- * Creates a story HTML element
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @return string The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ConstructStory(mixed $api_results): string
-{
-    $id = $api_results['id'];
-    $url = $api_results['url'];
-    $title = $api_results['title'];
-    $time = date('Y-m-d h:m:s', $api_results['time']);
-    $by = $api_results['by'];
-    $score = $api_results['score'];
-    if (array_key_exists('descendants', $api_results)) {
-        $descendants = $api_results['descendants'];
-    } else {
-        $descendants = 'No';
-    }
-
-    return <<<EOT
-        <div class="story">
-            <a href="$url">$title</a>
-            <p>
-                <time datetime="$time">$time</time>
-                by <a href="/user/$by/">$by</a>
-                | $score points
-                | <a href="/item/$id">$descendants comments</a>
-            </p>
-        </div>
-    EOT;
-}
-
-/**
- * Creates a story discussion page with comments
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @return string The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ConstructStoryDiscussion(mixed $api_results): string
-{
-    $id = $api_results['id'];
-    $url = $api_results['url'];
-    $title = $api_results['title'];
-    $time = date('Y-m-d h:m:s', $api_results['time']);
-    $by = $api_results['by'];
-    $score = $api_results['score'];
-    $descendants = $api_results['descendants'];
-    if (array_key_exists('text', $api_results)) {
-        $text = $api_results['text'];
-    } else {
-        $text = '';
-    }
-
-    $html_output = <<<EOT
-        <div class="story-discussion">
-            <h1><a href="$url" target="_blank" rel="noopener">$title</a></h1>
-            <p>
-                <time datetime="$time">$time</time>
-                by <a href="/user/$by/">$by</a>
-                | $score points
-                | <a href="/item/$id">$descendants comments</a>
-            </p>
-            <p>$text</p>
-        </div>
-    EOT;
-
-    // TODO: Add support for more than just top-level kids (i.e., recursive).
-    if ($api_results['descendants'] != 0) {
-        $html_output .= <<<EOT
-            <div class="story-discussion-comments">
-                <h2>Comments</h2>
-        EOT;
-        for ($i = 0; $i < count($api_results['kids']); $i++) {
-            $child_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['kids'][$i] . '.json');
-            $html_output .= GetItem($child_api_results);
-        }
-        $html_output .= '</div>';
-    }
-
-    return $html_output;
-}
-
-/**
- * Creates a comment HTML element
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @return string The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ConstructComment(mixed $api_results): string
-{
-    $time = date('Y-m-d h:m:s', $api_results['time']);
-    $text = $api_results['text'];
-    $by = $api_results['by'];
-    $parent = $api_results['parent'];
-
-    return <<<EOT
-        <div class="comment">
-            <p>$text</p>
-            <p><i>Submitted in response to: <a href="/item/$parent/">$parent</a></i></p>
-            <p><time datetime="$time">$time</time> by <a href="/user/$by/">$by</a></p>
-        </div>
-    EOT;
-}
-
-/**
- * Creates a poll HTML element
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @return string The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ConstructPoll(mixed $api_results): string
-{
-    return 'TODO';
-}
-
-/**
- * Creates a poll-option HTML element
- *
- * @access public
- * @param mixed $api_results The decoded API results
- * @return string The formatted HTML result of stories from the API or the error message
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function ConstructPollOpt(mixed $api_results): string
-{
-    return 'TODO';
-}
diff --git a/src/Model/CacheService.php b/src/Model/CacheService.php
deleted file mode 100644
index ccb0db4..0000000
--- a/src/Model/CacheService.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-/**
- * TODO: Implement a way to cache certain content in SQLite or something similarly performative
- *
- * @access public
- * @return void
- * @author Christian Cleberg <hello@cleberg.net>
- */
-function CacheService() {}
\ No newline at end of file
diff --git a/src/View/BaseTemplate.php b/src/View/BaseTemplate.php
deleted file mode 100644
index 1aa25ab..0000000
--- a/src/View/BaseTemplate.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<!doctype html>
-<html lang="en">
-
-<head>
-    <title><?php echo $this->title; ?></title>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-    <meta http-equiv="x-ua-compatible" content="ie=edge">
-    <meta name="author" content="<?php echo $GLOBALS['author_name']; ?>">
-    <meta name="description" content="<?php echo $this->description; ?>">
-    <link rel="canonical" href="<?php echo $this->canonical_url; ?>">
-    <link rel="stylesheet" href="/static/styles.min.css">
-</head>
-
-<body>
-<main id="main">
-    <nav class="links">
-        <span><a href="/">Top</a> &middot; </span>
-        <span><a href="/best/">Best</a> &middot;</span>
-        <span><a href="/new/">New</a> &middot;</span>
-        <span><a href="/ask/">Ask</a> &middot;</span>
-        <span><a href="/show/">Show</a> &middot;</span>
-        <span><a href="/job/">Job</a></span>
-    </nav>
-    <?php echo $this->content; ?>
-</main>
-
-<footer>
-    <p><a href="https://git.cleberg.net/hn.git">Source Code</a></p>
-    <p>Copyright &copy; 2023 - <?php echo $this->current_year; ?></p>
-</footer>
-
-</body>
-
-</html>
diff --git a/static/styles.css b/static/styles.css
deleted file mode 100644
index 72905ab..0000000
--- a/static/styles.css
+++ /dev/null
@@ -1,184 +0,0 @@
-/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
-button, hr, input {
-    overflow: visible
-}
-
-progress, sub, sup {
-    vertical-align: baseline
-}
-
-[type=checkbox], [type=radio], legend {
-    box-sizing: border-box;
-    padding: 0
-}
-
-html {
-    line-height: 1.15;
-    -webkit-text-size-adjust: 100%
-}
-
-body {
-    margin: 0
-}
-
-details, main {
-    display: block
-}
-
-h1 {
-    font-size: 2em;
-    margin: .67em 0
-}
-
-hr {
-    box-sizing: content-box;
-    height: 0
-}
-
-code, kbd, pre, samp {
-    font-family: monospace, monospace;
-    font-size: 1em
-}
-
-a {
-    background-color: transparent
-}
-
-abbr[title] {
-    border-bottom: none;
-    text-decoration: underline;
-    text-decoration: underline dotted
-}
-
-b, strong {
-    font-weight: bolder
-}
-
-small {
-    font-size: 80%
-}
-
-sub, sup {
-    font-size: 75%;
-    line-height: 0;
-    position: relative
-}
-
-sub {
-    bottom: -.25em
-}
-
-sup {
-    top: -.5em
-}
-
-img {
-    border-style: none
-}
-
-button, input, optgroup, select, textarea {
-    font-family: inherit;
-    font-size: 100%;
-    line-height: 1.15;
-    margin: 0
-}
-
-button, select {
-    text-transform: none
-}
-
-[type=button], [type=reset], [type=submit], button {
-    -webkit-appearance: button
-}
-
-[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {
-    border-style: none;
-    padding: 0
-}
-
-[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {
-    outline: ButtonText dotted 1px
-}
-
-fieldset {
-    padding: .35em .75em .625em
-}
-
-legend {
-    color: inherit;
-    display: table;
-    max-width: 100%;
-    white-space: normal
-}
-
-textarea {
-    overflow: auto
-}
-
-[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {
-    height: auto
-}
-
-[type=search] {
-    -webkit-appearance: textfield;
-    outline-offset: -2px
-}
-
-[type=search]::-webkit-search-decoration {
-    -webkit-appearance: none
-}
-
-::-webkit-file-upload-button {
-    -webkit-appearance: button;
-    font: inherit
-}
-
-summary {
-    display: list-item
-}
-
-[hidden], template {
-    display: none
-}
-
-/* custom css */
-body {
-    padding: 1rem;
-    font-family: system-ui, sans-serif;
-    max-width: 40em;
-}
-
-body > main > div {
-    margin-bottom: 1rem;
-}
-
-body > main > div > p {
-    margin-top: 0.5rem;
-}
-
-a {
-    text-decoration: none;
-}
-
-footer {
-    border-top: 1px solid black;
-}
-
-@media (prefers-color-scheme: dark) {
-    body {
-        background-color: #000;
-        color: #ccc;
-    }
-
-    h1, h2, h3, h4, h5, h6 {
-        color: #fff;
-    }
-
-    a, a:hover, a:visited {
-        color: #0f0;
-    }
-
-    footer {
-        border-color: #ccc;
-    }
-}
diff --git a/static/styles.min.css b/static/styles.min.css
deleted file mode 100644
index 0328079..0000000
--- a/static/styles.min.css
+++ /dev/null
@@ -1 +0,0 @@
-button, hr, input {overflow: visible }progress, sub, sup {vertical-align: baseline }[type=checkbox], [type=radio], legend {box-sizing: border-box;padding: 0 }html {line-height: 1.15;-webkit-text-size-adjust: 100% }body {margin: 0 }details, main {display: block }h1 {font-size: 2em;margin: .67em 0 }hr {box-sizing: content-box;height: 0 }code, kbd, pre, samp {font-family: monospace, monospace;font-size: 1em }a {background-color: transparent }abbr[title] {border-bottom: none;text-decoration: underline;text-decoration: underline dotted }b, strong {font-weight: bolder }small {font-size: 80% }sub, sup {font-size: 75%;line-height: 0;position: relative }sub {bottom: -.25em }sup {top: -.5em }img {border-style: none }button, input, optgroup, select, textarea {font-family: inherit;font-size: 100%;line-height: 1.15;margin: 0 }button, select {text-transform: none }[type=button], [type=reset], [type=submit], button {-webkit-appearance: button }[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {border-style: none;padding: 0 }[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {outline: ButtonText dotted 1px }fieldset {padding: .35em .75em .625em }legend {color: inherit;display: table;max-width: 100%;white-space: normal }textarea {overflow: auto }[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {height: auto }[type=search] {-webkit-appearance: textfield;outline-offset: -2px }[type=search]::-webkit-search-decoration {-webkit-appearance: none }::-webkit-file-upload-button {-webkit-appearance: button;font: inherit }summary {display: list-item }[hidden], template {display: none }body {padding: 1rem;font-family: system-ui, sans-serif;max-width: 40em;}body > main > div {margin-bottom: 1rem;}body > main > div > p {margin-top: 0.5rem;}a {text-decoration: none;}footer {border-top: 1px solid black;}.user-submission {border-bottom: 1px solid black;}@media (prefers-color-scheme: dark) {body {background-color: #000;color: #ccc;}h1, h2, h3, h4, h5, h6 {color: #fff;}a, a:hover, a:visited {color: #0f0;}footer {border-color: #ccc;}.user-submission {border-color: white;}}
\ No newline at end of file
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..d882f00
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>{{title}}</title>
+</head>
+<body>
+<nav>
+    <span><a href="/">Top</a></span> &middot;
+    <span><a href="/best/">Best</a></span> &middot;
+    <span><a href="/new/">New</a></span> &middot;
+    <span><a href="/ask/">Ask</a></span> &middot;
+    <span><a href="/show/">Show</a></span> &middot;
+    <span><a href="/job/">Job</a></span>
+</nav>
+<h1>{{title}}</h1>
+<ul>
+{{items}}
+</ul>
+<footer>
+    <a href="https://git.cleberg.net/hn.git">source code</a>
+    <br><span>{{build}}</span>
+</footer>
+</body>
+</html>
\ No newline at end of file