Commit 01bd81cf3f
Unsigned
Layout: unified · split
.gitignore +1 −1
| @@ -1 +1 @@ | |||
| 1 | .idea/ | 1 | output/\ No newline at end of file |
CONTRIBUTING.md +1 −1
| @@ -6,7 +6,7 @@ This project really only has a few guidelines to follow: | |||
| 6 | 6 | ||
| 7 | - Focus on the end-user's privacy and experience. | 7 | - Focus on the end-user's privacy and experience. |
| 8 | - Keep the codebase minimal and, ideally, package-free. | 8 | - 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. |
| 10 | 10 | ||
| 11 | ## Reporting Bugs | 11 | ## Reporting Bugs |
| 12 | 12 | ||
hn.py added +109
| @@ -0,0 +1,109 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """ | ||
| 3 | Static 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 | |||
| 9 | import json | ||
| 10 | import urllib.request | ||
| 11 | from pathlib import Path | ||
| 12 | from datetime import datetime | ||
| 13 | from typing import List, Dict | ||
| 14 | |||
| 15 | BASE_URL = "https://hacker-news.firebaseio.com/v0" | ||
| 16 | OUTPUT_DIR = Path(__file__).parent / "output" | ||
| 17 | TEMPLATE_PATH = Path(__file__).parent / "templates" / "base.html" | ||
| 18 | |||
| 19 | |||
| 20 | # ---------------------------------------------------------------------- | ||
| 21 | # Helper functions | ||
| 22 | # ---------------------------------------------------------------------- | ||
| 23 | def 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 | |||
| 29 | def 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 | |||
| 36 | def 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 | |||
| 42 | def 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 | |||
| 56 | def 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 | # ---------------------------------------------------------------------- | ||
| 68 | def 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 | |||
| 108 | if __name__ == "__main__": | ||
| 109 | generate_static_pages()\ No newline at end of file | ||
index.php deleted −10
| @@ -1,10 +0,0 @@ | |||
| 1 | <?php | ||
| 2 | |||
| 3 | require_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 @@ | |||
| 1 | json | ||
| 2 | urllib.request | ||
| 3 | pathlib | ||
| 4 | typing\ No newline at end of file | ||
src/Controller/FeedController.php deleted −47
| @@ -1,47 +0,0 @@ | |||
| 1 | <?php | ||
| 2 | |||
| 3 | namespace HN\Controllers; | ||
| 4 | |||
| 5 | class 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 | |||
| 3 | namespace HN\Controllers; | ||
| 4 | |||
| 5 | require_once 'src/Controller/FeedController.php'; | ||
| 6 | |||
| 7 | use function HN\Models\GetApiResults; | ||
| 8 | use function HN\Models\ParseItem; | ||
| 9 | use function HN\Models\ParseStories; | ||
| 10 | use function HN\Models\ParseUser; | ||
| 11 | |||
| 12 | class 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 | |||
| 3 | namespace 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 | */ | ||
| 13 | function 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 | */ | ||
| 28 | function 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 | */ | ||
| 52 | function 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 | */ | ||
| 99 | function 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 | */ | ||
| 137 | function 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 | */ | ||
| 159 | function 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 | */ | ||
| 194 | function 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 | */ | ||
| 246 | function 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 | */ | ||
| 270 | function 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 | */ | ||
| 283 | function 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 | */ | ||
| 10 | function 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> · </span> | ||
| 19 | <span><a href="/best/">Best</a> ·</span> | ||
| 20 | <span><a href="/new/">New</a> ·</span> | ||
| 21 | <span><a href="/ask/">Ask</a> ·</span> | ||
| 22 | <span><a href="/show/">Show</a> ·</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 © 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 */ | ||
| 2 | button, hr, input { | ||
| 3 | overflow: visible | ||
| 4 | } | ||
| 5 | |||
| 6 | progress, sub, sup { | ||
| 7 | vertical-align: baseline | ||
| 8 | } | ||
| 9 | |||
| 10 | [type=checkbox], [type=radio], legend { | ||
| 11 | box-sizing: border-box; | ||
| 12 | padding: 0 | ||
| 13 | } | ||
| 14 | |||
| 15 | html { | ||
| 16 | line-height: 1.15; | ||
| 17 | -webkit-text-size-adjust: 100% | ||
| 18 | } | ||
| 19 | |||
| 20 | body { | ||
| 21 | margin: 0 | ||
| 22 | } | ||
| 23 | |||
| 24 | details, main { | ||
| 25 | display: block | ||
| 26 | } | ||
| 27 | |||
| 28 | h1 { | ||
| 29 | font-size: 2em; | ||
| 30 | margin: .67em 0 | ||
| 31 | } | ||
| 32 | |||
| 33 | hr { | ||
| 34 | box-sizing: content-box; | ||
| 35 | height: 0 | ||
| 36 | } | ||
| 37 | |||
| 38 | code, kbd, pre, samp { | ||
| 39 | font-family: monospace, monospace; | ||
| 40 | font-size: 1em | ||
| 41 | } | ||
| 42 | |||
| 43 | a { | ||
| 44 | background-color: transparent | ||
| 45 | } | ||
| 46 | |||
| 47 | abbr[title] { | ||
| 48 | border-bottom: none; | ||
| 49 | text-decoration: underline; | ||
| 50 | text-decoration: underline dotted | ||
| 51 | } | ||
| 52 | |||
| 53 | b, strong { | ||
| 54 | font-weight: bolder | ||
| 55 | } | ||
| 56 | |||
| 57 | small { | ||
| 58 | font-size: 80% | ||
| 59 | } | ||
| 60 | |||
| 61 | sub, sup { | ||
| 62 | font-size: 75%; | ||
| 63 | line-height: 0; | ||
| 64 | position: relative | ||
| 65 | } | ||
| 66 | |||
| 67 | sub { | ||
| 68 | bottom: -.25em | ||
| 69 | } | ||
| 70 | |||
| 71 | sup { | ||
| 72 | top: -.5em | ||
| 73 | } | ||
| 74 | |||
| 75 | img { | ||
| 76 | border-style: none | ||
| 77 | } | ||
| 78 | |||
| 79 | button, input, optgroup, select, textarea { | ||
| 80 | font-family: inherit; | ||
| 81 | font-size: 100%; | ||
| 82 | line-height: 1.15; | ||
| 83 | margin: 0 | ||
| 84 | } | ||
| 85 | |||
| 86 | button, 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 | |||
| 103 | fieldset { | ||
| 104 | padding: .35em .75em .625em | ||
| 105 | } | ||
| 106 | |||
| 107 | legend { | ||
| 108 | color: inherit; | ||
| 109 | display: table; | ||
| 110 | max-width: 100%; | ||
| 111 | white-space: normal | ||
| 112 | } | ||
| 113 | |||
| 114 | textarea { | ||
| 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 | |||
| 136 | summary { | ||
| 137 | display: list-item | ||
| 138 | } | ||
| 139 | |||
| 140 | [hidden], template { | ||
| 141 | display: none | ||
| 142 | } | ||
| 143 | |||
| 144 | /* custom css */ | ||
| 145 | body { | ||
| 146 | padding: 1rem; | ||
| 147 | font-family: system-ui, sans-serif; | ||
| 148 | max-width: 40em; | ||
| 149 | } | ||
| 150 | |||
| 151 | body > main > div { | ||
| 152 | margin-bottom: 1rem; | ||
| 153 | } | ||
| 154 | |||
| 155 | body > main > div > p { | ||
| 156 | margin-top: 0.5rem; | ||
| 157 | } | ||
| 158 | |||
| 159 | a { | ||
| 160 | text-decoration: none; | ||
| 161 | } | ||
| 162 | |||
| 163 | footer { | ||
| 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 @@ | |||
| 1 | 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 | ||
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> · | ||
| 11 | <span><a href="/best/">Best</a></span> · | ||
| 12 | <span><a href="/new/">New</a></span> · | ||
| 13 | <span><a href="/ask/">Ask</a></span> · | ||
| 14 | <span><a href="/show/">Show</a></span> · | ||
| 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 | ||