diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | CONTRIBUTING.md | 2 | ||||
| -rw-r--r-- | hn.py | 109 | ||||
| -rw-r--r-- | index.php | 10 | ||||
| -rw-r--r-- | requirements.txt | 4 | ||||
| -rw-r--r-- | src/Controller/FeedController.php | 47 | ||||
| -rw-r--r-- | src/Controller/RouteController.php | 170 | ||||
| -rw-r--r-- | src/Model/ApiService.php | 286 | ||||
| -rw-r--r-- | src/Model/CacheService.php | 10 | ||||
| -rw-r--r-- | src/View/BaseTemplate.php | 35 | ||||
| -rw-r--r-- | static/styles.css | 184 | ||||
| -rw-r--r-- | static/styles.min.css | 1 | ||||
| -rw-r--r-- | templates/base.html | 26 |
13 files changed, 141 insertions, 745 deletions
@@ -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 @@ -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 <[email protected]> - */ - 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 <[email protected]> - */ - 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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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 <[email protected]> - */ -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> · </span> - <span><a href="/best/">Best</a> ·</span> - <span><a href="/new/">New</a> ·</span> - <span><a href="/ask/">Ask</a> ·</span> - <span><a href="/show/">Show</a> ·</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 © 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> · + <span><a href="/best/">Best</a></span> · + <span><a href="/new/">New</a></span> · + <span><a href="/ask/">Ask</a></span> · + <span><a href="/show/">Show</a></span> · + <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 |
