├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .env.example ├── .eslintrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── dependabot.yml ├── .gitignore ├── .gitpod.yml ├── .npmrc ├── LICENSE.txt ├── README.md ├── app ├── components │ ├── BlogPost.tsx │ ├── ErrorPage.tsx │ ├── Markdown.tsx │ ├── Search.tsx │ └── layout │ │ ├── Container.tsx │ │ ├── Footer.tsx │ │ ├── MobileNavigation.tsx │ │ └── Navigation.tsx ├── docs.config.ts ├── env.server.ts ├── root.tsx ├── routes │ ├── about.tsx │ ├── actions+ │ │ ├── search.tsx │ │ └── set-theme.tsx │ ├── blog+ │ │ ├── $.tsx │ │ └── index.tsx │ ├── docs+ │ │ ├── $.tsx │ │ └── index.tsx │ ├── healthcheck.ts │ ├── index.tsx │ ├── rss[.]xml.ts │ └── sitemap[.]xml.ts ├── seo.ts ├── tailwind.css ├── types.ts └── utils │ ├── blog.server.ts │ ├── cache-control.server.ts │ ├── fs.server.ts │ ├── github.server.ts │ ├── index.ts │ ├── markdoc.server.ts │ ├── sitemap.server.ts │ ├── theme-provider.tsx │ └── theme.server.ts ├── content ├── blog-cache.json ├── docs-cache.json ├── docs │ ├── changelog.mdx │ ├── getting-started │ │ ├── index.mdx │ │ └── part-2.mdx │ ├── index.mdx │ ├── installation │ │ └── index.mdx │ └── roadmap │ │ └── index.mdx ├── page-cache.json ├── pages │ ├── about │ │ └── index.mdx │ └── index.mdx └── posts │ ├── 2022 │ └── test │ │ └── index.mdx │ ├── another-post │ └── index.mdx │ └── hello-world │ ├── abc.mdx │ ├── index.mdx │ └── more-hello │ └── index.mdx ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── remix.init ├── gitignore ├── index.js └── package.json ├── scripts └── cachePosts.ts ├── server.js ├── tailwind.config.js └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node packages 16 | # RUN su node -c "npm install -g " -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local on arm64/Apple Silicon. 10 | "args": { 11 | "VARIANT": "16-bullseye" 12 | } 13 | }, 14 | 15 | // Set *default* container specific settings.json values on container create. 16 | "settings": {}, 17 | 18 | 19 | // Add the IDs of extensions you want installed when the container is created. 20 | "extensions": [ 21 | "dbaeumer.vscode-eslint", 22 | "esbenp.prettier-vscode", 23 | "bradlc.vscode-tailwindcss" 24 | ], 25 | 26 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 27 | // "forwardPorts": [], 28 | 29 | // Use 'postCreateCommand' to run commands after the container is created. 30 | // "postCreateCommand": "yarn install", 31 | 32 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 33 | "remoteUser": "node" 34 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Session secret 2 | SESSION_SECRET=something 3 | 4 | # This should be fs or gh 5 | USE_FILESYSTEM_OR_GITHUB="fs" 6 | #USE_FILESYSTEM_OR_GITHUB="gh" 7 | 8 | # GitHub Personal Access Token 9 | GITHUB_TOKEN= 10 | GITHUB_OWNER= 11 | GITHUB_REPO= -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: freekrai 2 | custom: ["https://www.paypal.me/joinednode"] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Something is wrong with the Stack. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: >- 7 | 👋 Hey, thanks for helping to improve Remix Blog! 8 | 9 | Our bandwidth on maintaining this stacs is limited. I'm currently focusing my efforts 10 | on several projects. The good news is you can fork and adjust this stack however 11 | you'd like and start using it today as a custom stack. Learn more from 12 | [the Remix Stacks docs](https://remix.run/stacks). 13 | 14 | If you'd still like to report a bug, please fill out this form. We can't 15 | promise a timely response, but hopefully when we have the bandwidth to 16 | work on these stacks again we can take a look. Thanks! 17 | 18 | - type: input 19 | attributes: 20 | label: Have you experienced this bug with the latest version of the template? 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Steps to Reproduce 26 | description: Steps to reproduce the behavior. 27 | validations: 28 | required: true 29 | - type: textarea 30 | attributes: 31 | label: Expected Behavior 32 | description: A concise description of what you expected to happen. 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: Actual Behavior 38 | description: A concise description of what you're experiencing. 39 | validations: 40 | required: true 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Get Help with This Stack 4 | url: https://github.com/freekrai/remix-docs/discussions/new?category=q-a 5 | about: 6 | Help with this stack itself. 7 | - name: Feature Request for This Stack 8 | url: https://github.com/freekrai/remix-docs/discussions/new?category=ideas 9 | about: 10 | I appreciate you taking the time to improve Remix Blog with your ideas, but let's 11 | use the Discussions for this instead of the issues tab 🙂. 12 | - name: Get Remix Help 13 | url: https://github.com/remix-run/remix/discussions/new?category=q-a 14 | about: 15 | If you can't get something to work the way you expect, open a question in 16 | the Remix discussions. 17 | - name: 💬 Remix Discord Channel 18 | url: https://rmx.as/discord 19 | about: Interact with other people using Remix 💿 20 | - name: 💬 New Updates (Twitter) 21 | url: https://twitter.com/remix_run 22 | about: Stay up to date with Remix news on twitter 23 | - name: 🍿 Remix YouTube Channel 24 | url: https://rmx.as/youtube 25 | about: Are you a tech lead or wanting to learn more about Remix in depth? Checkout the Remix YouTube Channel 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | 8 | /build/ 9 | /public/build 10 | /api 11 | /app/styles/*.css -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # https://www.gitpod.io/docs/config-gitpod-file 2 | 3 | ports: 4 | - port: 3000 5 | onOpen: notify 6 | 7 | tasks: 8 | - name: Restore .env file 9 | command: | 10 | if [ -f .env ]; then 11 | # If this workspace already has a .env, don't override it 12 | # Local changes survive a workspace being opened and closed 13 | # but they will not persist between separate workspaces for the same repo 14 | echo "Found .env in workspace" 15 | else 16 | # There is no .env 17 | if [ ! -n "${ENV}" ]; then 18 | # There is no $ENV from a previous workspace 19 | # Default to the example .env 20 | echo "Setting example .env" 21 | cp .env.example .env 22 | else 23 | # After making changes to .env, run this line to persist it to $ENV 24 | # eval $(gp env -e ENV="$(base64 .env | tr -d '\n')") 25 | # 26 | # Environment variables set this way are shared between all your workspaces for this repo 27 | # The lines below will read $ENV and print a .env file 28 | echo "Restoring .env from Gitpod" 29 | echo "${ENV}" | base64 -d | tee .env > /dev/null 30 | fi 31 | fi 32 | - init: npm install 33 | command: npm run build && npm run dev 34 | 35 | vscode: 36 | extensions: 37 | - esbenp.prettier-vscode 38 | - dbaeumer.vscode-eslint 39 | - bradlc.vscode-tailwindcss 40 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Roger Stringer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Docs Stack 2 | 3 | Learn more about [Remix Stacks](https://remix.run/stacks). 4 | 5 | ```bash 6 | npx create-remix --template freekrai/remix-docs 7 | ``` 8 | 9 | ## Remix Docs 📖 10 | 11 | Remix Docs is a documentation site starter. 12 | 13 | - `content`: where mdx is stored 14 | - `content/docs`: docs, stored as: `SLUG/index.mdx` 15 | - `content/posts`: blog posts, stored as: `SLUG/index.mdx` 16 | - `content/pages`: pages, stored as `SLUG/index.mdx` 17 | 18 | The structure is based on Gatsby and gives us more flexibility, each page and post is a folder and contains an `index.mdx` file, this folder name becomes the slug. 19 | 20 | This also gives you a lot of flexibility, for example, you can have multiple files inside one folder 21 | 22 | - `content/posts/hello-world/index.mdx` returns as `/hello-world` 23 | - `content/posts/hello-world/abc.mdx` returns as `/hello-world/abc` 24 | - `content/posts/hello-world/more-hello/index.mdx` returns as `hello-world/more-hello` 25 | - `content/posts/hello/still-hello/index.mdx` returns as `hello/still-hello` 26 | - `content/posts/2022/test/index.mdx` returns as `/2022/test` 27 | 28 | This lets you structure content however you want. 29 | 30 | On build, we generate a cached json file in content (`blog-cache.json`) for all blog posts, which we then reference later for the blog index, rss, sitemap, etc. 31 | 32 | We also generate a separate cache json file in content (`docs-cache.json`) for all docs, this can then be used for sitemap, etc as well. 33 | 34 | Finally, we generate a separate cache json file in content (`page-cache.json`) for all pages, this can then be used for sitemap, etc as well. 35 | 36 | Mdx files contain frontmatter which we use on the site, this frontmatter looks like: 37 | 38 | ```jsx 39 | --- 40 | meta: 41 | title: Another Post 42 | description: A description 43 | date: '2021-10-02T00:00:00' 44 | updated: '2021-10-02T00:00:00' 45 | excerpt: Hello Gaseous cloud... 46 | headers: 47 | Cache-Control: no-cache 48 | --- 49 | ``` 50 | 51 | ## Config 52 | 53 | There are two parts to config, first is our env variables: 54 | 55 | ### Env Variables 56 | 57 | By default, remix-docs will try to use the file system to read files, this works great but if you are on a hosting service like cloudflare where you can't access the file system then we need to use Github, you can configure how it accesses files in your .env file: 58 | 59 | - `SESSION_SECRET`: Session Secret used for sessions such as dark mode 60 | - `USE_FILESYSTEM_OR_GITHUB`: this is either `fs` or `gh` 61 | - `GITHUB_TOKEN`: your Personal access token 62 | - `GITHUB_OWNER`: your Github name 63 | - `GITHUB_REPO`: your Github repo 64 | 65 | The Github variables are only needed if `USE_FILESYSTEM_OR_GITHUB` is set to `gh`, it's `fs` by default. 66 | 67 | ### Docs Config 68 | 69 | The second part of our config is inside the `app/docs.config.ts` file: 70 | 71 | ```js 72 | export default { 73 | base: '/', 74 | lang: 'en-US', 75 | title: 'Remix Docs', 76 | description: 'Just playing around.', 77 | nav: [ 78 | { text: 'Docs', link: '/docs' }, 79 | { text: 'Blog', link: '/blog' }, 80 | ], 81 | head: [ 82 | 83 | ], 84 | sidebar: [ 85 | { 86 | title: 'Introduction', 87 | links: [ 88 | { title: 'Getting started', href: '/docs/getting-started' }, 89 | { title: 'Installation', href: '/docs/installation' }, 90 | ], 91 | }, 92 | { 93 | title: 'Core Concepts', 94 | links: [ 95 | { title: 'Roadmap', href: '/docs/roadmap' }, 96 | { title: 'Changelog', href: '/docs/changelog' }, 97 | ], 98 | }, 99 | ], 100 | search: { 101 | enabled: true, 102 | }, 103 | editLink: { 104 | link: 'https://github.com/freekrai/remix-docs', 105 | text: 'Edit this page on GitHub', 106 | }, 107 | }; 108 | ``` 109 | 110 | This lets you customize the top nav, sidebar links, enable search, etc. 111 | 112 | ## Available scripts 113 | 114 | - `build` - compile and build the Remix app, Tailwind and cache blog posts into a json file in `production` mode 115 | - `dev` - starts Remix watcher, blog cache watcher and Tawilwind CLI in watch mode 116 | 117 | ## Development 118 | 119 | To run your Remix app locally, first, copy `.env.example` to `.env` and configure as needed following the `Config` step above. 120 | 121 | Next, make sure your project's local dependencies are installed: 122 | 123 | ```bash 124 | npm install 125 | ``` 126 | 127 | Afterwards, start the Remix development server like so: 128 | 129 | ```bash 130 | npm run dev 131 | ``` 132 | 133 | Open up [http://localhost:3000](http://localhost:3000) and you should be ready to go! 134 | 135 | --- 136 | 137 | ## Deployment 138 | 139 | Initially, this stack is set up for deploying to Vercel, but it can be deployed to other hosts quickly and we'll update the wiki with instructions for each. 140 | 141 | ### Vercel 142 | 143 | Open `server.js` and save it as: 144 | 145 | ```jsx 146 | import { createRequestHandler } from "@remix-run/vercel"; 147 | import * as build from "@remix-run/dev/server-build"; 148 | export default createRequestHandler({ build, mode: process.env.NODE_ENV }); 149 | ``` 150 | 151 | Then update your `remix.config.js` file as follows: 152 | 153 | ```jsx 154 | /** @type {import('@remix-run/dev').AppConfig} */ 155 | module.exports = { 156 | serverBuildTarget: "vercel", 157 | server: process.env.NODE_ENV === "development" ? undefined : "./server.js", 158 | ignoredRouteFiles: ["**/.*"], 159 | }; 160 | ``` 161 | 162 | This will instruct your Remix app to use the Vercel runtime, after doing this, you only need to [import your Git repository](https://vercel.com/new) into Vercel, and it will be deployed. 163 | 164 | If you'd like to avoid using a Git repository, you can also deploy the directory by running [Vercel CLI](https://vercel.com/cli): 165 | 166 | ```bash 167 | npm i -g vercel 168 | vercel 169 | ``` 170 | 171 | It is generally recommended to use a Git repository, because future commits will then automatically be deployed by Vercel, through its [Git Integration](https://vercel.com/docs/concepts/git). 172 | 173 | ### Cloudflare Pages 174 | 175 | Coming Soon 176 | 177 | ### Netlify 178 | 179 | Coming Soon -------------------------------------------------------------------------------- /app/components/BlogPost.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLoaderData } from "@remix-run/react"; 2 | import cn from 'classnames'; 3 | 4 | export default function BlogPost({ 5 | title, 6 | excerpt, 7 | slug 8 | }){ 9 | return ( 10 | 11 |
12 |

