├── .gitignore ├── Procfile ├── README.md ├── project.clj ├── resources └── public │ └── css │ └── main.css ├── src-clj └── hackernews │ ├── api.clj │ ├── controllers │ ├── stories.clj │ └── users.clj │ ├── routes.clj │ └── views │ ├── layout.clj │ ├── stories │ ├── index.clj │ └── show.clj │ ├── users │ └── show.clj │ └── util.clj └── src-cljs └── hackernews └── hello.cljs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | /resources/public/js/ 11 | .swp 12 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java $JVM_OPTS -cp target/hackernews.jar hackernews.routes 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![hn](http://i.imgur.com/TvtPttG.gif) 2 | 3 | # Hacker News in Clojure 4 | 5 | This is an implementation of the website [Hacker News](http://news.ycombinator.com). It uses the official Hacker News [API](https://github.com/HackerNews/API). 6 | 7 | ### Features 8 | - Look at front page stories 9 | - View nested comments for each story 10 | - See a user's submitted stories 11 | - That's about it. 12 | 13 | ### How is it done? 14 | 15 | The Hacker News API is pretty simple, and it only takes about [50 lines](https://github.com/taylorlapeyre/hn-clojure/blob/master/src-clj/hackernews/api.clj) to comprehensively access it. 16 | 17 | HTML is generated via the [Hiccup](https://github.com/weavejester/hiccup) library, which translates Clojure data structures into HTML. If Hiccup sounds interesting to you, you might be interested in [making it yourself](https://gist.github.com/taylorlapeyre/7739c361a4f98d280722). It's a fun problem. 18 | 19 | I match routes to route handlers using [Compojure](https://github.com/weavejester/compojure). I thought Compojure was pretty cool, so I [made that](https://github.com/taylorlapeyre/nav), too. 20 | 21 | Finally, [Ring](http://github.com/ring-clojure/ring) ties everything togther with a very simple HTTP interface. 22 | 23 | ### What's left? 24 | 25 | At the moment, everything is done in Clojure. I plan to add some Clojurescript for collapsing comments, etc. 26 | 27 | The website is a little slow because I am waiting for every item to come back before rendering the page. This can be faster by rendering items on the front end as they come in via Reagent. 28 | 29 | ### Running it Yourself 30 | 31 | 1. First, make sure that you have **Java** installed. 32 | 33 | 2. Then, install [Leiningen](https://github.com/technomancy/leiningen): 34 | ```bash 35 | $ brew install leiningen 36 | ``` 37 | 38 | 3. Finally, start up the server: 39 | ``` bash 40 | $ cd hackernews 41 | $ lein run 42 | ``` 43 | 44 | The website will be up on [localhost:3000](http://localhost:3000). 45 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject hackernews "0.1.0" 2 | :description "A Hacker News implementation." 3 | :min-lein-version "2.0.0" 4 | :source-paths ["src-clj"] 5 | :dependencies [[org.clojure/clojure "1.6.0"] 6 | ; Backend 7 | [ring/ring-jetty-adapter "1.3.1"] 8 | [environ "0.5.0"] 9 | [compojure "1.1.6"] 10 | [clj-http "1.0.0"] 11 | [cheshire "5.3.1"] 12 | [hiccup "1.0.4"] 13 | ; Frontend 14 | [org.clojure/clojurescript "0.0-2371"] 15 | [reagent "0.4.2"]] 16 | :plugins [[lein-cljsbuild "1.0.3"] 17 | [lein-ring "0.8.7"]] 18 | :profiles {:uberjar {:aot :all}} 19 | :cljsbuild {:builds [{:source-paths ["src-cljs"] 20 | :compiler {:preamble ["reagent/react.js"] 21 | :output-to "resources/public/js/main.js" 22 | :optimizations :whitespace 23 | :pretty-print true}}]} 24 | :ring {:handler hackernews.routes/app} 25 | :uberjar-name "hackernews.jar" 26 | :main hackernews.routes) 27 | -------------------------------------------------------------------------------- /resources/public/css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: 'Verdana'; 3 | font-size: 13px; 4 | } 5 | 6 | body { 7 | width: 70%; 8 | margin: 0 auto; 9 | } 10 | 11 | .main-header { 12 | height: 25px; 13 | font-size: 13px; 14 | margin-top: 10px; 15 | white-space: nowrap; 16 | } 17 | 18 | .main-header h1 { 19 | font-size: 13px; 20 | display: inline; 21 | padding: 0 10px; 22 | } 23 | 24 | .main-header img { 25 | position: relative; 26 | top: 4px; 27 | } 28 | 29 | .story p { 30 | font-size: 9px; 31 | margin-top: 0; 32 | margin-bottom: 10px; 33 | } 34 | 35 | .comment { 36 | margin: 5px 0; 37 | padding: 0 5px; 38 | } 39 | 40 | .story-header h2 { 41 | margin-bottom: 0; 42 | } 43 | 44 | .story-header h3 { 45 | font-size: 13px; 46 | margin-top: 0; 47 | font-weight: normal; 48 | } 49 | 50 | .story-header a { 51 | color: #000; 52 | text-decoration: none; 53 | } 54 | -------------------------------------------------------------------------------- /src-clj/hackernews/api.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.api 2 | (:require [clj-http.client :as client] 3 | [cheshire.core :as json])) 4 | 5 | ;; The base URL for the HN API. 6 | (def base-url "https://hacker-news.firebaseio.com/v0") 7 | 8 | (defn get-json 9 | "Given a URL, parses the body of the HTTP GET request as JSON." 10 | [url] 11 | (println "GET " url) 12 | (->> (str url ".json") 13 | (client/get ) 14 | (:body) 15 | (json/parse-string))) 16 | 17 | (defn get-front-page-story-ids 18 | "Fetches an array of IDs to stories on the front page of HN." 19 | [] 20 | (get-json (str base-url "/topstories"))) 21 | 22 | (defn get-item 23 | "Fetches the item with a given item-id from the HN API. 24 | NOTE: In HN terminology, an 'item' can be either a comment or a story." 25 | [item-id] 26 | (get-json (str base-url "/item/" item-id))) 27 | 28 | (defn get-item-deep 29 | "Recursively fetches the story and all of its comments." 30 | [item-id] 31 | (let [item (get-item item-id)] 32 | (assoc item "comments" (pmap get-item-deep (item "kids"))))) 33 | 34 | (defn get-front-page 35 | "Fetches each story on the front page of HN. Takes an optional 36 | number of stories to fetch. Default is 30." 37 | ([] (pmap get-item (take 30 (get-front-page-story-ids)))) 38 | ([limit] (pmap get-item (take (Integer. limit) (get-front-page-story-ids))))) 39 | 40 | (defn get-user 41 | "Given a username, fetches that user from the HN API along with all of their 42 | submitted stories." 43 | [username] 44 | (let [user (get-json (str base-url "/user/" username))] 45 | (assoc user "stories" (pmap get-item (user "submitted"))))) 46 | -------------------------------------------------------------------------------- /src-clj/hackernews/controllers/stories.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.controllers.stories 2 | (require [hackernews.views.stories.index :as index-views] 3 | [hackernews.views.stories.show :as show-views] 4 | [hackernews.api :as api])) 5 | 6 | (defn index 7 | "Shows all stories on the front page of Hacker News." 8 | [limit] 9 | (if (nil? limit) 10 | (index-views/page (api/get-front-page)) 11 | (index-views/page (api/get-front-page limit)))) 12 | 13 | (defn show 14 | "Shows a particular story and its comments." 15 | [story-id] 16 | (show-views/page (api/get-item-deep story-id))) 17 | -------------------------------------------------------------------------------- /src-clj/hackernews/controllers/users.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.controllers.users 2 | (require [hackernews.views.users.show :as show-views] 3 | [hackernews.api :as api])) 4 | 5 | (defn show 6 | "Shows a particular user's information, as well as what they submitted." 7 | [username] 8 | (show-views/page (api/get-user username))) 9 | -------------------------------------------------------------------------------- /src-clj/hackernews/routes.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.routes 2 | (:gen-class) 3 | (:use compojure.core [hiccup.middleware :only (wrap-base-url)]) 4 | (:require [compojure.route :as route] 5 | [compojure.handler :as handler] 6 | [compojure.response :as response] 7 | [ring.adapter.jetty :as jetty] 8 | [environ.core :refer [env]] 9 | [hackernews.controllers.stories :as stories] 10 | [hackernews.controllers.users :as users])) 11 | 12 | (defroutes main-routes 13 | (GET "/" [limit] (stories/index limit)) 14 | (GET "/stories/:id" [id] (stories/show id)) 15 | (GET "/users/:username" [username] (users/show username)) 16 | 17 | (route/resources "/") 18 | (route/not-found "Page not found.")) 19 | 20 | (defn app 21 | "This is the handler for our routes. We wrap the base url to 22 | Use Hiccup." 23 | [] 24 | (-> (handler/site main-routes) 25 | (wrap-base-url))) 26 | 27 | (defn -main 28 | "This is the entry point into the application. It runs the server." 29 | [& [port]] 30 | (let [chosen-port (or port (env :port) "3000") 31 | parse-int #(Integer/parseInt (re-find #"\A-?\d+" %))] 32 | (jetty/run-jetty (app) {:port (parse-int chosen-port) :join? false}))) 33 | -------------------------------------------------------------------------------- /src-clj/hackernews/views/layout.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.views.layout 2 | (:require [hiccup 3 | [page :refer [html5]] 4 | [page :refer [include-js include-css]]] 5 | [hackernews.views.util :as util])) 6 | 7 | (defn github-banner 8 | "From https://github.com/blog/273-github-ribbons" 9 | [] 10 | [:a {:href "https://github.com/taylorlapeyre"} 11 | [:img {:style "position: absolute; top: 0; left: 0; border: 0;" 12 | :src "https://camo.githubusercontent.com/8b6b8ccc6da3aa5722903da7b58eb5ab1081adee/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f6c6566745f6f72616e67655f6666373630302e706e67" 13 | :alt "Fork me on GitHub"}]]) 14 | 15 | (defn main-header 16 | [] 17 | [:header {:class "main-header"} 18 | [:img {:src "https://news.ycombinator.com/y18.gif"}] 19 | [:h1 "Hacker News"] 20 | (util/link "new" "/newest") " | " 21 | (util/link "threads" "https://news.ycombinator.com/threads") " | " 22 | (util/link "comments" "https://news.ycombinator.com/comments") " | " 23 | (util/link "show" "https://news.ycombinator.com/show") " | " 24 | (util/link "ask" "https://news.ycombinator.com/ask") " | " 25 | (util/link "jobs" "https://news.ycombinator.com/jobs") " | " 26 | (util/link "submit" "https://news.ycombinator.com/submit") " | " 27 | (util/link "taylorlapeyre/hn-clojure" "https://github.com/taylorlapeyre/hn-clojure")]) 28 | 29 | (defn main-layout 30 | "The main chrome of the website. Accepts a map that contains information 31 | about the page, and whatever html is to get rendered into the layout." 32 | [{:keys [title]} & page-content] 33 | (html5 34 | [:head 35 | [:title title] 36 | (include-js "/js/main.js") 37 | (include-css "/css/main.css")] 38 | [:body 39 | (main-header) 40 | (github-banner) 41 | [:div {:class "content"} page-content]])) 42 | -------------------------------------------------------------------------------- /src-clj/hackernews/views/stories/index.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.views.stories.index 2 | (:require [hackernews.views.layout :refer [main-layout]] 3 | [hackernews.views.util :as util])) 4 | 5 | (defn story-html 6 | "The HTML for each story on the front page." 7 | [story] 8 | [:li {:class "story"} 9 | (util/link (story "title") (story "url")) 10 | [:p 11 | (story "score") " points by " 12 | (util/user-link (story "by") (story "by")) " | " 13 | (util/story-link 14 | (str (count (story "kids")) " comments") (story "id"))]]) 15 | 16 | (defn page [stories] 17 | (main-layout {:title "Hacker News"} 18 | [:ol (map story-html stories)])) 19 | -------------------------------------------------------------------------------- /src-clj/hackernews/views/stories/show.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.views.stories.show 2 | (:require [hackernews.views.layout :refer [main-layout]] 3 | [hackernews.views.util :as util])) 4 | 5 | (defn story-header 6 | "Displays information about the story (title, etc)" 7 | [story] 8 | [:header {:class "story-header"} 9 | (util/link [:h2 (story "title")] (story "url")) 10 | (util/user-link [:h3 "By " (story "by")] (story "by"))]) 11 | 12 | (defn comment-html 13 | "Recursively generates the HTML comment tree for a given comment." 14 | [comment] 15 | [:div {:class "comment"} 16 | (util/user-link [:h4 (comment "by")] (comment "by")) 17 | [:p (comment "text")] 18 | [:ul (map comment-html (comment "comments"))]]) 19 | 20 | (defn comment-section 21 | "Shows the nested comments on the story." 22 | [comments] 23 | (map comment-html comments)) 24 | 25 | (defn page [story] 26 | (main-layout {:title (str "HN: " (story "title"))} 27 | (story-header story) 28 | (comment-section (story "comments")))) 29 | -------------------------------------------------------------------------------- /src-clj/hackernews/views/users/show.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.views.users.show 2 | (:require [hackernews.views.layout :refer [main-layout]] 3 | [hackernews.views.util :as util])) 4 | 5 | (defn user-header 6 | "Displays information about the user (username, etc)" 7 | [user] 8 | [:header {:class "user-header"} 9 | [:h2 (user "id")] 10 | [:p "Karma: " (user "karma")]]) 11 | 12 | (defn submitted-html 13 | [story] 14 | [:div {:class "submitted-story"} 15 | (util/story-link [:h4 (story "title")] (story "id"))]) 16 | 17 | (defn submitted-section 18 | "Shows each story submitted by a user." 19 | [stories] 20 | (map submitted-html stories)) 21 | 22 | (defn page [user] 23 | (main-layout {:title (str "HN: " (user "id"))} 24 | (user-header user) 25 | (submitted-section (user "stories")))) 26 | -------------------------------------------------------------------------------- /src-clj/hackernews/views/util.clj: -------------------------------------------------------------------------------- 1 | (ns hackernews.views.util) 2 | 3 | (defn link 4 | "Simple helper function for creating HTML links." 5 | [text href] 6 | [:a {:href href} text]) 7 | 8 | (defn user-link 9 | "Link to a user's profile." 10 | [text username] 11 | (link text (str "/users/" username))) 12 | 13 | (defn story-link 14 | "Link to a story's comment page." 15 | [text story-id] 16 | (link text (str "/stories/" story-id))) 17 | -------------------------------------------------------------------------------- /src-cljs/hackernews/hello.cljs: -------------------------------------------------------------------------------- 1 | (ns hackernews.hello 2 | (:require [reagent.core :as reagent :refer [atom]])) 3 | 4 | ; NOTE: This doesn't do anything yet, but it will work if you run 5 | ; `hackernews.hello.run()` in the JS console. 6 | 7 | (defn greeting [message] 8 | [:h1 message]) 9 | 10 | (defn simple-example [] 11 | [:div [greeting "Hello World"]]) 12 | 13 | (defn ^:export run [] 14 | (reagent/render-component [simple-example] 15 | (.-body js/document))) 16 | --------------------------------------------------------------------------------