├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── deploy.yml │ ├── refresh-all-content.yml │ └── refresh-content.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── README.md ├── app ├── assets │ └── icons │ │ ├── github.svg │ │ ├── light-bulb.svg │ │ ├── moon.svg │ │ └── twitter.svg ├── components │ ├── blog-item.tsx │ ├── blog-list.tsx │ ├── client-only.tsx │ ├── footer.tsx │ ├── link-or-anchor.tsx │ ├── nav-link.tsx │ ├── nav.tsx │ └── theme-toggle.tsx ├── entry.client.tsx ├── entry.server.tsx ├── hooks │ └── use-hydrated.ts ├── model │ ├── content-state.server.ts │ └── content.server.ts ├── root.tsx ├── routes │ ├── _action │ │ └── set-theme.ts │ ├── _content │ │ ├── refresh-content.ts │ │ ├── refresh-content[.]json.ts │ │ └── update-content.ts │ ├── blog.$slug.tsx │ ├── blog.rss[.]xml.ts │ ├── blog.tsx │ ├── healthcheck.ts │ └── index.tsx ├── types.ts └── utils │ ├── compile-mdx.server.ts │ ├── db.server.ts │ ├── github.server.ts │ ├── mdx.server.ts │ ├── misc.ts │ ├── p-queue.server.ts │ ├── seo.ts │ ├── theme-session.server.ts │ └── theme.tsx ├── content └── index.ts ├── cypress.json ├── cypress ├── .eslintrc.js ├── e2e │ └── smoke.ts ├── fixtures │ └── example.json ├── plugins │ └── index.ts ├── support │ └── index.ts └── tsconfig.json ├── fly.toml ├── images ├── fly-sqlite-arch (light).png └── fly-sqlite-arch.png ├── mocks ├── github.ts ├── index.js └── start.ts ├── others ├── build-info.js ├── is-deployable.js ├── new-blog.ts ├── refresh-all-content.js ├── refresh-content.js ├── refresh-on-content-change.ts └── utils.js ├── package.json ├── prisma └── schema.prisma ├── public ├── favicon.ico └── fonts │ ├── Poppins-Bold.ttf │ └── Poppins-Regular.ttf ├── remix.config.js ├── remix.env.d.ts ├── remix.init ├── index.js ├── package-lock.json └── package.json ├── server └── index.ts ├── start_with_migrations.sh ├── styles └── app.css ├── tailwind.config.js ├── test └── setup-test-env.ts ├── tsconfig.json └── vitest.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | build 4 | public/build 5 | .env 6 | prisma/*.db 7 | 8 | content/ 9 | mocks/ 10 | 11 | app/styles 12 | app/**/*.ignore.* 13 | 14 | .eslintrc.js 15 | .eslintignore 16 | 17 | .prettierrc 18 | .prettierignore 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=file:./data.db 2 | REFRESH_TOKEN=my-secret 3 | GITHUB_TOKEN=my-secret 4 | SESSION_SECRETS=my-secret 5 | GITHUB_REPOSITORY=owner/repo -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache/ 3 | build/ 4 | public/build/ 5 | **/*.js 6 | **/*.ignore.* 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: [ 6 | '@remix-run/eslint-config', 7 | '@remix-run/eslint-config/node', 8 | '@remix-run/eslint-config/jest', 9 | 'prettier', 10 | ], 11 | // We're using vitest which has a very similar API to jest 12 | // (so the linting plugins work nicely), but we have to 13 | // set the jest version explicitly. 14 | settings: { 15 | jest: { 16 | version: 27, 17 | }, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | 8 | jobs: 9 | changes: 10 | name: 🔍 Determine Changes 11 | runs-on: ubuntu-latest 12 | outputs: 13 | DEPLOYABLE: ${{steps.changes.outputs.DEPLOYABLE}} 14 | steps: 15 | - name: 🛑 Cancel Previous Runs 16 | uses: styfle/cancel-workflow-action@0.9.1 17 | 18 | - name: ⬇️ Checkout repo 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: '50' 22 | 23 | - name: 🛠 Setup node 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: 16 27 | 28 | - name: ⚙️ Determine Changes 29 | id: changes 30 | run: >- 31 | echo ::set-output name=DEPLOYABLE::$(node ./others/is-deployable.js) 32 | env: 33 | FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} 34 | 35 | - name: ❓ Deployable 36 | run: >- 37 | echo "DEPLOYABLE: ${{steps.changes.outputs.DEPLOYABLE}}" 38 | 39 | vitest: 40 | name: ⚡ Vitest 41 | runs-on: ubuntu-latest 42 | needs: [changes] 43 | if: ${{ needs.changes.outputs.DEPLOYABLE == 'true' }} 44 | steps: 45 | - name: 🛑 Cancel Previous Runs 46 | uses: styfle/cancel-workflow-action@0.9.1 47 | 48 | - name: ⬇️ Checkout repo 49 | uses: actions/checkout@v2 50 | with: 51 | fetch-depth: '50' 52 | 53 | - name: 🛠 Setup node 54 | uses: actions/setup-node@v2 55 | with: 56 | node-version: 16 57 | 58 | - name: 📥 Download deps 59 | uses: bahmutov/npm-install@v1 60 | 61 | - name: ⚡ Run vitest 62 | run: npm run test -- --coverage --passWithNoTests 63 | 64 | lint: 65 | name: ⬣ ESLint 66 | runs-on: ubuntu-latest 67 | needs: [changes] 68 | if: ${{ needs.changes.outputs.DEPLOYABLE == 'true' }} 69 | steps: 70 | - name: 🛑 Cancel Previous Runs 71 | uses: styfle/cancel-workflow-action@0.9.1 72 | 73 | - name: ⬇️ Checkout repo 74 | uses: actions/checkout@v2 75 | with: 76 | fetch-depth: '50' 77 | 78 | - name: 🛠 Setup node 79 | uses: actions/setup-node@v2 80 | with: 81 | node-version: 16 82 | 83 | - name: 📥 Download deps 84 | uses: bahmutov/npm-install@v1 85 | 86 | - name: 🔬 Lint 87 | run: npm run lint 88 | 89 | typecheck: 90 | name: ʦ TypeScript 91 | runs-on: ubuntu-latest 92 | needs: [changes] 93 | if: ${{ needs.changes.outputs.DEPLOYABLE == 'true' }} 94 | steps: 95 | - name: 🛑 Cancel Previous Runs 96 | uses: styfle/cancel-workflow-action@0.9.1 97 | 98 | - name: ⬇️ Checkout repo 99 | uses: actions/checkout@v2 100 | with: 101 | fetch-depth: '50' 102 | 103 | - name: 🛠 Setup node 104 | uses: actions/setup-node@v2 105 | with: 106 | node-version: 16 107 | 108 | - name: 📥 Download deps 109 | uses: bahmutov/npm-install@v1 110 | 111 | - name: 🔎 Type check 112 | run: npm run typecheck 113 | 114 | cypress: 115 | name: ⚫️ Cypress 116 | runs-on: ubuntu-latest 117 | needs: [changes] 118 | if: ${{ needs.changes.outputs.DEPLOYABLE == 'true' }} 119 | steps: 120 | - name: 🛑 Cancel Previous Runs 121 | uses: styfle/cancel-workflow-action@0.9.1 122 | 123 | - name: ⬇️ Checkout repo 124 | uses: actions/checkout@v2 125 | 126 | - name: 🏄 Copy test env vars 127 | run: cp .env.example .env 128 | 129 | - name: ⎔ Setup node 130 | uses: actions/setup-node@v2 131 | with: 132 | node-version: 16 133 | 134 | - name: 📥 Download deps 135 | uses: bahmutov/npm-install@v1 136 | 137 | - name: ⚙️ Build 138 | run: npm run build 139 | 140 | - name: ▲ Prisma 141 | run: npx prisma migrate reset --force 142 | 143 | - name: 🌳 Cypress run 144 | uses: cypress-io/github-action@v2 145 | with: 146 | start: npm run start:mocks 147 | wait-on: 'http://localhost:8811' 148 | headless: true 149 | env: 150 | PORT: '8811' 151 | DISABLE_TELEMETRY: 'true' 152 | 153 | build: 154 | name: 🐳 Build 155 | needs: [changes] 156 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && 157 | needs.changes.outputs.DEPLOYABLE == 'true' }} 158 | runs-on: ubuntu-latest 159 | # only build/deploy main branch on pushes 160 | steps: 161 | - name: 🛑 Cancel Previous Runs 162 | uses: styfle/cancel-workflow-action@0.9.1 163 | 164 | - name: ⬇️ Checkout repo 165 | uses: actions/checkout@v2 166 | 167 | - name: 🐳 Set up Docker Buildx 168 | uses: docker/setup-buildx-action@v1 169 | 170 | # Setup cache 171 | - name: ⚡️ Cache Docker layers 172 | uses: actions/cache@v2 173 | with: 174 | path: /tmp/.buildx-cache 175 | key: ${{ runner.os }}-buildx-${{ github.sha }} 176 | restore-keys: | 177 | ${{ runner.os }}-buildx- 178 | 179 | - name: 🔑 Fly Registry Auth 180 | uses: docker/login-action@v1 181 | with: 182 | registry: registry.fly.io 183 | username: x 184 | password: ${{ secrets.FLY_API_TOKEN }} 185 | 186 | - name: 🐳 Docker build 187 | uses: docker/build-push-action@v2 188 | with: 189 | context: . 190 | push: true 191 | tags: registry.fly.io/${{ secrets.FLY_APP_NAME }}:${{ github.sha }} 192 | build-args: | 193 | COMMIT_SHA=${{ github.sha }} 194 | GITHUB_REPOSITORY=${{ github.repository }} 195 | cache-from: type=local,src=/tmp/.buildx-cache 196 | cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new 197 | 198 | # This ugly bit is necessary if you don't want your cache to grow forever 199 | # till it hits GitHub's limit of 5GB. 200 | # Temp fix 201 | # https://github.com/docker/build-push-action/issues/252 202 | # https://github.com/moby/buildkit/issues/1896 203 | - name: Move cache 204 | run: | 205 | rm -rf /tmp/.buildx-cache 206 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 207 | 208 | deploy: 209 | name: 🚀 Deploy 210 | runs-on: ubuntu-latest 211 | needs: [changes, build, lint, vitest, typecheck] 212 | # only build/deploy main branch on pushes 213 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && 214 | needs.changes.outputs.DEPLOYABLE == 'true' }} 215 | 216 | steps: 217 | - name: 🛑 Cancel Previous Runs 218 | uses: styfle/cancel-workflow-action@0.9.1 219 | 220 | - name: ⬇️ Checkout repo 221 | uses: actions/checkout@v2 222 | 223 | - name: 🚀 Deploy 224 | uses: superfly/flyctl-actions@1.1 225 | with: 226 | args: 227 | 'deploy -i registry.fly.io/${{ secrets.FLY_APP_NAME }}:${{ github.sha }} 228 | --strategy rolling' 229 | env: 230 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 231 | -------------------------------------------------------------------------------- /.github/workflows/refresh-all-content.yml: -------------------------------------------------------------------------------- 1 | name: 🌀 Refresh all content 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | refresh-cache: 7 | name: 🌟 Refresh Content 8 | runs-on: ubuntu-latest 9 | if: ${{ github.ref == 'refs/heads/main' }} 10 | steps: 11 | - name: 🛑 Cancel Previous Runs 12 | uses: styfle/cancel-workflow-action@0.9.1 13 | 14 | - name: ⬇️ Checkout repo 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: '50' 18 | 19 | - name: 🛠 Setup node 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 16 23 | 24 | - name: 💿 Refresh all Content 25 | run: node ./others/refresh-all-content.js 26 | env: 27 | REFRESH_TOKEN: ${{ secrets.REFRESH_TOKEN }} 28 | FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} 29 | -------------------------------------------------------------------------------- /.github/workflows/refresh-content.yml: -------------------------------------------------------------------------------- 1 | name: 🌟 Refresh Content 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | refresh-cache: 9 | name: 🌟 Refresh Content 10 | runs-on: ubuntu-latest 11 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} 12 | steps: 13 | - name: 🛑 Cancel Previous Runs 14 | uses: styfle/cancel-workflow-action@0.9.1 15 | 16 | - name: ⬇️ Checkout repo 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: '50' 20 | 21 | - name: 🛠 Setup node 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: 16 25 | 26 | - name: 💿 Refresh Content 27 | run: node ./others/refresh-content.js 28 | env: 29 | REFRESH_TOKEN: ${{ secrets.REFRESH_TOKEN }} 30 | FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | build/ 4 | /.cache 5 | /server/build 6 | /public/build 7 | coverage/ 8 | 9 | .env 10 | 11 | prisma/*.db 12 | prisma/*.db* 13 | 14 | app/styles 15 | app/**/*.ignore.* 16 | 17 | cypress/videos/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache/ 3 | build/ 4 | public/build/ 5 | app/styles 6 | **/*.ignore.* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "jsxSingleQuote": true, 5 | "arrowParens": "avoid", 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # Install openssl for Prisma 5 | RUN apt-get update && apt-get install -y openssl 6 | 7 | # Install all node_modules, including dev dependencies 8 | FROM base as deps 9 | 10 | RUN mkdir /app 11 | WORKDIR /app 12 | 13 | ADD package.json package-lock.json ./ 14 | RUN npm install --production=false 15 | 16 | # Setup production node_modules 17 | FROM base as production-deps 18 | 19 | RUN mkdir /app 20 | WORKDIR /app 21 | 22 | COPY --from=deps /app/node_modules /app/node_modules 23 | ADD package.json package-lock.json ./ 24 | RUN npm prune --production 25 | 26 | # Build the app 27 | FROM base as build 28 | 29 | ARG COMMIT_SHA 30 | ENV COMMIT_SHA=$COMMIT_SHA 31 | ARG GITHUB_REPOSITORY 32 | ENV GITHUB_REPOSITORY=$GITHUB_REPOSITORY 33 | ENV NODE_ENV=production 34 | 35 | RUN mkdir /app 36 | WORKDIR /app 37 | 38 | COPY --from=deps /app/node_modules /app/node_modules 39 | 40 | ADD prisma . 41 | RUN npx prisma generate 42 | 43 | ADD . . 44 | RUN npm run build 45 | 46 | # Finally, build the production image with minimal footprint 47 | FROM base 48 | 49 | ARG GITHUB_REPOSITORY 50 | ENV GITHUB_REPOSITORY=$GITHUB_REPOSITORY 51 | ENV NODE_ENV=production 52 | 53 | RUN mkdir /app 54 | WORKDIR /app 55 | 56 | COPY --from=production-deps /app/node_modules /app/node_modules 57 | COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma 58 | COPY --from=build /app/build /app/build 59 | COPY --from=build /app/public /app/public 60 | ADD . . 61 | 62 | CMD ["npm", "run", "start"] 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Speed Metal Stack 2 | 3 | Learn more about [Remix Stacks](https://remix.run/stacks). 4 | 5 | ```sh 6 | npx create-remix --template Girish21/speed-metal-stack 7 | ``` 8 | 9 | ## Remix Blog 📖 10 | 11 | This blog starter template was inspired heavily by Kent C. Dodds implementation of [kentcdodds.com][kcd]. You can read more about the architecture and the idea behind it at [How I built a modern website in 2021][kcd-arch]. 12 | 13 | ## Architecture 💡 14 | 15 | ![website-architecture](./images/fly-sqlite-arch.png) 16 | 17 | ## Important 🚧 18 | 19 | Fly requires a globally unique name for all the apps, and we've used the directory name and random hash as the app name. Of course, you can change this anytime you want BEFORE launching the app with Fly CLI. But it's not a big deal since you can reassign the internal Fly URL to any custom domain by adding a [`CNAME`][cname] record to your custom domain pointing to the Fly internal URL. We'll see that later when deploying the app to production. 20 | 21 | ## Quickstart 22 | 23 | ```sh 24 | # run database migrations and set up the initial blog 25 | npm run setup 26 | # run the initial build for the development server 27 | npm run build 28 | # start the development server and run other processes in parallel in watch mode 29 | npm run dev 30 | ``` 31 | 32 | ## Available scripts 33 | 34 | - `build` - compile and build the express server, Remix app, Tailwind in `production` mode 35 | - `dev` - starts the express server, Remix watcher, Tawilwind CLI in watch mode 36 | - `format` - runs prettier on the codebase and fixes fixable issues 37 | - `lint` - runs ESLint on the codebase 38 | - `new:blog` - create a new Blog post template from the command line 39 | - `start` - starts the express server (should only be executed after running `npm run build`) 40 | - `test` - runs `vitest` 41 | - `test:e2e:dev` - starts the cypress runner in development mode 42 | - `test:e2e:run` - starts the cypress runner in CI mode 43 | - `typecheck` - runs type check on the codebase 44 | 45 | ## Fly Setup 🛠 46 | 47 | 1. [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/) 48 | 49 | 2. Sign up and log in to Fly 50 | 51 | ```sh 52 | flyctl auth signup 53 | ``` 54 | 55 | ## Database 🗃 56 | 57 | We use [SQLite][sqlite] as the database in this template. SQLite is a fast database engine, a great option to persist data without reaching for advanced database engines like Postgres. 58 | 59 | ### Installation ⚙️ 60 | 61 | SQLite comes pre-installed on all Macs. You can check the official installation guides for other OS's [SQLite Installation Guides][sqlite-installation] 62 | 63 | ### Why do we need a database ❓ 64 | 65 | We use [MDX-Bundler][mdx-bundler] to compile the MDX files, and MDX-Bundler uses `esbuild` internally to compile the MDX files. Though `esbuild` is very fast, there is a noticeable lag during this process which is not suitable for the performance of the site and the user experience. And since there is no need to compile MDX on every request when the data does not change seems like a waste of time and performance. So instead, we can cache the compiled MDX and recompile it only when we know the content has changed. 66 | 67 | ### Prisma △ 68 | 69 | We use [Prisma][prisma] as the ORM in this template. To create the SQLite database and initialise the database schema, run: 70 | 71 | ```sh 72 | npx prisma migrate dev 73 | ``` 74 | 75 | The above command will prompt for a migration name, and you can name it as `initial migration`. This command will also install Prisma Client for interacting with the database. 76 | 77 | ## Development 💻 78 | 79 | We can start our development server with the migrations run and the SQLite database populated with the initial schema. Then, from a new tab in your terminal, run the command. 80 | 81 | ```sh 82 | npm run dev 83 | ``` 84 | 85 | This command starts four processes concurrently. 86 | 87 | - The Remix dev server starts in development mode and rebuilds assets on file change. 88 | - Tailwind CLI which rebuilds the stylesheet when the styles change 89 | - An [MSW][msw] server which intercepts the API calls to GitHub and serves the content from the local instead of calling the remote API 90 | - A file watcher watches over the `content` directory and rebuilds the assets. 91 | 92 | ### Relavant files 🔍 93 | 94 | - Tailwind config [tailwind.config.js](./tailwind.config.js) 95 | - MSW API mock server [mock](./mocks/start.ts) 96 | - Content change watcher [refresh-on-content-change](./others/refresh-on-content-change.ts) 97 | 98 | ## Deployment 🚀 99 | 100 | ### Initial setup 👀 101 | 102 | Before proceeding to deploy our app, we have some steps to take care of: 103 | 104 | - Create a GitHub account [GitHub](https://repo.new) 105 | - Create a new app on Fly 106 | 107 | ```sh 108 | flyctl launch --name YOUR_APP_NAME --copy-config --no-deploy 109 | ``` 110 | 111 | > ⚠️ Remember not to deploy since we have some setup steps left to complete! 112 | 113 | ### Environment variables and Secrets 🤫 114 | 115 | This template comes with GitHub actions workflows to automatically deploy the app to Fly.io. First, we need to set up our GitHub actions and the Fly app with some secrets. Let's do that now. 116 | 117 | To push the build image to the remote Fly registry from GitHub action, we need an access token from Fly. We can generate that using the Fly command line, run: 118 | 119 | ```sh 120 | flyctl auth token 121 | ``` 122 | 123 | The command will generate an access token. You can then add this token to your GitHub actions secrets by visiting your GitHub repository's `settings` page `https://github.com/:owner/:repo/settings/secrets/actions` and then click `New repository secret`. Next, GitHub will prompt for a key and a value. The key should be `FLY_API_TOKEN`, and the value will be the token generated by the command line. 124 | 125 | We also need to set the Fly app name as a secret, the key should be `FLY_APP_NAME`, and the value will be the app name specified in [fly.toml](./fly.toml) 126 | 127 | Now we need to set up secrets in our Fly app. 128 | 129 | Since we're fetching the content from GitHub on demand instead of building all the pages upfront, we need an access token from GitHub to call the GitHub API and fetch the content. Also, GitHub won't rate-limit the app from calling the GitHub API more often. So, you can generate an access token at [Personal access token](https://github.com/settings/tokens). Then, you can copy the generated token and set it to your app's secret. We can do that by running the following command: 130 | 131 | ```sh 132 | flyctl secrets set GITHUB_TOKEN={GITHUB_TOKEN} 133 | ``` 134 | 135 | We also need a secret to sign our session. We can do that by running the command: 136 | 137 | ```sh 138 | flyctl secrets set SESSION_SECRETS=$(openssl rand -hex 32) 139 | ``` 140 | 141 | > If `openssl` is not available, you can generate a secure token using a password generating service like [`1Password`][generate-password]. 142 | 143 | The last secret required is a token for securely communicating between the GitHub action and our app deployed on a remote server since we need a public-facing API for this communication. 144 | 145 | ```sh 146 | openssl rand -hex 32 147 | ``` 148 | 149 | We have to set this secret as part of the GitHub actions secret and a Fly secret. The key should be `REFRESH_TOKEN`. You can create a new actions secret in GitHub and create a new secret for the Fly app by running the command. 150 | 151 | ```sh 152 | flyctl secrets set REFRESH_TOKEN={GENERATED_PASSWORD} 153 | ``` 154 | 155 | ### Volumes 💾 156 | 157 | We also need to create a volume in Fly to persist our app data (SQLite DB) so that Fly can persist the data stored across deployments and container restarts. Again, we can do that using the Fly command line. 158 | 159 | ```sh 160 | flyctl volumes create data --region [REGION] --size 1 161 | ``` 162 | 163 | > Note: REGION should be the region selected when launching the app. You can check the region chosen by running `flyctl regions list`. 164 | 165 | It's important to note that Volumes are bound to an app in a region and cannot be shared between apps in the same region or across multiple regions. 166 | 167 | You can learn more about Fly Volumes [here][volumes] 168 | 169 | ### Push to Prod 🥳 170 | 171 | We are ready for our first deployment. GitHub actions workflows are configured to run on push to the `main` branch. So let's push the local branch `main` to remote, triggering the workflows. 172 | 173 | Once all the checks are passed, and the deployment is complete, you can run: 174 | 175 | ```sh 176 | flyctl info 177 | ``` 178 | 179 | To get the current app URL and IP address. The app URL will be `https://YOUR_APP_NAME.fly.dev`. You can visit that URL, and the site should be online. That's it. You have deployed your blog built using REMIX!. 180 | 181 | ### Adding Custom Domain 🔖 182 | 183 | To add a custom domain to the app, you first must buy a domain from a Domain Name Register, and you can choose one of your preferences. Some popular options are [Domain.com](https://www.domain.com/), [Google](https://domains.google.com/registrar), [Cloudflare](https://www.cloudflare.com/en-gb/products/registrar/). 184 | 185 | After buying the domain, we can add a DNS record to point to the domain or create a subdomain and point that to the Fly app URL. We can do that by adding a DNS record using the CNAME option and entering the Fly URL `https://YOUR_APP_NAME.fly.dev`. 186 | 187 | We also have to create an SSL certificate on Fly with the domain name. We can do that by running the command: 188 | 189 | ```sh 190 | flyctl certs create [DOMAIN] 191 | ``` 192 | 193 | You can read more about this at [SSL for Custom Domains](https://fly.io/docs/app-guides/custom-domains-with-fly/) 194 | 195 | That's it, and we are ready to share our blog with the rest of the world! But there is one more step to take care of before sharing it. 196 | 197 | ### Scaling ⚖️ 198 | 199 | There are two ways of scaling an application, vertical and horizontal. 200 | 201 | In vertical scaling, the system is scaled by adding more compute resources to the server (increasing the CPU/RAM). Fly supports vertical scaling, and you can check the docs here [scaling virtual machines](https://fly.io/docs/reference/scaling/#scaling-virtual-machines). 202 | 203 | Horizontal scaling is achieved by adding more replicas of the same application, either in the same region or in other regions worldwide. Fly supports many regions worldwide, and you can read more about them here [Fly regions](https://fly.io/docs/reference/scaling/#scaling-virtual-machines). 204 | 205 | Our app is currently deployed in only one region we selected when we ran `flyctl launch`. This is fine during prototyping and development, but when pushing for production, we would want our app to be accessible from regions worldwide and have similar performances for users worldwide. In this case, we can add more replicas of our app worldwide, at least in the regions with many targetted users, so that the app will run on the servers closer to the users, and all the users will have comparable performance. 206 | 207 | Since Fly [anchors the regions](https://fly.io/docs/reference/volumes/#creating-volumes) based on the volumes created, we can add more regions by creating a volume in the new region or adding a replica in the same region. For example, we can do that by: 208 | 209 | ```sh 210 | flyctl volumes create data --region [NEW_REGION] --size 1 211 | ``` 212 | 213 | > You can check this list of available regions at [Fly regions][fly-regions] 214 | 215 | And then increase the [scale count](https://fly.io/docs/reference/scaling/#count-scaling) of the Fly app by running the command: 216 | 217 | ```sh 218 | flyctl scale count 2 219 | ``` 220 | 221 | > The above command will set the scale count to 2, meaning two instances of our app will run on the specified regions where the volumes are created. 222 | 223 | You can read more about scaling at [Fly scaling][fly-scaling] 224 | 225 | ## API Mocks 🔸 226 | 227 | Our architecture is to fetch the blog content from the GitHub repository on demand and not bundle the content as part of the build. Therefore, we will be making a significant amount of API calls to GitHub. And with any API, there come restrictions such as rate limit, calls per minute, etc. And when we're writing an article, the process becomes tedious since we're making calls to the GitHub API; the article has to be on GitHub so that the API can return the content. This process is not ideal. We can do better. 228 | 229 | Instead, we can mock the API request to GitHub and serve the articles locally, providing a better experience. Instead of calling the GitHub API, we use [MSW][msw] to intercept the request and return a mocked response that serves the content from the local file system. 230 | 231 | ## Linting ⬡ 232 | 233 | This template comes preconfigured with [ESLint][eslint] and [Prettier][prettier] rules and is integrated with the workflow as a build step. For example, you can run `npm run lint` to run ESLint on the project and `npm run format` to run prettier. 234 | 235 | ## Styling 💅🏻 236 | 237 | This template comes preconfigured with [Tailwindcss][tailwind] with all the scripts required during development and production. 238 | 239 | The template also comes with a theme toggler preconfigured and can detect the suitable theme and prevent [FART][fart] 240 | 241 | ## Testing 🔬 242 | 243 | This template comes preconfigured with [Jest][jest] and [React testing library][rtl] for unit testing and [Cypress][cypress] for e2e testing and is configured to run as part of the GitHub actions. You can run the `npm run test` command to run the Jest test suite. And `npm run test:e2e:run` to run the Cypress tests in a headless mode. You can check [package.json](./package.json) for the available commands. 244 | 245 | ## Type check ʦ 246 | 247 | You can run `npm run typecheck` to run `tsc` on the codebase. Type check is also included as part of the deployment workflow. 248 | 249 | ## Debugging 250 | 251 | Some helpful commands to debug the application on Fly using the command line 252 | 253 | ### Logs 254 | 255 | You can check the logs using the command `flyctl logs` from the project directory, containing the `fly.toml` file in the project's root. You can also check the logs from the console by visiting [fly.io/apps](https://fly.io/apps). 256 | 257 | ### Console 258 | 259 | You can also log in to the remote console using the `flyctl ssh console` command. 260 | 261 | ### Database 262 | 263 | After logging in to the console, you can also inspect the SQLite DB. But first, we have to install SQLite on the remote machine. We can do that using the `apt-get install sqlite3` command. Then, `cd` into the volume using the `cd data` command (Note: `data` refers to the volume's name created from the command line). And then run the command `sqlite3 sqlite.db` to open a command-line interface into the database. 264 | 265 | ## Important links 266 | 267 | - [Remix](https://remix.run/) 268 | - [Remix docs](https://remix.run/docs/en/v1) 269 | - [Fly.io](https://fly.io/) 270 | - [flyctl](https://fly.io/docs/flyctl/) 271 | - [Fly secrets](https://fly.io/docs/reference/secrets/) 272 | - [Fly scaling][fly-scaling] 273 | - [Fly volumes](https://fly.io/docs/reference/volumes/) 274 | - [Fly regions][fly-regions] 275 | - [Fly configuration](https://fly.io/docs/reference/configuration/) 276 | - [MDX Bundler][mdx-bundler] 277 | - [SQLite][sqlite] 278 | 279 | [kcd]: https://kentcdodds.com/ 280 | [kcd-arch]: https://kentcdodds.com/blog/how-i-built-a-modern-website-in-2021 281 | [cname]: https://en.wikipedia.org/wiki/CNAME_record 282 | [sqlite]: https://www.sqlite.org/index.html 283 | [mdx-bundler]: https://github.com/kentcdodds/mdx-bundler 284 | [sqlite-installation]: https://www.sqlite.org/download.html 285 | [prisma]: https://prisma.io 286 | [msw]: https://mswjs.io/ 287 | [tailwind]: https://tailwindcss.com/ 288 | [fart]: https://css-tricks.com/flash-of-inaccurate-color-theme-fart/ 289 | [generate-password]: https://1password.com/password-generator/ 290 | [volumes]: https://fly.io/docs/reference/volumes/ 291 | [eslint]: https://eslint.org/ 292 | [prettier]: https://prettier.io/ 293 | [cypress]: https://www.cypress.io/ 294 | [jest]: https://jestjs.io/ 295 | [rtl]: https://testing-library.com/ 296 | [fly-regions]: https://fly.io/docs/reference/regions/ 297 | [fly-scaling]: https://fly.io/docs/reference/scaling/ 298 | -------------------------------------------------------------------------------- /app/assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/assets/icons/light-bulb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/assets/icons/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/assets/icons/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/components/blog-item.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react' 2 | import type { getMdxListItems } from '~/utils/mdx.server' 3 | 4 | type BlogItemType = Awaited>[0] 5 | 6 | export default function BlogItem({ description, slug, title }: BlogItemType) { 7 | return ( 8 |
  • 9 | 14 |

    15 | {title} 16 |

    17 |

    18 | {description} 19 |

    20 |
    21 | Read more 22 |
    23 | 24 |
  • 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/components/blog-list.tsx: -------------------------------------------------------------------------------- 1 | import type { getMdxListItems } from '~/utils/mdx.server' 2 | import BlogItem from './blog-item' 3 | 4 | type BlogListType = { blogList: Awaited> } 5 | 6 | export default function BlogList({ blogList }: BlogListType) { 7 | return ( 8 |
      9 | {blogList.map(blogItem => ( 10 | 11 | ))} 12 |
    13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/components/client-only.tsx: -------------------------------------------------------------------------------- 1 | // https://github.com/sergiodxa/remix-utils 2 | import * as React from 'react' 3 | import { useHydrated } from '../hooks/use-hydrated' 4 | 5 | /** 6 | * @deprecated Pass a function as children to avoid issues with client only 7 | * imported components 8 | */ 9 | type DeprecatedProps = { 10 | children: React.ReactNode 11 | fallback?: React.ReactNode 12 | } 13 | 14 | type Props = 15 | | DeprecatedProps 16 | | { 17 | /** 18 | * You are encouraged to add a fallback that is the same dimensions 19 | * as the client rendered children. This will avoid content layout 20 | * shift which is disgusting 21 | */ 22 | children: () => React.ReactNode 23 | fallback?: React.ReactNode 24 | } 25 | 26 | /** 27 | * Render the children only after the JS has loaded client-side. Use an optional 28 | * fallback component if the JS is not yet loaded. 29 | * 30 | * Example: Render a Chart component if JS loads, renders a simple FakeChart 31 | * component server-side or if there is no JS. The FakeChart can have only the 32 | * UI without the behavior or be a loading spinner or skeleton. 33 | * ```tsx 34 | * return ( 35 | * }> 36 | * {() => } 37 | * 38 | * ); 39 | * ``` 40 | */ 41 | export function ClientOnly({ children, fallback = null }: Props) { 42 | if (typeof children !== 'function') { 43 | console.warn( 44 | '[remix-utils] ClientOnly: Pass a function as children to avoid issues with client-only imported components', 45 | ) 46 | } 47 | 48 | return useHydrated() ? ( 49 | <>{typeof children === 'function' ? children() : children} 50 | ) : ( 51 | <>{fallback} 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import LinkOrAnchor from './link-or-anchor' 4 | import GitHubSvg from '~/assets/icons/github.svg' 5 | import TwitterSvg from '~/assets/icons/twitter.svg' 6 | 7 | export function preloadFooterSvg() { 8 | return [ 9 | { rel: 'preload', href: GitHubSvg, as: 'image', type: 'image/svg+xml' }, 10 | { rel: 'preload', href: TwitterSvg, as: 'image', type: 'image/svg+xml' }, 11 | ] 12 | } 13 | 14 | export default function Footer() { 15 | return ( 16 |
    17 |
    18 |
    19 |

    20 | Remix Blog 21 |

    22 |
      23 | 24 | 25 | 26 | 27 | GitHub 28 | 29 | 30 | 31 | 32 | 33 | Twitter 34 | 35 |
    36 |
    37 |
    38 |
      39 | Home 40 | Blog 41 |
    42 |
    43 |
    44 |
    45 | ) 46 | } 47 | 48 | function Svg({ children }: { children: React.ReactNode }) { 49 | return ( 50 | 54 | {children} 55 | 56 | ) 57 | } 58 | 59 | function Link({ 60 | children, 61 | href, 62 | reload, 63 | }: { 64 | children: React.ReactNode 65 | href: string 66 | reload?: boolean 67 | }) { 68 | return ( 69 |
  • 70 | 76 | {children} 77 | 78 |
  • 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/components/link-or-anchor.tsx: -------------------------------------------------------------------------------- 1 | import type { LinkProps } from '@remix-run/react' 2 | import { Link } from '@remix-run/react' 3 | import * as React from 'react' 4 | 5 | type AnchorProps = React.DetailedHTMLProps< 6 | React.AnchorHTMLAttributes, 7 | HTMLAnchorElement 8 | > & { 9 | to?: LinkProps['to'] 10 | prefetch?: LinkProps['prefetch'] 11 | reloadDocument?: LinkProps['reloadDocument'] 12 | download?: boolean 13 | href?: string 14 | } 15 | 16 | const LinkOrAnchor = React.forwardRef( 17 | function LinkOrAnchorImpl( 18 | { reloadDocument, download, to, href, prefetch, children, ...rest }, 19 | ref, 20 | ) { 21 | let url = '' 22 | let anchor = reloadDocument || download 23 | 24 | if (!anchor && typeof href === 'string') { 25 | anchor = href.includes(':') || href.includes('#') 26 | } 27 | if (!anchor && typeof to === 'string') { 28 | url = to 29 | anchor = to.includes(':') 30 | } 31 | if (!anchor && typeof to === 'object') { 32 | url = `${to.pathname ?? ''}${to.hash ? `#${to.hash}` : ''}${ 33 | to.search ? `?${to.search}` : '' 34 | }` 35 | anchor = url.includes(':') 36 | } 37 | 38 | if (anchor) { 39 | return ( 40 | 41 | {children} 42 | 43 | ) 44 | } 45 | 46 | return ( 47 | 54 | {children} 55 | 56 | ) 57 | }, 58 | ) 59 | 60 | export default LinkOrAnchor 61 | -------------------------------------------------------------------------------- /app/components/nav-link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type { NavLinkProps } from '@remix-run/react' 3 | import { NavLink as RemixNavLink } from '@remix-run/react' 4 | import clsx from 'clsx' 5 | 6 | export default function NavLink({ className, ...rest }: NavLinkProps) { 7 | return ( 8 | 10 | clsx( 11 | 'text-xl text-gray-800 dark:text-gray-50', 12 | isActive ? 'underline underline-offset-2' : null, 13 | className, 14 | ) 15 | } 16 | {...rest} 17 | /> 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/components/nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import NavLink from './nav-link' 3 | import ThemeToggle, { SsrPlaceholder } from './theme-toggle' 4 | import { ClientOnly } from './client-only' 5 | 6 | export default function Nav() { 7 | return ( 8 |
    9 | 25 |
    26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import MoonSvg from '~/assets/icons/moon.svg' 3 | import LightBulbSvg from '~/assets/icons/light-bulb.svg' 4 | import { Theme, useTheme } from '~/utils/theme' 5 | 6 | export function preloadSvg() { 7 | return [ 8 | { rel: 'preload', href: MoonSvg, as: 'image', type: 'image/svg+xml' }, 9 | { rel: 'preload', href: LightBulbSvg, as: 'image', type: 'image/svg+xml' }, 10 | ] 11 | } 12 | 13 | export function SsrPlaceholder() { 14 | return
    15 | } 16 | 17 | export default function ThemeToggle() { 18 | const [theme, setTheme] = useTheme() 19 | 20 | const dark = !theme || theme === Theme.dark 21 | 22 | return ( 23 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from 'react-dom' 2 | import { RemixBrowser } from '@remix-run/react' 3 | 4 | hydrate(, document) 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from 'react-dom/server' 2 | import { RemixServer } from '@remix-run/react' 3 | import type { EntryContext } from '@remix-run/server-runtime' 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext, 10 | ) { 11 | const markup = renderToString( 12 | , 13 | ) 14 | 15 | responseHeaders.set('Content-Type', 'text/html') 16 | 17 | return new Response('' + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /app/hooks/use-hydrated.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/sergiodxa/remix-utils 2 | import * as React from 'react' 3 | 4 | let hydrating = true 5 | 6 | export function useHydrated() { 7 | let [hydrated, setHydrated] = React.useState(() => !hydrating) 8 | 9 | React.useEffect(function hydrate() { 10 | hydrating = false 11 | setHydrated(true) 12 | }, []) 13 | 14 | return hydrated 15 | } 16 | -------------------------------------------------------------------------------- /app/model/content-state.server.ts: -------------------------------------------------------------------------------- 1 | import db from '~/utils/db.server' 2 | 3 | export async function getContentState() { 4 | const rows = await db.contentState.findUnique({ 5 | select: { sha: true, timestamp: true }, 6 | where: { key: 'content' }, 7 | }) 8 | 9 | return rows 10 | } 11 | 12 | export async function setContentSHA(sha: string) { 13 | return db.contentState.upsert({ 14 | where: { key: 'content' }, 15 | create: { sha, key: 'content' }, 16 | update: { sha }, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /app/model/content.server.ts: -------------------------------------------------------------------------------- 1 | import db from '~/utils/db.server' 2 | import { getQueue } from '~/utils/p-queue.server' 3 | 4 | export async function getMdxCount(contentDirectory: string) { 5 | const count = await db.content.aggregate({ 6 | _count: { _all: true }, 7 | where: { published: true, contentDirectory }, 8 | }) 9 | 10 | return count._count._all 11 | } 12 | 13 | export async function requiresUpdate(contentDirectory: string) { 14 | const requiresUpdateCount = await db.content.aggregate({ 15 | _count: { requiresUpdate: true }, 16 | where: { published: true, contentDirectory }, 17 | }) 18 | 19 | if (requiresUpdateCount._count.requiresUpdate === 0) { 20 | return null 21 | } 22 | 23 | const requiresUpdate = await db.content.findMany({ 24 | where: { requiresUpdate: true }, 25 | }) 26 | 27 | return requiresUpdate 28 | } 29 | 30 | export async function getContentList(contentDirectory = 'blog') { 31 | const contents = await db.content.findMany({ 32 | where: { published: true, contentDirectory }, 33 | select: { 34 | slug: true, 35 | title: true, 36 | timestamp: true, 37 | description: true, 38 | frontmatter: true, 39 | }, 40 | orderBy: { timestamp: 'desc' }, 41 | }) 42 | 43 | return contents 44 | } 45 | 46 | export async function getContent(slug: string) { 47 | const rows = await db.content.findMany({ 48 | where: { slug, published: true }, 49 | select: { 50 | code: true, 51 | contentDirectory: true, 52 | frontmatter: true, 53 | slug: true, 54 | timestamp: true, 55 | title: true, 56 | requiresUpdate: true, 57 | description: true, 58 | }, 59 | }) 60 | 61 | if (!rows || rows.length === 0) { 62 | return null 63 | } 64 | 65 | if (rows.length > 1) { 66 | throw new Error(`Something is very wrong for the slug ${slug}`) 67 | } 68 | 69 | const content = rows[0] 70 | 71 | return { 72 | ...content, 73 | frontmatter: JSON.parse(content.frontmatter) as Record, 74 | } 75 | } 76 | 77 | async function setRequiresUpdateImpl({ 78 | slug, 79 | contentDirectory, 80 | }: { 81 | slug: string 82 | contentDirectory: string 83 | }) { 84 | await db.content.upsert({ 85 | where: { slug }, 86 | create: { 87 | requiresUpdate: true, 88 | slug, 89 | code: '', 90 | contentDirectory, 91 | frontmatter: '', 92 | published: true, 93 | title: '', 94 | }, 95 | update: { 96 | requiresUpdate: true, 97 | }, 98 | }) 99 | } 100 | 101 | export async function setRequiresUpdate( 102 | ...params: Parameters 103 | ) { 104 | const queue = await getQueue() 105 | const result = await queue.add(() => setRequiresUpdateImpl(...params)) 106 | return result 107 | } 108 | 109 | async function upsertContentImpl({ 110 | contentDirectory, 111 | slug, 112 | title, 113 | code, 114 | published, 115 | frontmatter, 116 | timestamp, 117 | description, 118 | }: { 119 | contentDirectory: string 120 | slug: string 121 | title: string 122 | code: string 123 | published: boolean 124 | frontmatter: Record 125 | timestamp: Date 126 | description: string 127 | }) { 128 | await db.content.upsert({ 129 | where: { slug }, 130 | update: { 131 | code, 132 | frontmatter: JSON.stringify(frontmatter), 133 | published, 134 | title, 135 | requiresUpdate: false, 136 | description, 137 | }, 138 | create: { 139 | contentDirectory, 140 | code, 141 | frontmatter: JSON.stringify(frontmatter), 142 | published, 143 | slug, 144 | title, 145 | timestamp, 146 | description, 147 | }, 148 | }) 149 | } 150 | 151 | export async function deleteSlug(slug: string) { 152 | return db.content.delete({ where: { slug } }) 153 | } 154 | 155 | export async function refreshAllContent() { 156 | return db.content.updateMany({ data: { requiresUpdate: true } }) 157 | } 158 | 159 | export async function upsertContent( 160 | ...params: Parameters 161 | ) { 162 | const queue = await getQueue() 163 | 164 | const result = await queue.add(() => upsertContentImpl(...params)) 165 | 166 | return result 167 | } 168 | 169 | export async function deleteContent(slug: string) { 170 | const queue = await getQueue() 171 | 172 | const result = await queue.add(() => deleteSlug(slug)) 173 | 174 | return result 175 | } 176 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { SkipNavContent, SkipNavLink } from '@reach/skip-nav' 2 | import skipNavStyles from '@reach/skip-nav/styles.css' 3 | import { 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | useLoaderData, 11 | } from '@remix-run/react' 12 | import type { LinksFunction, LoaderFunction } from '@remix-run/server-runtime' 13 | import { json } from '@remix-run/server-runtime' 14 | import Nav from '~/components/nav' 15 | import appStyles from '~/styles/app.css' 16 | import Footer, { preloadFooterSvg } from './components/footer' 17 | import { preloadSvg } from './components/theme-toggle' 18 | import type { Theme } from './utils/theme' 19 | import { SsrTheme, ThemeMeta, ThemeProvider, useTheme } from './utils/theme' 20 | import { getThemeSession } from './utils/theme-session.server' 21 | 22 | type LoaderData = { theme: Theme | null } 23 | 24 | export const links: LinksFunction = () => { 25 | return [ 26 | { rel: 'stylesheet', href: skipNavStyles }, 27 | { rel: 'stylesheet', href: appStyles }, 28 | { 29 | rel: 'preload', 30 | href: '/fonts/Poppins-Regular.ttf', 31 | as: 'font', 32 | type: 'font/ttf', 33 | crossOrigin: 'anonymous', 34 | }, 35 | { 36 | rel: 'preload', 37 | href: '/fonts/Poppins-Bold.ttf', 38 | as: 'font', 39 | type: 'font/ttf', 40 | crossOrigin: 'anonymous', 41 | }, 42 | ...preloadSvg(), 43 | ...preloadFooterSvg(), 44 | ] 45 | } 46 | 47 | export const loader: LoaderFunction = async ({ request }) => { 48 | const { getTheme } = await getThemeSession(request) 49 | 50 | return json({ theme: getTheme() }) 51 | } 52 | 53 | function App() { 54 | const [theme] = useTheme() 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | Skip to content 67 |
    68 |
    75 | 76 | 77 | 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | export default function AppProviders() { 85 | const { theme } = useLoaderData() 86 | 87 | return ( 88 | 89 | 90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /app/routes/_action/set-theme.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from '@remix-run/server-runtime' 2 | import { json, redirect } from '@remix-run/server-runtime' 3 | import { isTheme } from '~/utils/theme' 4 | import { getThemeSession } from '~/utils/theme-session.server' 5 | 6 | export const action: ActionFunction = async ({ request }) => { 7 | const { commit, setTheme } = await getThemeSession(request) 8 | const data = await request.text() 9 | const queryParams = new URLSearchParams(data) 10 | const theme = queryParams.get('theme') 11 | 12 | if (isTheme(theme)) { 13 | setTheme(theme) 14 | 15 | return json(null, { headers: { 'set-cookie': await commit() } }) 16 | } 17 | 18 | return json({ message: 'Bad request' }) 19 | } 20 | 21 | export const loader = () => redirect('/', { status: 404 }) 22 | -------------------------------------------------------------------------------- /app/routes/_content/refresh-content.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from '@remix-run/server-runtime' 2 | import { json } from '@remix-run/server-runtime' 3 | import * as dns from 'dns' 4 | import { getRequiredEnvVar } from '~/utils/misc' 5 | 6 | export const action: ActionFunction = async ({ request }) => { 7 | if (request.headers.get('auth') !== getRequiredEnvVar('REFRESH_TOKEN')) { 8 | return json({ message: 'Not Authorised' }, { status: 401 }) 9 | } 10 | 11 | const body = await request.text() 12 | const address = `global.${getRequiredEnvVar('FLY_APP_NAME')}.internal` 13 | const ipv6s = await dns.promises.resolve6(address) 14 | 15 | const urls = ipv6s.map(ip => `http://[${ip}]:${getRequiredEnvVar('PORT')}`) 16 | 17 | const queryParams = new URLSearchParams() 18 | queryParams.set('_data', 'routes/_content/update-content') 19 | 20 | const fetches = urls.map(url => 21 | fetch(`${url}/_content/update-content?${queryParams}`, { 22 | method: 'POST', 23 | body, 24 | headers: { 25 | auth: getRequiredEnvVar('REFRESH_TOKEN'), 26 | 'content-type': 'application/json', 27 | 'content-length': Buffer.byteLength(body).toString(), 28 | }, 29 | }), 30 | ) 31 | 32 | const response = await Promise.all(fetches) 33 | 34 | return json(response) 35 | } 36 | -------------------------------------------------------------------------------- /app/routes/_content/refresh-content[.]json.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from '@remix-run/server-runtime' 2 | import { json } from '@remix-run/server-runtime' 3 | import { getContentState } from '~/model/content-state.server' 4 | 5 | export const loader: LoaderFunction = async () => { 6 | const rows = await getContentState() 7 | const data = rows || {} 8 | 9 | return json(data, { 10 | headers: { 11 | 'content-length': Buffer.byteLength(JSON.stringify(data)).toString(), 12 | }, 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /app/routes/_content/update-content.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from '@remix-run/server-runtime' 2 | import { json } from '@remix-run/server-runtime' 3 | import nodepath from 'path' 4 | import { refreshAllContent, setRequiresUpdate } from '~/model/content.server' 5 | import { getMdxListItems } from '~/utils/mdx.server' 6 | import { setContentSHA } from '~/model/content-state.server' 7 | import { getRequiredEnvVar } from '~/utils/misc' 8 | 9 | type Body = { 10 | refreshAll?: boolean 11 | paths?: Array 12 | sha: string 13 | } 14 | 15 | export const action: ActionFunction = async ({ request }) => { 16 | if (request.headers.get('auth') !== getRequiredEnvVar('REFRESH_TOKEN')) { 17 | return json({ message: 'Not Authorised' }, { status: 401 }) 18 | } 19 | 20 | const body = (await request.json()) as Body 21 | 22 | if ('refreshAll' in body && body.refreshAll === true) { 23 | await refreshAllContent() 24 | void getMdxListItems({ contentDirectory: 'blog' }) 25 | 26 | console.log(`🌀 Refreshing all contents. SHA: ${body.sha}`) 27 | 28 | return json({ message: 'refreshing all contents' }) 29 | } 30 | 31 | if ('paths' in body && Array.isArray(body.paths)) { 32 | const refreshPaths = [] 33 | for (const path of body.paths) { 34 | const [contentDirectory, dirOrFile] = path.split('/') 35 | if (!contentDirectory || !dirOrFile) { 36 | continue 37 | } 38 | const slug = nodepath.parse(dirOrFile).name 39 | await setRequiresUpdate({ slug, contentDirectory }) 40 | 41 | refreshPaths.push(path) 42 | } 43 | if (refreshPaths.some(p => p.startsWith('blog'))) { 44 | void getMdxListItems({ contentDirectory: 'blog' }) 45 | } 46 | if ('sha' in body) { 47 | void setContentSHA(body.sha) 48 | } 49 | 50 | console.log('💿 Updating content', { 51 | sha: body.sha, 52 | refreshPaths, 53 | message: 'refreshing content paths', 54 | }) 55 | 56 | return json({ 57 | sha: body.sha, 58 | refreshPaths, 59 | message: 'refreshing content paths', 60 | }) 61 | } 62 | 63 | return json({ message: 'no action' }, { status: 400 }) 64 | } 65 | -------------------------------------------------------------------------------- /app/routes/blog.$slug.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type { 3 | HeadersFunction, 4 | LinksFunction, 5 | LoaderFunction, 6 | MetaFunction, 7 | } from '@remix-run/server-runtime' 8 | import { json } from '@remix-run/server-runtime' 9 | import { useLoaderData } from '@remix-run/react' 10 | import { getMDXComponent } from 'mdx-bundler/client' 11 | import invariant from 'tiny-invariant' 12 | import { getMdxPage } from '~/utils/mdx.server' 13 | import type { MdxComponent } from '~/types' 14 | 15 | import styles from 'highlight.js/styles/night-owl.css' 16 | import { getSeoMeta } from '~/utils/seo' 17 | 18 | export const meta: MetaFunction = ({ data }: { data: MdxComponent }) => { 19 | const { keywords = [] } = data.frontmatter.meta ?? {} 20 | const seoMeta = getSeoMeta({ 21 | title: data.title, 22 | description: data.description, 23 | twitter: { 24 | description: data.description, 25 | title: data.title, 26 | }, 27 | }) 28 | 29 | return { ...seoMeta, keywords: keywords.join(', ') } 30 | } 31 | 32 | export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }] 33 | 34 | export const headers: HeadersFunction = ({ loaderHeaders }) => { 35 | return { 36 | 'cache-control': 37 | loaderHeaders.get('cache-control') ?? 'private, max-age=60', 38 | Vary: 'Cookie', 39 | } 40 | } 41 | 42 | export const loader: LoaderFunction = async ({ params }) => { 43 | const slug = params.slug 44 | invariant(typeof slug === 'string', 'Slug should be a string, and defined') 45 | 46 | const mdxPage = await getMdxPage({ contentDirectory: 'blog', slug }) 47 | 48 | if (!mdxPage) { 49 | throw json(null, { status: 404 }) 50 | } 51 | 52 | return json(mdxPage, { 53 | headers: { 'cache-control': 'private, max-age: 60', Vary: 'Cookie' }, 54 | }) 55 | } 56 | 57 | export default function Blog() { 58 | const data = useLoaderData() 59 | 60 | const Component = React.useMemo(() => getMDXComponent(data.code), [data]) 61 | 62 | return ( 63 |
    64 | 65 |
    66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /app/routes/blog.rss[.]xml.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from '@remix-run/server-runtime' 2 | import * as dateFns from 'date-fns' 3 | import invariant from 'tiny-invariant' 4 | 5 | import { getMdxListItems } from '~/utils/mdx.server' 6 | import { getDomainUrl } from '~/utils/misc' 7 | 8 | export const loader: LoaderFunction = async ({ request }) => { 9 | const posts = await getMdxListItems({ contentDirectory: 'blog' }) 10 | 11 | const blogUrl = `${getDomainUrl(request)}/blog` 12 | 13 | const rss = ` 14 | 15 | 16 | My Blog 17 | ${blogUrl} 18 | My Blog 19 | en-us 20 | 40 21 | ${posts 22 | .map(post => { 23 | const frontMatter = JSON.parse(post.frontmatter) 24 | 25 | invariant( 26 | typeof frontMatter.title === 'string', 27 | `${post.slug} should have a title in fronte matter`, 28 | ) 29 | invariant( 30 | typeof frontMatter.description === 'string', 31 | `${post.slug} should have a description in fronte matter`, 32 | ) 33 | invariant( 34 | typeof post.timestamp === 'object', 35 | `${post.slug} should have a timestamp`, 36 | ) 37 | 38 | return ` 39 | 40 | ${cdata(frontMatter.title ?? 'Untitled Post')} 41 | ${cdata( 42 | frontMatter.description ?? 'This post is... indescribable', 43 | )} 44 | ${dateFns.format( 45 | dateFns.add( 46 | post.timestamp 47 | ? dateFns.parseISO(post.timestamp.toISOString()) 48 | : Date.now(), 49 | { minutes: new Date().getTimezoneOffset() }, 50 | ), 51 | 'yyyy-MM-ii', 52 | )} 53 | ${blogUrl}/${post.slug} 54 | ${blogUrl}/${post.slug} 55 | 56 | `.trim() 57 | }) 58 | .join('\n')} 59 | 60 | 61 | `.trim() 62 | 63 | return new Response(rss, { 64 | headers: { 65 | 'Content-Type': 'application/xml', 66 | 'Content-Length': String(Buffer.byteLength(rss)), 67 | }, 68 | }) 69 | } 70 | 71 | function cdata(s: string) { 72 | return `` 73 | } 74 | -------------------------------------------------------------------------------- /app/routes/blog.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | HeadersFunction, 3 | LinksFunction, 4 | LoaderFunction, 5 | MetaFunction, 6 | } from '@remix-run/server-runtime' 7 | import { json } from '@remix-run/server-runtime' 8 | import { useLoaderData } from '@remix-run/react' 9 | import BlogList from '~/components/blog-list' 10 | import { getMdxListItems } from '~/utils/mdx.server' 11 | import { getSeo } from '~/utils/seo' 12 | 13 | type LoaderData = { blogList: Awaited> } 14 | 15 | const [seoMeta, seoLinks] = getSeo({ 16 | title: 'Blogs', 17 | description: 'Awesome blogs!', 18 | twitter: { 19 | title: 'Blogs', 20 | description: 'Awesome blogs!', 21 | }, 22 | }) 23 | 24 | export const meta: MetaFunction = () => { 25 | return { ...seoMeta } 26 | } 27 | 28 | export const links: LinksFunction = () => { 29 | return [...seoLinks] 30 | } 31 | 32 | export const headers: HeadersFunction = ({ loaderHeaders }) => { 33 | return { 34 | 'cache-control': 35 | loaderHeaders.get('cache-control') ?? 'private, max-age=60', 36 | Vary: 'Cookie', 37 | } 38 | } 39 | 40 | export const loader: LoaderFunction = async () => { 41 | const blogList = await getMdxListItems({ contentDirectory: 'blog' }) 42 | 43 | return json( 44 | { blogList }, 45 | { 46 | headers: { 'cache-control': 'private, max-age=60', Vary: 'Cookie' }, 47 | }, 48 | ) 49 | } 50 | 51 | export default function Blog() { 52 | const { blogList } = useLoaderData() 53 | 54 | return ( 55 |
    56 | 57 |
    58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /app/routes/healthcheck.ts: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import type { LoaderFunction } from '@remix-run/node' 3 | 4 | import { getMdxListItems } from '~/utils/mdx.server' 5 | 6 | export const loader: LoaderFunction = async ({ request }) => { 7 | const host = 8 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 9 | 10 | try { 11 | const url = new URL('/', `http://${host}`) 12 | // if we can connect to the database and make a simple query 13 | // and make a HEAD request to ourselves, then we're good. 14 | await Promise.all([ 15 | getMdxListItems({ contentDirectory: 'blog' }), 16 | fetch(url.toString(), { method: 'HEAD' }).then(r => { 17 | if (!r.ok) return Promise.reject(r) 18 | }), 19 | ]) 20 | return new Response('OK') 21 | } catch (error: unknown) { 22 | console.log('healthcheck ❌', { error }) 23 | return new Response('ERROR', { status: 500 }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useLoaderData } from '@remix-run/react' 3 | import type { 4 | HeadersFunction, 5 | LinksFunction, 6 | LoaderFunction, 7 | MetaFunction, 8 | } from '@remix-run/server-runtime' 9 | import { json } from '@remix-run/server-runtime' 10 | import BlogList from '~/components/blog-list' 11 | import { getMdxListItems } from '~/utils/mdx.server' 12 | import { getSeo } from '~/utils/seo' 13 | 14 | type LoaderData = { blogList: Awaited> } 15 | 16 | const [seoMeta, seoLinks] = getSeo() 17 | 18 | export const meta: MetaFunction = () => { 19 | return { ...seoMeta } 20 | } 21 | 22 | export const links: LinksFunction = () => { 23 | return [...seoLinks] 24 | } 25 | 26 | export const headers: HeadersFunction = ({ loaderHeaders }) => { 27 | return { 28 | 'cache-control': 29 | loaderHeaders.get('cache-control') ?? 'private, max-age=60', 30 | Vary: 'Cookie', 31 | } 32 | } 33 | 34 | export const loader: LoaderFunction = async () => { 35 | const blogList = await getMdxListItems({ contentDirectory: 'blog' }) 36 | 37 | return json( 38 | { blogList: blogList.slice(0, 10) }, 39 | { headers: { 'cache-control': 'private, max-age=60' } }, 40 | ) 41 | } 42 | 43 | export default function Index() { 44 | const { blogList } = useLoaderData() 45 | 46 | return ( 47 | <> 48 |
    49 |
    50 |

    51 | Remix 52 | Blog 53 |

    54 |
    55 |
    56 |
    57 |
    58 |

    59 | Recent Posts 60 |

    61 | 62 |
    63 |
    64 | 65 | ) 66 | } 67 | 68 | function GradientText(props: React.HTMLAttributes) { 69 | return ( 70 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /app/types.ts: -------------------------------------------------------------------------------- 1 | type GitHubFile = { 2 | content: string 3 | path: string 4 | } 5 | 6 | type MdxPage = { 7 | code: string 8 | slug: string 9 | frontmatter: { 10 | published?: boolean 11 | title?: string 12 | description?: string 13 | meta?: Record & { 14 | keywords?: Array 15 | } 16 | date?: string 17 | } 18 | } 19 | 20 | type MdxComponent = { 21 | frontmatter: MdxPage['frontmatter'] 22 | slug: string 23 | title: string 24 | code: string 25 | timestamp: Date 26 | description?: string 27 | } 28 | 29 | export type { GitHubFile, MdxPage, MdxComponent } 30 | -------------------------------------------------------------------------------- /app/utils/compile-mdx.server.ts: -------------------------------------------------------------------------------- 1 | import { bundleMDX } from 'mdx-bundler' 2 | import type { GitHubFile } from '~/types' 3 | import { getQueue } from './p-queue.server' 4 | 5 | async function compileMdxImpl>({ 6 | slug, 7 | files, 8 | }: { 9 | slug: string 10 | files: Array 11 | }) { 12 | // prettier-ignore 13 | const { default: remarkAutolinkHeader } = await import("remark-autolink-headings"); 14 | const { default: remarkGfm } = await import('remark-gfm') 15 | const { default: remarkSlug } = await import('remark-slug') 16 | const { default: rehypeHighlight } = await import('rehype-highlight') 17 | 18 | const indexPattern = /index.mdx?$/ 19 | const indexFile = files.find(({ path }) => path.match(indexPattern)) 20 | if (!indexFile) { 21 | return null 22 | } 23 | 24 | const rootDir = indexFile.path.replace(indexPattern, '') 25 | const relativeFiles = files.map(({ path, content }) => ({ 26 | path: path.replace(rootDir, './'), 27 | content, 28 | })) 29 | 30 | const filesObject = arrayToObject(relativeFiles, { 31 | keyname: 'path', 32 | valuename: 'content', 33 | }) 34 | 35 | try { 36 | const { code, frontmatter } = await bundleMDX({ 37 | source: indexFile.content, 38 | files: filesObject, 39 | xdmOptions: options => ({ 40 | remarkPlugins: [ 41 | ...(options.remarkPlugins ?? []), 42 | remarkSlug, 43 | [remarkAutolinkHeader, { behavior: 'wrap' }], 44 | remarkGfm, 45 | ], 46 | rehypePlugins: [...(options.rehypePlugins ?? []), rehypeHighlight], 47 | }), 48 | }) 49 | 50 | return { code, frontmatter: frontmatter as FrontmatterType } 51 | } catch (e) { 52 | throw new Error(`MDX Compilation failed for ${slug}`) 53 | } 54 | } 55 | 56 | function arrayToObject>( 57 | array: Array, 58 | { keyname, valuename }: { keyname: keyof Item; valuename: keyof Item }, 59 | ) { 60 | const obj: Record = {} 61 | 62 | for (const item of array) { 63 | const key = item[keyname] 64 | if (typeof key !== 'string') { 65 | throw new Error(`Type of ${key} should be a string`) 66 | } 67 | const value = item[valuename] 68 | obj[key] = value 69 | } 70 | 71 | return obj 72 | } 73 | 74 | async function queuedCompileMdx< 75 | FrontmatterType extends Record, 76 | >(...params: Parameters) { 77 | const queue = await getQueue() 78 | 79 | const result = await queue.add(() => 80 | compileMdxImpl(...params), 81 | ) 82 | 83 | return result 84 | } 85 | 86 | export { queuedCompileMdx as compileMdx } 87 | -------------------------------------------------------------------------------- /app/utils/db.server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import { PrismaClient } from '@prisma/client' 3 | import { getRequiredEnvVar } from './misc' 4 | 5 | declare global { 6 | var client: PrismaClient | undefined 7 | } 8 | 9 | let client: PrismaClient 10 | 11 | if (getRequiredEnvVar('NODE_ENV') === 'production') { 12 | client = new PrismaClient() 13 | } else { 14 | if (!global.client) { 15 | global.client = client = new PrismaClient() 16 | } else { 17 | client = global.client 18 | } 19 | } 20 | 21 | export default client 22 | -------------------------------------------------------------------------------- /app/utils/github.server.ts: -------------------------------------------------------------------------------- 1 | import nodepath from 'path' 2 | import { Octokit as createOctokit } from '@octokit/rest' 3 | import { throttling } from '@octokit/plugin-throttling' 4 | import Lrucache from 'lru-cache' 5 | import type { GitHubFile } from '~/types' 6 | import { getRequiredEnvVar } from './misc' 7 | 8 | type ThrottleOptions = { 9 | method: string 10 | url: string 11 | request: { retryCount: number } 12 | } 13 | 14 | const cache = new Lrucache({ 15 | maxAge: 1000 * 60, 16 | length: value => Buffer.byteLength(JSON.stringify(value)), 17 | }) 18 | 19 | const Octokit = createOctokit.plugin(throttling) 20 | 21 | function getGHOwner() { 22 | return getRequiredEnvVar('GITHUB_REPOSITORY').split('/')[0] 23 | } 24 | 25 | function getGHRepository() { 26 | return getRequiredEnvVar('GITHUB_REPOSITORY').split('/')[1] 27 | } 28 | 29 | const octokit = new Octokit({ 30 | auth: getRequiredEnvVar('GITHUB_TOKEN'), 31 | throttle: { 32 | onRateLimit: (retryAfter: number, options: ThrottleOptions) => { 33 | console.warn( 34 | `Request quota exhausted for request ${options.method} ${options.url}. Retrying after ${retryAfter} seconds.`, 35 | ) 36 | 37 | return true 38 | }, 39 | onAbuseLimit: (_: number, options: ThrottleOptions) => { 40 | octokit.log.warn( 41 | `Abuse detected for request ${options.method} ${options.url}`, 42 | ) 43 | }, 44 | }, 45 | }) 46 | 47 | function cachify(fn: (args: TArgs) => Promise) { 48 | return async function (args: TArgs): Promise { 49 | if (cache.has(args)) { 50 | return cache.get(args) as TReturn 51 | } 52 | const result = await fn(args) 53 | cache.set(args, result) 54 | return result 55 | } 56 | } 57 | 58 | async function downloadDirectoryListImpl(path: string) { 59 | const { data } = await octokit.repos.getContent({ 60 | owner: getGHOwner(), 61 | repo: getGHRepository(), 62 | path, 63 | }) 64 | 65 | if (!Array.isArray(data)) { 66 | throw new Error( 67 | `GitHub should always return an array, not sure what happened for the path ${path}`, 68 | ) 69 | } 70 | 71 | return data 72 | } 73 | 74 | async function downloadFileByShaImpl(sha: string) { 75 | const { data } = await octokit.request( 76 | 'GET /repos/{owner}/{repo}/git/blobs/{file_sha}', 77 | { 78 | owner: getGHOwner(), 79 | repo: getGHRepository(), 80 | file_sha: sha, 81 | }, 82 | ) 83 | 84 | const encoding = data.encoding as Parameters['1'] 85 | return Buffer.from(data.content, encoding).toString() 86 | } 87 | 88 | export const downloadFileBySha = cachify(downloadFileByShaImpl) 89 | 90 | async function downloadFirstMdxFileImpl( 91 | list: Array<{ name: string; sha: string; type: string }>, 92 | ) { 93 | const filesOnly = list.filter(({ type }) => type === 'file') 94 | 95 | for (const extension of ['.mdx', '.md']) { 96 | const file = filesOnly.find(({ name }) => name.endsWith(extension)) 97 | if (file) { 98 | return downloadFileBySha(file.sha) 99 | } 100 | } 101 | 102 | return null 103 | } 104 | 105 | const downloadFirstMdxFile = cachify(downloadFirstMdxFileImpl) 106 | export const downloadDirectoryList = cachify(downloadDirectoryListImpl) 107 | 108 | async function downloadDirectoryImpl(path: string): Promise> { 109 | const fileOrDirectoryList = await downloadDirectoryList(path) 110 | 111 | const results: Array = [] 112 | 113 | for (const fileOrDirectory of fileOrDirectoryList) { 114 | switch (fileOrDirectory.type) { 115 | case 'file': { 116 | const content = await downloadFileBySha(fileOrDirectory.sha) 117 | results.push({ path: fileOrDirectory.path, content }) 118 | break 119 | } 120 | case 'dir': { 121 | const fileList = await downloadDirectoryImpl(fileOrDirectory.path) 122 | results.push(...fileList) 123 | break 124 | } 125 | default: 126 | throw new Error( 127 | `Unknown file type returned for the file ${fileOrDirectory.path}`, 128 | ) 129 | } 130 | } 131 | 132 | return results 133 | } 134 | 135 | export const downloadDirectory = cachify(downloadDirectoryImpl) 136 | 137 | export async function downloadMdxOrDirectory(relativePath: string) { 138 | const path = `content/${relativePath}` 139 | 140 | const directory = nodepath.dirname(path) 141 | const basename = nodepath.basename(path) 142 | const nameWithoutExt = nodepath.parse(path).name 143 | 144 | const directoryList = await downloadDirectoryList(directory) 145 | 146 | const potentials = directoryList.filter(({ name }) => 147 | name.startsWith(basename), 148 | ) 149 | const potentialDirectory = potentials.find(({ type }) => type === 'dir') 150 | const exactMatch = potentials.find( 151 | ({ name }) => nodepath.parse(name).name === nameWithoutExt, 152 | ) 153 | 154 | const content = await downloadFirstMdxFile( 155 | exactMatch ? [exactMatch] : potentials, 156 | ) 157 | 158 | let entry = path 159 | let files: Array = [] 160 | 161 | if (content) { 162 | entry = path.endsWith('.mdx') || path.endsWith('.md') ? path : `${path}.mdx` 163 | files = [{ path: nodepath.join(path, 'index.mdx'), content }] 164 | } else if (potentialDirectory) { 165 | entry = potentialDirectory.path 166 | files = await downloadDirectory(path) 167 | } 168 | 169 | return { entry, files } 170 | } 171 | -------------------------------------------------------------------------------- /app/utils/mdx.server.ts: -------------------------------------------------------------------------------- 1 | import type { Content } from '@prisma/client' 2 | import { 3 | deleteContent, 4 | getContent, 5 | getContentList, 6 | getMdxCount, 7 | requiresUpdate, 8 | upsertContent as upsertContentImpl, 9 | } from '~/model/content.server' 10 | import type { MdxPage } from '~/types' 11 | import { compileMdx } from './compile-mdx.server' 12 | import { downloadDirectoryList, downloadMdxOrDirectory } from './github.server' 13 | 14 | async function dirList(dir: string) { 15 | const basePath = `content/${dir}` 16 | const dirList = await downloadDirectoryList(basePath) 17 | 18 | return dirList.map(({ name, path }) => { 19 | return { 20 | name, 21 | slug: path.replace(`${basePath}/`, '').replace(/.mdx?$/, ''), 22 | } 23 | }) 24 | } 25 | 26 | async function downloadMdx( 27 | filesList: Array<{ slug: string }>, 28 | contentDir: string, 29 | ) { 30 | return Promise.all( 31 | filesList.map(async ({ slug }) => { 32 | const path = `${contentDir}/${slug}` 33 | 34 | return { 35 | ...(await downloadMdxOrDirectory(path)), 36 | path, 37 | slug, 38 | } 39 | }), 40 | ) 41 | } 42 | 43 | async function compileMdxPages(pages: Awaited>) { 44 | return Promise.all( 45 | pages.map(async ({ files, slug }) => { 46 | const compiledPage = await compileMdx({ 47 | files, 48 | slug, 49 | }) 50 | 51 | if (!compiledPage) { 52 | await deleteContent(slug) 53 | return null 54 | } 55 | 56 | return { 57 | ...compiledPage, 58 | slug, 59 | } 60 | }), 61 | ) 62 | } 63 | 64 | async function upsertContent( 65 | compiledPages: Awaited>, 66 | contentDirectory: string, 67 | ) { 68 | return Promise.all( 69 | compiledPages.map(compiledPage => { 70 | if (compiledPage) { 71 | return upsertContentImpl({ 72 | contentDirectory, 73 | code: compiledPage.code, 74 | frontmatter: compiledPage.frontmatter, 75 | published: compiledPage.frontmatter.published ?? false, 76 | slug: compiledPage.slug, 77 | title: compiledPage.frontmatter.title ?? '', 78 | timestamp: new Date(compiledPage.frontmatter.date ?? ''), 79 | description: compiledPage.frontmatter.description ?? '', 80 | }) 81 | } 82 | return null 83 | }), 84 | ) 85 | } 86 | 87 | async function populateMdx(contentDirectory: string) { 88 | const filesList = await dirList(contentDirectory) 89 | const pages = await downloadMdx(filesList, contentDirectory) 90 | const compiledPages = await compileMdxPages(pages) 91 | await upsertContent(compiledPages, contentDirectory) 92 | } 93 | 94 | async function updateMdx(mdxToUpdate: Content[], contentDirectory: string) { 95 | const pages = await downloadMdx(mdxToUpdate, contentDirectory) 96 | const compiledPages = await compileMdxPages(pages) 97 | await upsertContent(compiledPages, contentDirectory) 98 | } 99 | 100 | export async function getMdxListItems({ 101 | contentDirectory, 102 | }: { 103 | contentDirectory: string 104 | }) { 105 | const [count, pagesToUpdates] = await Promise.all([ 106 | getMdxCount(contentDirectory), 107 | requiresUpdate(contentDirectory), 108 | ]) 109 | 110 | if (count === 0) { 111 | await populateMdx(contentDirectory) 112 | } 113 | if (pagesToUpdates && pagesToUpdates.length > 0) { 114 | await updateMdx(pagesToUpdates, contentDirectory) 115 | } 116 | return getContentList() 117 | } 118 | 119 | /** 120 | * function which returns the compiled MDX page 121 | * and the meta info for a given slug and the 122 | * content directory. 123 | * 124 | * If the page is not pre compiled or requires update, 125 | * the page is fetched, compiled and returned 126 | * 127 | * The refreshed page is not persisted in DB 128 | * because of a corner case. Let's say you deploy 129 | * the app to a new Fly region which has a fresh 130 | * data store. If the user vists the page directly via 131 | * a link, since we don't have that page in our cache, 132 | * we go and download the page, compile MDX and now 133 | * if we try to persist it in our DB, we will end up 134 | * in a situation where we might not be able to fetch the 135 | * rest of the pages when we vist the index route. 136 | * 137 | * Because we don't store the total count of pages, 138 | * there is no way to know if we have downloaded and 139 | * cached all the pages. 140 | * 141 | * You may as why not have a count. Yeah we can, but hey 142 | * this is a simple starter to get started 143 | * and feel free to rewrite this block as per you requirement :). 144 | */ 145 | export async function getMdxPage({ 146 | slug, 147 | contentDirectory, 148 | }: { 149 | slug: string 150 | contentDirectory: string 151 | }): ReturnType { 152 | const data = await getContent(slug) 153 | 154 | if (data && !data.requiresUpdate) { 155 | return data 156 | } 157 | 158 | const pages = await downloadMdx([{ slug }], contentDirectory) 159 | const [compiledPage] = await compileMdxPages(pages) 160 | 161 | if (!compiledPage) { 162 | console.error(`Page ${slug} could not be compiled`) 163 | return null 164 | } 165 | 166 | if (!compiledPage.frontmatter.published) { 167 | return null 168 | } 169 | 170 | return { 171 | code: compiledPage.code, 172 | contentDirectory, 173 | frontmatter: compiledPage.frontmatter, 174 | requiresUpdate: false, 175 | slug, 176 | timestamp: new Date(compiledPage.frontmatter.date ?? ''), 177 | title: compiledPage.frontmatter.title ?? '', 178 | description: compiledPage.frontmatter.description ?? '', 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /app/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export function getRequiredEnvVar(key: string, env = process.env): string { 2 | if (key in env && typeof env[key] === 'string') { 3 | return env[key] ?? '' 4 | } 5 | 6 | throw new Error(`Environment variable ${key} is not defined`) 7 | } 8 | 9 | export function getDomainUrl(request: Request) { 10 | const host = 11 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 12 | if (!host) { 13 | throw new Error('Could not determine domain URL.') 14 | } 15 | const protocol = host.includes('localhost') ? 'http' : 'https' 16 | return `${protocol}://${host}` 17 | } 18 | -------------------------------------------------------------------------------- /app/utils/p-queue.server.ts: -------------------------------------------------------------------------------- 1 | import type TQueue from 'p-queue' 2 | 3 | let _queue: TQueue | null = null 4 | export async function getQueue() { 5 | const { default: PQueue } = await import('p-queue') 6 | 7 | if (_queue) { 8 | return _queue 9 | } 10 | 11 | _queue = new PQueue({ concurrency: 1 }) 12 | 13 | return _queue 14 | } 15 | -------------------------------------------------------------------------------- /app/utils/seo.ts: -------------------------------------------------------------------------------- 1 | import { initSeo } from 'remix-seo' 2 | 3 | export const { getSeo, getSeoLinks, getSeoMeta } = initSeo({ 4 | title: 'Remix Blog', 5 | description: 'Blog built using Remix', 6 | twitter: { 7 | card: 'summary', 8 | creator: '@handle', 9 | site: 'https://my-site.dev', 10 | title: 'Remix Blog', 11 | description: 'Blog built using Remix', 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /app/utils/theme-session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from '@remix-run/node' 2 | import { getRequiredEnvVar } from './misc' 3 | import type { Theme } from './theme' 4 | 5 | const { commitSession, getSession } = createCookieSessionStorage({ 6 | cookie: { 7 | path: '/', 8 | sameSite: 'lax', 9 | name: 'theme', 10 | httpOnly: true, 11 | expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100), 12 | secrets: [getRequiredEnvVar('SESSION_SECRETS')], 13 | secure: true, 14 | }, 15 | }) 16 | 17 | export async function getThemeSession(request: Request) { 18 | const themeSession = await getSession(request.headers.get('Cookie')) 19 | 20 | return { 21 | getTheme: (): Theme | null => themeSession.get('theme') || null, 22 | setTheme: (theme: Theme) => themeSession.set('theme', theme), 23 | commit: async () => commitSession(themeSession), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/utils/theme.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useFetcher } from '@remix-run/react' 3 | 4 | export enum Theme { 5 | light = 'light', 6 | dark = 'dark', 7 | } 8 | 9 | type ThemeContextType = [ 10 | Theme | null, 11 | React.Dispatch>, 12 | ] 13 | 14 | const themes = Object.values(Theme) 15 | 16 | const ThemeContext = React.createContext(null) 17 | 18 | const themeMediaQuery = '(prefers-color-scheme: dark)' 19 | 20 | const getPrefferedTheme = () => { 21 | return window.matchMedia(themeMediaQuery).matches ? Theme.dark : Theme.light 22 | } 23 | 24 | export function ThemeProvider({ 25 | children, 26 | ssrTheme, 27 | }: { 28 | children: React.ReactNode 29 | ssrTheme: Theme | null 30 | }) { 31 | const [theme, setTheme] = React.useState(() => { 32 | if (ssrTheme) { 33 | if (themes.includes(ssrTheme)) { 34 | return ssrTheme 35 | } else { 36 | return null 37 | } 38 | } 39 | 40 | if (typeof window === 'undefined') { 41 | return null 42 | } 43 | 44 | return getPrefferedTheme() 45 | }) 46 | 47 | const themeFetcher = useFetcher() 48 | const skipFirstRender = React.useRef(true) 49 | const themeFetcherRef = React.useRef(themeFetcher) 50 | 51 | React.useEffect(() => { 52 | if (skipFirstRender.current) { 53 | skipFirstRender.current = false 54 | return 55 | } 56 | if (!theme) { 57 | return 58 | } 59 | 60 | themeFetcherRef.current.submit( 61 | { theme }, 62 | { method: 'post', action: '_action/set-theme' }, 63 | ) 64 | }, [theme]) 65 | 66 | React.useEffect(() => { 67 | const media = window.matchMedia(themeMediaQuery) 68 | 69 | function handleThemeChange() { 70 | setTheme(media.matches ? Theme.dark : Theme.light) 71 | } 72 | 73 | media.addEventListener('change', handleThemeChange) 74 | 75 | return () => { 76 | window.removeEventListener('change', handleThemeChange) 77 | } 78 | }, []) 79 | 80 | return ( 81 | 82 | {children} 83 | 84 | ) 85 | } 86 | 87 | export function useTheme() { 88 | const data = React.useContext(ThemeContext) 89 | 90 | if (!data) { 91 | throw new Error('useTheme should only be used under ThemeProvider') 92 | } 93 | 94 | return data 95 | } 96 | 97 | export function isTheme(theme: unknown): theme is Theme { 98 | return typeof theme === 'string' && themes.includes(theme as Theme) 99 | } 100 | 101 | export function ThemeMeta() { 102 | const [theme] = useTheme() 103 | 104 | return ( 105 | 109 | ) 110 | } 111 | 112 | export function SsrTheme({ serverTheme }: { serverTheme?: boolean }) { 113 | return ( 114 | <> 115 | {serverTheme ? null : ( 116 |