13 | {title} 14 |

15 |
16 | {excerpt &&

{excerpt}...

} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/components/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { Link} from "@remix-run/react"; 2 | 3 | export default function ErrorPage({ 4 | code=404, 5 | title='Page Not Found', 6 | message='Please check the URL in the address bar and try again' 7 | }){ 8 | return ( 9 |
10 |
11 |
12 |

{code}

13 |
14 |
15 |

{title}

16 |

{message}

17 |
18 |
19 | 23 | Go back home 24 | 25 |
26 |
27 |
28 |
29 |
30 | ) 31 | } -------------------------------------------------------------------------------- /app/components/Markdown.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | This file handles all our Markdoc rendering on the frontend. 3 | */ 4 | 5 | import type { RenderableTreeNodes } from "@markdoc/markdoc"; 6 | import { renderers } from "@markdoc/markdoc"; 7 | import * as React from "react"; 8 | import type { ReactNode } from "react"; 9 | import { Link } from "@remix-run/react"; 10 | 11 | import { 12 | LightBulbIcon, 13 | ExclamationTriangleIcon, 14 | CheckIcon, 15 | } from '@heroicons/react/24/solid' 16 | 17 | import cn from 'classnames' 18 | 19 | const callOutStyles = { 20 | note: { 21 | container: 'bg-sky-50 dark:bg-slate-800/60 dark:ring-1 dark:ring-slate-300/10', 22 | title: 'text-sky-900 dark:text-sky-400', 23 | icon: 'text-sky-900 dark:text-sky-400', 24 | body: 'text-sky-800 [--tw-prose-background:theme(colors.sky.50)] prose-a:text-sky-900 prose-code:text-sky-900 dark:text-slate-300 dark:prose-code:text-slate-300', 25 | }, 26 | warning: { 27 | container: 'bg-amber-50 dark:bg-slate-800/60 dark:ring-1 dark:ring-slate-300/10', 28 | title: 'text-amber-900 dark:text-amber-500', 29 | icon: 'text-amber-900 dark:text-amber-500', 30 | body: 'text-amber-800 [--tw-prose-underline:theme(colors.amber.400)] [--tw-prose-background:theme(colors.amber.50)] prose-a:text-amber-900 prose-code:text-amber-900 dark:text-slate-300 dark:[--tw-prose-underline:theme(colors.sky.700)] dark:prose-code:text-slate-300', 31 | }, 32 | success: { 33 | container: 'bg-green-50 dark:bg-green-800/60 dark:ring-1 dark:ring-green-300/10', 34 | title: 'text-green-900 dark:text-green-400', 35 | icon: 'text-green-900 dark:text-green-400', 36 | body: 'text-green-800 [--tw-prose-background:theme(colors.green.50)] prose-a:text-green-900 prose-code:text-green-900 dark:text-green-300 dark:prose-code:text-green-300', 37 | }, 38 | } 39 | 40 | const icons = { 41 | note: (props: JSX.IntrinsicAttributes & React.SVGProps) => , 42 | warning: (props: JSX.IntrinsicAttributes & React.SVGProps) => , 43 | success: (props: JSX.IntrinsicAttributes & React.SVGProps) => , 44 | } 45 | 46 | type Types = "check" | "error" | "note" | "warning"; 47 | 48 | type CalloutProps = { 49 | children: ReactNode; 50 | title?: string; 51 | type: Types 52 | }; 53 | 54 | type QuickLinksProps = { 55 | children: ReactNode; 56 | }; 57 | 58 | type QuickLinkProps = { 59 | title: string; 60 | description: string; 61 | href: string; 62 | }; 63 | 64 | /*** 65 | * check if the link is external or internal, if internal, use Remix Link, if external use a href... 66 | ***/ 67 | const CustomLink = (props) => { 68 | const href = props.href; 69 | const isInternalLink = href && (href.startsWith('/') || href.startsWith('#')); 70 | 71 | if (isInternalLink) { 72 | return ( 73 | {props.children} 74 | ); 75 | } 76 | 77 | return ; 78 | }; 79 | 80 | 81 | export function QuickLinks({ children }: QuickLinksProps) { 82 | return ( 83 |
84 | {children} 85 |
86 | ) 87 | } 88 | 89 | QuickLinks.scheme = { 90 | render: QuickLinks.name, 91 | description: "Display the enclosed content in a box", 92 | }; 93 | 94 | 95 | export function QuickLink({ title, description, href }: QuickLinkProps) { 96 | return ( 97 |
98 |
99 |
100 |

101 | 102 | 103 | {title} 104 | 105 |

106 |

107 | {description} 108 |

109 |
110 |
111 | ) 112 | } 113 | 114 | QuickLink.scheme = { 115 | render: QuickLink.name, 116 | description: "Display the enclosed content in a quick link box", 117 | children: ["paragraph", "tag", "list"], 118 | selfClosing: true, 119 | attributes: { 120 | title: { type: String }, 121 | description: { type: String }, 122 | href: { type: String }, 123 | }, 124 | }; 125 | 126 | export function Callout({ children, title, type }: CalloutProps) { 127 | let IconComponent = icons[type] 128 | return ( 129 |
130 | 131 |
132 | {title &&

133 | {title} 134 |

} 135 |
136 | {children} 137 |
138 |
139 |
140 | ); 141 | } 142 | 143 | Callout.scheme = { 144 | render: Callout.name, 145 | description: "Display the enclosed content in a callout box", 146 | children: ["paragraph", "tag", "list"], 147 | attributes: { 148 | type: { 149 | type: String, 150 | default: "note", 151 | matches: ["success", "check", "error", "note", "warning"], 152 | }, 153 | }, 154 | }; 155 | 156 | 157 | type Props = { 158 | content: RenderableTreeNodes; 159 | components?: Record; 160 | }; 161 | 162 | 163 | export function MarkdownView({ content, components = {} }: Props) { 164 | return ( 165 | <>{renderers.react( 166 | content, 167 | React, { 168 | components: { 169 | Callout, 170 | QuickLinks, 171 | QuickLink 172 | } 173 | })} 174 | ) 175 | } -------------------------------------------------------------------------------- /app/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState, useEffect } from 'react' 2 | import { MagnifyingGlassIcon } from '@heroicons/react/20/solid' 3 | import { Combobox, Dialog, Transition } from '@headlessui/react' 4 | 5 | import { useFetcher } from "@remix-run/react"; 6 | 7 | import { loader } from "~/routes/actions/search"; 8 | 9 | function classNames(...classes) { 10 | return classes.filter(Boolean).join(' ') 11 | } 12 | 13 | export function SearchPalette({ open, setOpen }) { 14 | let [query, setQuery] = useState(""); 15 | 16 | let { data, load, state } = useFetcher(); 17 | let posts = data?.results ?? []; // initially data is undefined 18 | 19 | useEffect( 20 | function getInitialData() { 21 | load("/actions/search"); 22 | }, 23 | [load] 24 | ); 25 | 26 | useEffect( 27 | function getFilteredPosts() { 28 | console.log(query); 29 | load(`/actions/search?term=${query}`); 30 | }, 31 | [load, query] 32 | ); 33 | 34 | return ( 35 | setQuery('')} appear> 36 | 37 | 46 |
47 | 48 | 49 |
50 | 59 | 60 | (window.location = post.url)}> 61 |
62 |
72 | 73 | {posts.length > 0 && ( 74 | 75 | {posts.map((post) => ( 76 | 80 | classNames('cursor-default select-none px-4 py-2', active && 'bg-indigo-600 text-white') 81 | } 82 | > 83 | {post.type}{' / '}{post.title} 84 | 85 | ))} 86 | 87 | )} 88 | 89 | {query !== '' && posts.length === 0 && ( 90 |

No Matches found.

91 | )} 92 |
93 |
94 |
95 |
96 |
97 |
98 | ) 99 | } -------------------------------------------------------------------------------- /app/components/layout/Container.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { Link, NavLink } from "@remix-run/react"; 3 | import * as React from 'react'; 4 | 5 | import { Theme, Themed, useTheme } from "~/utils/theme-provider"; 6 | import MobileNavigation from '~/components/layout/MobileNavigation' 7 | import Navigation from '~/components/layout/Navigation' 8 | import config from '~/docs.config'; 9 | 10 | import {SearchPalette} from '~/components/Search'; 11 | 12 | import { 13 | MagnifyingGlassIcon 14 | } from '@heroicons/react/24/solid' 15 | 16 | import Footer from '~/components/layout/Footer'; 17 | 18 | const GitHubIcon = (props) => ( 19 | 22 | ) 23 | 24 | export default function Container({children}) { 25 | let [isScrolled, setIsScrolled] = React.useState(false) 26 | let [isSearching, setIsSearching] = React.useState(false); 27 | 28 | const [, setTheme] = useTheme(); 29 | 30 | const toggleTheme = () => { 31 | setTheme((prevTheme) => 32 | prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT 33 | ); 34 | }; 35 | 36 | 37 | React.useEffect(() => { 38 | function onScroll() { 39 | setIsScrolled(window.scrollY > 0) 40 | } 41 | onScroll() 42 | window.addEventListener('scroll', onScroll, { passive: true }) 43 | 44 | return () => { 45 | window.removeEventListener('scroll', onScroll) 46 | } 47 | }, []) 48 | return ( 49 |
50 |
58 |
59 | 60 |
61 |
62 | clsx( 67 | isActive 68 | ? 'font-semibold text-sky-500 before:bg-sky-500' 69 | : 'text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300' 70 | )} 71 | >Home 72 | {config.nav && config.nav.map( nav => ( 73 | clsx( 79 | isActive 80 | ? 'font-semibold text-sky-500 before:bg-sky-500' 81 | : 'text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300' 82 | )} 83 | > 84 | {nav.text} 85 | 86 | ))} 87 |
88 |
89 | 94 | {config.title} 95 | 96 |
97 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | 154 |
155 |
156 |
157 |
158 | {children} 159 |
160 |
161 |
162 |
163 |
164 | ); 165 | } -------------------------------------------------------------------------------- /app/components/layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | const RssIcon = (props) => ( 4 | 12 | 17 | 18 | ) 19 | 20 | const navigation = { 21 | main: [ 22 | { name: 'Home', href: '/' }, 23 | { name: 'Docs', href: '/docs' }, 24 | { name: 'Blog', href: '/blog' }, 25 | { name: 'About', href: '/about' }, 26 | ], 27 | social: [ 28 | { 29 | name: 'Facebook', 30 | href: '#', 31 | icon: (props) => ( 32 | 33 | 38 | 39 | ), 40 | }, 41 | { 42 | name: 'Twitter', 43 | href: 'https://twitter.com/', 44 | icon: (props) => ( 45 | 46 | 47 | 48 | ), 49 | }, 50 | { 51 | name: 'GitHub', 52 | href: 'https://github.com/freekrai/remix-docs', 53 | icon: (props) => ( 54 | 55 | 60 | 61 | ), 62 | }, 63 | { 64 | name: 'Instagram', 65 | href: 'https://instagram.com/', 66 | icon: (props) => ( 67 | 68 | 73 | 74 | ), 75 | }, 76 | { 77 | name: 'RSS', 78 | href: '/rss.xml', 79 | icon: (props) => ( 80 | 81 | ) 82 | }, 83 | ], 84 | } 85 | 86 | export default function Footer () { 87 | return ( 88 |
89 |
90 |
91 | 100 |
101 | {navigation.social.map((item) => ( 102 | 103 | {item.name} 104 | 106 | ))} 107 |
108 |

109 | © {new Date().getFullYear()} Remix Docs. All rights reserved. 110 |

111 |
112 |
113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /app/components/layout/MobileNavigation.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { Link, NavLink } from "@remix-run/react"; 3 | import { Dialog } from '@headlessui/react' 4 | import Navigation from '~/components/layout/Navigation' 5 | import config from '~/docs.config'; 6 | import clsx from 'clsx'; 7 | 8 | function MenuIcon(props) { 9 | return ( 10 | 20 | ) 21 | } 22 | 23 | function CloseIcon(props) { 24 | return ( 25 | 35 | ) 36 | } 37 | 38 | export default function MobileNavigation() { 39 | let [isOpen, setIsOpen] = useState(false) 40 | 41 | useEffect(() => { 42 | if (!isOpen) return 43 | 44 | function onRouteChange() { 45 | setIsOpen(false) 46 | } 47 | }, [isOpen]) 48 | 49 | return ( 50 | <> 51 | 59 | 65 | 66 |
67 | 74 | 75 | {config.title} 76 | 77 |
78 |
    82 |
  • 83 | clsx( 88 | 'block w-full pl-3.5', 89 | isActive 90 | ? 'font-semibold text-sky-500 before:bg-sky-500' 91 | : 'text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300' 92 | )} 93 | >Home 94 |
  • 95 | {config.nav && config.nav.map( nav => ( 96 |
  • 97 | clsx( 102 | 'block w-full pl-3.5', 103 | isActive 104 | ? 'font-semibold text-sky-500 before:bg-sky-500' 105 | : 'text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300' 106 | )} 107 | > 108 | {nav.text} 109 | 110 |
  • 111 | ))} 112 |
113 | 114 |
115 |
116 | 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /app/components/layout/Navigation.tsx: -------------------------------------------------------------------------------- 1 | 2 | import clsx from 'clsx'; 3 | import { Link, NavLink } from "@remix-run/react"; 4 | import * as React from 'react'; 5 | 6 | import { Dialog } from '@headlessui/react' 7 | import config from '~/docs.config'; 8 | 9 | export default function Navigation({ navigation, className }) { 10 | return ( 11 | 44 | ) 45 | } -------------------------------------------------------------------------------- /app/docs.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | base: '/', 3 | lang: 'en-US', 4 | title: 'Remix Docs', 5 | description: 'Just playing around.', 6 | publicURL: 'http://localhost:300', 7 | nav: [ 8 | { text: 'Docs', link: '/docs' }, 9 | { text: 'Blog', link: '/blog' }, 10 | ], 11 | head: [ 12 | 13 | ], 14 | sidebar: [ 15 | { 16 | title: 'Introduction', 17 | links: [ 18 | { title: 'Getting started', href: '/docs/getting-started' }, 19 | { title: 'Installation', href: '/docs/installation' }, 20 | ], 21 | }, 22 | { 23 | title: 'Core Concepts', 24 | links: [ 25 | { title: 'Roadmap', href: '/docs/roadmap' }, 26 | { title: 'Changelog', href: '/docs/changelog' }, 27 | ], 28 | }, 29 | ], 30 | search: { 31 | enabled: true, 32 | }, 33 | editLink: { 34 | enabled: true, 35 | link: 'https://github.com/freekrai/remix-docs', 36 | text: 'Edit this page on GitHub', 37 | }, 38 | }; -------------------------------------------------------------------------------- /app/env.server.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export let envSchema = z.object({ 4 | SESSION_SECRET: z.string().min(1), 5 | BOT_GITHUB_TOKEN: process.env.USE_FILESYSTEM_OR_GITHUB === 'gh' ? z.string().min(1) : z.optional(z.string()), 6 | GITHUB_OWNER: process.env.USE_FILESYSTEM_OR_GITHUB === 'gh' ? z.string().min(1) : z.optional(z.string()), 7 | GITHUB_REPO: process.env.USE_FILESYSTEM_OR_GITHUB === 'gh' ? z.string().min(1) : z.optional(z.string()), 8 | USE_FILESYSTEM_OR_GITHUB: z 9 | .union([ 10 | z.literal("fs"), 11 | z.literal("gh"), 12 | ]) 13 | .default("fs"), 14 | NODE_ENV: z 15 | .union([ 16 | z.literal("test"), 17 | z.literal("development"), 18 | z.literal("production"), 19 | ]) 20 | .default("development"), 21 | }); 22 | 23 | export type Env = z.infer; -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { HeadersFunction, LinksFunction, LoaderFunction, V2_MetaFunction } from "@vercel/remix"; 2 | import { json } from "@vercel/remix"; 3 | import { 4 | Links, 5 | useLoaderData, 6 | LiveReload, 7 | Meta, 8 | Outlet, 9 | Scripts, 10 | ScrollRestoration, 11 | isRouteErrorResponse, 12 | useRouteError, 13 | } from "@remix-run/react"; 14 | 15 | import { 16 | ThemeBody, 17 | ThemeHead, 18 | ThemeProvider, 19 | useTheme, 20 | } from "~/utils/theme-provider"; 21 | import type { Theme } from "~/utils/theme-provider"; 22 | import { getThemeSession } from "~/utils/theme.server"; 23 | 24 | import { CacheControl } from "~/utils/cache-control.server"; 25 | import ErrorPage from '~/components/ErrorPage' 26 | 27 | import tailwindStyles from "./tailwind.css" 28 | 29 | //import type {SideBarItem, SidebarGroup} from '~/utils/docs.server'; 30 | import Container from "~/components/layout/Container"; 31 | 32 | import {getDomainUrl, removeTrailingSlash} from '~/utils' 33 | 34 | import config from '~/docs.config'; 35 | import { getSeo} from '~/seo' 36 | 37 | export const meta: V2_MetaFunction = ({ data, matches }) => { 38 | if(!data) return []; 39 | 40 | return [ 41 | getSeo({ 42 | title: config.title, 43 | description: config.description, 44 | url: data.canonical ? data.canonical : '', 45 | }), 46 | ] 47 | } 48 | 49 | export const handle = { 50 | id: 'root', 51 | } 52 | 53 | export type LoaderData = { 54 | theme: Theme | null; 55 | canonical?: string; 56 | requestInfo: { 57 | url: string; 58 | origin: string 59 | path: string 60 | } | null; 61 | }; 62 | 63 | export const links: LinksFunction = () => [ 64 | { rel: "preconnect", href: "//fonts.gstatic.com", crossOrigin: "anonymous" }, 65 | {rel: "stylesheet", href: tailwindStyles}, 66 | { rel: "stylesheet", href: "//fonts.googleapis.com/css?family=Work+Sans:300,400,600,700&lang=en" }, 67 | ] 68 | 69 | export const headers: HeadersFunction = () => { 70 | return { "Cache-Control": new CacheControl("swr").toString() }; 71 | }; 72 | 73 | export const loader: LoaderFunction = async ({ request }) => { 74 | const themeSession = await getThemeSession(request); 75 | 76 | const url = getDomainUrl(request); 77 | const path = new URL(request.url).pathname; 78 | 79 | return json({ 80 | theme: themeSession.getTheme(), 81 | canonical: removeTrailingSlash(`${url}${path}`), 82 | requestInfo: { 83 | url: removeTrailingSlash(`${url}${path}`), 84 | origin: getDomainUrl(request), 85 | path: new URL(request.url).pathname, 86 | }, 87 | }); 88 | }; 89 | 90 | function App() { 91 | const data = useLoaderData(); 92 | const [theme] = useTheme(); 93 | return ( 94 | 95 | 96 | 97 | 98 | 99 | {data.requestInfo && } 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | ); 119 | } 120 | 121 | export default function AppWithProviders() { 122 | const data = useLoaderData(); 123 | 124 | return ( 125 | 126 | 127 | 128 | ); 129 | } 130 | 131 | export function ErrorBoundary() { 132 | let error = useRouteError(); 133 | let status = '500'; 134 | let message = ''; 135 | let stacktrace; 136 | 137 | // when true, this is what used to go to `CatchBoundary` 138 | if ( error.status === 404 ) { 139 | status = 404; 140 | message = 'Page Not Found'; 141 | } else if (error instanceof Error) { 142 | status = '500'; 143 | message = error.message; 144 | stacktrace = error.stack; 145 | } else { 146 | status = '500'; 147 | message = 'Unknown Error'; 148 | } 149 | return ( 150 | 151 | 156 | 157 | ); 158 | } 159 | 160 | function ErrorDocument({ 161 | children, 162 | title 163 | }: { 164 | children: React.ReactNode; 165 | title?: string; 166 | }) { 167 | return ( 168 | 169 | 170 | 171 | 172 | {title ? {title} : null} 173 | 174 | 175 | 176 | 177 | {children} 178 | 179 | 180 | {process.env.NODE_ENV === "development" && } 181 | 182 | 183 | ); 184 | } 185 | -------------------------------------------------------------------------------- /app/routes/about.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | LoaderArgs, 3 | V2_MetaFunction, 4 | HeadersFunction, 5 | SerializeFrom, 6 | } from "@vercel/remix"; 7 | import { json } from '@vercel/remix'; 8 | import { useLoaderData } from '@remix-run/react' 9 | 10 | import invariant from "tiny-invariant"; 11 | import { getContent } from "~/utils/blog.server"; 12 | import { CacheControl } from "~/utils/cache-control.server"; 13 | 14 | import { MarkdownView } from "~/components/Markdown"; 15 | import { parseMarkdown } from "~/utils/markdoc.server"; 16 | 17 | import { getSeo } from "~/seo"; 18 | export const meta: V2_MetaFunction = ({ data, matches }) => { 19 | if(!data) return []; 20 | 21 | const parentData = matches.flatMap((match) => match.data ?? [] ); 22 | 23 | return [ 24 | getSeo({ 25 | title: data.post.frontmatter.meta.title, 26 | description: data.post.frontmatter.meta.description, 27 | url: `${parentData[0].requestInfo.url}`, 28 | }), 29 | ] 30 | } 31 | 32 | export const loader = async ({params}: LoaderArgs) => { 33 | const files = await getContent(`pages/about`); 34 | let post = files && parseMarkdown(files[0].content); 35 | 36 | invariant(post, "Not found"); 37 | 38 | return json({post}, { 39 | headers: { 40 | "Cache-Control": new CacheControl("swr").toString(), 41 | } 42 | }) 43 | } 44 | 45 | export const headers: HeadersFunction = ({loaderHeaders}) => { 46 | return { 47 | 'Cache-Control': loaderHeaders.get('Cache-Control')! 48 | } 49 | } 50 | 51 | export default function BlogPost() { 52 | const {post} = useLoaderData(); 53 | 54 | return ( 55 |
56 |

{post.frontmatter.meta.title}

57 |
58 | {post.body && } 59 |
60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /app/routes/actions+/search.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@vercel/remix"; 2 | import { json } from "@vercel/remix"; 3 | import { getPosts, getDocs } from '~/utils/blog.server'; 4 | import {getDomainUrl} from '~/utils' 5 | 6 | export let loader = async ({ request }: LoaderArgs) => { 7 | const blogUrl = `${getDomainUrl(request)}` 8 | 9 | let url = new URL(request.url); 10 | let term = url.searchParams.get("term"); 11 | 12 | let [posts, docs] = await Promise.all([ 13 | getPosts(), 14 | getDocs(), 15 | ]); 16 | 17 | const postUrls = [...posts, ...docs].map( post => { 18 | let url = post.url.replace("//", "/"); 19 | return { 20 | url: `${blogUrl}${url}`, 21 | title: post.attributes.meta.title, 22 | body: post.body, 23 | type: url.includes('docs/') ? 'Docs' : 'Blog', 24 | } 25 | }) || [] 26 | 27 | // this function should query the DB or fetch an API to get the users 28 | const results = term ? postUrls.filter( 29 | post => post.title.toLowerCase().includes( String(term) ) || 30 | post.body.toLowerCase().includes( String(term) ) 31 | ) : [] 32 | 33 | return json({ results }); 34 | }; -------------------------------------------------------------------------------- /app/routes/actions+/set-theme.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@vercel/remix"; 2 | import { json, redirect } from "@vercel/remix"; 3 | 4 | import { getThemeSession } from "~/utils/theme.server"; 5 | import { isTheme } from "~/utils/theme-provider"; 6 | 7 | export const action: ActionFunction = async ({ request }) => { 8 | const themeSession = await getThemeSession(request); 9 | const requestText = await request.text(); 10 | const form = new URLSearchParams(requestText); 11 | const theme = form.get("theme"); 12 | 13 | if (!isTheme(theme)) { 14 | return json({ 15 | success: false, 16 | message: `theme value of ${theme} is not a valid theme`, 17 | }); 18 | } 19 | 20 | themeSession.setTheme(theme); 21 | return json( 22 | { success: true }, 23 | { headers: { "Set-Cookie": await themeSession.commit() } } 24 | ); 25 | }; 26 | 27 | export const loader: LoaderFunction = () => redirect("/", { status: 404 }); -------------------------------------------------------------------------------- /app/routes/blog+/$.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | LoaderArgs, 3 | V2_MetaFunction, 4 | HeadersFunction, 5 | } from "@vercel/remix"; 6 | import { json, redirect } from '@vercel/remix'; 7 | import { useLoaderData } from '@remix-run/react' 8 | import { parseISO, format } from 'date-fns'; 9 | import invariant from "tiny-invariant"; 10 | import { getContent } from "~/utils/blog.server"; 11 | import { CacheControl } from "~/utils/cache-control.server"; 12 | import { getSeo } from "~/seo"; 13 | 14 | import { MarkdownView } from "~/components/Markdown"; 15 | import { parseMarkdown } from "~/utils/markdoc.server"; 16 | 17 | export const loader = async ({params}: LoaderArgs) => { 18 | let path = params["*"]; 19 | 20 | invariant(path, "BlogPost: path is required"); 21 | 22 | //if (!path) return redirect("/blog"); 23 | if (!path) { 24 | throw new Error('path is not defined') 25 | } 26 | 27 | const files = await getContent(`posts/${path}`); 28 | 29 | let post = files && parseMarkdown(files[0].content); 30 | if (!post) { 31 | throw json({}, { 32 | status: 404, headers: {} 33 | }) 34 | } 35 | 36 | return json({post}, { 37 | headers: { 38 | "Cache-Control": new CacheControl("swr").toString() 39 | } 40 | }) 41 | } 42 | 43 | export const headers: HeadersFunction = ({loaderHeaders}) => { 44 | return { 45 | 'Cache-Control': loaderHeaders.get('Cache-Control')! 46 | } 47 | } 48 | 49 | export const meta: V2_MetaFunction = ({ data, matches }) => { 50 | if(!data) return []; 51 | 52 | const parentData = matches.flatMap((match) => match.data ?? [] ); 53 | 54 | return [ 55 | getSeo({ 56 | title: data.post.frontmatter.meta.title, 57 | description: data.post.frontmatter.meta.description, 58 | url: `${parentData[0].requestInfo.url}`, 59 | }), 60 | ] 61 | } 62 | 63 | export default function BlogPost() { 64 | const {post} = useLoaderData(); 65 | 66 | return ( 67 |
68 |
69 |

{post.frontmatter.meta.title}

70 |
71 |
72 | {post.frontmatter.date &&

73 | {post.frontmatter.date && format(parseISO(post.frontmatter.date), 'MMMM dd, yyyy')} 74 |

} 75 |
76 |

77 | {post.readTime.text} 78 |

79 |
80 |
81 | {post.body && } 82 |
83 |
84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /app/routes/blog+/index.tsx: -------------------------------------------------------------------------------- 1 | import { V2_MetaFunction, json, LoaderArgs, } from '@vercel/remix'; 2 | import { Link, useLoaderData } from "@remix-run/react"; 3 | import { BlogPost as BlogPostType } from '~/types'; 4 | import { getPosts } from '~/utils/blog.server'; 5 | import BlogPost from '~/components/BlogPost'; 6 | import { CacheControl } from "~/utils/cache-control.server"; 7 | import { getSeo } from "~/seo"; 8 | 9 | export const meta: V2_MetaFunction = ({ data, matches }) => { 10 | if(!data) return []; 11 | 12 | const parentData = matches.flatMap((match) => match.data ?? [] ); 13 | 14 | return [ 15 | getSeo({ 16 | title: 'Blog', 17 | description: 'Blog', 18 | url: `${parentData[0].requestInfo.url}`, 19 | }), 20 | ] 21 | } 22 | 23 | export let loader = async function({}: LoaderArgs) { 24 | return json({ 25 | blogPosts: await getPosts(), 26 | }, { 27 | headers: { 28 | "Cache-Control": new CacheControl("swr").toString(), 29 | } 30 | }); 31 | } 32 | 33 | 34 | export default function Index() { 35 | const data = useLoaderData(); 36 | 37 | return ( 38 |
39 |

40 | Blog 41 |

42 |

43 |

44 | {data.blogPosts.map(post => ( 45 | 50 | ))} 51 |
52 | ); 53 | } -------------------------------------------------------------------------------- /app/routes/docs+/$.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | LoaderArgs, 3 | V2_MetaFunction, 4 | HeadersFunction, 5 | SerializeFrom, 6 | } from "@vercel/remix"; 7 | import { json, redirect } from '@vercel/remix'; 8 | import { useLoaderData } from '@remix-run/react' 9 | import { parseISO, format } from 'date-fns'; 10 | 11 | import invariant from "tiny-invariant"; 12 | 13 | import { MarkdownView } from "~/components/Markdown"; 14 | import { parseMarkdown } from "~/utils/markdoc.server"; 15 | 16 | import { getContent } from "~/utils/blog.server"; 17 | import { CacheControl } from "~/utils/cache-control.server"; 18 | import { getSeo } from "~/seo"; 19 | 20 | export const loader = async ({params}: LoaderArgs) => { 21 | let path = params["*"]; 22 | 23 | invariant(path, "BlogPost: path is required"); 24 | 25 | if (!path) { 26 | throw new Error('path is not defined') 27 | } 28 | 29 | const files = await getContent(`docs/${path}`); 30 | let post = files && parseMarkdown(files[0].content); 31 | 32 | //invariant(post, "Not found"); 33 | if (!post) { 34 | throw json({}, { 35 | status: 404, headers: {} 36 | }) 37 | } 38 | 39 | return json({post}, { 40 | headers: { 41 | "Cache-Control": new CacheControl("swr").toString() 42 | } 43 | }) 44 | } 45 | 46 | export const headers: HeadersFunction = ({loaderHeaders}) => { 47 | return { 48 | 'Cache-Control': loaderHeaders.get('Cache-Control')! 49 | } 50 | } 51 | 52 | export const meta: V2_MetaFunction = ({ data, matches }) => { 53 | if(!data) return []; 54 | 55 | const parentData = matches.flatMap((match) => match.data ?? [] ); 56 | 57 | return [ 58 | getSeo({ 59 | title: data.post.frontmatter.meta.title, 60 | description: data.post.frontmatter.meta.description, 61 | url: `${parentData[0].requestInfo.url}`, 62 | }), 63 | ] 64 | } 65 | 66 | export default function BlogPost() { 67 | const {post} = useLoaderData(); 68 | 69 | return ( 70 |
71 |
72 |

{post.frontmatter.meta.title}

73 |
74 |
75 | {post.frontmatter.date &&

76 | {'Created: '} 77 | {post.frontmatter.date && format(parseISO(post.frontmatter.date), 'MMMM dd, yyyy')} 78 |

} 79 | {post.frontmatter.updated &&

80 | {'Last updated: '} 81 | {post.frontmatter.updated && format(parseISO(post.frontmatter.updated), 'MMMM dd, yyyy')} 82 |

} 83 |
84 |

85 | {post.readTime.text} 86 |

87 |
88 |
89 | {post.body && } 90 |
91 |
92 |
93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /app/routes/docs+/index.tsx: -------------------------------------------------------------------------------- 1 | import { V2_MetaFunction, json, LoaderArgs, SerializeFrom} from '@vercel/remix'; 2 | import { Link, useLoaderData } from "@remix-run/react"; 3 | import { BlogPost as BlogPostType } from '~/types'; 4 | import { getContent } from '~/utils/blog.server'; 5 | import BlogPost from '~/components/BlogPost'; 6 | import { CacheControl } from "~/utils/cache-control.server"; 7 | import { getSeo } from "~/seo"; 8 | 9 | import { MarkdownView } from "~/components/Markdown"; 10 | import { parseMarkdown } from "~/utils/markdoc.server"; 11 | 12 | export const meta: V2_MetaFunction = ({ data, matches }) => { 13 | if(!data) return []; 14 | 15 | const parentData = matches.flatMap((match) => match.data ?? [] ); 16 | 17 | return [ 18 | getSeo({ 19 | title: 'Docs', 20 | description: 'Docs', 21 | url: `${parentData[0].requestInfo.url}`, 22 | }), 23 | ] 24 | } 25 | 26 | export let loader = async function({}: LoaderArgs) { 27 | const files = await getContent(`docs/index`); 28 | let post = files && parseMarkdown(files[0].content); 29 | 30 | return json({ 31 | post 32 | }, { 33 | headers: { 34 | "Cache-Control": new CacheControl("swr").toString(), 35 | } 36 | }); 37 | } 38 | 39 | export default function Index() { 40 | const {post} = useLoaderData(); 41 | 42 | return ( 43 |
44 |

{post.frontmatter.meta.title}

45 |
46 | {post.body && } 47 |
48 |
49 | ) 50 | } -------------------------------------------------------------------------------- /app/routes/healthcheck.ts: -------------------------------------------------------------------------------- 1 | export async function loader() { 2 | return new Response("OK", { status: 200 }); 3 | } -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | LoaderArgs, 3 | V2_MetaFunction, 4 | HeadersFunction, 5 | } from "@vercel/remix"; 6 | import { json } from '@vercel/remix'; 7 | import { useLoaderData } from '@remix-run/react' 8 | 9 | import invariant from "tiny-invariant"; 10 | import { getContent } from "~/utils/blog.server"; 11 | import { CacheControl } from "~/utils/cache-control.server"; 12 | import { getSeo } from "~/seo"; 13 | 14 | import { MarkdownView } from "~/components/Markdown"; 15 | import { parseMarkdown } from "~/utils/markdoc.server"; 16 | 17 | export const loader = async ({params}: LoaderArgs) => { 18 | const files = await getContent(`pages/index`); 19 | let post = files && parseMarkdown(files[0].content); 20 | 21 | invariant(post, "Not found"); 22 | 23 | return json({post}, { 24 | headers: { 25 | "Cache-Control": new CacheControl("swr").toString(), 26 | } 27 | }) 28 | } 29 | 30 | export const headers: HeadersFunction = ({loaderHeaders}) => { 31 | return { 32 | 'Cache-Control': loaderHeaders.get('Cache-Control')! 33 | } 34 | } 35 | 36 | export const meta: V2_MetaFunction = ({ data, matches }) => { 37 | if(!data) return []; 38 | 39 | const parentData = matches.flatMap((match) => match.data ?? [] ); 40 | 41 | return [ 42 | getSeo({ 43 | title: data.post.frontmatter.meta.title, 44 | description: data.post.frontmatter.meta.description, 45 | url: `${parentData[0].requestInfo.url}`, 46 | }), 47 | ] 48 | } 49 | 50 | export default function BlogPost() { 51 | const {post} = useLoaderData(); 52 | 53 | return ( 54 |
55 |

{post.frontmatter.meta.title}

56 |
57 | {post.body && } 58 |
59 |
60 | ) 61 | } -------------------------------------------------------------------------------- /app/routes/rss[.]xml.ts: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@vercel/remix' 2 | import * as dateFns from 'date-fns' 3 | import {getDomainUrl} from '~/utils' 4 | import { getPosts } from '~/utils/blog.server'; 5 | import { CacheControl } from "~/utils/cache-control.server"; 6 | 7 | const SITENAME='' 8 | const SITEDESCRIPTION='' 9 | 10 | export const loader: LoaderFunction = async ({request}) => { 11 | const blogUrl = `${getDomainUrl(request)}` 12 | 13 | const posts = await getPosts() 14 | 15 | const rss = ` 16 | 17 | 18 | ${SITENAME} 19 | ${blogUrl} 20 | ${SITEDESCRIPTION} 21 | en-us 22 | Remix 23 | 40 24 | ${posts 25 | .map(post => 26 | ` 27 | 28 | ${cdata(post.attributes.meta.title ?? 'Untitled Post')} 29 | ${cdata( 30 | post.attributes.excerpt ?? 'This post is... indescribable', 31 | )} 32 | ${dateFns.format( 33 | dateFns.add( 34 | post.attributes.date 35 | ? dateFns.parseISO(post.attributes.date) 36 | : Date.now(), 37 | {minutes: new Date().getTimezoneOffset()}, 38 | ), 39 | 'yyyy-MM-ii', 40 | )} 41 | ${blogUrl}${post.url} 42 | ${blogUrl}${post.url} 43 | 44 | `.trim(), 45 | ) 46 | .join('\n')} 47 | 48 | 49 | `.trim() 50 | 51 | return new Response(rss, { 52 | headers: { 53 | 'Content-Type': 'application/xml', 54 | "Cache-Control": new CacheControl("swr").toString(), 55 | 'Content-Length': String(Buffer.byteLength(rss)), 56 | }, 57 | }) 58 | } 59 | 60 | function cdata(s: string) { 61 | return `` 62 | } -------------------------------------------------------------------------------- /app/routes/sitemap[.]xml.ts: -------------------------------------------------------------------------------- 1 | import type {LoaderFunction} from '@vercel/remix' 2 | import * as dateFns from 'date-fns' 3 | import {getDomainUrl} from '~/utils' 4 | import { getPosts, getPages, getDocs } from '~/utils/blog.server'; 5 | import { createSitemap } from '~/utils/sitemap.server'; 6 | import { CacheControl } from "~/utils/cache-control.server"; 7 | 8 | export const loader = async ({request}) => { 9 | const blogUrl = `${getDomainUrl(request)}` 10 | let [pageUrls, pages, posts, docs] = await Promise.all([ 11 | [ 12 | '/', 13 | '/blog', 14 | '/docs', 15 | ].map((url) => ({ url: `${blogUrl}${url}` })), 16 | getPages(), 17 | getPosts(), 18 | getDocs(), 19 | ]); 20 | 21 | const postUrls = [...pages, ...posts, ...docs].map( post => { 22 | return {url: `${blogUrl}${post.url}`} 23 | }) || [] 24 | 25 | const urls = [...pageUrls, ...postUrls]; 26 | const sitemap = createSitemap(urls); 27 | return new Response(sitemap, { 28 | headers: { 29 | 'Content-Type': 'application/xml', 30 | "Cache-Control": new CacheControl("swr").toString() 31 | }, 32 | }); 33 | }; -------------------------------------------------------------------------------- /app/seo.ts: -------------------------------------------------------------------------------- 1 | import config from '~/docs.config'; 2 | 3 | export const getSeo = ({title, url, description, ogimage}: {title: string, url?: string, description?: string, ogimage?: string}) => { 4 | 5 | let seoDescription = description || config.description 6 | let seoKeywords = config.title; 7 | 8 | return [ 9 | { title: `${title} | ${config.title}` }, 10 | { name: "description", content: seoDescription }, 11 | { name: "keywords", content: seoKeywords }, 12 | { property: "og:title", content: `${title} | ${config.title}` }, 13 | { property: "og:description", content: seoDescription }, 14 | { property: "twitter::title", content: `${title} | ${config.title}` }, 15 | { property: "twitter::description", content: seoDescription }, 16 | { name: "robots", content: "index,follow" }, 17 | { name: "googlebot", content: "index,follow" }, 18 | ]; 19 | } 20 | 21 | -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | ::selection { 6 | background-color: #47a3f3; 7 | color: #fefefe; 8 | } 9 | 10 | html { 11 | min-width: 360px; 12 | scroll-behavior: smooth; 13 | @apply bg-blue-50 dark:bg-gray-900 h-full; 14 | display: flex; 15 | flex-direction: column; 16 | min-height: 100vh; 17 | } 18 | 19 | html.light { 20 | @apply bg-blue-50; 21 | } 22 | 23 | html.dark { 24 | @apply bg-gray-900; 25 | } 26 | 27 | /* https://seek-oss.github.io/capsize/ */ 28 | .capsize::before { 29 | content: ''; 30 | margin-bottom: -0.098em; 31 | display: table; 32 | } 33 | 34 | .capsize::after { 35 | content: ''; 36 | margin-top: -0.219em; 37 | display: table; 38 | } 39 | 40 | p { 41 | @apply leading-loose; 42 | } 43 | /* 44 | html { 45 | --background-colour: white; 46 | 47 | background-color: var(--background-colour); 48 | } 49 | 50 | html.dark { 51 | --background-colour: black; 52 | } 53 | 54 | .dark-component { 55 | color: white; 56 | } 57 | 58 | .light-component { 59 | color: black; 60 | } 61 | */ 62 | .skip-nav { 63 | @apply absolute px-4 py-3 transition-transform duration-200 transform -translate-y-12 -left-1/4 focus:top-4 focus:translate-y-3 -top-8; 64 | } 65 | 66 | #skip { 67 | scroll-margin-top: 1.125rem; 68 | } 69 | /* 70 | text-gray-800 dark:text-gray-100 71 | */ 72 | pre { 73 | @apply w-full max-w-full overflow-auto; 74 | } 75 | 76 | p { 77 | @apply my-2; 78 | } 79 | 80 | ul, li { 81 | @apply my-2; 82 | } 83 | 84 | h1 { 85 | @apply my-2 text-3xl font-bold tracking-tight text-black md:text-5xl dark:text-white; 86 | } 87 | 88 | h2 { 89 | @apply my-2 text-2xl font-bold tracking-tight text-black md:text-2xl dark:text-white; 90 | } 91 | 92 | h3 { 93 | @apply my-2 text-xl font-bold tracking-tight text-black md:text-xl dark:text-white; 94 | } 95 | 96 | h4 { 97 | @apply my-2 text-lg font-bold tracking-tight text-black md:text-lg dark:text-white; 98 | } 99 | 100 | h5 { 101 | @apply my-2 text-lg font-medium tracking-tight text-black md:text-lg dark:text-white; 102 | } 103 | 104 | h6 { 105 | @apply my-2 text-lg tracking-tight text-black md:text-lg dark:text-white; 106 | } 107 | 108 | @supports not (backdrop-filter: none) { 109 | .sticky-nav { 110 | backdrop-filter: none; 111 | @apply bg-opacity-100; 112 | } 113 | } 114 | 115 | .prose .anchor { 116 | @apply absolute invisible no-underline; 117 | margin-left: -1em; 118 | padding-right: 0.5em; 119 | width: 80%; 120 | max-width: 700px; 121 | cursor: pointer; 122 | } 123 | 124 | .prose blockquote { 125 | @apply p-4 italic border-l-4 text-gray-600 border-gray-500 dark:border-gray-200; 126 | } 127 | 128 | .prose p { 129 | @apply mb-2; 130 | } 131 | 132 | .anchor:hover { 133 | @apply visible; 134 | } 135 | 136 | .prose a { 137 | @apply transition-all text-blue-500; 138 | } 139 | 140 | .prose .anchor:after { 141 | @apply text-gray-300 dark:text-gray-700; 142 | content: '#'; 143 | } 144 | 145 | .prose *:hover > .anchor { 146 | @apply visible; 147 | } 148 | 149 | .prose img { 150 | /* Don't apply styles to next/image */ 151 | @apply m-0; 152 | } 153 | 154 | .prose > :first-child { 155 | /* Override removing top margin, causing layout shift */ 156 | margin-top: 1.25em !important; 157 | margin-bottom: 1.25em !important; 158 | } 159 | 160 | .rehype-code-title { 161 | @apply px-5 py-3 font-mono text-sm font-bold text-gray-800 bg-gray-200 border border-b-0 border-gray-200 rounded-t-lg dark:text-gray-200 dark:border-gray-700 dark:bg-gray-800; 162 | } 163 | 164 | .rehype-code-title + pre { 165 | @apply mt-0 rounded-t-none; 166 | } 167 | 168 | .highlight-line { 169 | @apply block px-4 -mx-4 bg-gray-100 border-l-4 border-blue-500 dark:bg-gray-800; 170 | } 171 | 172 | 173 | /* 174 | 175 | 176 | */ 177 | 178 | .prose callout-muted a, 179 | .prose callout-info a, 180 | .prose callout-warning a, 181 | .prose callout-danger a, 182 | .prose callout-success a { 183 | text-decoration: underline; 184 | } 185 | 186 | .prose callout-muted p, 187 | .prose callout-info p, 188 | .prose callout-warning p, 189 | .prose callout-danger p, 190 | .prose callout-success p { 191 | margin-bottom: 0; 192 | } 193 | 194 | .prose callout-muted, 195 | .prose callout-info, 196 | .prose callout-warning, 197 | .prose callout-danger, 198 | .prose callout-success { 199 | width: 100%; 200 | margin-top: 0; 201 | margin-bottom: 2rem; 202 | } 203 | 204 | .prose callout-muted, 205 | .prose callout-info, 206 | .prose callout-warning, 207 | .prose callout-danger, 208 | .prose callout-success { 209 | display: block; 210 | border-left: solid 4px; 211 | padding: 0.5rem 1rem; 212 | position: relative; 213 | border-top-right-radius: 0.5rem; 214 | border-bottom-right-radius: 0.5rem; 215 | } 216 | 217 | .prose callout-muted, 218 | .prose callout-info, 219 | .prose callout-warning, 220 | .prose callout-danger, 221 | .prose callout-success, 222 | .prose callout-muted *, 223 | .prose callout-info *, 224 | .prose callout-warning *, 225 | .prose callout-danger *, 226 | .prose callout-success * { 227 | @apply text-lg; 228 | /*font-size: 1.2rem;*/ 229 | } 230 | 231 | .prose callout-muted.aside, 232 | .prose callout-info.aside, 233 | .prose callout-warning.aside, 234 | .prose callout-danger.aside, 235 | .prose callout-success.aside, 236 | .prose callout-muted.aside *, 237 | .prose callout-info.aside *, 238 | .prose callout-warning.aside *, 239 | .prose callout-danger.aside *, 240 | .prose callout-success.aside * { 241 | @apply text-base; 242 | /*font-size: 0.875rem;*/ 243 | } 244 | 245 | .prose callout-muted.important, 246 | .prose callout-info.important, 247 | .prose callout-warning.important, 248 | .prose callout-danger.important, 249 | .prose callout-success.important, 250 | .prose callout-muted.important *, 251 | .prose callout-info.important *, 252 | .prose callout-warning.important *, 253 | .prose callout-danger.important *, 254 | .prose callout-success.important * { 255 | @apply text-lg; 256 | 257 | /* 258 | font-size: 1.4rem; 259 | font-weight: bold; 260 | */ 261 | } 262 | 263 | .prose callout-muted:before, 264 | .prose callout-info:before, 265 | .prose callout-warning:before, 266 | .prose callout-danger:before, 267 | .prose callout-success:before { 268 | border-top-right-radius: 0.5rem; 269 | border-bottom-right-radius: 0.5rem; 270 | content: ''; 271 | position: absolute; 272 | inset: 0; 273 | opacity: 0.1; 274 | pointer-events: none; 275 | } 276 | 277 | /* the warning yellow is really inaccessible in light mode, so we have a special case for light mode */ 278 | .light .prose callout-warning, 279 | .light .prose callout-warning ol > li:before { 280 | color: #676000; 281 | } 282 | .light .prose callout-warning:before { 283 | background: #ffd800; 284 | } 285 | .prose callout-warning, 286 | .prose callout-warning ol > li:before { 287 | color: #ffd644; 288 | } 289 | .prose callout-warning:before { 290 | background: #ffd644; 291 | } 292 | 293 | /* the muted gray is really inaccessible in light mode, so we have a special case for light mode */ 294 | .light .prose callout-muted, 295 | .light .prose callout-muted ol > li:before { 296 | color: #4c4b5e; 297 | } 298 | .light .prose callout-muted:before { 299 | background: #3c3e4d; 300 | } 301 | 302 | .prose callout-muted, 303 | .prose callout-muted ol > li:before { 304 | color: #b9b9c3; 305 | } 306 | .prose callout-muted:before { 307 | background: #3c3e4d; 308 | } 309 | 310 | .prose callout-info, 311 | .prose callout-info ol > li:before { 312 | color: #4b96ff; 313 | } 314 | .prose callout-info:before { 315 | background: #4b96ff; 316 | } 317 | 318 | .prose callout-danger, 319 | .prose callout-danger ol > li:before { 320 | color: #ff4545; 321 | } 322 | .prose callout-danger:before { 323 | background: #ff4545; 324 | } 325 | 326 | .prose callout-success, 327 | .prose callout-success ol > li:before { 328 | color: #30c85e; 329 | } 330 | .prose callout-success:before { 331 | background: #30c85e; 332 | } 333 | 334 | /* Remove Safari input shadow on mobile */ 335 | input[type='text'], 336 | input[type='email'] { 337 | -webkit-appearance: none; 338 | -moz-appearance: none; 339 | appearance: none; 340 | } 341 | 342 | .metric-card > a { 343 | @apply no-underline; 344 | } 345 | 346 | .metric-card > p { 347 | @apply my-2; 348 | } 349 | 350 | .step > h3 { 351 | @apply my-0; 352 | } 353 | 354 | .prose .tweet a { 355 | text-decoration: inherit; 356 | font-weight: inherit; 357 | } 358 | 359 | .prose table { 360 | display: block; 361 | max-width: fit-content; 362 | overflow-x: auto; 363 | white-space: nowrap; 364 | } 365 | 366 | .prose .callout > p { 367 | margin: 0 !important; 368 | } 369 | 370 | pre[class*='language-'] { 371 | color: theme('colors.slate.50'); 372 | } 373 | 374 | .token.tag, 375 | .token.class-name, 376 | .token.selector, 377 | .token.selector .class, 378 | .token.selector.class, 379 | .token.function { 380 | color: theme('colors.pink.400'); 381 | } 382 | 383 | .token.attr-name, 384 | .token.keyword, 385 | .token.rule, 386 | .token.pseudo-class, 387 | .token.important { 388 | color: theme('colors.slate.300'); 389 | } 390 | 391 | .token.module { 392 | color: theme('colors.pink.400'); 393 | } 394 | 395 | .token.attr-value, 396 | .token.class, 397 | .token.string, 398 | .token.property { 399 | color: theme('colors.sky.300'); 400 | } 401 | 402 | .token.punctuation, 403 | .token.attr-equals { 404 | color: theme('colors.slate.500'); 405 | } 406 | 407 | .token.unit, 408 | .language-css .token.function { 409 | color: theme('colors.teal.200'); 410 | } 411 | 412 | .token.comment, 413 | .token.operator, 414 | .token.combinator { 415 | color: theme('colors.slate.400'); 416 | } -------------------------------------------------------------------------------- /app/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | */ 4 | 5 | export type BlogPost = { 6 | attributes: BlogPostAttributes 7 | body: string 8 | url: string 9 | } 10 | 11 | export type BlogPostAttributes = { 12 | meta: { 13 | [key: string]: string 14 | } 15 | headers: { 16 | [key: string]: string 17 | } 18 | date: string 19 | hero: string 20 | excerpt: string 21 | } 22 | 23 | export type FrontMatterBlogPost = { 24 | attributes: BlogPostAttributes 25 | body: string 26 | } 27 | 28 | export type NonNullProperties = { 29 | [Key in keyof Type]-?: Exclude 30 | } 31 | 32 | export type SitemapEntry = { 33 | route: string 34 | lastmod?: string 35 | changefreq?: 36 | | 'always' 37 | | 'hourly' 38 | | 'daily' 39 | | 'weekly' 40 | | 'monthly' 41 | | 'yearly' 42 | | 'never' 43 | priority?: 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0 44 | } 45 | 46 | export type Handle = { 47 | /** this just allows us to identify routes more directly rather than relying on pathnames */ 48 | id?: string 49 | /** this is here to allow us to disable scroll restoration until Remix gives us better control */ 50 | restoreScroll?: false 51 | getSitemapEntries?: ( 52 | request: Request, 53 | ) => 54 | | Promise | null> 55 | | Array 56 | | null 57 | } 58 | -------------------------------------------------------------------------------- /app/utils/blog.server.ts: -------------------------------------------------------------------------------- 1 | import { BlogPost } from "~/types"; 2 | import { GitHubFile, MdxFile } from '~/utils/mdx.server'; 3 | import { envSchema } from "~/env.server"; 4 | 5 | import { getLocalFile, getLocalContent } from "./fs.server"; 6 | import { downloadFile, downloadMdxFileOrDirectory } from "./github.server"; 7 | 8 | let env = envSchema.parse(process.env); 9 | 10 | const contentPath = 'content' 11 | 12 | export const getCacheFile = async (file: string): Promise => { 13 | if( env.USE_FILESYSTEM_OR_GITHUB === 'fs' ) { 14 | return JSON.parse(await getLocalFile(file) ); 15 | } 16 | const data = await JSON.parse(await downloadFile(`content/${file}`)); 17 | return data || []; 18 | } 19 | 20 | export const getPosts = async (): Promise => { 21 | return getCacheFile('blog-cache.json') 22 | } 23 | 24 | export const getDocs = async (): Promise => { 25 | return getCacheFile('docs-cache.json') 26 | } 27 | 28 | export const getPages = async (): Promise => { 29 | return getCacheFile('page-cache.json') 30 | } 31 | 32 | export const getFile = async (path: string): Promise => { 33 | if( env.USE_FILESYSTEM_OR_GITHUB === 'fs' ) { 34 | return getLocalFile(path); 35 | } else if( env.USE_FILESYSTEM_OR_GITHUB === 'gh' ) { 36 | return downloadFile(path) 37 | } 38 | return ''; 39 | } 40 | 41 | export const getContent = async (path: string) => { 42 | try { 43 | if( env.USE_FILESYSTEM_OR_GITHUB === 'fs' ) { 44 | return getLocalContent(path); 45 | } else if( env.USE_FILESYSTEM_OR_GITHUB === 'gh' ) { 46 | const files = await downloadMdxFileOrDirectory(path).then((post) => post.files); 47 | return files; 48 | } 49 | return []; 50 | } catch (error: any) { 51 | if (error.code?.includes('ENOENT')) { 52 | throw new Error('Not found') 53 | } 54 | throw error; 55 | } 56 | } -------------------------------------------------------------------------------- /app/utils/cache-control.server.ts: -------------------------------------------------------------------------------- 1 | type CacheStrategy = "prevent" | "swr" | "forever" | "require revalidation"; 2 | 3 | type Cacheability = "public" | "private" | "no-cache" | "no-store"; 4 | 5 | let SECOND_PER_YEAR = 3.154e7; 6 | 7 | 8 | /* 9 | import { CacheControl } from "~/utils/cache-control"; 10 | { headers: { "Cache-Control": new CacheControl("swr").toString() } } 11 | */ 12 | 13 | 14 | export class CacheControl { 15 | public cacheability: Cacheability = "public"; 16 | 17 | public maxAge?: number; 18 | public sMaxAge?: number; 19 | public maxStale?: number; 20 | public minFresh?: number; 21 | public staleWhileRevalidate?: number; 22 | public staleIfError?: number; 23 | public mustRevalidate?: boolean; 24 | public proxyRevalidate?: boolean; 25 | public immutable?: boolean; 26 | public noTransform?: boolean; 27 | public onlyIfCached?: boolean; 28 | 29 | constructor(readonly strategy?: CacheStrategy) { 30 | if (strategy === "prevent") { 31 | this.cacheability = "no-cache"; 32 | this.maxAge = 0; 33 | } 34 | if (strategy === "swr") { 35 | this.sMaxAge = 1; 36 | this.staleWhileRevalidate = SECOND_PER_YEAR; 37 | } 38 | if (strategy === "forever") { 39 | this.cacheability = "public"; 40 | this.maxAge = SECOND_PER_YEAR; 41 | this.immutable = true; 42 | } 43 | if (strategy === "require revalidation") { 44 | this.maxAge = 0; 45 | this.mustRevalidate = true; 46 | } 47 | } 48 | 49 | toString() { 50 | let result:string[] = []; 51 | 52 | if (this.cacheability) result.push(this.cacheability); 53 | 54 | if (this.maxAge !== undefined) result.push(`max-age=${this.maxAge}`); 55 | if (this.sMaxAge !== undefined) result.push(`s-maxage=${this.sMaxAge}`); 56 | if (this.maxStale !== undefined) result.push(`max-stale=${this.maxStale}`); 57 | if (this.minFresh !== undefined) result.push(`min-fresh=${this.minFresh}`); 58 | if (this.staleWhileRevalidate !== undefined) { 59 | result.push(`stale-while-revalidate=${this.staleWhileRevalidate}`); 60 | } 61 | if (this.staleIfError !== undefined) { 62 | result.push(`stale-if-error=${this.staleIfError}`); 63 | } 64 | 65 | if (this.mustRevalidate) result.push("must-revalidate"); 66 | if (this.proxyRevalidate) result.push("proxy-revalidate"); 67 | if (this.immutable) result.push("immutable"); 68 | if (this.noTransform) result.push("no-transform"); 69 | if (this.onlyIfCached) result.push("only-if-cached"); 70 | 71 | return result.join(", "); 72 | } 73 | 74 | toJSON() { 75 | return this.toString(); 76 | } 77 | } -------------------------------------------------------------------------------- /app/utils/fs.server.ts: -------------------------------------------------------------------------------- 1 | import { statSync, existsSync, readdirSync, readFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import fs from 'fs/promises'; 4 | import fm from 'front-matter'; 5 | import { GitHubFile } from '~/utils/mdx.server'; 6 | 7 | const contentPath = 'content' 8 | const blogBasePath = join(process.cwd(), 'content'); 9 | 10 | export const getLocalFile = async (path: string): Promise => { 11 | const jsonDirectory = __dirname + "/../" + contentPath; 12 | const data = await fs.readFile(`${jsonDirectory}/${path}`, "utf8"); 13 | return data.toString(); 14 | } 15 | 16 | export const getLocalContent = async (path: string) => { 17 | try { 18 | const mdxPath = __dirname + "/../" + contentPath; 19 | // if it's a file then load the file 20 | if ( existsSync( join(mdxPath, `${path}.mdx`) ) ){ 21 | const data = readFileSync(join(mdxPath, `${path}.mdx`), { encoding: 'utf-8'}); 22 | return [{ 23 | path: `${path}.mdx`, 24 | content: data.toString(), 25 | }] 26 | // otherwise... if it's a directory then we load the directory with index.mdx 27 | } else if ( statSync( join(mdxPath, path) ).isDirectory() ) { 28 | if (path.slice(-1) != '/') path += '/'; 29 | const file = path + "index.mdx"; 30 | const data = readFileSync( join(mdxPath, file), { encoding: 'utf-8'}); 31 | return [{ 32 | path: file, 33 | content: data.toString(), 34 | }] 35 | } 36 | } catch (error: any) { 37 | if (error.code?.includes('ENOENT')) { 38 | throw new Error('Not found') 39 | } 40 | 41 | throw error; 42 | } 43 | } 44 | 45 | export function loadMdxSingle(filepath) { 46 | const relativeFilePath = filepath.replace(/^\/blog\//, '').replace(/\/$/, ''); 47 | const fileContents = readFileSync( join(blogBasePath, `${relativeFilePath}.mdx`), {encoding: 'utf-8'} ); 48 | 49 | const { attributes } = fm(fileContents); 50 | 51 | return attributes; 52 | } 53 | 54 | export function loadMdx() { 55 | const dirEntries = readdirSync(blogBasePath, { withFileTypes: true }); 56 | const dirs = dirEntries.filter((entry) => entry.isDirectory()); 57 | const files = dirEntries.filter((entry) => entry.isFile()); 58 | 59 | const subFiles = dirs 60 | .map((dir) => { 61 | const subDirEntries = readdirSync(join(blogBasePath, dir.name), { 62 | withFileTypes: true, 63 | }) 64 | .filter((e) => e.isFile()) 65 | .map((e) => ({ name: join(dir.name, e.name) })); 66 | 67 | return subDirEntries; 68 | }) 69 | .flat(); 70 | 71 | const entries = [...files, ...subFiles].map((entry) => { 72 | if (entry.name === 'index.jsx') { 73 | return; 74 | } 75 | 76 | const fileContents = readFileSync(join(blogBasePath, entry.name), { 77 | encoding: 'utf-8', 78 | }); 79 | 80 | const { attributes } = fm(fileContents); 81 | 82 | return { 83 | date: attributes.date, 84 | slug: entry.name.replace('.mdx', ''), 85 | title: attributes.meta.title, 86 | description: attributes.meta.description, 87 | }; 88 | }); 89 | 90 | return entries.filter(Boolean).sort((a, b) => b.date - a.date); 91 | } -------------------------------------------------------------------------------- /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 { GitHubFile } from './mdx.server' 5 | 6 | import { envSchema } from "~/env.server"; 7 | 8 | let env = envSchema.parse(process.env); 9 | 10 | const Octokit = createOctokit.plugin(throttling) 11 | 12 | type ThrottleOptions = { 13 | method: string 14 | url: string 15 | request: { retryCount: number } 16 | } 17 | 18 | const octokit = new Octokit({ 19 | auth: env.BOT_GITHUB_TOKEN, 20 | throttle: { 21 | onRateLimit: (retryAfter: number, options: any) => { 22 | octokit.log.warn( 23 | `Request quota exhausted for request ${options.method} ${options.url}`, 24 | ); 25 | 26 | // Retry twice after hitting a rate limit error, then give up 27 | if (options.request.retryCount <= 2) { 28 | console.log(`Retrying after ${retryAfter} seconds!`); 29 | return true; 30 | } 31 | }, 32 | onSecondaryRateLimit: (retryAfter: number, options: any, octokit: any) => { 33 | // does not retry, only logs a warning 34 | octokit.log.warn( 35 | `Secondary quota detected for request ${options.method} ${options.url}`, 36 | ); 37 | }, 38 | }, 39 | }) 40 | 41 | async function downloadFirstMdxFile( 42 | list: Array<{ name: string; type: string; path: string; sha: string }>, 43 | ) { 44 | const filesOnly = list.filter(({ type }) => type === 'file') 45 | for (const extension of ['.mdx', '.md']) { 46 | const file = filesOnly.find(({ name }) => name.endsWith(extension)) 47 | if (file) return downloadFileBySha(file.sha) 48 | } 49 | return null 50 | } 51 | 52 | async function downloadMdxFileOrDirectory( 53 | relativeMdxFileOrDirectory: string, 54 | ): Promise<{ entry: string; files: Array }> { 55 | const mdxFileOrDirectory = `content/${relativeMdxFileOrDirectory}` 56 | 57 | const parentDir = nodePath.dirname(mdxFileOrDirectory) 58 | const dirList = await downloadDirList(parentDir) 59 | 60 | const basename = nodePath.basename(mdxFileOrDirectory) 61 | const mdxFileWithoutExt = nodePath.parse(mdxFileOrDirectory).name 62 | const potentials = dirList.filter(({ name }) => name.startsWith(basename)) 63 | const exactMatch = potentials.find( 64 | ({ name }) => nodePath.parse(name).name === mdxFileWithoutExt, 65 | ) 66 | const dirPotential = potentials.find(({ type }) => type === 'dir') 67 | 68 | const content = await downloadFirstMdxFile( 69 | exactMatch ? [exactMatch] : potentials, 70 | ) 71 | let files: Array = [] 72 | let entry = mdxFileOrDirectory 73 | if (content) { 74 | entry = mdxFileOrDirectory.endsWith('.mdx') 75 | ? mdxFileOrDirectory 76 | : `${mdxFileOrDirectory}.mdx` 77 | files = [{ path: nodePath.join(mdxFileOrDirectory, 'index.mdx'), content }] 78 | } else if (dirPotential) { 79 | entry = dirPotential.path 80 | files = await downloadDirectory(mdxFileOrDirectory) 81 | } 82 | 83 | return { entry, files } 84 | } 85 | 86 | async function downloadDirectory(dir: string): Promise> { 87 | const dirList = await downloadDirList(dir) 88 | 89 | const result = await Promise.all( 90 | dirList.map(async ({ path: fileDir, type, sha }) => { 91 | switch (type) { 92 | case 'file': { 93 | const content = await downloadFileBySha(sha) 94 | return { path: fileDir, content } 95 | } 96 | case 'dir': { 97 | return downloadDirectory(fileDir) 98 | } 99 | default: { 100 | throw new Error(`Unexpected repo file type: ${type}`) 101 | } 102 | } 103 | }), 104 | ) 105 | 106 | return result.flat() 107 | } 108 | 109 | async function downloadFileBySha(sha: string) { 110 | const { data } = await octokit.request( 111 | 'GET /repos/{owner}/{repo}/git/blobs/{file_sha}', 112 | { 113 | owner: env.GITHUB_OWNER, 114 | repo: env.GITHUB_REPO, 115 | file_sha: sha, 116 | }, 117 | ) 118 | const encoding = data.encoding as Parameters['1'] 119 | return Buffer.from(data.content, encoding).toString() 120 | } 121 | 122 | async function downloadFile(path: string) { 123 | const { data } = (await octokit.request( 124 | 'GET /repos/{owner}/{repo}/contents/{path}', 125 | { 126 | owner: env.GITHUB_OWNER, 127 | repo: env.GITHUB_REPO, 128 | path, 129 | }, 130 | )) as { data: { content?: string; encoding?: string } } 131 | 132 | if (!data.content || !data.encoding) { 133 | console.error(data) 134 | throw new Error( 135 | `Tried to get ${path} but got back something that was unexpected. It doesn't have a content or encoding property`, 136 | ) 137 | } 138 | 139 | const encoding = data.encoding as Parameters['1'] 140 | return Buffer.from(data.content, encoding).toString() 141 | } 142 | 143 | async function downloadDirList(path: string) { 144 | const resp = await octokit.repos.getContent({ 145 | owner: env.GITHUB_OWNER, 146 | repo: env.GITHUB_REPO, 147 | path, 148 | }) 149 | const data = resp.data 150 | 151 | if (!Array.isArray(data)) { 152 | throw new Error( 153 | `Tried to download content from ${path}. GitHub did not return an array of files. This should never happen...`, 154 | ) 155 | } 156 | 157 | return data 158 | } 159 | 160 | export { downloadMdxFileOrDirectory, downloadDirList, downloadFile } -------------------------------------------------------------------------------- /app/utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type {HeadersFunction} from '@vercel/remix' 3 | import * as dateFns from 'date-fns' 4 | import type {NonNullProperties} from '~/types' 5 | import { camelize, underscore } from "inflected"; 6 | 7 | const DEFAULT_REDIRECT = "/"; 8 | 9 | export function safeRedirect( 10 | to: FormDataEntryValue | string | null | undefined, 11 | defaultRedirect: string = DEFAULT_REDIRECT 12 | ) { 13 | if (!to || typeof to !== "string") { 14 | return defaultRedirect; 15 | } 16 | 17 | if (!to.startsWith("/") || to.startsWith("//")) { 18 | return defaultRedirect; 19 | } 20 | 21 | return to; 22 | } 23 | 24 | const useSSRLayoutEffect = 25 | typeof window === 'undefined' ? () => {} : React.useLayoutEffect 26 | 27 | function formatTime(seconds: number) { 28 | return dateFns.format(dateFns.addSeconds(new Date(0), seconds), 'mm:ss') 29 | } 30 | 31 | const formatNumber = (num: number) => new Intl.NumberFormat().format(num) 32 | 33 | function formatAbbreviatedNumber(num: number) { 34 | return num < 1_000 35 | ? formatNumber(num) 36 | : num < 1_000_000 37 | ? `${formatNumber(Number((num / 1_000).toFixed(2)))}k` 38 | : num < 1_000_000_000 39 | ? `${formatNumber(Number((num / 1_000_000).toFixed(2)))}m` 40 | : num < 1_000_000_000_000 41 | ? `${formatNumber(Number((num / 1_000_000_000).toFixed(2)))}b` 42 | : 'a lot' 43 | } 44 | 45 | function formatDate(dateString: string) { 46 | return dateFns.format( 47 | dateFns.add(dateFns.parseISO(dateString), { 48 | minutes: new Date().getTimezoneOffset(), 49 | }), 50 | 'PPP', 51 | ) 52 | } 53 | 54 | function getErrorMessage(error: unknown) { 55 | if (typeof error === 'string') return error 56 | if (error instanceof Error) return error.message 57 | return 'Unknown Error' 58 | } 59 | 60 | function getErrorStack(error: unknown) { 61 | if (typeof error === 'string') return error 62 | if (error instanceof Error) return error.stack 63 | return 'Unknown Error' 64 | } 65 | 66 | function getNonNull>( 67 | obj: Type, 68 | ): NonNullProperties { 69 | for (const [key, val] of Object.entries(obj)) { 70 | assertNonNull(val, `The value of ${key} is null but it should not be.`) 71 | } 72 | return obj as NonNullProperties 73 | } 74 | 75 | function typedBoolean( 76 | value: T, 77 | ): value is Exclude { 78 | return Boolean(value) 79 | } 80 | 81 | function assertNonNull( 82 | possibleNull: PossibleNullType, 83 | errorMessage: string, 84 | ): asserts possibleNull is Exclude { 85 | if (possibleNull == null) throw new Error(errorMessage) 86 | } 87 | 88 | function getDomainUrl(request: Request) { 89 | const host = 90 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 91 | if (!host) { 92 | throw new Error('Could not determine domain URL.') 93 | } 94 | const protocol = host.includes('localhost') ? 'http' : 'https' 95 | return `${protocol}://${host}` 96 | } 97 | 98 | function removeTrailingSlash(s: string) { 99 | return s.endsWith('/') ? s.slice(0, -1) : s 100 | } 101 | 102 | function getDisplayUrl(requestInfo?: {origin: string; path: string}) { 103 | return getUrl(requestInfo).replace(/^https?:\/\//, ''); 104 | } 105 | 106 | function getUrl(requestInfo?: {origin: string; path: string}) { 107 | return removeTrailingSlash( 108 | `${requestInfo?.origin ?? 'localhost'}${ 109 | requestInfo?.path ?? '' 110 | }`, 111 | ) 112 | } 113 | 114 | function toBase64(string: string) { 115 | if (typeof window === 'undefined') { 116 | return Buffer.from(string).toString('base64') 117 | } else { 118 | return window.btoa(string) 119 | } 120 | } 121 | 122 | function useUpdateQueryStringValueWithoutNavigation( 123 | queryKey: string, 124 | queryValue: string, 125 | ) { 126 | React.useEffect(() => { 127 | const currentSearchParams = new URLSearchParams(window.location.search) 128 | const oldQuery = currentSearchParams.get(queryKey) ?? '' 129 | if (queryValue === oldQuery) return 130 | 131 | if (queryValue) { 132 | currentSearchParams.set(queryKey, queryValue) 133 | } else { 134 | currentSearchParams.delete(queryKey) 135 | } 136 | const newUrl = [window.location.pathname, currentSearchParams.toString()].filter(Boolean).join('?') 137 | window.history.replaceState(null, '', newUrl) 138 | }, [queryKey, queryValue]) 139 | } 140 | 141 | function debounce) => void>( 142 | fn: Callback, 143 | delay: number, 144 | ) { 145 | let timer: ReturnType | null = null 146 | return (...args: Parameters) => { 147 | if (timer) clearTimeout(timer) 148 | timer = setTimeout(() => { 149 | fn(...args) 150 | }, delay) 151 | } 152 | } 153 | 154 | function useDebounce) => unknown>( 155 | callback: Callback, 156 | delay: number, 157 | ) { 158 | const callbackRef = React.useRef(callback) 159 | React.useEffect(() => { 160 | callbackRef.current = callback 161 | }) 162 | return React.useMemo( 163 | () => debounce((...args) => callbackRef.current(...args), delay), 164 | [delay], 165 | ) 166 | } 167 | 168 | const reuseUsefulLoaderHeaders: HeadersFunction = ({loaderHeaders}) => { 169 | const headers = new Headers() 170 | const usefulHeaders = ['Cache-Control', 'Vary', 'Server-Timing'] 171 | for (const headerName of usefulHeaders) { 172 | if (loaderHeaders.has(headerName)) { 173 | headers.set(headerName, loaderHeaders.get(headerName)!) 174 | } 175 | } 176 | 177 | return headers 178 | } 179 | 180 | function callAll>( 181 | ...fns: Array<((...args: Args) => unknown) | undefined> 182 | ) { 183 | return (...args: Args) => fns.forEach(fn => fn?.(...args)) 184 | } 185 | 186 | function useDoubleCheck() { 187 | const [doubleCheck, setDoubleCheck] = React.useState(false) 188 | 189 | function getButtonProps(props?: JSX.IntrinsicElements['button']) { 190 | const onBlur: JSX.IntrinsicElements['button']['onBlur'] = () => 191 | setDoubleCheck(false) 192 | 193 | const onClick: JSX.IntrinsicElements['button']['onClick'] = doubleCheck 194 | ? undefined 195 | : e => { 196 | e.preventDefault() 197 | setDoubleCheck(true) 198 | } 199 | 200 | return { 201 | ...props, 202 | onBlur: callAll(onBlur, props?.onBlur), 203 | onClick: callAll(onClick, props?.onClick), 204 | } 205 | } 206 | 207 | return {doubleCheck, getButtonProps} 208 | } 209 | 210 | 211 | /** 212 | * Wrap a value in a resolved Promise returning it 213 | */ 214 | export function resolved(value: Value): Promise { 215 | return Promise.resolve(value); 216 | } 217 | 218 | /** 219 | * Wrap a value in an array if it's not already an array 220 | */ 221 | export function toArray(value: Value | Value[]): Value[] { 222 | if (Array.isArray(value)) return value; 223 | return [value]; 224 | } 225 | 226 | /** 227 | * Remove duplicated values from an array (only primitives and references) 228 | */ 229 | export function unique(array: Value[]): Value[] { 230 | return [...new Set(array)]; 231 | } 232 | 233 | /** 234 | * Check the environment the app is currently running 235 | */ 236 | export function env( 237 | environment: "production" | "test" | "development" 238 | ): boolean { 239 | return process.env.NODE_ENV === environment; 240 | } 241 | 242 | /** 243 | * Check if an object has a property 244 | */ 245 | export function hasOwn>( 246 | object: This, 247 | property: keyof This 248 | ): boolean { 249 | return Object.prototype.hasOwnProperty.call(object, property); 250 | } 251 | 252 | /** 253 | * A function that does nothing 254 | */ 255 | // eslint-disable-next-line @typescript-eslint/no-empty-function 256 | export function noop(): void {} 257 | 258 | /** 259 | * Check if the current runtime of the code is server or browser 260 | */ 261 | export function runtime(name: "server" | "browser"): boolean { 262 | switch (name) { 263 | case "browser": { 264 | return typeof window === "object" && typeof document === "object"; 265 | } 266 | case "server": { 267 | return typeof process !== "undefined" && Boolean(process.versions?.node); 268 | } 269 | } 270 | } 271 | 272 | let browser = false; 273 | 274 | /** 275 | * Check if the component is currently on a Browser environment 276 | */ 277 | export function useIsBrowser() { 278 | const [isBrowser, setIsBrowser] = React.useState(browser); 279 | 280 | React.useEffect(() => { 281 | if (browser) return; 282 | browser = true; 283 | setIsBrowser(true); 284 | }, []); 285 | 286 | return isBrowser; 287 | } 288 | 289 | /** 290 | * Wait for a certain amount of time before doing something else 291 | */ 292 | export function wait(time: number): Promise { 293 | return new Promise((resolve) => setTimeout(resolve, time)); 294 | } 295 | 296 | /** 297 | * Get a random number from a range of two possible numbers 298 | */ 299 | export function random( 300 | min = Number.MIN_SAFE_INTEGER, 301 | max = Number.MAX_SAFE_INTEGER 302 | ): number { 303 | return Math.floor(Math.random() * (max - min + 1)) + min; 304 | } 305 | 306 | /** 307 | * Check if the user requested to not be tracked 308 | */ 309 | export function doNotTrack(request: Request) { 310 | const header = request.headers.get("DNT") ?? "null"; 311 | return header === "1"; 312 | } 313 | 314 | /** 315 | * Check if the user requested to receive less data 316 | */ 317 | export function saveData(request: Request) { 318 | const header = request.headers.get("Save-Data") ?? "off"; 319 | return header === "on"; 320 | } 321 | 322 | /** 323 | * Capitalize every word in a sentence 324 | */ 325 | export function capitalize(string: string): string { 326 | return string 327 | .split(" ") 328 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 329 | .join(" "); 330 | } 331 | 332 | /** 333 | * Check if an array has any element inside 334 | */ 335 | export function hasAny(list: Value[]): boolean { 336 | return list.length > 0; 337 | } 338 | 339 | /** 340 | * Check if an array is empty 341 | */ 342 | export function isEmpty(list: Value[]): boolean { 343 | return list.length === 0; 344 | } 345 | 346 | /** 347 | * Serialize a value to a JSON string using snake_case for the keys 348 | */ 349 | export function serialize(input: Input): string { 350 | return JSON.stringify(input, (_: string, value: unknown) => { 351 | if (typeof value === "object" && !Array.isArray(value) && value !== null) { 352 | let entries = Object.entries(value).map((entry) => [ 353 | underscore(entry[0]), 354 | entry[1], 355 | ]); 356 | return Object.fromEntries(entries); 357 | } 358 | 359 | return value; 360 | }); 361 | } 362 | 363 | /** 364 | * Parse an JSON string to a JS object with the keys in camelCase 365 | */ 366 | export function parse(input: string): Output { 367 | return JSON.parse(input, (_key, value: unknown) => { 368 | if (typeof value === "object" && !Array.isArray(value) && value !== null) { 369 | let entries = Object.entries(value).map((entry) => [ 370 | camelize(entry[0], false), 371 | entry[1], 372 | ]); 373 | return Object.fromEntries(entries); 374 | } 375 | return value; 376 | }); 377 | } 378 | 379 | /** 380 | * Get the first n items of an array, defaults to one item 381 | */ 382 | export function first(list: Value[], limit = 1): Value[] { 383 | return list.slice(0, limit); 384 | } 385 | 386 | export { 387 | getErrorMessage, 388 | getErrorStack, 389 | getNonNull, 390 | assertNonNull, 391 | useUpdateQueryStringValueWithoutNavigation, 392 | useSSRLayoutEffect, 393 | useDoubleCheck, 394 | useDebounce, 395 | typedBoolean, 396 | getDomainUrl, 397 | getUrl, 398 | getDisplayUrl, 399 | toBase64, 400 | removeTrailingSlash, 401 | reuseUsefulLoaderHeaders, 402 | formatDate, 403 | formatTime, 404 | formatNumber, 405 | formatAbbreviatedNumber, 406 | } -------------------------------------------------------------------------------- /app/utils/markdoc.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | The server side part of our markdoc markdown processing. 3 | */ 4 | 5 | import { parse, transform, type Config } from "@markdoc/markdoc"; 6 | import { Callout, QuickLink, QuickLinks } from "~/components/Markdown"; 7 | import fm from 'front-matter'; 8 | import calculateReadingTime from 'reading-time' 9 | 10 | export function parseMarkdown(markdown: string, options: Config = {}) { 11 | const { attributes } = fm(markdown); 12 | const readTime = calculateReadingTime(markdown) 13 | 14 | return { 15 | frontmatter: attributes, 16 | readTime: readTime, 17 | body: transform( parse(markdown), { 18 | tags: { 19 | callout: Callout.scheme, 20 | "quick-links": QuickLinks.scheme, 21 | "quick-link": QuickLink.scheme, 22 | }, 23 | }) 24 | } 25 | } -------------------------------------------------------------------------------- /app/utils/sitemap.server.ts: -------------------------------------------------------------------------------- 1 | type SitemapUrl = { 2 | url: string; 3 | }; 4 | 5 | export function createSitemap(urls: SitemapUrl[]): string { 6 | const urlEntries = urls 7 | .map(({ url }) => { 8 | const loc = `${url}`; 9 | return ` 10 | 11 | ${loc} 12 | 13 | `; 14 | }) 15 | .join(''); 16 | 17 | return ` 18 | 19 | ${urlEntries} 20 | 21 | `; 22 | } -------------------------------------------------------------------------------- /app/utils/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { useFetcher } from "@remix-run/react"; 2 | import type { Dispatch, ReactNode, SetStateAction } from "react"; 3 | import { 4 | createContext, 5 | createElement, 6 | useContext, 7 | useEffect, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | 12 | enum Theme { 13 | DARK = "dark", 14 | LIGHT = "light", 15 | } 16 | const themes: Array = Object.values(Theme); 17 | 18 | type ThemeContextType = [Theme | null, Dispatch>]; 19 | 20 | const ThemeContext = createContext(undefined); 21 | 22 | const prefersDarkMQ = "(prefers-color-scheme: dark)"; 23 | const getPreferredTheme = () => 24 | window.matchMedia(prefersDarkMQ).matches ? Theme.DARK : Theme.LIGHT; 25 | 26 | function ThemeProvider({ 27 | children, 28 | specifiedTheme, 29 | }: { 30 | children: ReactNode; 31 | specifiedTheme: Theme | null; 32 | }) { 33 | const [theme, setTheme] = useState(() => { 34 | if (specifiedTheme) { 35 | if (themes.includes(specifiedTheme)) { 36 | return specifiedTheme; 37 | } else { 38 | return null; 39 | } 40 | } 41 | 42 | if (typeof document === "undefined") { 43 | return null; 44 | } 45 | 46 | return getPreferredTheme(); 47 | }); 48 | 49 | const persistTheme = useFetcher(); 50 | 51 | const persistThemeRef = useRef(persistTheme); 52 | useEffect(() => { 53 | persistThemeRef.current = persistTheme; 54 | }, [persistTheme]); 55 | 56 | const mountRun = useRef(false); 57 | 58 | useEffect(() => { 59 | if (!mountRun.current) { 60 | mountRun.current = true; 61 | return; 62 | } 63 | if (!theme) { 64 | return; 65 | } 66 | 67 | persistThemeRef.current.submit( 68 | { theme }, 69 | { action: "actions/set-theme", method: "post" } 70 | ); 71 | }, [theme]); 72 | 73 | useEffect(() => { 74 | const mediaQuery = window.matchMedia(prefersDarkMQ); 75 | const handleChange = () => { 76 | setTheme(mediaQuery.matches ? Theme.DARK : Theme.LIGHT); 77 | }; 78 | mediaQuery.addEventListener("change", handleChange); 79 | return () => mediaQuery.removeEventListener("change", handleChange); 80 | }, []); 81 | 82 | return ( 83 | 84 | {children} 85 | 86 | ); 87 | } 88 | 89 | const clientThemeCode = ` 90 | // hi there dear reader 👋 91 | // this is how I make certain we avoid a flash of the wrong theme. If you select 92 | // a theme, then I'll know what you want in the future and you'll not see this 93 | // script anymore. 94 | ;(() => { 95 | const theme = window.matchMedia(${JSON.stringify(prefersDarkMQ)}).matches 96 | ? 'dark' 97 | : 'light'; 98 | const cl = document.documentElement.classList; 99 | const themeAlreadyApplied = cl.contains('light') || cl.contains('dark'); 100 | if (themeAlreadyApplied) { 101 | // this script shouldn't exist if the theme is already applied! 102 | console.warn( 103 | "Hi there, could you let me know you're seeing this message? Thanks!", 104 | ); 105 | } else { 106 | cl.add(theme); 107 | } 108 | const meta = document.querySelector('meta[name=color-scheme]'); 109 | if (meta) { 110 | if (theme === 'dark') { 111 | meta.content = 'dark light'; 112 | } else if (theme === 'light') { 113 | meta.content = 'light dark'; 114 | } 115 | } else { 116 | console.warn( 117 | "Hey, could you let me know you're seeing this message? Thanks!", 118 | ); 119 | } 120 | })(); 121 | `; 122 | 123 | const themeStylesCode = ` 124 | /* default light, but app-preference is "dark" */ 125 | html.dark { 126 | light-mode { 127 | display: none; 128 | } 129 | } 130 | 131 | /* default light, and no app-preference */ 132 | html:not(.dark) { 133 | dark-mode { 134 | display: none; 135 | } 136 | } 137 | 138 | @media (prefers-color-scheme: dark) { 139 | /* prefers dark, but app-preference is "light" */ 140 | html.light { 141 | dark-mode { 142 | display: none; 143 | } 144 | } 145 | 146 | /* prefers dark, and app-preference is "dark" */ 147 | html.dark, 148 | /* prefers dark and no app-preference */ 149 | html:not(.light) { 150 | light-mode { 151 | display: none; 152 | } 153 | } 154 | } 155 | `; 156 | 157 | function ThemeHead({ ssrTheme }: { ssrTheme: boolean }) { 158 | const [theme] = useTheme(); 159 | 160 | return ( 161 | <> 162 | {/* 163 | On the server, "theme" might be `null`, so clientThemeCode ensures that 164 | this is correct before hydration. 165 | */} 166 | 170 | {/* 171 | If we know what the theme is from the server then we don't need 172 | to do fancy tricks prior to hydration to make things match. 173 | */} 174 | {ssrTheme ? null : ( 175 | <> 176 |