├── client ├── robots.txt ├── favicon-128.png ├── favicon-16.png ├── favicon-180.png ├── favicon-192.png ├── favicon-32.png ├── index.html ├── manifest.json ├── sw.js └── @ │ ├── js │ ├── hn.js │ ├── about.js │ ├── view.js │ └── app.js │ ├── css │ └── app.css │ └── 3rd │ └── uhtml.js ├── docs ├── robots.txt ├── favicon-128.png ├── favicon-16.png ├── favicon-180.png ├── favicon-192.png ├── favicon-32.png ├── manifest.json ├── index.html ├── @ │ ├── js │ │ ├── hn.js │ │ ├── app.js │ │ ├── about.js │ │ └── view.js │ ├── css │ │ └── app.css │ └── 3rd │ │ └── uhtml.js ├── sw.js ├── about │ └── index.html ├── ask │ └── index.html ├── item │ └── index.html ├── job │ └── index.html ├── new │ └── index.html ├── show │ └── index.html ├── top │ └── index.html └── user │ └── index.html ├── .gitignore ├── server ├── args.js └── app.js ├── LICENSE ├── package.json ├── template └── index.html └── README.md /client/robots.txt: -------------------------------------------------------------------------------- 1 | # 👋 lighthouse 2 | -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | # 👋 lighthouse 2 | -------------------------------------------------------------------------------- /docs/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/docs/favicon-128.png -------------------------------------------------------------------------------- /docs/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/docs/favicon-16.png -------------------------------------------------------------------------------- /docs/favicon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/docs/favicon-180.png -------------------------------------------------------------------------------- /docs/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/docs/favicon-192.png -------------------------------------------------------------------------------- /docs/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/docs/favicon-32.png -------------------------------------------------------------------------------- /client/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/client/favicon-128.png -------------------------------------------------------------------------------- /client/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/client/favicon-16.png -------------------------------------------------------------------------------- /client/favicon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/client/favicon-180.png -------------------------------------------------------------------------------- /client/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/client/favicon-192.png -------------------------------------------------------------------------------- /client/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebReflection/hn/HEAD/client/favicon-32.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | client/about 3 | client/ask 4 | client/item 5 | client/job 6 | client/new 7 | client/show 8 | client/top 9 | client/user 10 | node_modules/ 11 | !docs/node_modules/ 12 | package-lock.json 13 | -------------------------------------------------------------------------------- /docs/manifest.json: -------------------------------------------------------------------------------- 1 | {"background_color":"#123","description":"Isomorphic Hacker News","display":"standalone","name":"iHN","orientation":"portrait-primary","short_name":"iHN","start_url":"./top/?1","theme_color":"#123","icons":[{"src":"./favicon-128.png","sizes":"128x128","type":"image/png"},{"src":"./favicon-192.png","sizes":"192x192","type":"image/png"}]} -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Isomorphic Hacker News 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Isomorphic Hacker News 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#123", 3 | "description": "Isomorphic Hacker News", 4 | "display": "standalone", 5 | "name": "iHN", 6 | "orientation": "portrait-primary", 7 | "short_name": "iHN", 8 | "start_url": "./top/?1", 9 | "theme_color": "#123", 10 | "icons": [ 11 | { 12 | "src": "./favicon-128.png", 13 | "sizes": "128x128", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "./favicon-192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /server/args.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from 'fs'; 2 | import {resolve} from 'path'; 3 | 4 | import {partial} from 'tag-params'; 5 | 6 | import umeta from 'umeta'; 7 | const {dirName} = umeta(import.meta); 8 | 9 | const cache = new Map; 10 | 11 | const exposeTemplate = page => page.replace(//g, '${$1}'); 12 | 13 | const set = path => { 14 | const content = readFileSync(resolve(dirName, `../client/${path}`)); 15 | const params = partial(exposeTemplate(content.toString())); 16 | cache.set(path, params); 17 | return params; 18 | }; 19 | 20 | export default path => cache.get(path) || set(path); 21 | -------------------------------------------------------------------------------- /docs/@/js/hn.js: -------------------------------------------------------------------------------- 1 | const e=(e,t)=>{e.delete(t)};export default(t,s=3e5)=>{t.initializeApp({databaseURL:"https://hacker-news.firebaseio.com"});const a=t.database().ref("v0"),n=["top","new","show","ask","job"],i=new Map,r=t=>i.get(t)||i.set(t,(t=>new Promise(n=>{a.child(t).once("value",a=>{const r=a.val();r?setTimeout(e,s,i,t):i.delete(t),n(r)})}))(t)).get(t);return{cache:i,stories:n,item:e=>r("item/"+e),user:e=>r("user/"+e),story:e=>r(e+"stories"),parse:e=>{const t={type:"unknown",id:-1,page:1,user:"",pathname:""};if(/\/([a-z]+)\/(\?[^&]+)$/.test(e)){const{$1:e,$2:s}=RegExp,a=s.slice(1);if("user"===e)t.pathname=e,t.type="user",t.user=a;else if("item"===e){const s=parseInt(a,10);s&&0new Response("null",teapot),teapot={headers:{"Content-Type":"application/json"},status:418},openCache=caches.open("iHN-teapot");addEventListener("fetch",e=>{const{request:t}=e;e.respondWith(openCache.then(e=>Promise.all([e.match(t),fetch(t).catch(offline)]).then(([s,a])=>{const{status:n}=a;return 199{e.waitUntil(openCache.then(e=>e.addAll(["./@/3rd/uhtml.js","./@/css/app.css","./@/js/about.js","./@/js/app.js","./@/js/hn.js","./@/js/view.js","./about/","./top/?1"])))}),addEventListener("message",({data:e})=>{const{action:t}=e;switch(t){case"purge":const e=Date.now();openCache.then(t=>{t.keys().then(s=>{s.forEach(s=>{t.match(s).then(({headers:a})=>{if(a.has("date")){Date.parse(a.get("date")) 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/ask/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/item/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/job/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/new/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/show/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/top/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/user/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /template/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Isomorphic Hacker News 12 | 13 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /client/sw.js: -------------------------------------------------------------------------------- 1 | // expiration time in seconds: one week 2 | const EXPIRATION = 7 * 24 * 3600; 3 | const EXPIRATION_KEY = 'date'; 4 | 5 | const offline = () => new Response('null', teapot); 6 | const teapot = { 7 | headers: {'Content-Type': 'application/json'}, 8 | status: 418 9 | }; 10 | 11 | const openCache = caches.open('iHN-teapot'); 12 | 13 | addEventListener('fetch', e => { 14 | const {request} = e; 15 | e.respondWith( 16 | openCache.then( 17 | cache => Promise.all([ 18 | cache.match(request), 19 | fetch(request).catch(offline) 20 | ]).then(([prev, curr]) => { 21 | const {status} = curr; 22 | if (199 < status && status < 400) { 23 | cache.put(request, curr.clone()); 24 | return curr; 25 | } 26 | return prev || curr; 27 | }) 28 | ) 29 | ); 30 | }); 31 | 32 | addEventListener('activate', event => { 33 | event.waitUntil(clients.claim()); 34 | }); 35 | 36 | addEventListener('install', e => { 37 | e.waitUntil( 38 | openCache.then(cache => cache.addAll([ 39 | './@/3rd/uhtml.js', 40 | './@/css/app.css', 41 | './@/js/about.js', 42 | './@/js/app.js', 43 | './@/js/hn.js', 44 | './@/js/view.js', 45 | './about/', 46 | './top/?1' 47 | ])) 48 | ); 49 | }); 50 | 51 | addEventListener('message', ({data}) => { 52 | const {action} = data; 53 | switch (action) { 54 | case 'purge': 55 | const now = Date.now(); 56 | openCache.then(cache => { 57 | cache.keys().then(keys => { 58 | keys.forEach(key => { 59 | cache.match(key).then(({headers}) => { 60 | if (headers.has(EXPIRATION_KEY)) { 61 | const date = Date.parse(headers.get(EXPIRATION_KEY)); 62 | if (date < now) 63 | cache.delete(key); 64 | } 65 | }); 66 | }); 67 | }); 68 | }); 69 | break; 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /client/@/js/hn.js: -------------------------------------------------------------------------------- 1 | const remove = (map, key) => { 2 | map.delete(key); 3 | }; 4 | 5 | export default (firebase, MAX_AGE = 1000 * 60 * 5) => { 6 | 7 | firebase.initializeApp({ 8 | databaseURL: 'https://hacker-news.firebaseio.com' 9 | }); 10 | 11 | const db = firebase.database().ref('v0'); 12 | 13 | const stories = [ 14 | 'top', 15 | 'new', 16 | 'show', 17 | 'ask', 18 | 'job' 19 | ]; 20 | 21 | const cache = new Map; 22 | const request = key => new Promise($ => { 23 | db.child(key).once('value', snap => { 24 | const value = snap.val(); 25 | // if there is a value, clean it up in MAX_AGE time 26 | if (value) 27 | setTimeout(remove, MAX_AGE, cache, key); 28 | // otherwise drop it already, as it's useless 29 | else 30 | cache.delete(key); 31 | // resolve for the time being whatever value it has 32 | $(value); 33 | }); 34 | }); 35 | 36 | // cache each request until it's resolved 37 | const load = key => cache.get(key) || 38 | cache.set(key, request(key)).get(key); 39 | 40 | return { 41 | cache, 42 | stories, 43 | item: id => load(`item/${id}`), 44 | user: id => load(`user/${id}`), 45 | story: type => load(`${type}stories`), 46 | parse: url => { 47 | const result = { 48 | type: 'unknown', 49 | id: -1, 50 | page: 1, 51 | user: '', 52 | pathname: '' 53 | }; 54 | if (/\/([a-z]+)\/(\?[^&]+)$/.test(url)) { 55 | const {$1: pathname, $2: search} = RegExp; 56 | const sliced = search.slice(1); 57 | if (pathname === 'user') { 58 | result.pathname = pathname; 59 | result.type = 'user'; 60 | result.user = sliced; 61 | } 62 | else if (pathname === 'item') { 63 | const id = parseInt(sliced, 10); 64 | if (id && 0 < id) { 65 | result.pathname = pathname; 66 | result.type = 'item'; 67 | result.id = id; 68 | } 69 | } 70 | else if (stories.includes(pathname)) { 71 | result.pathname = pathname; 72 | result.type = 'story'; 73 | result.page = Math.max(parseInt(sliced, 10) || 1, 1); 74 | } 75 | } 76 | return result; 77 | } 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Isomorphic Hacker News 2 | 3 | **Social Media Photo by [AbsolutVision](https://unsplash.com/@freegraphictoday) on [Unsplash](https://unsplash.com/)** 4 | 5 | - - - 6 | 7 | ### [Live static site](https://webreflection.github.io/hn/top/?1). 8 | 9 | - - - 10 | 11 | The goal of this project is to showcase a 100% isomorphic version of the famous [Hacker News PWA](https://hnpwa.com/), through my tiny [µhtml](https://github.com/WebReflection/uhtml#readme) and [µcontent](https://github.com/WebReflection/ucontent#readme) libraries, able to produce a fully static, yet _SSR_ ready, Hacker News reader. 12 | 13 | ### Achievements Unlocked 14 | 15 | * this _PWA_ works on a 100% static host 16 | * this _PWA_ could be pre-rendered via _SSR_ too 17 | * the client renders everything incrementally 18 | * the server renders everything with, or without, _JS_ 19 | * both client and server share exactly the [same view](https://github.com/WebReflection/hn/blob/master/client/%40/js/view.js) 20 | * both client and server share exactly the [same controller](https://github.com/WebReflection/hn/blob/master/client/%40/js/hn.js) 21 | * both client and server logic is route based, meaning each _URL_ can be shared 22 | * no bundlers whatsoever are involved, everything is standards based 23 | * _Lighthouse_ scores ~100% on a _GitHub_ hosted static site 24 | 25 | ### Extra Details 26 | 27 | All you need to do, in order to test this project locally, is the following: 28 | 29 | ```sh 30 | git clone https://github.com/WebReflection/hn.git 31 | cd hn 32 | npm i 33 | npm test 34 | # npm run test:ssr # for SSR 35 | ``` 36 | 37 | The client side part is within the [client/@/](./client/@/) folder, while the server side part is within the [server/](./server/) folder. 38 | 39 | The reason to choose a `@` as folder prefix, is to have a portable pattern that would never interfere with the name of any other possible folder. 40 | 41 | As example, the _SSR_ part uses those folders indexes to render each page, but the structure is fully compatible with a static host too. 42 | 43 | The client side is served either through [µcdn](https://github.com/WebReflection/ucdn#readme), or pre-built via [µcompress](https://github.com/WebReflection/ucompress#readme), after a npm run build command. 44 | 45 | To keep it simple, I have also targeted the docs folder, instead of public, simply to be able to tell _GitHub_ to publish this Web App via such folder. 46 | 47 | I hope this project will inspire new, as well as old, Web developers, as it's definitively something anyone could do without needing to move away from plain Web standards 🎉 48 | -------------------------------------------------------------------------------- /docs/@/css/app.css: -------------------------------------------------------------------------------- 1 | *,::after,::before{box-sizing:border-box}body,html,main.details>ul,nav li,nav ul{padding:0;margin:0}body.loading main,footer,h1+p,h2+p,main.about li,main.about p,main.details small,nav .icon:hover::before{opacity:var(--opacity)}.nowrap{white-space:nowrap}header,main.about code{background-color:var(--background-header)}main,nav{max-width:var(--max-width);margin:auto}main.stories>article,nav,nav ul{display:flex;flex-direction:row}nav .icon{cursor:none}nav .icon::before{position:absolute;content:"Isomorphic Hacker News";font-style:italic;font-size:.7rem;opacity:0;transition:opacity .3s;top:calc(var(--padding)/2);pointer-events:none}main.stories>article>:last-of-type,nav ul{flex-grow:1}li,ul{list-style:none}a,a:visited{color:var(--color)}main{word-wrap:break-word;transition:opacity .3s}pre{font-size:.9rem;white-space:pre-wrap}main,main.about pre,main.details ul>li,nav .icon,nav a,ul.comments{padding:var(--padding)}main.details ul>li,ul.comments{padding-inline-end:0}nav .icon,nav a{--tb-padding: calc(var(--padding) * 2);padding-top:var(--tb-padding);padding-bottom:var(--tb-padding)}ul.comments{padding-bottom:0;margin-bottom:0}nav a,nav a:visited{display:inline-block;text-decoration:none}nav a.selected{border-bottom:2px solid var(--color);padding-bottom:calc(var(--tb-padding) - 2px)}nav li:last-of-type{flex-grow:1;text-align:end}article,main.details li{width:100%;opacity:1;transition:opacity .3s}article.placeholder,main.details li.placeholder{opacity:0}.paginator a.hidden,article.placeholder>*,main.details li.placeholder>*{visibility:hidden}h1,h2{font-weight:400}h1{font-size:1.3rem}h2{font-size:1.1rem;margin:0}h1,h2+p,main.details h2,main.details small+div{margin-top:var(--padding)}h3{font-size:1rem}h3,ul.comments{border-bottom:1px solid var(--background-header)}article a,footer a{color:var(--color);text-decoration:none;border-bottom:1px dotted var(--color)}main.stories>article>:first-of-type{margin-top:.175rem;min-width:calc(var(--font-size)*2.1)}.paginator{display:flex;flex-direction:row;justify-content:center;align-items:center;text-align:center}.paginator>*{padding:var(--padding);user-select:none}.paginator a{min-width:calc(var(--font-size)*2);display:inline-block;text-decoration:none}.paginator :not(a){flex-grow:1}footer{text-align:center;padding-top:calc(var(--padding)*2);padding-bottom:calc(var(--padding)*4);font-size:.9rem}.paginator a,footer a,nav a,nav a:visited{font-weight:700}.paginator :not(a),main.stories>article>:first-of-type{font-size:.7rem;opacity:var(--opacity)}a[data-count]::before{display:inline-block;content:"[ - ]"}a[data-count]:hover::before{text-decoration:underline}.collapsed a[data-count]::before{content:"[" attr(data-count) " more]"}.collapsed>div,.collapsed>ul{display:none}@media (max-width:480px){nav .icon{display:none}main.stories>article>:first-of-type{min-width:.7rem;max-width:.7rem;word-wrap:break-word}main.about ul{padding:var(--padding)}} -------------------------------------------------------------------------------- /docs/@/js/app.js: -------------------------------------------------------------------------------- 1 | const e={initializeApp({databaseURL:e}){this.databaseURL=e},database(){const{databaseURL:e}=this,t=e=>e.json(),a={credentials:"same-origin"};return{ref:s=>({child:n=>({once(o,r){fetch(`${e}/${s}/${n}.json`,a).then(t).then(e=>r({val:()=>e}))}})})}}},t=self.requestIdleCallback||setTimeout;var a,s;Promise.all([(a="../@/3rd/uhtml.js",s="uhtml",new Promise(e=>{const t=document.createElement("script");t.onload=()=>e(self[s]),t.async=!0,t.src=a,document.head.appendChild(t)})),import("./hn.js"),import("./view.js")]).then(([a,{default:s},{default:n}])=>{const o=!(matchMedia("(display-mode: standalone)").matches||navigator.standalone||document.referrer.includes("android-app://")),{stories:r,story:i,item:c,user:l,parse:d,cache:h}=s(e),{render:u,html:m}=a,{body:p}=document,{header:g,main:b,footer:f,about:v,details:k,profile:$,notFound:x}=n(a),y=()=>{p.classList.remove("loading")},L=(e,t)=>{u(p,m`${e}${t}${f()}`)};let w=((e,t=(()=>{}))=>({next(a){t(),t=e(a)}}))(e=>{p.classList.add("loading");const{id:a,page:s,type:n,pathname:o,user:h}=d(e),u=g({page:s,header:{current:o,stories:r}});let m=!0,f=!0;const w=()=>clearTimeout(j),j=setTimeout(()=>{m&&location.reload(!0)},5e3);switch(n){case"item":c(a).then(e=>{if(w(),e){t(y),document.title="iHN: "+e.title;const a=e=>{const n=c(e);return n.then(e=>{e&&(n.model=e,e.comments=(e.kids||[]).map(a),f&&(f=!f,t(s)))}),n},s=()=>{m&&(L(u,k(e)),f=!0)};e.comments=(e.kids||[]).map(a),s()}else L(u,x())});break;case"story":i(o).then(e=>{w(),t(y);const a=Math.ceil(e.length/20),n=20*(s-1),r=20*s;document.title=`iHN: ${o} (${s}/${a})`;const i=e.slice(n,r).map((e,a)=>{const s=c(e);return s.then(e=>{e&&(s.index=n+a+1,s.model=e,f&&(f=!f,t(l)))}),s}),l=()=>{m&&(L(u,b(o,i,s,a)),f=!0)};l()});break;case"user":l(h).then(e=>{w(),m&&(e?(t(y),document.title="iHN: user "+e.id,L(u,$(e))):(t(y),L(u,x())))});break;default:if(/\/about\/$/.test(e)){const e=g({page:s,header:{current:"about",stories:r}});v().then(a=>{w(),m&&(t(y),document.title="iHN: about",L(e,a))})}else w(),t(y),L(u,x())}return()=>{m=!1}});const j=[location.href];self.SSR||w.next(location.href),p.addEventListener("click",e=>{const{target:t}=e,a=t.closest("a");if(a){e.preventDefault();const t=a.getAttribute("href");switch(!0){case"#back"===t:o?history.back():1{t.textContent="⚠ error"};navigator.permissions.query({name:"clipboard-write"}).then(({state:a})=>{/^(?:granted|prompt)$/.test(a)?navigator.clipboard.writeText(e).then(()=>{t.textContent="✔ copied"},s):s()})}break;case/^(?:\.|\/)/.test(t):w.next(t),o?history.pushState(null,document.title,t):50{w.next(location.href)}),addEventListener("online",()=>h.clear()),"serviceWorker"in navigator&&navigator.serviceWorker.register("../sw.js",{scope:"../"}).then(()=>navigator.serviceWorker.ready).then(()=>{const{controller:e}=navigator.serviceWorker;e&&e.postMessage({action:"purge"})})}); -------------------------------------------------------------------------------- /docs/@/js/about.js: -------------------------------------------------------------------------------- 1 | export default e=>e` 2 |
3 |
4 | < 5 | go back / share 6 | 📤 7 |
8 |

