Commit 01bd81cf3f

01bd81cf3f943b4aff4e79f93910ab764a873548

parent: 2fd901458c

Unsigned

cmc <hello@cleberg.net> · 2026-01-08 02:18 UTC

MASSIVE UPDATE TO PYTHON STATIC SERVICE

Layout: unified · split

.gitignore +1 −1
@@ -1 +1 @@
1.idea/
1output/
\ No newline at end of file
CONTRIBUTING.md +1 −1
@@ -6,7 +6,7 @@ This project really only has a few guidelines to follow:
66
77- Focus on the end-user's privacy and experience.
88- Keep the codebase minimal and, ideally, package-free.
9- Follow the [PHP Style Guide](https://gist.github.com/ryansechrest/8138375).
9- Follow the [PEP 8](https://peps.python.org/pep-0008/) style guide.
1010
1111## Reporting Bugs
1212
hn.py added +109
@@ -0,0 +1,109 @@
1#!/usr/bin/env python3
2"""
3Static Hacker News page generator.
4
5* Top stories → output/index.html (site root)
6* Best, New, Ask, Show, Job → output/<section>/index.html
7"""
8
9import json
10import urllib.request
11from pathlib import Path
12from datetime import datetime
13from typing import List, Dict
14
15BASE_URL = "https://hacker-news.firebaseio.com/v0"
16OUTPUT_DIR = Path(__file__).parent / "output"
17TEMPLATE_PATH = Path(__file__).parent / "templates" / "base.html"
18
19
20# ----------------------------------------------------------------------
21# Helper functions
22# ----------------------------------------------------------------------
23def fetch_json(url: str) -> dict:
24 """GET a JSON endpoint and return the parsed object."""
25 with urllib.request.urlopen(url) as resp:
26 return json.load(resp)
27
28
29def get_story_ids(endpoint: str, limit: int = 10) -> List[int]:
30 """Return the first ``limit`` IDs for a given endpoint."""
31 url = f"{BASE_URL}/{endpoint}.json"
32 all_ids = fetch_json(url)
33 return all_ids[:limit]
34
35
36def get_item(item_id: int) -> Dict:
37 """Fetch a single Hacker News item."""
38 url = f"{BASE_URL}/item/{item_id}.json"
39 return fetch_json(url)
40
41
42def render_page(title: str, items_html: str, build_time: str) -> str:
43 """
44 Insert title, items, and the build timestamp into the base template.
45 """
46 template = TEMPLATE_PATH.read_text(encoding="utf-8")
47 rendered = (
48 template
49 .replace("{{title}}", title)
50 .replace("{{items}}", items_html)
51 .replace("{{build}}", build_time)
52 )
53 return rendered
54
55
56def build_list_item(story: Dict) -> str:
57 """Turn a story dict into a single <li> element."""
58 url = story.get("url") or f"https://news.ycombinator.com/item?id={story['id']}"
59 title = story.get("title", "(no title)")
60 score = story.get("score", 0)
61 by = story.get("by", "unknown")
62 return f'<li><a href="{url}">{title}</a> ({score} points) by {by}</li>'
63
64
65# ----------------------------------------------------------------------
66# Main generation logic
67# ----------------------------------------------------------------------
68def generate_static_pages():
69 # Make sure the top‑level output folder exists
70 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
71
72 # One timestamp for the whole run
73 build_timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
74
75 # Mapping: endpoint → (human‑readable title, sub‑folder name or None for root)
76 sections = {
77 "topstories": ("Top Stories", None), # None → write to root index.html
78 "beststories": ("Best Stories", "best"),
79 "newstories": ("New Stories", "new"),
80 "askstories": ("Ask HN", "ask"),
81 "showstories": ("Show HN", "show"),
82 "jobstories": ("Jobs", "job"),
83 }
84
85 for endpoint, (title, subdir) in sections.items():
86 print(f"Fetching {title}…")
87 ids = get_story_ids(endpoint, limit=10)
88 stories = [get_item(i) for i in ids]
89
90 items_html = "\n".join(build_list_item(s) for s in stories)
91
92 page_html = render_page(title, items_html, build_timestamp)
93
94 # Determine where to write the file
95 if subdir is None:
96 target_path = OUTPUT_DIR / "index.html"
97 else:
98 target_dir = OUTPUT_DIR / subdir
99 target_dir.mkdir(parents=True, exist_ok=True)
100 target_path = target_dir / "index.html"
101
102 target_path.write_text(page_html, encoding="utf-8")
103 print(f" → wrote {target_path}")
104
105 print("All pages generated (built at", build_timestamp, ")")
106
107
108if __name__ == "__main__":
109 generate_static_pages()
\ No newline at end of file
index.php deleted −10
@@ -1,10 +0,0 @@
1<?php
2
3require_once 'src/Controller/RouteController.php';
4
5$GLOBALS['full_domain'] = 'https://hn.cleberg.net';
6$GLOBALS['author_name'] = 'Christian Cleberg';
7$GLOBALS['site_title'] = 'hn';
8
9$route = new HN\Controllers\RouteController($_SERVER['REQUEST_URI']);
10$route->routeUser();
requirements.txt added +4
@@ -0,0 +1,4 @@
1json
2urllib.request
3pathlib
4typing
\ No newline at end of file
src/Controller/FeedController.php deleted −47
@@ -1,47 +0,0 @@
1<?php
2
3namespace HN\Controllers;
4
5class FeedController
6{
7 /**
8 * @var string
9 */
10 private string $canonical_url;
11 /**
12 * @var string
13 */
14 private string $description;
15 /**
16 * @var string
17 */
18 private string $title;
19 /**
20 * @var string
21 */
22 private string $content;
23 /**
24 * @var false|string
25 */
26 private mixed $current_year;
27
28 public function __construct(string $canonical_url, string $description, string $title, string $content)
29 {
30 $this->canonical_url = $canonical_url;
31 $this->description = $description;
32 $this->title = $title;
33 $this->content = $content;
34 $this->current_year = date("Y");
35 }
36
37 /**
38 * Request template to be presented to the user
39 *
40 * @access public
41 * @author Christian Cleberg <hello@cleberg.net>
42 */
43 public function render(): void
44 {
45 include_once 'src/View/BaseTemplate.php';
46 }
47}
src/Controller/RouteController.php deleted −170
@@ -1,170 +0,0 @@
1<?php
2
3namespace HN\Controllers;
4
5require_once 'src/Controller/FeedController.php';
6
7use function HN\Models\GetApiResults;
8use function HN\Models\ParseItem;
9use function HN\Models\ParseStories;
10use function HN\Models\ParseUser;
11
12class RouteController
13{
14 /**
15 * @var string
16 */
17 private string $request;
18
19 public function __construct(string $request)
20 {
21 $this->request = $request;
22 }
23
24 /**
25 * Route the user to the appropriate function, based on the URL
26 *
27 * @access public
28 * @return void No return type; send user to FeedController->render() or a 404 error
29 * @author Christian Cleberg <hello@cleberg.net>
30 */
31 public function routeUser(): void
32 {
33 include_once 'src/Model/ApiService.php';
34 $path = ltrim($this->request, '/');
35 $elements = explode('/', $path);
36
37 switch (array_shift($elements)) {
38 case '':
39 case 'top':
40 $feed = new FeedController(
41 $GLOBALS['full_domain'],
42 'The top stories from Hacker News, proxied by hn.',
43 'hn',
44 ParseStories(
45 GetApiResults(
46 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"'
47 ),
48 'Top'
49 )
50 );
51
52 $feed->render();
53 break;
54
55 case 'best':
56 $feed = new FeedController(
57 $GLOBALS['full_domain'] . '/Best/',
58 'The best stories from Hacker News, proxied by hn.',
59 'hn ~ best',
60 ParseStories(
61 GetApiResults(
62 'https://hacker-news.firebaseio.com/v0/beststories.json?limitToFirst=10&orderBy="$key"'
63 ),
64 'Best'
65 )
66 );
67
68 $feed->render();
69 break;
70
71 case 'new':
72 $feed = new FeedController(
73 $GLOBALS['full_domain'] . '/new/',
74 'The newest stories from Hacker News, proxied by hn.',
75 'hn ~ new',
76 ParseStories(
77 GetApiResults(
78 'https://hacker-news.firebaseio.com/v0/newstories.json?limitToFirst=10&orderBy="$key"'
79 ),
80 'New'
81 )
82 );
83
84 $feed->render();
85 break;
86
87 case 'ask':
88 $feed = new FeedController(
89 $GLOBALS['full_domain'] . '/ask/',
90 'The top asks from Hacker News, proxied by hn.',
91 'hn ~ ask',
92 ParseStories(
93 GetApiResults(
94 'https://hacker-news.firebaseio.com/v0/askstories.json?limitToFirst=10&orderBy="$key"'
95 ),
96 'Ask'
97 )
98 );
99
100 $feed->render();
101 break;
102
103 case 'show':
104 $feed = new FeedController(
105 $GLOBALS['full_domain'] . '/show/',
106 'The latest showcases from Hacker News, proxied by hn.',
107 'hn ~ show',
108 ParseStories(
109 GetApiResults(
110 'https://hacker-news.firebaseio.com/v0/showstories.json?limitToFirst=10&orderBy="$key"'
111 ),
112 'Show'
113 )
114 );
115
116 $feed->render();
117 break;
118
119 case 'job':
120 $feed = new FeedController(
121 $GLOBALS['full_domain'] . '/job/',
122 'The latest jobs from Hacker News, proxied by hn.',
123 'hn ~ jobs',
124 ParseStories(
125 GetApiResults(
126 'https://hacker-news.firebaseio.com/v0/jobstories.json?limitToFirst=10&orderBy="$key"'
127 ),
128 'Job'
129 )
130 );
131
132 $feed->render();
133 break;
134
135 case 'user':
136 $feed = new FeedController(
137 $GLOBALS['full_domain'] . '/user/' . $elements[0],
138 'The Hacker News profile for ' . $elements[0] . ', proxied by hn.',
139 'hn ~ ' . $elements[0],
140 ParseUser(
141 GetApiResults(
142 'https://hacker-news.firebaseio.com/v0/user/' . $elements[0] . '.json'
143 ),
144 'User: ' . $elements[0]
145 )
146 );
147
148 $feed->render();
149 break;
150
151 case 'item':
152 $feed = new FeedController(
153 $GLOBALS['full_domain'] . '/item/' . $elements[0],
154 'Hacker News story ' . $elements[0] . ', proxied by hn.',
155 'hn ~ ' . $elements[0],
156 ParseItem(
157 GetApiResults(
158 'https://hacker-news.firebaseio.com/v0/item/' . $elements[0] . '.json'
159 )
160 )
161 );
162
163 $feed->render();
164 break;
165
166 default:
167 header('HTTP/1.1 404 Not Found');
168 }
169 }
170}
\ No newline at end of file
src/Model/ApiService.php deleted −286
@@ -1,286 +0,0 @@
1<?php
2
3namespace HN\Models;
4
5/**
6 * Extract a set of stories from the Hacker News API
7 *
8 * @access public
9 * @param string $api_url The API endpoint to use for extraction
10 * @return mixed The API results formatted into an HTML section
11 * @author Christian Cleberg <hello@cleberg.net>
12 */
13function GetApiResults(string $api_url): mixed
14{
15 $response = file_get_contents($api_url);
16 return json_decode($response, true);
17}
18
19/**
20 * Formats a given set of API results into an HTML section
21 *
22 * @access public
23 * @param mixed $api_results The decoded API results
24 * @param string $inline_title The <h1> title to use in the HTML
25 * @return string $html_output The formatted HTML result of stories from the API or the error message
26 * @author Christian Cleberg <hello@cleberg.net>
27 */
28function ParseStories(mixed $api_results, string $inline_title): string
29{
30 if ($api_results == "null") {
31 return '<p>ERROR: Stories not found. API returned `null`.</p>';
32 } else {
33 $html_output = '<h1>' . $inline_title . '</h1>';
34 for ($i = 0; $i < count($api_results); $i++) {
35 $story_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results[$i] . '.json');
36 $html_output .= ConstructStory($story_api_results);
37 }
38
39 return $html_output;
40 }
41}
42
43/**
44 *Extract a user's profile from Hacker News API and format in HTML
45 *
46 * @access public
47 * @param mixed $api_results The decoded API results
48 * @param string $inline_title The <h1> title to use in the HTML
49 * @return string $html_output The formatted HTML result of stories from the API
50 * @author Christian Cleberg <hello@cleberg.net>
51 */
52function ParseUser(mixed $api_results, string $inline_title): string
53{
54 if ($api_results == "null") {
55 return '<p>ERROR: User not found.</p>';
56 } else {
57 // TODO: Create function to format $about using the following guidelines
58 // : https://news.ycombinator.com/formatdoc
59 // : hint: nl2br() will solve the first formatting requirement
60 $about = $api_results['about'];
61 $karma = $api_results['karma'];
62 $created = date('Y-m-d h:m:s', $api_results['created']);
63
64 $html_output = <<<EOT
65 <div class="user-details">
66 <h1>$inline_title</h1>
67 <p>About: $about</p>
68 <p>Karma: $karma</p>
69 <p>Created: <time datetime="$created">$created</time></p>
70 <br>
71 <h2>Recently Submitted</h2>
72 </div>
73 EOT;
74
75 $limit = (count($api_results['submitted']) > 10) ? 10 : count($api_results['submitted']);
76 if (count($api_results['submitted']) > 0) {
77 for ($i = 0; $i < $limit; $i++) {
78 $user_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['submitted'][$i] . '.json');
79 $html_output .= GetItem($user_api_results);
80 }
81 } else {
82 $html_output .= '<p>User has no submissions.</p>';
83 }
84
85 return $html_output;
86 }
87}
88
89
90/**
91 * Formats one specific item requested by the user
92 *
93 * @access public
94 * @param mixed $api_results The decoded API results
95 * @param string $inline_title The <h1> title to use in the HTML
96 * @return string $html_output The formatted HTML result of stories from the API or the error message
97 * @author Christian Cleberg <hello@cleberg.net>
98 */
99function ParseItem(mixed $api_results): string
100{
101 if ($api_results == "null") {
102 return '<p>ERROR: User not found.</p>';
103 } else {
104 $html_output = '';
105
106 if (in_array($api_results['type'], array("job", "story", "poll", "pollopt"))) {
107 $html_output .= ConstructStoryDiscussion($api_results);
108 } else {
109 if (array_key_exists('parent', $api_results)) {
110 $parent_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['parent'] . '.json');
111 $html_output .= GetItem($parent_api_results);
112 $html_output .= '<hr>';
113 }
114
115 $html_output .= GetItem($api_results);
116
117 if ($api_results['descendants'] != 0) {
118 $html_output .= '<hr>';
119 $child_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['kids'][0] . '.json');
120 $html_output .= GetItem($child_api_results);
121 }
122 }
123
124 return $html_output;
125 }
126}
127
128
129/**
130 * Formats one item from the API to HTML
131 *
132 * @access public
133 * @param mixed $api_results The decoded API results
134 * @return string The formatted HTML result of stories from the API or the error message
135 * @author Christian Cleberg <hello@cleberg.net>
136 */
137function GetItem(mixed $api_results): string
138{
139 $type = $api_results['type'];
140
141 return match ($type) {
142 'story', 'job' => ConstructStory($api_results),
143 'comment' => ConstructComment($api_results),
144 'poll' => ConstructPoll($api_results),
145 'pollopt' => ConstructPollOpt($api_results),
146 default => '[ERROR] Item type not found: ' . $type,
147 };
148}
149
150
151/**
152 * Creates a story HTML element
153 *
154 * @access public
155 * @param mixed $api_results The decoded API results
156 * @return string The formatted HTML result of stories from the API or the error message
157 * @author Christian Cleberg <hello@cleberg.net>
158 */
159function ConstructStory(mixed $api_results): string
160{
161 $id = $api_results['id'];
162 $url = $api_results['url'];
163 $title = $api_results['title'];
164 $time = date('Y-m-d h:m:s', $api_results['time']);
165 $by = $api_results['by'];
166 $score = $api_results['score'];
167 if (array_key_exists('descendants', $api_results)) {
168 $descendants = $api_results['descendants'];
169 } else {
170 $descendants = 'No';
171 }
172
173 return <<<EOT
174 <div class="story">
175 <a href="$url">$title</a>
176 <p>
177 <time datetime="$time">$time</time>
178 by <a href="/user/$by/">$by</a>
179 | $score points
180 | <a href="/item/$id">$descendants comments</a>
181 </p>
182 </div>
183 EOT;
184}
185
186/**
187 * Creates a story discussion page with comments
188 *
189 * @access public
190 * @param mixed $api_results The decoded API results
191 * @return string The formatted HTML result of stories from the API or the error message
192 * @author Christian Cleberg <hello@cleberg.net>
193 */
194function ConstructStoryDiscussion(mixed $api_results): string
195{
196 $id = $api_results['id'];
197 $url = $api_results['url'];
198 $title = $api_results['title'];
199 $time = date('Y-m-d h:m:s', $api_results['time']);
200 $by = $api_results['by'];
201 $score = $api_results['score'];
202 $descendants = $api_results['descendants'];
203 if (array_key_exists('text', $api_results)) {
204 $text = $api_results['text'];
205 } else {
206 $text = '';
207 }
208
209 $html_output = <<<EOT
210 <div class="story-discussion">
211 <h1><a href="$url" target="_blank" rel="noopener">$title</a></h1>
212 <p>
213 <time datetime="$time">$time</time>
214 by <a href="/user/$by/">$by</a>
215 | $score points
216 | <a href="/item/$id">$descendants comments</a>
217 </p>
218 <p>$text</p>
219 </div>
220 EOT;
221
222 // TODO: Add support for more than just top-level kids (i.e., recursive).
223 if ($api_results['descendants'] != 0) {
224 $html_output .= <<<EOT
225 <div class="story-discussion-comments">
226 <h2>Comments</h2>
227 EOT;
228 for ($i = 0; $i < count($api_results['kids']); $i++) {
229 $child_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['kids'][$i] . '.json');
230 $html_output .= GetItem($child_api_results);
231 }
232 $html_output .= '</div>';
233 }
234
235 return $html_output;
236}
237
238/**
239 * Creates a comment HTML element
240 *
241 * @access public
242 * @param mixed $api_results The decoded API results
243 * @return string The formatted HTML result of stories from the API or the error message
244 * @author Christian Cleberg <hello@cleberg.net>
245 */
246function ConstructComment(mixed $api_results): string
247{
248 $time = date('Y-m-d h:m:s', $api_results['time']);
249 $text = $api_results['text'];
250 $by = $api_results['by'];
251 $parent = $api_results['parent'];
252
253 return <<<EOT
254 <div class="comment">
255 <p>$text</p>
256 <p><i>Submitted in response to: <a href="/item/$parent/">$parent</a></i></p>
257 <p><time datetime="$time">$time</time> by <a href="/user/$by/">$by</a></p>
258 </div>
259 EOT;
260}
261
262/**
263 * Creates a poll HTML element
264 *
265 * @access public
266 * @param mixed $api_results The decoded API results
267 * @return string The formatted HTML result of stories from the API or the error message
268 * @author Christian Cleberg <hello@cleberg.net>
269 */
270function ConstructPoll(mixed $api_results): string
271{
272 return 'TODO';
273}
274
275/**
276 * Creates a poll-option HTML element
277 *
278 * @access public
279 * @param mixed $api_results The decoded API results
280 * @return string The formatted HTML result of stories from the API or the error message
281 * @author Christian Cleberg <hello@cleberg.net>
282 */
283function ConstructPollOpt(mixed $api_results): string
284{
285 return 'TODO';
286}
src/Model/CacheService.php deleted −10
@@ -1,10 +0,0 @@
1<?php
2
3/**
4 * TODO: Implement a way to cache certain content in SQLite or something similarly performative
5 *
6 * @access public
7 * @return void
8 * @author Christian Cleberg <hello@cleberg.net>
9 */
10function CacheService() {}
\ No newline at end of file
src/View/BaseTemplate.php deleted −35
@@ -1,35 +0,0 @@
1<!doctype html>
2<html lang="en">
3
4<head>
5 <title><?php echo $this->title; ?></title>
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
8 <meta http-equiv="x-ua-compatible" content="ie=edge">
9 <meta name="author" content="<?php echo $GLOBALS['author_name']; ?>">
10 <meta name="description" content="<?php echo $this->description; ?>">
11 <link rel="canonical" href="<?php echo $this->canonical_url; ?>">
12 <link rel="stylesheet" href="/static/styles.min.css">
13</head>
14
15<body>
16<main id="main">
17 <nav class="links">
18 <span><a href="/">Top</a> &middot; </span>
19 <span><a href="/best/">Best</a> &middot;</span>
20 <span><a href="/new/">New</a> &middot;</span>
21 <span><a href="/ask/">Ask</a> &middot;</span>
22 <span><a href="/show/">Show</a> &middot;</span>
23 <span><a href="/job/">Job</a></span>
24 </nav>
25 <?php echo $this->content; ?>
26</main>
27
28<footer>
29 <p><a href="https://git.cleberg.net/hn.git">Source Code</a></p>
30 <p>Copyright &copy; 2023 - <?php echo $this->current_year; ?></p>
31</footer>
32
33</body>
34
35</html>
static/styles.css deleted −184
@@ -1,184 +0,0 @@
1/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
2button, hr, input {
3 overflow: visible
4}
5
6progress, sub, sup {
7 vertical-align: baseline
8}
9
10[type=checkbox], [type=radio], legend {
11 box-sizing: border-box;
12 padding: 0
13}
14
15html {
16 line-height: 1.15;
17 -webkit-text-size-adjust: 100%
18}
19
20body {
21 margin: 0
22}
23
24details, main {
25 display: block
26}
27
28h1 {
29 font-size: 2em;
30 margin: .67em 0
31}
32
33hr {
34 box-sizing: content-box;
35 height: 0
36}
37
38code, kbd, pre, samp {
39 font-family: monospace, monospace;
40 font-size: 1em
41}
42
43a {
44 background-color: transparent
45}
46
47abbr[title] {
48 border-bottom: none;
49 text-decoration: underline;
50 text-decoration: underline dotted
51}
52
53b, strong {
54 font-weight: bolder
55}
56
57small {
58 font-size: 80%
59}
60
61sub, sup {
62 font-size: 75%;
63 line-height: 0;
64 position: relative
65}
66
67sub {
68 bottom: -.25em
69}
70
71sup {
72 top: -.5em
73}
74
75img {
76 border-style: none
77}
78
79button, input, optgroup, select, textarea {
80 font-family: inherit;
81 font-size: 100%;
82 line-height: 1.15;
83 margin: 0
84}
85
86button, select {
87 text-transform: none
88}
89
90[type=button], [type=reset], [type=submit], button {
91 -webkit-appearance: button
92}
93
94[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {
95 border-style: none;
96 padding: 0
97}
98
99[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {
100 outline: ButtonText dotted 1px
101}
102
103fieldset {
104 padding: .35em .75em .625em
105}
106
107legend {
108 color: inherit;
109 display: table;
110 max-width: 100%;
111 white-space: normal
112}
113
114textarea {
115 overflow: auto
116}
117
118[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {
119 height: auto
120}
121
122[type=search] {
123 -webkit-appearance: textfield;
124 outline-offset: -2px
125}
126
127[type=search]::-webkit-search-decoration {
128 -webkit-appearance: none
129}
130
131::-webkit-file-upload-button {
132 -webkit-appearance: button;
133 font: inherit
134}
135
136summary {
137 display: list-item
138}
139
140[hidden], template {
141 display: none
142}
143
144/* custom css */
145body {
146 padding: 1rem;
147 font-family: system-ui, sans-serif;
148 max-width: 40em;
149}
150
151body > main > div {
152 margin-bottom: 1rem;
153}
154
155body > main > div > p {
156 margin-top: 0.5rem;
157}
158
159a {
160 text-decoration: none;
161}
162
163footer {
164 border-top: 1px solid black;
165}
166
167@media (prefers-color-scheme: dark) {
168 body {
169 background-color: #000;
170 color: #ccc;
171 }
172
173 h1, h2, h3, h4, h5, h6 {
174 color: #fff;
175 }
176
177 a, a:hover, a:visited {
178 color: #0f0;
179 }
180
181 footer {
182 border-color: #ccc;
183 }
184}
static/styles.min.css deleted −1
@@ -1 +0,0 @@
1button, 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
templates/base.html added +26
@@ -0,0 +1,26 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>{{title}}</title>
7</head>
8<body>
9<nav>
10 <span><a href="/">Top</a></span> &middot;
11 <span><a href="/best/">Best</a></span> &middot;
12 <span><a href="/new/">New</a></span> &middot;
13 <span><a href="/ask/">Ask</a></span> &middot;
14 <span><a href="/show/">Show</a></span> &middot;
15 <span><a href="/job/">Job</a></span>
16</nav>
17<h1>{{title}}</h1>
18<ul>
19{{items}}
20</ul>
21<footer>
22 <a href="https://git.cleberg.net/hn.git">source code</a>
23 <br><span>{{build}}</span>
24</footer>
25</body>
26</html>
\ No newline at end of file