├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── README.md ├── build.js ├── package-lock.json ├── package.json ├── src ├── assets │ └── lazysizes.min.ts ├── components │ ├── Footer.tsx │ ├── List.tsx │ ├── ListPagination.tsx │ ├── ListPost.tsx │ ├── PostDetail.tsx │ └── PostNavigation.tsx ├── consts.ts ├── index.tsx ├── pages │ ├── Home.tsx │ └── Post.tsx ├── renderer.tsx ├── styles │ ├── codeStyles.ts │ ├── cssVars.ts │ ├── embedStyles.ts │ ├── globalStyles.ts │ └── water.ts ├── types.ts └── utils.ts ├── tsconfig.json └── wrangler.toml /.env.example: -------------------------------------------------------------------------------- 1 | PASSWORD="test" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | rules: { 7 | indent: ['error', 2], 8 | quotes: ['error', 'single'], 9 | semi: ['error', 'never'], 10 | 'no-trailing-spaces': 'error', 11 | }, 12 | parser: '@typescript-eslint/parser', 13 | plugins: [ 14 | '@typescript-eslint', 15 | ], 16 | extends: [ 17 | 'eslint:recommended', 18 | 'plugin:@typescript-eslint/recommended', 19 | ], 20 | } -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | workflow_dispatch: 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | name: Deploy 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 18 16 | - name: Install dependencies 17 | run: npm i 18 | - name: Lint 19 | run: npm run lint 20 | - name: Type 21 | run: npm run check-types 22 | - name: Publish 23 | uses: cloudflare/wrangler-action@2.0.0 24 | with: 25 | apiToken: ${{ secrets.CF_API_TOKEN }} 26 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 27 | command: publish dist/index.js 28 | secrets: PASSWORD 29 | env: 30 | PASSWORD: ${{ secrets.PASSWORD }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | worker 4 | .cargo-ok -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [blog.bryce.io](https://blog.bryce.io) 2 | 3 | [![Deploy](https://github.com/brycedorn/blog/actions/workflows/deploy.yml/badge.svg)](https://github.com/brycedorn/blog/actions/workflows/deploy.yml) [![RSS](https://img.shields.io/static/v1?&message=feed&label=RSS&color=orange&style=flat&logo=rss 4 | )](https://blog.bryce.io/rss) 5 | 6 | A project built using [Hono](https://github.com/honojs/hono/) for Cloudflare Workers. Uses [KV](https://developers.cloudflare.com/workers/learning/how-kv-works/) for [edge caching](https://blog.bryce.io/using-workers-kv-to-build-an-edge-cached-blog) and [thumbhash](https://github.com/evanw/thumbhash) to [generate image placeholders](https://blog.bryce.io/generate-thumbhash-at-edge-for-tiny-progressive-images). 7 | 8 | Fork and deploy your own for free! 9 | 10 | ## Development 11 | 12 | Install dependencies: 13 | 14 | ```sh 15 | npm install 16 | ``` 17 | 18 | Set up environment: 19 | 20 | ```sh 21 | cp .env.example .env 22 | ``` 23 | 24 | Start via [miniflare](https://miniflare.dev/): 25 | 26 | ```sh 27 | npm start 28 | ``` 29 | 30 | ## Updating cache 31 | 32 | This project uses [KV](https://developers.cloudflare.com/workers/learning/how-kv-works/) as a distributed store for article data and image placeholders. 33 | 34 | To populate the cache, open the `/update` endpoint in your browser with the password set in environment passed via query parameter, e.g. [/update?password=test](http://localhost:8787/update?password=test). 35 | 36 | ## Deploying your own blog 37 | 38 | Fork this repository & set your dev.to username in [consts.ts](https://github.com/brycedorn/blog/blob/master/src/consts.ts) and a password in [actions secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets). 39 | 40 | Then [generate an API token](https://dash.cloudflare.com/profile/api-tokens) and set `CF_API_TOKEN` and `CF_ACCOUNT_ID` in actions secrets as well. The [deploy action](https://github.com/brycedorn/blog/blob/master/.github/workflows/deploy.yml) will automatically deploy via Wrangler. -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable-next-line @typescript-eslint/no-var-requires */ 4 | const { build } = require('esbuild') 5 | 6 | const APP_BASE = 'src' 7 | const ENTRY_FILE = 'index.tsx' 8 | const OUTPUT_DIR = 'dist' 9 | const OUTPUT_FILE = 'index.js' 10 | 11 | build({ 12 | entryPoints: [`${APP_BASE}/${ENTRY_FILE}`], 13 | define: { 14 | 'process.env.PASSWORD': JSON.stringify(process.env.PASSWORD), 15 | }, 16 | bundle: true, 17 | sourcemap: true, 18 | minify: true, 19 | outfile: `${OUTPUT_DIR}/${OUTPUT_FILE}`, 20 | }) 21 | .then(() => { 22 | console.log('Build succeeded.') }) 23 | .catch((e) => { 24 | console.error('Error building:', e.message) 25 | process.exit(1) 26 | }) 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog", 3 | "version": "0.0.2", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "build": "./build.js", 7 | "start": "miniflare --live-reload --debug --verbose", 8 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix", 9 | "check-types": "npx tsc --noEmit" 10 | }, 11 | "license": "MIT", 12 | "dependencies": { 13 | "csso": "^5.0.3", 14 | "dotenv": "^16.0.0", 15 | "hono": "1.1.1", 16 | "jpeg-js": "^0.4.4", 17 | "nano-jsx": "^0.0.30", 18 | "pica": "^9.0.1", 19 | "thumbhash": "^0.1.1" 20 | }, 21 | "devDependencies": { 22 | "@types/csso": "^5.0.0", 23 | "@types/node": "^17.0.31", 24 | "@types/pica": "^9.0.1", 25 | "@typescript-eslint/eslint-plugin": "^5.23.0", 26 | "@typescript-eslint/parser": "^5.23.0", 27 | "esbuild": "^0.17.15", 28 | "eslint": "^8.15.0", 29 | "miniflare": "^2.13.0", 30 | "typescript": "^4.6.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/assets/lazysizes.min.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | /*! lazysizes - v5.3.2 */ 3 | 4 | !function(e){var t=function(u,D,f){"use strict";var k,H;if(function(){var e;var t={lazyClass:"lazyload",loadedClass:"lazyloaded",loadingClass:"lazyloading",preloadClass:"lazypreload",errorClass:"lazyerror",autosizesClass:"lazyautosizes",fastLoadedClass:"ls-is-cached",iframeLoadMode:0,srcAttr:"data-src",srcsetAttr:"data-srcset",sizesAttr:"data-sizes",minSize:40,customMedia:{},init:true,expFactor:1.5,hFac:.8,loadMode:2,loadHidden:true,ricTimeout:0,throttleDelay:125};H=u.lazySizesConfig||u.lazysizesConfig||{};for(e in t){if(!(e in H)){H[e]=t[e]}}}(),!D||!D.getElementsByClassName){return{init:function(){},cfg:H,noSupport:true}}var O=D.documentElement,i=u.HTMLPictureElement,P="addEventListener",$="getAttribute",q=u[P].bind(u),I=u.setTimeout,U=u.requestAnimationFrame||I,o=u.requestIdleCallback,j=/^picture$/i,r=["load","error","lazyincluded","_lazyloaded"],a={},G=Array.prototype.forEach,J=function(e,t){if(!a[t]){a[t]=new RegExp("(\\s|^)"+t+"(\\s|$)")}return a[t].test(e[$]("class")||"")&&a[t]},K=function(e,t){if(!J(e,t)){e.setAttribute("class",(e[$]("class")||"").trim()+" "+t)}},Q=function(e,t){var a;if(a=J(e,t)){e.setAttribute("class",(e[$]("class")||"").replace(a," "))}},V=function(t,a,e){var i=e?P:"removeEventListener";if(e){V(t,a)}r.forEach(function(e){t[i](e,a)})},X=function(e,t,a,i,r){var n=D.createEvent("Event");if(!a){a={}}a.instance=k;n.initEvent(t,!i,!r);n.detail=a;e.dispatchEvent(n);return n},Y=function(e,t){var a;if(!i&&(a=u.picturefill||H.pf)){if(t&&t.src&&!e[$]("srcset")){e.setAttribute("srcset",t.src)}a({reevaluate:true,elements:[e]})}else if(t&&t.src){e.src=t.src}},Z=function(e,t){return(getComputedStyle(e,null)||{})[t]},s=function(e,t,a){a=a||e.offsetWidth;while(a49?function(){o(t,{timeout:n});if(n!==H.ricTimeout){n=H.ricTimeout}}:te(function(){I(t)},true);return function(e){var t;if(e=e===true){n=33}if(a){return}a=true;t=r-(f.now()-i);if(t<0){t=0}if(e||t<9){s()}else{I(s,t)}}},ie=function(e){var t,a;var i=99;var r=function(){t=null;e()};var n=function(){var e=f.now()-a;if(e0;if(r&&Z(i,"overflow")!="visible"){a=i.getBoundingClientRect();r=C>a.left&&pa.top-1&&g500&&O.clientWidth>500?500:370:H.expand;k._defEx=u;f=u*H.expFactor;c=H.hFac;A=null;if(w2&&h>2&&!D.hidden){w=f;N=0}else if(h>1&&N>1&&M<6){w=u}else{w=_}}if(l!==n){y=innerWidth+n*c;z=innerHeight+n;s=n*-1;l=n}a=d[t].getBoundingClientRect();if((b=a.bottom)>=s&&(g=a.top)<=z&&(C=a.right)>=s*c&&(p=a.left)<=y&&(b||C||p||g)&&(H.loadHidden||x(d[t]))&&(m&&M<3&&!o&&(h<3||N<4)||W(d[t],n))){R(d[t]);r=true;if(M>9){break}}else if(!r&&m&&!i&&M<4&&N<4&&h>2&&(v[0]||H.preloadAfterLoad)&&(v[0]||!o&&(b||C||p||g||d[t][$](H.sizesAttr)!="auto"))){i=v[0]||d[t]}}if(i&&!r){R(i)}}};var a=ae(t);var S=function(e){var t=e.target;if(t._lazyCache){delete t._lazyCache;return}L(e);K(t,H.loadedClass);Q(t,H.loadingClass);V(t,B);X(t,"lazyloaded")};var i=te(S);var B=function(e){i({target:e.target})};var T=function(e,t){var a=e.getAttribute("data-load-mode")||H.iframeLoadMode;if(a==0){e.contentWindow.location.replace(t)}else if(a==1){e.src=t}};var F=function(e){var t;var a=e[$](H.srcsetAttr);if(t=H.customMedia[e[$]("data-media")||e[$]("media")]){e.setAttribute("media",t)}if(a){e.setAttribute("srcset",a)}};var s=te(function(t,e,a,i,r){var n,s,o,l,u,f;if(!(u=X(t,"lazybeforeunveil",e)).defaultPrevented){if(i){if(a){K(t,H.autosizesClass)}else{t.setAttribute("sizes",i)}}s=t[$](H.srcsetAttr);n=t[$](H.srcAttr);if(r){o=t.parentNode;l=o&&j.test(o.nodeName||"")}f=e.firesLoad||"src"in t&&(s||n||l);u={target:t};K(t,H.loadingClass);if(f){clearTimeout(c);c=I(L,2500);V(t,B,true)}if(l){G.call(o.getElementsByTagName("source"),F)}if(s){t.setAttribute("srcset",s)}else if(n&&!l){if(d.test(t.nodeName)){T(t,n)}else{t.src=n}}if(r&&(s||l)){Y(t,{src:n})}}if(t._lazyRace){delete t._lazyRace}Q(t,H.lazyClass);ee(function(){var e=t.complete&&t.naturalWidth>1;if(!f||e){if(e){K(t,H.fastLoadedClass)}S(u);t._lazyCache=true;I(function(){if("_lazyCache"in t){delete t._lazyCache}},9)}if(t.loading=="lazy"){M--}},true)});var R=function(e){if(e._lazyRace){return}var t;var a=n.test(e.nodeName);var i=a&&(e[$](H.sizesAttr)||e[$]("sizes"));var r=i=="auto";if((r||!m)&&a&&(e[$]("src")||e.srcset)&&!e.complete&&!J(e,H.errorClass)&&J(e,H.lazyClass)){return}t=X(e,"lazyunveilread").detail;if(r){re.updateElem(e,true,e.offsetWidth)}e._lazyRace=true;M++;s(e,t,r,i,a)};var r=ie(function(){H.loadMode=3;a()});var o=function(){if(H.loadMode==3){H.loadMode=2}r()};var l=function(){if(m){return}if(f.now()-e<999){I(l,999);return}m=true;H.loadMode=3;a();q("scroll",o,true)};return{_:function(){e=f.now();k.elements=D.getElementsByClassName(H.lazyClass);v=D.getElementsByClassName(H.lazyClass+" "+H.preloadClass);q("scroll",a,true);q("resize",a,true);q("pageshow",function(e){if(e.persisted){var t=D.querySelectorAll("."+H.loadingClass);if(t.length&&t.forEach){U(function(){t.forEach(function(e){if(e.complete){R(e)}})})}}});if(u.MutationObserver){new MutationObserver(a).observe(O,{childList:true,subtree:true,attributes:true})}else{O[P]("DOMNodeInserted",a,true);O[P]("DOMAttrModified",a,true);setInterval(a,999)}q("hashchange",a,true);["focus","mouseover","click","load","transitionend","animationend"].forEach(function(e){D[P](e,a,true)});if(/d$|^c/.test(D.readyState)){l()}else{q("load",l);D[P]("DOMContentLoaded",a);I(l,2e4)}if(k.elements.length){t();ee._lsFlush()}else{a()}},checkElems:a,unveil:R,_aLSL:o}}(),re=function(){var a;var n=te(function(e,t,a,i){var r,n,s;e._lazysizesWidth=i;i+="px";e.setAttribute("sizes",i);if(j.test(t.nodeName||"")){r=t.getElementsByTagName("source");for(n=0,s=r.length;n 6 |
7 |
8 | Built with Hono & Cloudflare Workers • View source on GitHub 9 |
10 |
11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/List.tsx: -------------------------------------------------------------------------------- 1 | import Nano, { Fragment } from 'nano-jsx' 2 | import { withMinifiedStyles } from '../utils' 3 | import ListPost from './ListPost' 4 | 5 | import type { PostType } from '../types' 6 | import { DEV_TO_URL } from '../consts' 7 | 8 | export default function List({ posts, thumbs }: { posts: PostType[], thumbs: string[] }) { 9 | const user = posts && posts[0].user 10 | 11 | const css = ` 12 | h1 { 13 | margin: var(--gap) 0; 14 | } 15 | 16 | h1 a, h2 a { 17 | border-bottom: 2px solid var(--focus); 18 | box-shadow: inset 0 -8px 0 var(--focus); 19 | border-radius: var(--radius); 20 | color: inherit; 21 | transition: box-shadow var(--animation-duration) ease, border-color var(--animation-duration) ease; 22 | } 23 | 24 | h1 a { 25 | box-shadow: inset 0 -10px 0 var(--focus); 26 | } 27 | 28 | h1 a:hover, h2 a:hover { 29 | box-shadow: inset 0 -32px 0 var(--focus); 30 | text-decoration: none; 31 | border-color: var(--focus); 32 | } 33 | 34 | h1 a:hover { 35 | box-shadow: inset 0 -48px 0 var(--focus); 36 | } 37 | 38 | hr { 39 | border-color: var(--border-dark); 40 | } 41 | 42 | ul { 43 | padding-left: 0; 44 | } 45 | 46 | #me { 47 | display: flex; 48 | } 49 | 50 | #me img { 51 | border-radius: calc(var(--radius) * 2); 52 | max-width: calc(var(--list-image-size) / 2); 53 | max-height: calc(var(--list-image-size) / 2); 54 | border: var(--text-bright) solid 2px; 55 | margin-right: 0.8em; 56 | } 57 | 58 | #title { 59 | display: flex; 60 | align-items: center; 61 | } 62 | 63 | .empty { 64 | text-align: center; 65 | margin: 10em 0; 66 | } 67 | ` 68 | 69 | return withMinifiedStyles(css)( 70 | <> 71 |
72 | {user && {`Profile} 73 |

my posts on dev.to

74 |
75 |
    76 | {posts ? posts.map((post, i) => ( 77 | <> 78 | {i > 0 &&
    } 79 | 80 | 81 | )) : ( 82 |

    There's nothing here. Maybe try updating the cache?

    83 | )} 84 |
85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/components/ListPagination.tsx: -------------------------------------------------------------------------------- 1 | import Nano from 'nano-jsx' 2 | import { withMinifiedStyles } from '../utils' 3 | import { PageInfoType } from '../types' 4 | 5 | export default function ListPagination({ pageInfo }: { pageInfo: PageInfoType }) { 6 | const {pageNumber, isFirstPage, isLastPage} = pageInfo 7 | 8 | const css = ` 9 | #pagination { 10 | display: flex; 11 | justify-content: ${isFirstPage ? 'end' : 'space-between'}; 12 | } 13 | ` 14 | 15 | return withMinifiedStyles(css)( 16 | 24 | ) 25 | } -------------------------------------------------------------------------------- /src/components/ListPost.tsx: -------------------------------------------------------------------------------- 1 | import Nano from 'nano-jsx' 2 | import { generateThumbURL, withMinifiedStyles } from '../utils' 3 | import type { PostType } from '../types' 4 | import { BLOG_URL } from '../consts' 5 | 6 | export default function ListPost({ post, thumb }: { post: PostType, thumb: string }) { 7 | const date = new Date(post.published_at) 8 | const formattedDate = new Intl.DateTimeFormat('en-US').format(date) 9 | const postUrl = `${BLOG_URL}/${post.cached_slug || `post/${post.id}`}` 10 | 11 | const css = ` 12 | li { 13 | margin: calc(var(--gap) * 1.5) 0; 14 | list-style-type: none; 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-between; 18 | align-items: start; 19 | } 20 | 21 | h2 { 22 | margin: 0 0 0 var(--gap); 23 | } 24 | 25 | time { 26 | color: var(--text-main); 27 | border-radius: var(--radius); 28 | margin-top: calc(var(--gap) / 2); 29 | } 30 | 31 | p { 32 | margin: var(--gap) 0 0 var(--gap); 33 | } 34 | 35 | .post-image { 36 | width: var(--list-image-size); 37 | height: var(--list-image-size); 38 | border-radius: calc(var(--radius) * 2); 39 | border: var(--text-bright) solid 2px; 40 | overflow: hidden; 41 | } 42 | 43 | .post-image img { 44 | height: var(--list-image-size); 45 | object-fit: cover; 46 | } 47 | 48 | .right { 49 | width: 100%; 50 | align-items: start; 51 | } 52 | 53 | .left { 54 | align-items: center; 55 | } 56 | 57 | .left, .right { 58 | display: flex; 59 | flex-direction: column; 60 | justify-content: space-between; 61 | } 62 | 63 | p a { 64 | color: var(--text-main); 65 | } 66 | 67 | p a:hover, .left:hover { 68 | text-decoration: none; 69 | } 70 | 71 | .blur-up { 72 | filter: blur(5px); 73 | transition: filter var(--unblur-duration); 74 | position: relative; 75 | z-index: 2; 76 | } 77 | 78 | .blur-up.lazyloaded { 79 | filter: blur(0); 80 | } 81 | 82 | .behind { 83 | position: absolute; 84 | z-index: 1; 85 | } 86 | ` 87 | 88 | return withMinifiedStyles(css)( 89 |
  • 90 | 91 | {post.cover_image &&
    92 | Placeholder image for post thumbnail 93 | Post thumbnail 94 |
    } 95 | 96 |
    97 |
    98 |

    99 | {post.title} 100 |

    101 |

    102 | {post.description} 103 |

    104 |
    105 |
  • 106 | ) 107 | } -------------------------------------------------------------------------------- /src/components/PostDetail.tsx: -------------------------------------------------------------------------------- 1 | import Nano, { Fragment } from 'nano-jsx' 2 | import { withMinifiedStyles } from '../utils' 3 | import type { PostDetailType } from '../types' 4 | import codeStyles from '../styles/codeStyles' 5 | import embedStyles from '../styles/embedStyles' 6 | import PostNavigation from './PostNavigation' 7 | 8 | export default function PostDetail({ post, pageNumber, thumbhash }: { post: PostDetailType, pageNumber: number, thumbhash: string }) { 9 | const date = new Date(post.published_at) 10 | const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric', year: 'numeric' } 11 | const formattedDate = new Intl.DateTimeFormat('en-US', options).format(date) 12 | 13 | const css = ` 14 | h1 { 15 | margin-bottom: var(--gap); 16 | font-size: 2.2em; 17 | } 18 | 19 | a { 20 | color: var(--text-bright); 21 | } 22 | 23 | a:hover { 24 | text-decoration: none; 25 | } 26 | 27 | h3 { 28 | color: var(--text-muted); 29 | margin: 0 0 calc(var(--gap)/2) 0; 30 | font-weight: normal; 31 | } 32 | 33 | #cover { 34 | margin-top: calc(var(--gap)*2); 35 | display: flex; 36 | } 37 | 38 | #cover img { 39 | border-radius: var(--radius); 40 | } 41 | 42 | #content { 43 | margin-bottom: calc(var(--gap) * 4); 44 | } 45 | 46 | .blur-up { 47 | filter: blur(5px); 48 | transition: filter var(--unblur-duration); 49 | } 50 | 51 | .blur-up.lazyloaded { 52 | filter: blur(0); 53 | } 54 | 55 | .behind { 56 | aspect-ratio: 50 / 21; 57 | width: 100%; 58 | z-index: 1; 59 | overflow: hidden; 60 | } 61 | 62 | .blur-container { 63 | position: absolute; 64 | left: 0; 65 | z-index: 2; 66 | overflow: hidden; 67 | margin: 0 calc(var(--gap)/4) calc(var(--gap)/4) calc(var(--gap)/4); 68 | } 69 | 70 | ${codeStyles} 71 | ${embedStyles} 72 | ` 73 | 74 | return withMinifiedStyles(css)( 75 | <> 76 | 77 | {post.cover_image && 78 | Placeholder for post cover image 79 |
    80 | Cover image for post 81 |
    82 |
    } 83 |

    {post.title}

    84 |

    {formattedDate}

    85 |
    86 | 87 | 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/components/PostNavigation.tsx: -------------------------------------------------------------------------------- 1 | import Nano from 'nano-jsx' 2 | import { withMinifiedStyles } from '../utils' 3 | 4 | export default function PostNavigation({ pageNumber }: {pageNumber:number}) { 5 | const css = ` 6 | h1.nav { 7 | margin: 0 0 var(--gap) 0; 8 | font-size: 2.2em; 9 | } 10 | 11 | h1.nav a { 12 | border-bottom: 2px solid var(--focus); 13 | box-shadow: inset 0 -8px 0 var(--focus); 14 | border-radius: var(--radius); 15 | color: inherit; 16 | transition: box-shadow var(--animation-duration) ease, border-color var(--animation-duration) ease; 17 | } 18 | 19 | h1.nav a { 20 | box-shadow: inset 0 -10px 0 var(--focus); 21 | } 22 | 23 | h1.nav a:hover { 24 | box-shadow: inset 0 -32px 0 var(--focus); 25 | text-decoration: none; 26 | border-color: var(--focus); 27 | } 28 | 29 | h1.nav a:hover { 30 | box-shadow: inset 0 -48px 0 var(--focus); 31 | } 32 | ` 33 | 34 | return withMinifiedStyles(css)( 35 |

    go back

    36 | ) 37 | } -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const USERNAME = 'bryce' 2 | export const DEV_TO_URL = `https://dev.to/${USERNAME}` 3 | export const BLOG_TITLE = 'blog.bryce.io' 4 | export const BLOG_URL = `https://${BLOG_TITLE}` 5 | // export const BLOG_URL = 'http://localhost:8787' 6 | export const PAGE_SIZE = 4 7 | export const API_URL = 'https://dev.to/api' 8 | export const DESCRIPTION = 'This is my developer blog and sandbox for playing with edge workers. I mostly write about web and JavaScript, sometimes Rust.' -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { poweredBy } from 'hono/powered-by' 3 | import Nano from 'nano-jsx' 4 | import { getCachedPosts, getCachedPost, cacheIndex, cachePost, refreshPost, cleanSlug } from './utils' 5 | import { render, renderFeed, renderRobotsTxt, renderSitemap } from './renderer' 6 | import Home from './pages/Home' 7 | import Post from './pages/Post' 8 | import { USERNAME, PAGE_SIZE } from './consts' 9 | 10 | export const app = new Hono() 11 | 12 | app.use('*', poweredBy()) 13 | 14 | app.get('/', async (c) => { 15 | const posts = await getCachedPosts() 16 | const page = posts?.slice(0, PAGE_SIZE) 17 | const defaultPageInfo = { pageNumber: 0, isFirstPage: true, isLastPage: posts?.length <= PAGE_SIZE } 18 | const thumbs = await Promise.all((page || []).map(post => THUMBS.get(`${post.id}`))) 19 | const html = await render() 20 | return c.html(html) 21 | }) 22 | 23 | app.get('/page/:pageNumber', async (c) => { 24 | const pageNumber = Number(c.req.param('pageNumber')) || 0 25 | const posts = await getCachedPosts() 26 | const pageStart = pageNumber * PAGE_SIZE 27 | const pageEnd = pageStart + PAGE_SIZE 28 | const page = posts?.slice(pageStart, pageEnd) 29 | const isFirstPage = pageNumber === 0 30 | const isLastPage = pageEnd >= posts?.length 31 | const thumbs = await Promise.all(page.map(post => THUMBS.get(`${post.id}`))) 32 | const html = await render() 33 | return c.html(html) 34 | }) 35 | 36 | app.get('/post/:id', async (c) => { 37 | const id = Number(c.req.param('id')) 38 | const slug = await cachePost(id) 39 | return c.redirect(`/${slug}`, 301) 40 | }) 41 | 42 | app.get('/update/:id', async (c) => { 43 | const id = Number(c.req.param('id')) 44 | const password = c.req.query('password') 45 | return await refreshPost(password, id) 46 | }) 47 | 48 | app.get('/update', async (c) => { 49 | const password = c.req.query('password') 50 | return await cacheIndex(password, USERNAME) 51 | }) 52 | 53 | app.get('/favicon.ico', () => new Response()) 54 | 55 | app.get('/:slug', async (c) => { 56 | const slug = c.req.param('slug') 57 | const post = await getCachedPost(slug) 58 | const posts = await getCachedPosts() 59 | const postIndex = posts.findIndex(({ cached_slug }: { cached_slug?: string }) => cached_slug === slug) 60 | const thumbhash = await THUMBS.get(`${post.id}`) 61 | const pageNumber = Math.floor(postIndex / PAGE_SIZE) 62 | const html = await render(, post) 63 | return c.html(html) 64 | }) 65 | 66 | app.get(`/${USERNAME}/:path`, async (c) => { 67 | const slug = cleanSlug(c.req.param('path')) 68 | return c.redirect(slug, 301) 69 | }) 70 | 71 | app.get('/rss', async (c) => { 72 | const posts = await getCachedPosts() 73 | const xml = await renderFeed(posts) 74 | return c.body(xml, 200, { 'content-type': 'application/rss+xml' }) 75 | }) 76 | 77 | app.get('/sitemap.xml', async (c) => { 78 | const posts = await getCachedPosts() 79 | console.log(posts) 80 | const xml = await renderSitemap(posts) 81 | return c.body(xml, 200, { 'content-type': 'application/xml' }) 82 | }) 83 | 84 | app.get('/robots.txt', (c) => { 85 | const txt = renderRobotsTxt() 86 | return c.text(txt) 87 | }) 88 | 89 | app.fire() 90 | -------------------------------------------------------------------------------- /src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import Nano, { Helmet } from 'nano-jsx' 2 | import type { PostType, PageInfoType } from '../types' 3 | import Footer from '../components/Footer' 4 | import List from '../components/List' 5 | import withGlobalStyles from '../styles/globalStyles' 6 | import ListPagination from '../components/ListPagination' 7 | import { BLOG_TITLE, BLOG_URL, DESCRIPTION } from '../consts' 8 | 9 | export default function Home({ posts, pageInfo, thumbs }: { posts: PostType[], pageInfo: PageInfoType, thumbs: string[] }) { 10 | const post = posts?.[0] 11 | 12 | return withGlobalStyles( 13 |
    14 | 15 | bryce.io | blog 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
    36 |
    37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/pages/Post.tsx: -------------------------------------------------------------------------------- 1 | import Nano, { Fragment, Helmet } from 'nano-jsx' 2 | import type { PostDetailType } from '../types' 3 | import Footer from '../components/Footer' 4 | import PostDetail from '../components/PostDetail' 5 | import withGlobalStyles from '../styles/globalStyles' 6 | import { BLOG_TITLE, BLOG_URL } from '../consts' 7 | 8 | export default function Home({ post, pageNumber, thumbhash }: { post: PostDetailType, pageNumber: number, thumbhash: string }) { 9 | return withGlobalStyles( 10 | <> 11 | 12 | {post.title} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
    33 | 34 |
    35 |