Isomorphic Hacker News

9 |
10 |

11 | Hello there, I am Andrea Giammarchi, aka @webreflection, and I am the developer behind this project. 12 |

13 |

14 | Thank you for visiting ♥ and now please let me introduce what is this about ... 15 |

16 |

17 | The goal of this project is to showcase a 100% isomorphic version of the famous 18 | Hacker News PWA, through my tiny 19 | µhtml and 20 | µcontent libraries, able to produce a fully static, yet SSR ready, Hacker News reader. 21 |

22 |

Achievements Unlocked

23 |
    24 |
  • ✔ this PWA works on a 100% static host
  • 25 |
  • ✔ this PWA could be pre-rendered via SSR too
  • 26 |
  • ✔ the client renders everything incrementally
  • 27 |
  • ✔ the server renders everything with, or without, JS
  • 28 |
  • ✔ both client and server share exactly the same view
  • 29 |
  • ✔ both client and server share exactly the same controller
  • 30 |
  • ✔ both client and server logic is route based, meaning each URL can be shared
  • 31 |
  • ✔ no bundlers whatsoever are involved, everything is standards based
  • 32 |
  • Lighthouse scores nearly 100% on a GitHub hosted static site
  • 33 |
34 |

Extra Details

35 |

36 | All sources are available on GitHub, and all you need to do, in order to test this project locally, is the following: 37 |

38 |
39 |   git clone https://github.com/WebReflection/hn.git
40 |   cd hn
41 |   npm i
42 |   npm test
43 |   # npm run test:ssr # for SSR
44 |       
45 |

46 | The client side part is within the client/@/ folder, while the server side part is within the server folder. 47 |

48 |

49 | The reason to choose a @ as folder prefix, is to have a portable pattern that would never interfere with the name of any other possible folder. 50 |

51 |

52 | As example, the SSR part uses those folders indexes to render each page, but the structure is fully compatible with a static host too. 53 |

54 |

55 | The client side is served either through µcdn, or pre-built via µcompress, after a npm run build command. 56 |

57 |

58 | To keep it simple, I have also targeted the docs folder, instead of public, simply to be able to tell GitHub to publish this Web App via such folder. 59 |

60 |

61 | I hope this project will inspire new, as well as old, Web developers, as it's definitively something anyone could do without needing to move away from plain Web standards 🎉 62 |

63 |
64 |
65 | < 66 | go back / share 67 | 📤 68 |
69 |
70 | `; -------------------------------------------------------------------------------- /client/@/js/about.js: -------------------------------------------------------------------------------- 1 | export default html => html` 2 |
3 |
4 | < 5 | go back / share 6 | 📤 7 |
8 |

Isomorphic Hacker News

9 |
10 |

11 | Hello there, I am Andrea Giammarchi, aka @webreflection, 12 | and I am the developer behind this project. 13 |

14 |

15 | Thank you for visiting ♥ and now please let me introduce what is this about ... 16 |

17 |

18 | The goal of this project is to showcase a 100% isomorphic version of the famous 19 | Hacker News PWA, through my tiny 20 | µhtml and 21 | µcontent libraries, 22 | able to produce a fully static, yet SSR ready, Hacker News reader. 23 |

24 |

Achievements Unlocked

25 |
    26 |
  • ✔ this PWA works on a 100% static host
  • 27 |
  • ✔ this PWA could be pre-rendered via SSR too
  • 28 |
  • ✔ the client renders everything incrementally
  • 29 |
  • ✔ the server renders everything with, or without, JS
  • 30 |
  • ✔ both client and server share exactly the same view
  • 31 |
  • ✔ both client and server share exactly the same controller
  • 32 |
  • ✔ both client and server logic is route based, meaning each URL can be shared
  • 33 |
  • ✔ no bundlers whatsoever are involved, everything is standards based
  • 34 |
  • Lighthouse scores nearly 100% on a GitHub hosted static site
  • 35 |
36 |

Extra Details

37 |

38 | All sources are available on GitHub, 39 | and all you need to do, in order to test this project locally, is the following: 40 |

41 |
42 |   git clone https://github.com/WebReflection/hn.git
43 |   cd hn
44 |   npm i
45 |   npm test
46 |   # npm run test:ssr # for SSR
47 |       
48 |

49 | The client side part is within the client/@/ folder, 50 | while the server side part is within the server folder. 51 |

52 |

53 | The reason to choose a @ as folder prefix, is to have a portable pattern 54 | that would never interfere with the name of any other possible folder. 55 |

56 |

57 | As example, the SSR part uses those folders indexes to render each page, 58 | but the structure is fully compatible with a static host too. 59 |

60 |

61 | The client side is served either through µcdn, 62 | or pre-built via µcompress, 63 | after a npm run build command. 64 |

65 |

66 | To keep it simple, I have also targeted the docs folder, instead of public, 67 | simply to be able to tell GitHub to publish this Web App via such folder. 68 |

69 |

70 | I hope this project will inspire new, as well as old, Web developers, as it's definitively something 71 | anyone could do without needing to move away from plain Web standards 🎉 72 |

73 |
74 |
75 | < 76 | go back / share 77 | 📤 78 |
79 |
80 | `; 81 | -------------------------------------------------------------------------------- /docs/@/js/view.js: -------------------------------------------------------------------------------- 1 | const e=(a=[])=>a.reduce((a,t)=>a+e((t.model||{}).comments),a.length),a=(e,a)=>(1!==(e>>>=0)&&(a+="s"),`${e} ${a}`),t=(e,a)=>e===a?"selected":"",o=()=>{scrollTo({top:0,left:0,behavior:"smooth"})};let s=0;const n=new Map,l=(e=Date.now(),t=Date.now()/1e3)=>{const o=t-e;return o<3600?a(o/60,"minute"):o<86400?a(o/3600,"hour"):a(o/86400,"day")};export default({html:a})=>{const r=({model:t={comments:[]}})=>a` 2 |
  • 3 | 4 | ${t.by||"..."} 5 | ${l(t.time)} ago 6 | 7 | 8 |
    9 | ${a(n.get(t.text||"...")||(e=>{s||(s=setTimeout(()=>{s=0,n.clear()},1e4));const a=[e];return n.set(e,a),a})(t.text||"..."))} 10 |
    11 |
      12 | ${t.comments.map(r)} 13 |
    14 |
  • 15 | `,i=()=>a` 16 |
    17 | < 18 | go back / share 19 | 📤 20 |
    21 | `,c=(e,t,s)=>a` 22 |
    23 | 24 | < 25 | 26 | ${t}/${s} 27 | 28 | > 29 | 30 |
    31 | `;return{about:()=>import("./about.js").then(({default:e})=>e(a)),header:({header:{current:e,stories:s}})=>a` 32 |
    33 | 49 |
    50 | `,footer:()=>a` 51 | 57 | `,main:(e,t,s,n)=>a` 58 |
    59 | ${c(e,s,n)} ${t.map(({index:e,model:t={}})=>a` 60 | 78 | `)} ${c(e,s,n)} 79 |
    80 | `,details:e=>a` 81 |
    82 | ${i()} 83 | 96 |

    97 | ${e.descendants||0} comments 98 |

    99 |
      100 | ${e.comments.map(r)} 101 |
    102 | ${i()} 103 |
    104 | `,profile:({about:e,created:t,id:o,karma:s})=>a` 105 |
    106 | ${i()} 107 |
    108 |

    ${o}

    109 |

    110 | ... joined ${(e=>{const a=new Date,t=(new Date(a.getFullYear(),a.getMonth(),a.getDate())-new Date(1e3*e))/864e5;return t<0?"today":t<1?"yesterday":Math.ceil(t)+" days ago"})(t||0)}, and has ${s} karma 111 |

    112 |

    113 | submissions / 114 | comments / 115 | favourites 116 |

    117 |
    118 | ${a([e])} 119 |
    120 |
    121 | ${i()} 122 |
    123 | `,notFound:()=>a` 124 |
    125 | ${i()} 126 |
    127 |

    Not Found

    128 |

    The page you are looking for is not here.

    129 |
    130 |
    131 | `}}; -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | // standard modules 2 | import {createServer} from 'http'; 3 | import {join} from 'path'; 4 | import {env} from 'process'; 5 | 6 | // dependencies 7 | import firebase from 'firebase'; 8 | import ucdn from 'ucdn'; 9 | import umeta from 'umeta'; 10 | import * as ucontent from 'ucontent'; 11 | 12 | // node specific 13 | import partial from './args.js'; 14 | 15 | // isomorphic 16 | import hn from '../client/@/js/hn.js'; 17 | import view from '../client/@/js/view.js'; 18 | 19 | // constants 20 | const ITEMS_PP = 20; 21 | const HTML = {'content-type': 'text/html;charset-utf-8'}; 22 | 23 | // utils 24 | const {dirName} = umeta(import.meta); 25 | const cdn = ucdn({source: join(dirName, '..', 'client')}); 26 | 27 | // app 28 | const {stories, story, item, user, parse} = hn(firebase); 29 | const {render, html} = ucontent; 30 | 31 | const { 32 | header, main, footer, 33 | about, details, profile, 34 | notFound 35 | } = view(ucontent); 36 | 37 | const SSR = html``; 38 | 39 | const addComments = async (model) => { 40 | const {kids} = model; 41 | model.comments = kids ? await Promise.all(kids.map(comments)) : []; 42 | return model; 43 | }; 44 | 45 | const comments = id => item(id).then(getModel); 46 | 47 | const getModel = async (model) => ( 48 | {model: model ? await addComments(model) : null} 49 | ); 50 | 51 | const renderPage = (res, template, nav, main) => { 52 | const params = partial(template); 53 | res.writeHead(200, HTML); 54 | render( 55 | res, 56 | html(...params({ 57 | SSR, main, 58 | header: header(nav), 59 | footer: footer() 60 | })) 61 | ).end(); 62 | }; 63 | 64 | // serving 65 | createServer(async (req, res) => { 66 | const {url} = req; 67 | const {id, page, type, pathname: current, user: name} = parse(url); 68 | 69 | switch (type) { 70 | // show a specific item details with comments 71 | case 'item': { 72 | const model = await item(id); 73 | if (model) 74 | renderPage( 75 | res, 76 | `${current}/index.html`, 77 | {page, header: {current, stories}}, 78 | details(await addComments(model)) 79 | ); 80 | else 81 | renderPage( 82 | res, 83 | `about/index.html`, 84 | {page, header: {current: '', stories}}, 85 | notFound() 86 | ); 87 | break; 88 | } 89 | 90 | // show all items associated to this story 91 | case 'story': { 92 | const ids = await story(current); 93 | const total = Math.ceil(ids.length / ITEMS_PP); 94 | const start = ITEMS_PP * (page - 1); 95 | const end = ITEMS_PP * page; 96 | renderPage( 97 | res, 98 | `${current}/index.html`, 99 | {page, header: {current, stories}}, 100 | main( 101 | current, 102 | await Promise.all(ids.slice(start, end).map( 103 | (id, index) => item(id).then( 104 | model => ( 105 | model ? 106 | {model, index: start + index + 1} : 107 | {model: {}, index: -1} 108 | ) 109 | ) 110 | )), 111 | page, 112 | total 113 | ) 114 | ); 115 | break; 116 | } 117 | 118 | // show user details 119 | case 'user': { 120 | const model = await user(name); 121 | if (model) 122 | renderPage( 123 | res, 124 | `${current}/index.html`, 125 | {page, header: {current, stories}}, 126 | profile(model) 127 | ); 128 | else 129 | renderPage( 130 | res, 131 | `about/index.html`, 132 | {page, header: {current: '', stories}}, 133 | notFound() 134 | ); 135 | break; 136 | } 137 | 138 | default: { 139 | // root of the site 140 | if (/^\/(?:\?.*)?$/.test(url)) { 141 | const params = partial('index.html'); 142 | res.writeHead(200, HTML); 143 | render(res, html(...params())).end(); 144 | } 145 | // about page 146 | else if (/\/about\/$/.test(url)) 147 | renderPage( 148 | res, 149 | `about/index.html`, 150 | {page, header: {current: 'about', stories}}, 151 | await about() 152 | ); 153 | // any other asset with a 404 fallback 154 | else 155 | cdn(req, res, () => { 156 | renderPage( 157 | res, 158 | `about/index.html`, 159 | {page, header: {current: '', stories}}, 160 | notFound() 161 | ); 162 | }); 163 | break; 164 | } 165 | } 166 | }) 167 | .listen( 168 | env.PORT || 0, 169 | function ({port} = this.address()) { 170 | console.log(`\x1b[2mvisit\x1b[0m http://localhost:${port}/`); 171 | } 172 | ); 173 | -------------------------------------------------------------------------------- /client/@/css/app.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, body, nav ul, nav li, main.details > ul { 6 | padding: 0; 7 | margin: 0; 8 | } 9 | 10 | body.loading main, 11 | nav .icon:hover::before, 12 | h1 + p, 13 | h2 + p, 14 | main.details small, 15 | main.about li, 16 | main.about p, 17 | footer { 18 | opacity: var(--opacity); 19 | } 20 | 21 | .nowrap { 22 | white-space: nowrap; 23 | } 24 | 25 | header, 26 | main.about code { 27 | background-color: var(--background-header); 28 | } 29 | 30 | nav, main { 31 | max-width: var(--max-width); 32 | margin: auto; 33 | } 34 | 35 | nav, nav ul, main.stories > article { 36 | display: flex; 37 | flex-direction: row; 38 | } 39 | 40 | nav .icon { 41 | cursor: none; 42 | } 43 | 44 | nav .icon::before { 45 | position: absolute; 46 | content: "Isomorphic Hacker News"; 47 | font-style: italic; 48 | font-size: .7rem; 49 | opacity: 0; 50 | transition: opacity .3s; 51 | top: calc(var(--padding) / 2); 52 | pointer-events: none; 53 | } 54 | 55 | nav ul, main.stories > article > *:last-of-type { 56 | flex-grow: 1; 57 | } 58 | 59 | ul, li { 60 | list-style: none; 61 | } 62 | 63 | a, a:visited { 64 | color: var(--color); 65 | } 66 | 67 | main { 68 | word-wrap: break-word; 69 | } 70 | 71 | pre { 72 | font-size: .9rem; 73 | white-space: pre-wrap; 74 | } 75 | 76 | nav .icon, nav a, 77 | ul.comments, main.details ul > li, 78 | main.about pre { 79 | padding: var(--padding); 80 | } 81 | 82 | ul.comments, main.details ul > li 83 | { 84 | padding-inline-end: 0; 85 | } 86 | 87 | nav .icon, nav a 88 | { 89 | --tb-padding: calc(var(--padding) * 2); 90 | padding-top: var(--tb-padding); 91 | padding-bottom: var(--tb-padding); 92 | } 93 | 94 | ul.comments { 95 | padding-bottom: 0; 96 | margin-bottom: 0; 97 | } 98 | 99 | nav a, nav a:visited { 100 | display: inline-block; 101 | text-decoration: none; 102 | } 103 | 104 | nav a.selected { 105 | border-bottom: 2px solid var(--color); 106 | padding-bottom: calc(var(--tb-padding) - 2px); 107 | } 108 | 109 | nav li:last-of-type { 110 | flex-grow: 1; 111 | text-align: end; 112 | } 113 | 114 | main { 115 | padding: var(--padding); 116 | transition: opacity .3s; 117 | } 118 | 119 | article, main.details li { 120 | width: 100%; 121 | opacity: 1; 122 | transition: opacity .3s; 123 | } 124 | 125 | article.placeholder, 126 | main.details li.placeholder { 127 | opacity: 0; 128 | } 129 | 130 | article.placeholder > *, 131 | main.details li.placeholder > *{ 132 | visibility: hidden; 133 | } 134 | 135 | h1, h2 { 136 | font-weight: normal; 137 | } 138 | 139 | h1 { 140 | font-size: 1.3rem; 141 | } 142 | 143 | h2 { 144 | font-size: 1.1rem; 145 | margin: 0; 146 | } 147 | 148 | h1, h2 + p, 149 | main.details h2, 150 | main.details small + div { 151 | margin-top: var(--padding); 152 | } 153 | 154 | h3 { 155 | font-size: 1rem; 156 | } 157 | 158 | h3, 159 | ul.comments { 160 | border-bottom: 1px solid var(--background-header); 161 | } 162 | 163 | article a, footer a { 164 | color: var(--color); 165 | text-decoration: none; 166 | border-bottom: 1px dotted var(--color); 167 | } 168 | 169 | main.stories > article > *:first-of-type { 170 | margin-top: .175rem; 171 | min-width: calc(var(--font-size) * 2.1); 172 | } 173 | 174 | .paginator { 175 | display: flex; 176 | flex-direction: row; 177 | justify-content: center; 178 | align-items: center; 179 | text-align: center; 180 | } 181 | 182 | .paginator > * { 183 | padding: var(--padding); 184 | user-select: none; 185 | } 186 | 187 | .paginator a { 188 | min-width: calc(var(--font-size) * 2); 189 | display: inline-block; 190 | text-decoration: none; 191 | } 192 | 193 | .paginator a.hidden { 194 | visibility: hidden; 195 | } 196 | 197 | .paginator :not(a) { 198 | flex-grow: 1; 199 | } 200 | 201 | footer { 202 | text-align: center; 203 | padding-top: calc(var(--padding) * 2); 204 | padding-bottom: calc(var(--padding) * 4); 205 | font-size: .9rem; 206 | } 207 | 208 | nav a, nav a:visited, 209 | .paginator a, footer a { 210 | font-weight: bold; 211 | } 212 | 213 | main.stories > article > *:first-of-type, 214 | .paginator :not(a) { 215 | font-size: .7rem; 216 | opacity: var(--opacity); 217 | } 218 | 219 | /* collapsible comments */ 220 | a[data-count]::before { 221 | display: inline-block; 222 | content: "[ - ]"; 223 | } 224 | 225 | a[data-count]:hover::before { 226 | text-decoration: underline; 227 | } 228 | 229 | /* should no comments not be collapsible? 230 | a[data-count="0"]::before { 231 | display: none; 232 | } 233 | */ 234 | 235 | .collapsed a[data-count]::before { 236 | content: "[" attr(data-count) " more]"; 237 | } 238 | 239 | .collapsed > div, .collapsed > ul { 240 | display: none; 241 | } 242 | 243 | @media (max-width: 480px) { 244 | nav .icon { 245 | display: none; 246 | } 247 | main.stories > article > *:first-of-type { 248 | min-width: .7rem; 249 | max-width: .7rem; 250 | word-wrap: break-word; 251 | } 252 | main.about ul { 253 | padding: var(--padding); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /docs/@/3rd/uhtml.js: -------------------------------------------------------------------------------- 1 | var uhtml=function(e){"use strict";var t=e=>({get:t=>e.get(t),set:(t,n)=>(e.set(t,n),n)});const n=/([^\s\\>"'=]+)\s*=\s*(['"]?)$/,r=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,l=/<[a-z][^>]+$/i,s=/>[^<>]*$/,o=/<([a-z]+[a-z0-9:._-]*)([^>]*?)(\/>)/gi,i=/\s+$/,a=(e,t)=>0r.test(t)?e:`<${t}${n.replace(i,"")}>`,{isArray:u}=Array,{indexOf:d,slice:p}=[],f=(e,t)=>111===e.nodeType?1/t<0?t?(({firstChild:e,lastChild:t})=>{const n=document.createRange();return n.setStartAfter(e),n.setEndAfter(t),n.deleteContents(),e})(e):e.lastChild:t?e.valueOf():e.firstChild:e,h=e=>document.createElementNS("http://www.w3.org/1999/xhtml",e),m=(e,t)=>("svg"===t?y:g)(e),g=e=>{const t=h("template");return t.innerHTML=e,t.content},y=e=>{const{content:t}=h("template"),n=h("div");n.innerHTML=''+e+"";const{childNodes:r}=n.firstChild;let{length:l}=r;for(;l--;)t.appendChild(r[0]);return t},v=({childNodes:e},t)=>e[t],w=e=>{const t=[];let{parentNode:n}=e;for(;n;)t.push(d.call(n.childNodes,e)),n=(e=n).parentNode;return t},{createTreeWalker:b,importNode:C}=document,N=1!=C.length,x=N?(e,t)=>C.call(document,m(e,t),!0):m,k=N?e=>b.call(document,e,129,null,!1):e=>b.call(document,e,129),$=(e,t,n)=>((e,t,n,r,l)=>{const s=n.length;let o=t.length,i=s,a=0,c=0,u=null;for(;al-c){const s=r(t[a],0);for(;c{let t,n,r=[];const l=s=>{switch(typeof s){case"string":case"number":case"boolean":t!==s&&(t=s,n?n.textContent=s:n=document.createTextNode(s),r=$(e,r,[n]));break;case"object":case"undefined":if(null==s){t!=s&&(t=s,r=$(e,r,[]));break}if(u(s)){t=s,0===s.length?r=$(e,r,[]):"object"==typeof s[0]?r=$(e,r,s):l(String(s));break}"ELEMENT_NODE"in s&&t!==s&&(t=s,r=$(e,r,11===s.nodeType?p.call(s.childNodes):[s]))}};return l})(r):"attr"===t?((e,t)=>"ref"===t?(e=>t=>{"function"==typeof t?t(e):t.current=e})(e):"aria"===t?(e=>t=>{for(const n in t)e.setAttribute("role"===n?n:"aria-"+n,t[n])})(e):"data"===t?(({dataset:e})=>t=>{for(const n in t)e[n]=t[n]})(e):"."===t.slice(0,1)?((e,t)=>n=>{e[t]=n})(e,t.slice(1)):"on"===t.slice(0,2)?((e,t)=>{let n,r=t.slice(2);return!(t in e)&&t.toLowerCase()in e&&(r=r.toLowerCase()),t=>{const l=u(t)?t:[t,!1];n!==l[0]&&(n&&e.removeEventListener(r,n,l[1]),(n=l[0])&&e.addEventListener(r,n,l[1]))}})(e,t):((e,t)=>{let n,r=!0;const l=document.createAttributeNS(null,t);return t=>{n!==t&&(n=t,null==n?r||(e.removeAttributeNode(l),r=!0):(l.value=t,r&&(e.setAttributeNodeNS(l),r=!1)))}})(e,t))(r,e.name):(e=>{let t;return n=>{t!=n&&(t=n,e.textContent=null==n?"":n)}})(r)}const A=t(new WeakMap),T=(e,t)=>{const r=((e,t,r)=>{const l=[],{length:s}=e;for(let t=1;t`isµ${t-1}=${r||'"'}${n}${r?"":'"'}`):`${r}\x3c!--isµ${t-1}--\x3e`)}l.push(e[s-1]);const i=l.join("").trim();return r?i:i.replace(o,c)})(t,0,"svg"===e),l=x(r,e),s=k(l),i=[],u=t.length-1;let d=0,p="isµ"+d;for(;d{const{length:l}=r;M(e,r,l);let{entry:s}=e;s&&s.template===n&&s.type===t||(e.entry=s=((e,t)=>{const{content:n,updates:r}=((e,t)=>{const{content:n,nodes:r}=A.get(t)||A.set(t,T(e,t)),l=C.call(document,n,!0);return{content:l,updates:r.map(E,l)}})(e,t);return{type:e,template:t,content:n,updates:r,wire:null}})(t,n));const{content:o,updates:i,wire:a}=s;for(let e=0;e{const{childNodes:t}=e,{length:n}=t;if(n<2)return t[0];const r=p.call(t,0);return{ELEMENT_NODE:1,nodeType:111,firstChild:r[0],lastChild:r[n-1],valueOf(){if(t.length!==n){let t=0;for(;t{for(let r=0;r{const n=t(new WeakMap);return j((t,...n)=>new O(e,t,n),{for:{value(t,r){const l=n.get(t)||n.set(t,S(null));return l[r]||(l[r]=(t=>(n,...r)=>L(t,{type:e,template:n,values:r}))({stack:[],entry:null,wire:null}))}},node:{value:(t,...n)=>L({stack:[],entry:null,wire:null},{type:e,template:t,values:n}).valueOf()}})},z=W("html"),_=W("svg");return e.html=z,e.render=(e,t)=>{const n="function"==typeof t?t():t,r=B.get(e)||B.set(e,{stack:[],entry:null,wire:null}),l=n instanceof O?L(r,n):n;return l!==r.wire&&(r.wire=l,e.textContent="",e.appendChild(l.valueOf())),e},e.svg=_,e}({}); -------------------------------------------------------------------------------- /client/@/3rd/uhtml.js: -------------------------------------------------------------------------------- 1 | var uhtml=function(e){"use strict";var t=e=>({get:t=>e.get(t),set:(t,n)=>(e.set(t,n),n)});const n=/([^\s\\>"'=]+)\s*=\s*(['"]?)$/,r=/^(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$/i,l=/<[a-z][^>]+$/i,s=/>[^<>]*$/,o=/<([a-z]+[a-z0-9:._-]*)([^>]*?)(\/>)/gi,i=/\s+$/,a=(e,t)=>0r.test(t)?e:`<${t}${n.replace(i,"")}>`;const{isArray:u}=Array,{indexOf:d,slice:p}=[],f=(e,t)=>111===e.nodeType?1/t<0?t?(({firstChild:e,lastChild:t})=>{const n=document.createRange();return n.setStartAfter(e),n.setEndAfter(t),n.deleteContents(),e})(e):e.lastChild:t?e.valueOf():e.firstChild:e;const h=e=>document.createElementNS("http://www.w3.org/1999/xhtml",e),m=(e,t)=>("svg"===t?y:g)(e),g=e=>{const t=h("template");return t.innerHTML=e,t.content},y=e=>{const{content:t}=h("template"),n=h("div");n.innerHTML=''+e+"";const{childNodes:r}=n.firstChild;let{length:l}=r;for(;l--;)t.appendChild(r[0]);return t},v=({childNodes:e},t)=>e[t],w=e=>{const t=[];let{parentNode:n}=e;for(;n;)t.push(d.call(n.childNodes,e)),n=(e=n).parentNode;return t},{createTreeWalker:b,importNode:C}=document,N=1!=C.length,x=N?(e,t)=>C.call(document,m(e,t),!0):m,$=N?e=>b.call(document,e,129,null,!1):e=>b.call(document,e,129),k=(e,t,n)=>((e,t,n,r,l)=>{const s=n.length;let o=t.length,i=s,a=0,c=0,u=null;for(;al-c){const s=r(t[a],0);for(;c"ref"===t?(e=>t=>{"function"==typeof t?t(e):t.current=e})(e):"aria"===t?(e=>t=>{for(const n in t)e.setAttribute("role"===n?n:"aria-"+n,t[n])})(e):"data"===t?(({dataset:e})=>t=>{for(const n in t)e[n]=t[n]})(e):"."===t.slice(0,1)?((e,t)=>n=>{e[t]=n})(e,t.slice(1)):"on"===t.slice(0,2)?((e,t)=>{let n,r=t.slice(2);return!(t in e)&&t.toLowerCase()in e&&(r=r.toLowerCase()),t=>{const l=u(t)?t:[t,!1];n!==l[0]&&(n&&e.removeEventListener(r,n,l[1]),(n=l[0])&&e.addEventListener(r,n,l[1]))}})(e,t):((e,t)=>{let n,r=!0;const l=document.createAttributeNS(null,t);return t=>{n!==t&&(n=t,null==n?r||(e.removeAttributeNode(l),r=!0):(l.value=t,r&&(e.setAttributeNodeNS(l),r=!1)))}})(e,t);function A(e){const{type:t,path:n}=e,r=n.reduceRight(v,this);return"node"===t?(e=>{let t,n,r=[];const l=s=>{switch(typeof s){case"string":case"number":case"boolean":t!==s&&(t=s,n?n.textContent=s:n=document.createTextNode(s),r=k(e,r,[n]));break;case"object":case"undefined":if(null==s){t!=s&&(t=s,r=k(e,r,[]));break}if(u(s)){t=s,0===s.length?r=k(e,r,[]):"object"==typeof s[0]?r=k(e,r,s):l(String(s));break}"ELEMENT_NODE"in s&&t!==s&&(t=s,r=k(e,r,11===s.nodeType?p.call(s.childNodes):[s]))}};return l})(r):"attr"===t?E(r,e.name):(e=>{let t;return n=>{t!=n&&(t=n,e.textContent=null==n?"":n)}})(r)}const T=t(new WeakMap),L=(e,t)=>{const r=((e,t,r)=>{const l=[],{length:s}=e;for(let r=1;r`${t}${r-1}=${l||'"'}${n}${l?"":'"'}`):`${s}\x3c!--${t}${r-1}--\x3e`)}l.push(e[s-1]);const i=l.join("").trim();return r?i:i.replace(o,c)})(t,"isµ","svg"===e),l=x(r,e),s=$(l),i=[],u=t.length-1;let d=0,p="isµ"+d;for(;d{const{content:n,nodes:r}=T.get(t)||T.set(t,L(e,t)),l=C.call(document,n,!0);return{content:l,updates:r.map(A,l)}},O=(e,{type:t,template:n,values:r})=>{const{length:l}=r;S(e,r,l);let{entry:s}=e;s&&s.template===n&&s.type===t||(e.entry=s=((e,t)=>{const{content:n,updates:r}=M(e,t);return{type:e,template:t,content:n,updates:r,wire:null}})(t,n));const{content:o,updates:i,wire:a}=s;for(let e=0;e{const{childNodes:t}=e,{length:n}=t;if(n<2)return t[0];const r=p.call(t,0);return{ELEMENT_NODE:1,nodeType:111,firstChild:r[0],lastChild:r[n-1],valueOf(){if(t.length!==n){let t=0;for(;t{for(let r=0;r{const n=t(new WeakMap);return W((t,...n)=>new j(e,t,n),{for:{value(t,r){const l=n.get(t)||n.set(t,B(null));return l[r]||(l[r]=(t=>(n,...r)=>O(t,{type:e,template:n,values:r}))({stack:[],entry:null,wire:null}))}},node:{value:(t,...n)=>O({stack:[],entry:null,wire:null},{type:e,template:t,values:n}).valueOf()}})},D=_("html"),H=_("svg");return e.html=D,e.render=(e,t)=>{const n="function"==typeof t?t():t,r=z.get(e)||z.set(e,{stack:[],entry:null,wire:null}),l=n instanceof j?O(r,n):n;return l!==r.wire&&(r.wire=l,e.textContent="",e.appendChild(l.valueOf())),e},e.svg=H,e}({}); 2 | -------------------------------------------------------------------------------- /client/@/js/view.js: -------------------------------------------------------------------------------- 1 | const MINUTE = 60; 2 | const HOUR = 60 * MINUTE; 3 | const DAY = 24 * HOUR; 4 | 5 | const ago = (created) => { 6 | const d = new Date(); 7 | const today = new Date(d.getFullYear(), d.getMonth(), d.getDate()); 8 | const elapsedDays = (today - new Date(created * 1e3)) / (86400 * 1e3); 9 | if (elapsedDays < 0) return 'today'; 10 | if (elapsedDays < 1) return 'yesterday'; 11 | return Math.ceil(elapsedDays) + ' days ago'; 12 | }; 13 | 14 | const count = (comments = []) => comments.reduce( 15 | (total, value) => total + count((value.model || {}).comments), 16 | comments.length 17 | ); 18 | 19 | const plural = (num, unit) => { 20 | num = num >>> 0; 21 | if (num !== 1) unit += 's'; 22 | return `${num} ${unit}`; 23 | }; 24 | 25 | const selected = (current, story) => current === story ? 'selected' : ''; 26 | 27 | const scrollTop = () => { 28 | scrollTo({top: 0, left: 0, behavior: 'smooth'}); 29 | }; 30 | 31 | let cleanup = 0; 32 | const content = new Map; 33 | const cache = chunk => { 34 | if (!cleanup) 35 | cleanup = setTimeout(() => { cleanup = 0; content.clear(); }, 10000); 36 | const template = [chunk]; 37 | content.set(chunk, template); 38 | return template; 39 | }; 40 | 41 | const timeBetween = ( 42 | a = Date.now(), 43 | b = (Date.now() / 1e3) 44 | ) => { 45 | const elapsed = b - a; 46 | if (elapsed < HOUR) 47 | return plural(elapsed / MINUTE, 'minute'); 48 | else if (elapsed < DAY) 49 | return plural(elapsed / HOUR, 'hour'); 50 | return plural(elapsed / DAY, 'day'); 51 | }; 52 | 53 | // const {head, main, footer} = view(uhtml || ucontent); 54 | export default ({html}) => { 55 | 56 | // internal helpers 57 | const comment = ({model = {comments: []}}) => html` 58 |
  • 62 | 63 | ${model.by || '...'} 67 | ${timeBetween(model.time)} ago 68 | 69 | 70 |
    71 | ${html(content.get(model.text || '...') || cache(model.text || '...'))} 72 |
    73 |
      74 | ${model.comments.map(comment)} 75 |
    76 |
  • 77 | `; 78 | 79 | const goBack = () => html` 80 |
    81 | < 82 | go back / share 83 | 📤 84 |
    85 | `; 86 | 87 | const paginator = (current, page, total) => html` 88 |
    89 | 94 | < 95 | 96 | ${page}/${total} 97 | 102 | > 103 | 104 |
    105 | `; 106 | 107 | return { 108 | // the about section contains only static content 109 | // it is OK in such case to import it on demand, instead of having it 110 | // bundled within the rest of the logic 111 | about: () => import('./about.js').then(({default: about}) => about(html)), 112 | 113 | // all other sections are neither too big nor static, 114 | // so these are grouped in here for simplicity 115 | header: ({header: {current, stories}}) => html` 116 |
    117 | 140 |
    141 | `, 142 | 143 | footer: () => html` 144 | 150 | `, 151 | 152 | // show all details per story 153 | main: (current, stories, page, total) => html` 154 |
    155 | ${paginator(current, page, total)} 156 | ${stories.map(({index, model = {}}) => html` 157 | 185 | `)} 186 | ${paginator(current, page, total)} 187 |
    188 | `, 189 | 190 | // show all details per item 191 | details: model => html` 192 |
    193 | ${goBack()} 194 | 210 |

    211 | ${model.descendants || 0} comments 212 |

    213 |
      214 | ${model.comments.map(comment)} 215 |
    216 | ${goBack()} 217 |
    218 | `, 219 | 220 | // show all details per user 221 | profile: ({about, created, id, karma}) => html` 222 |
    223 | ${goBack()} 224 |
    225 |

    ${id}

    226 |

    227 | ... joined ${ago(created || 0)}, 228 | and has ${karma} karma 229 |

    230 |

    231 | submissions / 233 | comments / 235 | favourites 237 |

    238 |
    239 | ${html([about])} 240 |
    241 |
    242 | ${goBack()} 243 |
    244 | `, 245 | 246 | // final fallback to show when something goes wrong 247 | // such as non-existent user/item 248 | notFound: () => html` 249 |
    250 | ${goBack()} 251 |
    252 |

    Not Found

    253 |

    The page you are looking for is not here.

    254 |
    255 |
    256 | ` 257 | }; 258 | }; 259 | -------------------------------------------------------------------------------- /client/@/js/app.js: -------------------------------------------------------------------------------- 1 | const ITEMS_PP = 20; 2 | const MAX_STATE = 50; 3 | 4 | // the most minimal implementation of firebase.js 5 | // needed to fetch hacker-news.firebaseio.com 6 | const fakebase = { 7 | initializeApp({databaseURL}) { 8 | this.databaseURL = databaseURL; 9 | }, 10 | database() { 11 | const {databaseURL} = this; 12 | const asJSON = b => b.json(); 13 | const options = {credentials: 'same-origin'}; 14 | return { 15 | ref: v => ({ 16 | child: path => ({ 17 | once(_, value) { 18 | fetch(`${databaseURL}/${v}/${path}.json`, options) 19 | .then(asJSON) 20 | .then($ => value({val: () => $})) 21 | } 22 | }) 23 | }) 24 | }; 25 | } 26 | }; 27 | 28 | // given a function that returns an "effect" 29 | // that should run before being invoked again, 30 | // it grants such effect is indeed invoked, 31 | // then it executes it with new arguments 32 | // and it stores the returned effect (repeat) 33 | const fx = (f, x = () => {}) => ({ 34 | next($) { x(); x = f($); } 35 | }); 36 | 37 | // promisify a