├── .dockerignore
├── .env.example
├── .eslintrc.cjs
├── .github
├── dependabot.yml
└── workflows
│ ├── deploy.production.yml
│ ├── deploy.staging.yml
│ ├── format.yml
│ ├── lint.yml
│ └── no-response.yml
├── .gitignore
├── .gitpod.Dockerfile
├── .gitpod.yml
├── .npmrc
├── .nvmrc
├── .prettierignore
├── Dockerfile
├── LICENSE.md
├── README.md
├── _redirects
├── app
├── actions
│ └── color-scheme.ts
├── components
│ ├── README.md
│ ├── _playground
│ │ ├── README.md
│ │ ├── fake-menu.ts
│ │ └── playground.tsx
│ ├── color-scheme-toggle.tsx
│ ├── details-popup.tsx
│ ├── doc-error-boundary.tsx
│ ├── doc-layout.tsx
│ ├── docs-footer.tsx
│ ├── docs-header
│ │ ├── data.server.ts
│ │ ├── docs-header.tsx
│ │ └── use-header-data.ts
│ ├── docs-menu
│ │ ├── data.server.ts
│ │ ├── menu-desktop.tsx
│ │ ├── menu-mobile.tsx
│ │ └── menu.tsx
│ ├── on-this-page.tsx
│ ├── package-select.tsx
│ ├── popup-label.tsx
│ ├── version-nav.tsx
│ ├── version-select.tsx
│ └── version-warning.tsx
├── hooks
│ ├── README.md
│ ├── use-delayed-value.ts
│ ├── use-doc.ts
│ └── use-navigation.ts
├── http.ts
├── icons.svg
├── modules
│ ├── README.md
│ ├── color-scheme
│ │ ├── components.tsx
│ │ ├── server.ts
│ │ └── types.ts
│ ├── details-menu
│ │ └── index.tsx
│ ├── docsearch.tsx
│ ├── gh-docs
│ │ └── .server
│ │ │ ├── __fixture__
│ │ │ └── docs
│ │ │ │ ├── components
│ │ │ │ ├── index.md
│ │ │ │ ├── link.md
│ │ │ │ └── router.md
│ │ │ │ ├── index.md
│ │ │ │ └── pages
│ │ │ │ ├── index.md
│ │ │ │ ├── overview.md
│ │ │ │ └── tutorial.md
│ │ │ ├── branches.ts
│ │ │ ├── compat-tokens.ts
│ │ │ ├── docs.test.ts
│ │ │ ├── docs.ts
│ │ │ ├── github.ts
│ │ │ ├── index.ts
│ │ │ ├── md.ts
│ │ │ ├── reference-docs.ts
│ │ │ ├── repo-content.ts
│ │ │ ├── repo-tarball.ts
│ │ │ ├── tags.ts
│ │ │ ├── tarball.test.ts
│ │ │ └── tarball.ts
│ ├── http-utils
│ │ ├── ensure-secure.ts
│ │ ├── is-host.ts
│ │ └── remove-slashes.ts
│ ├── redirects
│ │ └── .server
│ │ │ ├── __fixtures__
│ │ │ └── _redirects
│ │ │ ├── check-url.test.ts
│ │ │ ├── check-url.ts
│ │ │ ├── get-redirects.ts
│ │ │ └── index.ts
│ ├── remix-seo
│ │ ├── index.ts
│ │ ├── seo.test.ts
│ │ └── seo.ts
│ └── stats
│ │ └── index.tsx
├── pages
│ ├── brand.tsx
│ ├── doc.tsx
│ ├── docs-home.tsx
│ ├── docs-layout.tsx
│ ├── healthcheck.tsx
│ ├── redirect-major-version.tsx
│ └── splash.tsx
├── root.tsx
├── routes.ts
├── seo.ts
├── styles
│ ├── docs.css
│ ├── docsearch.css
│ └── tailwind.css
└── ui
│ ├── README.md
│ ├── delegate-markdown-links.ts
│ ├── meta.ts
│ └── utils.ts
├── data
├── api-docs.json
└── base16.json
├── fly.production.toml
├── fly.staging.toml
├── notes.md
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── _brand
│ ├── React Router Brand Assets.zip
│ └── React Router Brand Assets
│ │ ├── React Router Lockup
│ │ ├── Dark.png
│ │ ├── Dark.svg
│ │ ├── Light.png
│ │ ├── Light.svg
│ │ └── One Color
│ │ │ ├── Dark.png
│ │ │ ├── Dark.svg
│ │ │ ├── Light.png
│ │ │ └── Light.svg
│ │ ├── React Router Logo
│ │ ├── Dark.png
│ │ ├── Dark.svg
│ │ ├── Light.png
│ │ ├── Light.svg
│ │ └── One Color
│ │ │ ├── Dark.png
│ │ │ ├── Dark.svg
│ │ │ ├── Light.png
│ │ │ └── Light.svg
│ │ └── React Router Wordmark
│ │ ├── Dark.png
│ │ ├── Dark.svg
│ │ ├── Light.png
│ │ └── Light.svg
├── _docs
│ ├── mode-icons.svg
│ ├── tutorial
│ │ ├── 01.webp
│ │ ├── 02.webp
│ │ ├── 03.webp
│ │ ├── 04.webp
│ │ ├── 05.webp
│ │ ├── 06.webp
│ │ ├── 07.webp
│ │ ├── 08.webp
│ │ ├── 09.webp
│ │ ├── 10.webp
│ │ ├── 11.webp
│ │ ├── 12.webp
│ │ ├── 13.webp
│ │ ├── 14.webp
│ │ ├── 15.webp
│ │ ├── 16.webp
│ │ ├── 17.webp
│ │ ├── 18.webp
│ │ ├── 19.webp
│ │ ├── 20.webp
│ │ ├── 21.webp
│ │ ├── 22.webp
│ │ ├── 23.webp
│ │ ├── 24.webp
│ │ ├── 25.webp
│ │ ├── 26.webp
│ │ └── 27.webp
│ ├── tutorial_source_files
│ │ ├── 01.png
│ │ ├── 02.png
│ │ ├── 03.png
│ │ ├── 04.png
│ │ ├── 05.png
│ │ ├── 06.png
│ │ ├── 07.png
│ │ ├── 08.png
│ │ ├── 09.png
│ │ ├── 10.png
│ │ ├── 11.png
│ │ ├── 12.png
│ │ ├── 13.png
│ │ ├── 14.png
│ │ ├── 15.png
│ │ ├── 16.png
│ │ ├── 17.png
│ │ ├── 18.png
│ │ ├── 19.png
│ │ ├── 20.png
│ │ ├── 21.png
│ │ ├── 22.png
│ │ ├── 23.png
│ │ ├── 24.png
│ │ ├── 25.png
│ │ ├── 26.png
│ │ └── 27.png
│ └── v7_address_book_tutorial
│ │ ├── 01.webp
│ │ ├── 02.webp
│ │ ├── 03.webp
│ │ ├── 04.webp
│ │ ├── 05.webp
│ │ ├── 06.webp
│ │ ├── 07.webp
│ │ ├── 08.webp
│ │ ├── 09.webp
│ │ ├── 10.webp
│ │ ├── 11.webp
│ │ ├── 12.webp
│ │ ├── 13.webp
│ │ ├── 14.webp
│ │ ├── 15.webp
│ │ ├── 16.webp
│ │ ├── 17.webp
│ │ ├── 18.webp
│ │ ├── 19.webp
│ │ ├── 20.webp
│ │ ├── 21.webp
│ │ └── 22.webp
├── android-chrome-144x144.png
├── browserconfig.xml
├── favicon-dark.png
├── favicon-light.png
├── favicon.ico
├── font
│ ├── inter-italic-latin-var.woff2
│ ├── inter-roman-latin-var.woff2
│ ├── source-code-pro-italic-var.woff2
│ └── source-code-pro-roman-var.woff2
├── manifest.json
├── mstile-150x150.png
├── og-image.png
└── splash
│ ├── hero-3d-logo.dark.webp
│ ├── hero-3d-logo.webp
│ ├── shopify-badge.svg
│ ├── v7-badge-1.svg
│ └── v7-badge-2.svg
├── react-router.config.ts
├── server.js
├── server
└── app.ts
├── start.sh
├── tailwind.config.ts
├── test
├── fixture.tar.gz
└── setup-test-env.ts
├── tsconfig.json
├── vite.config.ts
└── vitest.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | *.log
3 | .DS_Store
4 | .env
5 | /.cache
6 | /public/build
7 | /build
8 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # For the server
2 | SESSION_SECRET="anything-you-want"
3 |
4 | # GitHub repo to pull docs from
5 | SOURCE_REPO="remix-run/react-router"
6 |
7 | # A token to increase the rate limiting from 60/hr to 1000/hr
8 | GH_TOKEN="..."
9 |
10 | # For development, reading the docs from a local repo
11 | LOCAL_REPO_RELATIVE_PATH="../react-router"
12 |
13 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@types/eslint').Linter.BaseConfig}
3 | */
4 | module.exports = {
5 | extends: [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/recommended",
8 | "plugin:react/recommended",
9 | "plugin:react-hooks/recommended",
10 | "prettier",
11 | ],
12 | env: {
13 | node: true,
14 | },
15 | plugins: ["@typescript-eslint", "react"],
16 | settings: {
17 | react: {
18 | version: "detect",
19 | },
20 | },
21 | rules: {
22 | "no-unused-vars": "off",
23 | "no-inner-declarations": "off",
24 | "no-var": "off",
25 | "prefer-const": "off",
26 | "react/react-in-jsx-scope": "off", // Not needed in modern React
27 | "react/prop-types": "off", // We use TypeScript instead
28 | "react/no-unescaped-entities": "off",
29 | "@typescript-eslint/no-unused-vars": "warn",
30 | "@typescript-eslint/no-explicit-any": "off",
31 | "@typescript-eslint/no-non-null-assertion": "off",
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: /
5 | schedule:
6 | interval: daily
7 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.production.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Deploy (production)
2 | on:
3 | push:
4 | branches:
5 | - main
6 | paths-ignore:
7 | - "README.md"
8 |
9 | concurrency:
10 | group: ${{ github.workflow }}-${{ github.ref }}
11 | cancel-in-progress: true
12 |
13 | env:
14 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
15 |
16 | jobs:
17 | lint:
18 | name: ⬣ ESLint
19 | runs-on: ubuntu-latest
20 | steps:
21 | - name: ⬇️ Checkout repo
22 | uses: actions/checkout@v4
23 |
24 | - name: ⎔ Setup node
25 | uses: actions/setup-node@v4
26 | with:
27 | cache: npm
28 | cache-dependency-path: ./package-lock.json
29 | node-version: 22
30 |
31 | - name: 📥 Install deps
32 | run: npm install
33 |
34 | - name: 🔬 Lint
35 | run: npm run lint
36 |
37 | typecheck:
38 | name: ʦ TypeScript
39 | runs-on: ubuntu-latest
40 | steps:
41 | - name: ⬇️ Checkout repo
42 | uses: actions/checkout@v4
43 |
44 | - name: ⎔ Setup node
45 | uses: actions/setup-node@v4
46 | with:
47 | cache: npm
48 | cache-dependency-path: ./package-lock.json
49 | node-version: 22
50 |
51 | - name: 📥 Install deps
52 | run: npm install
53 |
54 | - name: 🔎 Type check
55 | run: npm run typecheck --if-present
56 |
57 | vitest:
58 | name: ⚡ Vitest
59 | runs-on: ubuntu-latest
60 | steps:
61 | - name: ⬇️ Checkout repo
62 | uses: actions/checkout@v4
63 |
64 | - name: ⎔ Setup node
65 | uses: actions/setup-node@v4
66 | with:
67 | cache: npm
68 | cache-dependency-path: ./package-lock.json
69 | node-version: 22
70 |
71 | - name: 📥 Install deps
72 | run: npm install
73 |
74 | - name: ⚡ Run vitest
75 | run: npm run test -- --coverage
76 |
77 | deploy:
78 | name: 🚀 Deploy
79 | runs-on: ubuntu-latest
80 | needs: [lint, typecheck, vitest]
81 | steps:
82 | - name: ⬇️ Checkout repo
83 | uses: actions/checkout@v4
84 |
85 | - name: 🎈 Setup Fly
86 | uses: superfly/flyctl-actions/setup-flyctl@1.5
87 |
88 | - name: 🚀 Deploy Production
89 | if: ${{ github.ref == 'refs/heads/main' }}
90 | run: flyctl deploy --remote-only --config ./fly.production.toml --build-arg COMMIT_SHA=${{ github.sha }}
91 | env:
92 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
93 |
94 | purge:
95 | name: 🧹 Purge CDN
96 | runs-on: ubuntu-latest
97 | needs: [deploy]
98 | steps:
99 | - name: 🧹 Purge All
100 | run: |
101 | curl -D - -X POST --location "https://api.fastly.com/service/${{ secrets.FASTLY_SERVICE_ID }}/purge_all" -H "Accept: application/json" -H "Fastly-Key: ${{ secrets.FASTLY_API_TOKEN }}" -H "fastly-soft-purge: 1"
102 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.staging.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Deploy (staging)
2 | on:
3 | push:
4 | tags:
5 | - stage
6 | branches:
7 | - dev
8 | paths-ignore:
9 | - "README.md"
10 |
11 | concurrency:
12 | group: ${{ github.workflow }}-${{ github.ref }}
13 | cancel-in-progress: true
14 |
15 | env:
16 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
17 |
18 | jobs:
19 | lint:
20 | name: ⬣ ESLint
21 | runs-on: ubuntu-latest
22 | steps:
23 | - name: ⬇️ Checkout repo
24 | uses: actions/checkout@v4
25 |
26 | - name: ⎔ Setup node
27 | uses: actions/setup-node@v4
28 | with:
29 | cache: npm
30 | cache-dependency-path: ./package-lock.json
31 | node-version: 22
32 |
33 | - name: 📥 Install deps
34 | run: npm install
35 |
36 | - name: 🔬 Lint
37 | run: npm run lint
38 |
39 | typecheck:
40 | name: ʦ TypeScript
41 | runs-on: ubuntu-latest
42 | steps:
43 | - name: ⬇️ Checkout repo
44 | uses: actions/checkout@v4
45 |
46 | - name: ⎔ Setup node
47 | uses: actions/setup-node@v4
48 | with:
49 | cache: npm
50 | cache-dependency-path: ./package-lock.json
51 | node-version: 22
52 |
53 | - name: 📥 Install deps
54 | run: npm install
55 |
56 | - name: 🔎 Type check
57 | run: npm run typecheck --if-present
58 |
59 | vitest:
60 | name: ⚡ Vitest
61 | runs-on: ubuntu-latest
62 | steps:
63 | - name: ⬇️ Checkout repo
64 | uses: actions/checkout@v4
65 |
66 | - name: ⎔ Setup node
67 | uses: actions/setup-node@v4
68 | with:
69 | cache: npm
70 | cache-dependency-path: ./package-lock.json
71 | node-version: 22
72 |
73 | - name: 📥 Install deps
74 | run: npm install
75 |
76 | - name: ⚡ Run vitest
77 | run: npm run test -- --coverage
78 |
79 | deploy:
80 | name: 🚀 Deploy
81 | runs-on: ubuntu-latest
82 | needs: [lint, typecheck, vitest]
83 | steps:
84 | - name: ⬇️ Checkout repo
85 | uses: actions/checkout@v4
86 |
87 | - name: 🎈 Setup Fly
88 | uses: superfly/flyctl-actions/setup-flyctl@1.5
89 |
90 | - name: 🚀 Deploy Staging
91 | run: flyctl deploy --remote-only --config ./fly.staging.toml --build-arg COMMIT_SHA=${{ github.sha }} --strategy bluegreen
92 | env:
93 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
94 |
--------------------------------------------------------------------------------
/.github/workflows/format.yml:
--------------------------------------------------------------------------------
1 | name: 👔 Format
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency:
9 | group: ${{ github.workflow }}-${{ github.ref }}
10 | cancel-in-progress: true
11 |
12 | jobs:
13 | format:
14 | if: github.repository == 'remix-run/react-router-website'
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: ⬇️ Checkout repo
19 | uses: actions/checkout@v4
20 |
21 | - name: ⎔ Setup node
22 | uses: actions/setup-node@v4
23 | with:
24 | cache: npm
25 | node-version: 22
26 |
27 | - name: 📥 Install deps
28 | run: npm ci
29 |
30 | - name: 👔 Format
31 | run: npm run format
32 |
33 | - name: 💪 Commit
34 | run: |
35 | git config --local user.email "github-actions[bot]@users.noreply.github.com"
36 | git config --local user.name "github-actions[bot]"
37 |
38 | git add .
39 | if [ -z "$(git status --porcelain)" ]; then
40 | echo "💿 no formatting changed"
41 | exit 0
42 | fi
43 | git commit -m "chore: format"
44 | git push
45 | echo "💿 pushed formatting changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)"
46 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: ⬣ Lint
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | concurrency:
10 | group: ${{ github.workflow }}-${{ github.ref }}
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | lint:
15 | name: ⬣ Lint
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: ⬇️ Checkout repo
19 | uses: actions/checkout@v4
20 |
21 | - name: ⎔ Setup node
22 | uses: actions/setup-node@v4
23 | with:
24 | cache: npm
25 | node-version: 22
26 |
27 | - name: 📥 Install deps
28 | run: npm ci
29 |
30 | - name: 🔬 Lint
31 | run: npm run lint
32 |
--------------------------------------------------------------------------------
/.github/workflows/no-response.yml:
--------------------------------------------------------------------------------
1 | name: 🥺 No Response
2 |
3 | on:
4 | schedule:
5 | # Schedule for five minutes after the hour, every hour
6 | - cron: "5 * * * *"
7 |
8 | permissions:
9 | issues: write
10 | pull-requests: write
11 |
12 | jobs:
13 | stale:
14 | if: github.repository == 'remix-run/react-router-website'
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: 🥺 Handle Ghosting
18 | uses: actions/stale@v9
19 | with:
20 | close-issue-message: >
21 | This issue has been automatically closed because we haven't received a
22 | response from the original author 🙈. This automation helps keep the issue
23 | tracker clean from issues that aren't actionable. Please reach out if you
24 | have more information for us! 🙂
25 | close-pr-message: >
26 | This PR has been automatically closed because we haven't received a
27 | response from the original author 🙈. This automation helps keep the issue
28 | tracker clean from PRs that aren't actionable. Please reach out if you
29 | want to resume the work on this PR! 🙂
30 | # don't automatically mark issues/PRs as stale
31 | days-before-stale: -1
32 | stale-issue-label: needs-response
33 | stale-pr-label: needs-response
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 |
4 | /build
5 | /public/build
6 | .react-router
7 | .env
8 |
9 | /cypress/screenshots
10 | /cypress/videos
11 | /prisma/data.db
12 | /prisma/data.db-journal
13 |
14 | /.local.tgz
15 | app/modules/gh-docs/.server/__fixture__/tar.tgz
16 |
17 | *.code-workspace
18 |
--------------------------------------------------------------------------------
/.gitpod.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM gitpod/workspace-full
2 |
3 | # Install Fly
4 | RUN curl -L https://fly.io/install.sh | sh
5 | ENV FLYCTL_INSTALL="/home/gitpod/.fly"
6 | ENV PATH="$FLYCTL_INSTALL/bin:$PATH"
7 |
8 | # Install GitHub CLI
9 | RUN brew install gh
10 |
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | # https://www.gitpod.io/docs/config-gitpod-file
2 |
3 | image:
4 | file: .gitpod.Dockerfile
5 |
6 | ports:
7 | - port: 3000
8 | onOpen: notify
9 |
10 | tasks:
11 | - name: Restore .env file
12 | command: |
13 | if [ -f .env ]; then
14 | # If this workspace already has a .env, don't override it
15 | # Local changes survive a workspace being opened and closed
16 | # but they will not persist between separate workspaces for the same repo
17 |
18 | echo "Found .env in workspace"
19 | else
20 | # There is no .env
21 | if [ ! -n "${ENV}" ]; then
22 | # There is no $ENV from a previous workspace
23 | # Default to the example .env
24 | echo "Setting example .env"
25 |
26 | cp .env.example .env
27 | else
28 | # After making changes to .env, run this line to persist it to $ENV
29 | # eval $(gp env -e ENV="$(base64 .env | tr -d '\n')")
30 | #
31 | # Environment variables set this way are shared between all your workspaces for this repo
32 | # The lines below will read $ENV and print a .env file
33 |
34 | echo "Restoring .env from Gitpod"
35 |
36 | echo "${ENV}" | base64 -d | tee .env > /dev/null
37 | fi
38 | fi
39 |
40 | - init: npm install
41 | command: npm run setup && npm run dev
42 |
43 | vscode:
44 | extensions:
45 | - ms-azuretools.vscode-docker
46 | - esbenp.prettier-vscode
47 | - dbaeumer.vscode-eslint
48 | - bradlc.vscode-tailwindcss
49 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | # Allows us to upgrade to React 19 -- remove when @docsearch/react updates
2 | legacy-peer-deps=true
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /build
4 | /public/build
5 | .env
6 | _redirects
7 |
8 | /data
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS development-dependencies-env
2 | COPY . /app
3 | WORKDIR /app
4 | RUN npm ci
5 |
6 | FROM node:22-alpine AS production-dependencies-env
7 | COPY ./package.json package-lock.json .npmrc /app/
8 | WORKDIR /app
9 | RUN npm ci --omit=dev
10 |
11 | FROM node:22-alpine AS build-env
12 | COPY . /app/
13 | COPY --from=development-dependencies-env /app/node_modules /app/node_modules
14 | WORKDIR /app
15 | RUN npm run build
16 |
17 | FROM node:22-alpine
18 | COPY ./package.json package-lock.json server.js /app/
19 |
20 |
21 | ENV PORT="8080"
22 | ENV NODE_ENV="production"
23 |
24 | COPY --from=production-dependencies-env /app/node_modules /app/node_modules
25 | COPY --from=build-env /app/build /app/build
26 | COPY --from=build-env /app/start.sh /app/start.sh
27 |
28 |
29 | WORKDIR /app
30 | CMD ["npm", "run", "start"]
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) React Training LLC 2015-2021
4 | Copyright (c) Shopify Inc. 2022-2025
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Router Website!
2 |
3 | ## Contributing
4 |
5 | If you want to make a contribution
6 |
7 | - [Fork and clone](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) this repo
8 | - Create a branch
9 | - Push any changes you make to your branch
10 | - Open up a PR in this Repo
11 |
12 | ## Setup
13 |
14 | First setup your `.env` file, use `.env.example` to know what to set.
15 |
16 | ```sh
17 | cp .env.example .env
18 | ```
19 |
20 | Install dependencies
21 |
22 | ```sh
23 | npm i
24 | ```
25 |
26 | ## Local Development
27 |
28 | Now you should be good to go:
29 |
30 | ```sh
31 | npm run dev
32 | ```
33 |
34 | To preview local changes to the `docs` folder in the React Router repo, select "local" from the version dropdown menu on the site. Make sure you have the [react-router repo](https://github.com/remix-run/react-router) cloned locally and `LOCAL_REPO_RELATIVE_PATH` is pointed to the correct filepath.
35 |
36 | We leverage a number of LRUCache's to server-side cache various resources, such as processed markdown from GitHub, that expire at various times (usually after 5 minutes). If you want them to expire immediately for local development, set the `NO_CACHE` environment variable.
37 |
38 | ```sh
39 | NO_CACHE=1 npm run dev
40 | ```
41 |
42 | Note that by default this assumes the relative path to your local copy of the React Router docs is `../react-router`. This can be configured via `LOCAL_REPO_RELATIVE_PATH` in your `.env` file.
43 |
44 | ## Preview
45 |
46 | To preview the production build locally:
47 |
48 | ```sh
49 | npm run build
50 | npm run preview
51 | ```
52 |
53 | ## Deployment
54 |
55 | The production server is always in sync with `main`
56 |
57 | ```sh
58 | git push origin main
59 | open https://reactrouter.com
60 | ```
61 |
62 | Pushing the "stage" tag will deploy to [staging](https://reactrouterdotcomstaging.fly.dev/).
63 |
64 | ```sh
65 | git checkout my/branch
66 |
67 | # moves the `stage` tag and pushes it, triggering a deploy
68 | npm run push:stage
69 | ```
70 |
71 | When you're happy with it, merge your branch into `main` and push.
72 |
73 | ## CSS Notes
74 |
75 | You'll want the [tailwind VSCode plugin](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) for sure, the hints are amazing.
76 |
77 | The color scheme has various shades but we also have a special "brand" rule for each of our brand colors so we don't have to know the specific number of that color like this: `
`.
78 |
79 | We want to use Tailwind's default classes as much as possible to avoid a large CSS file. A few things you can do to keep the styles shared:
80 |
81 | - Avoid changing anything but the theme in `tailwind.config.js`, no special classes, etc.
82 | - Avoid "inline rules" like `color-[#ccc]` as much as possible.
83 | - Silly HTML (like a wrapper div to add padding on a container) is better than one-off css rules.
84 |
85 | ## Algolia Search
86 |
87 | We use [DocSearch](https://docsearch.algolia.com/) by Algolia for our documentation's search. The site is automatically scraped and indexed weekly by the [Algolia Crawler](https://crawler.algolia.com/).
88 |
89 | If the doc search results ever seem outdated or incorrect be sure to check that the crawler isn't blocked. If it is, it might just need to be canceled and restarted to kick things off again. There is also an editor in the Crawler admin that lets you adjust the crawler's script if needed.
90 |
--------------------------------------------------------------------------------
/app/actions/color-scheme.ts:
--------------------------------------------------------------------------------
1 | export { action } from "~/modules/color-scheme/server";
2 |
--------------------------------------------------------------------------------
/app/components/README.md:
--------------------------------------------------------------------------------
1 | These components are specific to this app
2 |
--------------------------------------------------------------------------------
/app/components/_playground/README.md:
--------------------------------------------------------------------------------
1 | Visit `http://localhost:3000/__components` to render the playground.
2 |
3 | It's like bootleg storybook so you can build/tweak/play around with components in isolation
4 |
--------------------------------------------------------------------------------
/app/components/_playground/playground.tsx:
--------------------------------------------------------------------------------
1 | import { Header } from "../docs-header/docs-header";
2 | import { fakeMenu } from "./fake-menu";
3 |
4 | export async function loader() {
5 | return {
6 | menu: fakeMenu,
7 | header: {
8 | branches: ["main", "dev", "local"],
9 | currentGitHubRef: "main",
10 | isLatest: true,
11 | latestVersion: "7.0.1",
12 | releaseBranch: "main",
13 | versions: ["6.26.1", "7.0.0"],
14 | lang: "en",
15 | hasAPIDocs: true,
16 | apiDocsRef: "dev",
17 | },
18 | };
19 | }
20 |
21 | export default function Playground() {
22 | return (
23 | <>
24 |
25 | {/* */}
26 | {/*
27 |
53 | `
54 | .split("\n")
55 | .map((line) => line.replace(/^\s+/, ""))
56 | .filter(Boolean)
57 | .join("");
58 |
59 | return fromHtml(html, { fragment: true }).children[0] as Element;
60 | }
61 |
62 | const MODES_REGEX = /^\[MODES:\s*([^\]]+)\]$/;
63 | const MODES_SMALL_REGEX = /^\[modes:\s*([^\]]+)\]$/;
64 |
65 | const remarkCompatLists: Plugin<[CompatOptions?], Root> = () => {
66 | const baseUrl = "../../start/modes";
67 |
68 | return (tree) => {
69 | visit(tree, "paragraph", (node, index, parent) => {
70 | if (!parent || typeof index === "undefined" || node.children.length !== 1)
71 | return;
72 |
73 | const child = node.children[0];
74 | if (child.type !== "text") return;
75 |
76 | const text = child.value.trim();
77 | const matchBig = text.match(MODES_REGEX);
78 | const matchSmall = text.match(MODES_SMALL_REGEX);
79 |
80 | if (matchBig || matchSmall) {
81 | const modes = (matchBig || matchSmall)![1]
82 | .split(",")
83 | .map((mode) => mode.trim())
84 | .filter(Boolean);
85 |
86 | const compatList = matchBig
87 | ? createCompatList(modes, baseUrl)
88 | : createSmallCompatList(modes, baseUrl);
89 |
90 | // Replace the paragraph node with our HTML node
91 | parent.children.splice(index, 1, {
92 | type: "html",
93 | value: toHtml(compatList),
94 | });
95 | return index + 1;
96 | }
97 | });
98 | };
99 | };
100 |
101 | export default remarkCompatLists;
102 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/docs.test.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import fs from "fs";
3 | import tar from "tar";
4 | import { getMenuFromStream } from "./docs";
5 |
6 | describe("getMenuFromStream", () => {
7 | it("sorts the menu with children and stuff", async () => {
8 | let stream = await getFixtureStream();
9 | let menu = await getMenuFromStream(stream);
10 |
11 | // removes `index.md` so only 2, not 3
12 | expect(menu.length).toBe(2);
13 | expect(menu[0].attrs.title).toBe("Components");
14 | expect(menu[0].children.length).toBe(2);
15 |
16 | expect(menu[1].attrs.title).toBe("Pages");
17 | expect(menu[1].children.length).toBe(2);
18 |
19 | expect(menu[1].children.length).toBe(2);
20 | expect(menu[1].slug).toBe("pages");
21 | expect(menu[1].children[0].attrs.title).toBe("Quickstart Tutorial");
22 | expect(menu[1].children[0].slug).toBe("pages/tutorial");
23 | });
24 | });
25 |
26 | async function getFixtureStream(): Promise {
27 | let fixturePath = path.join(__dirname, "__fixture__");
28 | let writePath = path.join(fixturePath, "tar.tgz");
29 | await tar.c({ gzip: true, file: writePath }, [fixturePath]);
30 | return fs.createReadStream(writePath);
31 | }
32 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/github.ts:
--------------------------------------------------------------------------------
1 | import { Octokit } from "octokit";
2 |
3 | const GH_TOKEN = process.env.GH_TOKEN!;
4 |
5 | const env = process.env.NODE_ENV;
6 |
7 | if (env !== "test" && !GH_TOKEN) {
8 | if (env === "production") {
9 | throw new Error("No GH_TOKEN provided");
10 | }
11 | console.warn(
12 | "\nNo GH_TOKEN provided. You can increase the rate limit from 60/hr to 1000/hr by adding a token to your .env file.\n",
13 | );
14 | }
15 |
16 | const octokit = new Octokit({ auth: GH_TOKEN });
17 |
18 | export { octokit };
19 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/index.ts:
--------------------------------------------------------------------------------
1 | import { getDoc, getMenu } from "./docs";
2 | import { getBranches } from "./branches";
3 | import { getReferenceAPI } from "./reference-docs";
4 | import { getLatestVersion, getTags } from "./tags";
5 | import invariant from "tiny-invariant";
6 | import semver from "semver";
7 |
8 | export { getRepoTarballStream } from "./repo-tarball";
9 |
10 | export type { Doc } from "./docs";
11 |
12 | const REPO = process.env.SOURCE_REPO!;
13 | if (!REPO) throw new Error("Missing process.env.SOURCE_REPO");
14 |
15 | export function getRepoTags() {
16 | return getTags(REPO);
17 | }
18 |
19 | export function getRepoBranches() {
20 | return getBranches(REPO);
21 | }
22 |
23 | export async function getLatestRepoTag(): Promise {
24 | let tags = await getTags(REPO);
25 | invariant(tags, "Expected tags in getLatestRepoTag");
26 | return getLatestVersion(tags);
27 | }
28 |
29 | export function getRepoDocsMenu(ref: string, lang: string) {
30 | return getMenu(REPO, fixupRefName(ref), lang);
31 | }
32 |
33 | export async function getRepoDocsReferenceMenu(ref: string) {
34 | const api = await getReferenceAPI(REPO, ref);
35 | return api.getReferenceNav();
36 | }
37 |
38 | export function getRepoDoc(ref: string, slug: string) {
39 | return getDoc(REPO, fixupRefName(ref), slug);
40 | }
41 |
42 | export async function getRepoReferenceDoc(
43 | ref: string,
44 | pkgName: string,
45 | qualifiedName: string,
46 | ) {
47 | const api = await getReferenceAPI(REPO, ref);
48 | return api.getDoc(pkgName, qualifiedName);
49 | }
50 |
51 | export async function getPackageIndexDoc(ref: string, pkgName: string) {
52 | const api = await getReferenceAPI(REPO, ref);
53 | return api.getPackageIndexDoc(pkgName);
54 | }
55 |
56 | function fixupRefName(ref: string) {
57 | if (["dev", "main", "release-next", "local"].includes(ref)) {
58 | return ref;
59 | }
60 |
61 | // pre changesets, tags were like v6.2.0, so add a "v" to the ref from the URL
62 | if (semver.lt(ref, "6.4.0")) {
63 | return `v${ref}`;
64 | }
65 |
66 | // add react-router@ because that's what the tags are called after changesets
67 | return `react-router@${ref}`;
68 | }
69 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/repo-content.ts:
--------------------------------------------------------------------------------
1 | import fsp from "fs/promises";
2 | import invariant from "tiny-invariant";
3 | import path from "path";
4 |
5 | /**
6 | * Fetches the contents of a file in a repository or from your local disk.
7 | *
8 | * @param ref The GitHub ref, use `"local"` for local docs development
9 | * @param filepath The filepath inside the repo (including "docs/")
10 | * @returns The text of the file
11 | */
12 | export async function getRepoContent(
13 | repoPair: string,
14 | ref: string,
15 | filepath: string,
16 | ): Promise {
17 | if (ref === "local") return getLocalContent(filepath);
18 | let [owner, repo] = repoPair.split("/");
19 | let pathname = `/${owner}/${repo}/${ref}/${filepath}`;
20 | let response = await fetch(
21 | new URL(pathname, "https://raw.githubusercontent.com/").href,
22 | { headers: { "User-Agent": `docs:${owner}/${repo}` } },
23 | );
24 | if (!response.ok) return null;
25 | return response.text();
26 | }
27 |
28 | /**
29 | * Reads a single file from your local source repository
30 | */
31 | async function getLocalContent(filepath: string): Promise {
32 | invariant(
33 | process.env.LOCAL_REPO_RELATIVE_PATH,
34 | "Expected LOCAL_REPO_RELATIVE_PATH",
35 | );
36 | let localFilePath = path.join(process.env.LOCAL_REPO_RELATIVE_PATH, filepath);
37 | let content = await fsp.readFile(localFilePath);
38 | return content.toString();
39 | }
40 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/repo-tarball.ts:
--------------------------------------------------------------------------------
1 | import followRedirects from "follow-redirects";
2 | import fs from "fs";
3 | import invariant from "tiny-invariant";
4 | import path from "path";
5 | import tar from "tar";
6 |
7 | /**
8 | * Fetches a repo tarball from GitHub or your local repo as a tarball in
9 | * development.
10 | *
11 | * @param ref GitHub ref (main, v6.0.0, etc.) use "local" for local repo.
12 | * @returns The repo tarball
13 | */
14 | export async function getRepoTarballStream(
15 | repo: string,
16 | ref: string,
17 | ): Promise {
18 | if (ref === "local") {
19 | return getLocalTarballStream();
20 | }
21 |
22 | let agent = new followRedirects.https.Agent({ keepAlive: true });
23 | let tarballURL = `https://github.com/${repo}/archive/${ref}.tar.gz`;
24 | let { hostname, pathname } = new URL(tarballURL);
25 | let options = { agent: agent, hostname: hostname, path: pathname };
26 |
27 | let res = await get(options);
28 |
29 | if (res.statusCode === 200) {
30 | return res;
31 | }
32 |
33 | throw new Error(`Could not fetch ${tarballURL}`);
34 | }
35 |
36 | /**
37 | * Creates a tarball out of your local source repository so that the rest of the
38 | * code in this app can continue to work the same for local dev as in
39 | * production.
40 | */
41 | export async function getLocalTarballStream(): Promise {
42 | invariant(
43 | process.env.LOCAL_REPO_RELATIVE_PATH,
44 | "Expected LOCAL_REPO_RELATIVE_PATH",
45 | );
46 | let localDocsPath = path.join(
47 | process.cwd(),
48 | process.env.LOCAL_REPO_RELATIVE_PATH,
49 | "docs",
50 | );
51 | await tar.c({ gzip: true, file: ".local.tgz" }, [localDocsPath]);
52 | return fs.createReadStream(".local.tgz");
53 | }
54 |
55 | // FIXME: I don't know the types here
56 | function get(options: any): any {
57 | return new Promise((accept, reject) => {
58 | followRedirects.https.get(options, accept).on("error", reject);
59 | });
60 | }
61 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/tags.ts:
--------------------------------------------------------------------------------
1 | import LRUCache from "lru-cache";
2 | import parseLinkHeader from "parse-link-header";
3 | import semver from "semver";
4 | import { octokit } from "./github";
5 |
6 | /**
7 | * Fetches the repo tags
8 | */
9 | export async function getTags(repo: string) {
10 | return tagsCache.fetch(repo);
11 | }
12 |
13 | export function getLatestVersion(tags: string[]) {
14 | let sortedTags = [...tags].sort(semver.rcompare);
15 |
16 | return sortedTags.filter((tag) =>
17 | semver.satisfies(tag, "*", { includePrerelease: false }),
18 | )[0];
19 | }
20 |
21 | export function getLatestV6Version(tags: string[]) {
22 | return (
23 | tags.filter((tag) =>
24 | semver.satisfies(tag, "6.x", { includePrerelease: false }),
25 | )[0] ?? "v6"
26 | );
27 | }
28 |
29 | declare global {
30 | var tagsCache: LRUCache;
31 | }
32 |
33 | // global for SS "HMR", we need a better story here
34 | global.tagsCache ??= new LRUCache({
35 | // let tagsCache = new LRUCache({
36 | max: 3,
37 | ttl: 1000 * 60 * 5, // 5 minutes, so we can see new tags quickly
38 | allowStale: true,
39 | noDeleteOnFetchRejection: true,
40 | fetchMethod: async (key) => {
41 | console.log("Fetching fresh tags (releases)");
42 | let [owner, repo] = key.split("/");
43 | return getAllReleases(owner, repo, "react-router");
44 | },
45 | });
46 |
47 | // TODO: implementation details of the react router site leaked into here cause
48 | // I'm in a hurry now, sorry!
49 | export async function getAllReleases(
50 | owner: string,
51 | repo: string,
52 | primaryPackage: string,
53 | page = 1,
54 | releases: string[] = [],
55 | ): Promise {
56 | console.log("Fetching fresh releases, page", page);
57 | const { data, headers, status } = await octokit.rest.repos.listReleases({
58 | mediaType: { format: "json" },
59 | owner,
60 | repo,
61 | per_page: 100,
62 | page,
63 | });
64 |
65 | if (status !== 200) {
66 | throw new Error(`Failed to fetch releases: ${data}`);
67 | }
68 |
69 | releases.push(
70 | ...data
71 | .filter((release) => {
72 | return Boolean(
73 | // We aren't consistently using the "primaryPackage" tag to create
74 | // release notes (sometimes react-router, sometimes react-dom) so we
75 | // just check the release name here
76 | release.name?.startsWith("v6") ||
77 | // ideally all we care about is release.name, but we have some old
78 | // releases that don't have that set, so we check the tag name too
79 | // After changesets, we look for react-router@6.4.0
80 | release.tag_name.split("@")[0] === primaryPackage ||
81 | // pre-changesets, tag_name started with "v"
82 | release.tag_name.startsWith("v6"),
83 | );
84 | })
85 | .map((release) => {
86 | return (
87 | // pre-changesets, is like v6.2.0
88 | release.tag_name.startsWith("v6")
89 | ? release.tag_name
90 | : // with changesets its like react-router@6.4.0
91 | release.tag_name.split("@")[1] || "unknown"
92 | );
93 | }),
94 | );
95 |
96 | let parsed = parseLinkHeader(headers.link);
97 | if (parsed?.next) {
98 | return await getAllReleases(
99 | owner,
100 | repo,
101 | primaryPackage,
102 | page + 1,
103 | releases,
104 | );
105 | }
106 |
107 | return releases;
108 | }
109 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/tarball.test.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 | import { createTarFileProcessor } from "./tarball";
4 |
5 | describe("createTarFileProcessor", () => {
6 | it("extracts and processes files one-by-one", async () => {
7 | let fixturePath = path.join(process.cwd(), "test/fixture.tar.gz");
8 | let stream = fs.createReadStream(fixturePath);
9 |
10 | let processFiles = createTarFileProcessor(stream);
11 | let docs: string[] = [];
12 | await processFiles(async ({ filename }) => {
13 | docs.push(filename);
14 | });
15 |
16 | expect(docs).toMatchInlineSnapshot(`
17 | [
18 | "docs/api.md",
19 | "docs/contributing.md",
20 | "docs/faq.md",
21 | "docs/getting-started/concepts.md",
22 | "docs/getting-started/index.md",
23 | "docs/getting-started/installation.md",
24 | "docs/getting-started/overview.md",
25 | "docs/getting-started/tutorial.md",
26 | "docs/guides/index.md",
27 | "docs/guides/ssr.md",
28 | "docs/guides/testing.md",
29 | "docs/index.md",
30 | "docs/upgrading/index.md",
31 | "docs/upgrading/reach.md",
32 | "docs/upgrading/v5.md",
33 | ]
34 | `);
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/app/modules/gh-docs/.server/tarball.ts:
--------------------------------------------------------------------------------
1 | import gunzip from "gunzip-maybe";
2 | import tar from "tar-stream";
3 |
4 | type ProcessFile = ({
5 | filename,
6 | content,
7 | }: {
8 | filename: string;
9 | content: string;
10 | }) => Promise;
11 |
12 | export function createTarFileProcessor(
13 | stream: NodeJS.ReadableStream,
14 | pattern: RegExp = /docs\/(.+)\.md$/,
15 | ) {
16 | return (processFile: ProcessFile) =>
17 | processFilesFromRepoTarball(stream, pattern, processFile);
18 | }
19 |
20 | async function processFilesFromRepoTarball(
21 | stream: NodeJS.ReadableStream,
22 | pattern: RegExp = /docs\/(.+)\.md$/,
23 | processFile: ProcessFile,
24 | ): Promise {
25 | return new Promise((accept, reject) => {
26 | stream
27 | .pipe(gunzip())
28 | .pipe(tar.extract())
29 | .on("entry", async (header, stream, next) => {
30 | // Make sure the file matches the ones we want to process
31 | let isMatch = header.type === "file" && pattern.test(header.name);
32 | if (isMatch) {
33 | // remove "react-router-main" and "remix-v1.0.0" from the full name
34 | // that's something like "react-router-main/docs/index.md"
35 | let filename = removeRepoRefName(header.name);
36 | // buffer the contents of this file stream so we can send the entire
37 | // string to be processed by the caller
38 | let content = await bufferStream(stream);
39 | await processFile({ filename, content });
40 | next();
41 | } else {
42 | // ignore this entry
43 | stream.resume();
44 | stream.on("end", next);
45 | }
46 | })
47 | .on("error", reject)
48 | .on("finish", accept);
49 | });
50 | }
51 |
52 | function removeRepoRefName(headerName: string): string {
53 | return headerName.replace(/^.+?[/]/, "");
54 | }
55 |
56 | async function bufferStream(stream: NodeJS.ReadableStream): Promise {
57 | return new Promise((accept, reject) => {
58 | let chunks: Uint8Array[] = [];
59 | stream
60 | .on("error", reject)
61 | .on("data", (chunk) => chunks.push(chunk))
62 | .on("end", () => accept(Buffer.concat(chunks).toString()));
63 | });
64 | }
65 |
--------------------------------------------------------------------------------
/app/modules/http-utils/ensure-secure.ts:
--------------------------------------------------------------------------------
1 | import { redirect, type unstable_MiddlewareFunction } from "react-router";
2 |
3 | export const ensureSecure: unstable_MiddlewareFunction<
4 | void | Response
5 | > = async ({ request }) => {
6 | let proto = request.headers.get("x-forwarded-proto");
7 | // this indirectly allows `http://localhost` because there is no
8 | // "x-forwarded-proto" in the local server headers
9 | if (proto === "http") {
10 | let secureUrl = new URL(request.url);
11 | secureUrl.protocol = "https:";
12 | throw redirect(secureUrl.toString());
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/app/modules/http-utils/is-host.ts:
--------------------------------------------------------------------------------
1 | export function isHost(expectedHost: string, request: Request) {
2 | let host =
3 | request.headers.get("X-Forwarded-Host") ??
4 | request.headers.get("Host") ??
5 | "unknown";
6 |
7 | return expectedHost === host;
8 | }
9 |
--------------------------------------------------------------------------------
/app/modules/http-utils/remove-slashes.ts:
--------------------------------------------------------------------------------
1 | import { redirect, type unstable_MiddlewareFunction } from "react-router";
2 |
3 | export const removeTrailingSlashes: unstable_MiddlewareFunction<
4 | void | Response
5 | > = async ({ request }) => {
6 | let url = new URL(request.url);
7 | if (url.pathname.endsWith("/") && url.pathname !== "/") {
8 | url.pathname = url.pathname.slice(0, -1);
9 | throw redirect(url.toString());
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/app/modules/redirects/.server/__fixtures__/_redirects:
--------------------------------------------------------------------------------
1 | # ignore this
2 |
3 | /hamburger /taco
4 | /beef/* /cheese/*
5 |
6 | # Ignore this
7 |
8 | /docs/* /*
9 | /core/* https://v5.reactrouter.com/core/*
--------------------------------------------------------------------------------
/app/modules/redirects/.server/check-url.test.ts:
--------------------------------------------------------------------------------
1 | import { checkUrl } from "./check-url";
2 | import { getRedirects } from "./get-redirects";
3 | import type { Redirect } from "./get-redirects";
4 |
5 | describe("handleRedirects", () => {
6 | let redirects: Redirect[];
7 |
8 | beforeAll(async () => {
9 | vi.mock(
10 | "../../../../_redirects?raw",
11 | async () => await import("./__fixtures__/_redirects?raw"),
12 | );
13 | redirects = await getRedirects();
14 | });
15 |
16 | afterAll(() => {
17 | vi.restoreAllMocks();
18 | });
19 |
20 | it("redirects static string", async () => {
21 | let response = await checkUrl("/hamburger", redirects);
22 | expect(response?.headers.get("location")).toBe("/taco");
23 | });
24 |
25 | it("redirects splats", async () => {
26 | let response = await checkUrl("/beef/one/two/three", redirects);
27 | expect(response?.headers.get("location")).toBe("/cheese/one/two/three");
28 | });
29 |
30 | it("redirects more splats", async () => {
31 | let response = await checkUrl("/docs/one/two/three", redirects);
32 | expect(response?.headers.get("location")).toBe("/one/two/three");
33 | });
34 |
35 | it("redirects to root", async () => {
36 | let response = await checkUrl("/docs", redirects);
37 | expect(response?.headers.get("location")).toBe("/");
38 | });
39 |
40 | it("redirects splats to other domains", async () => {
41 | let response = await checkUrl("/core/one/two", redirects);
42 | expect(response?.headers.get("location")).toBe(
43 | "https://v5.reactrouter.com/core/one/two",
44 | );
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/app/modules/redirects/.server/check-url.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from "react-router";
2 | import type { Redirect } from "./get-redirects";
3 |
4 | export async function checkUrl(url: string, redirects: Redirect[]) {
5 | for (let r of redirects) {
6 | let [from, to, splat, status] = r;
7 | let match = splat ? url.startsWith(from) : from === url;
8 | if (match) {
9 | let location = to;
10 | if (to.endsWith("/*")) {
11 | let base = to.slice(0, -2);
12 | let splatPath = url.replace(from, "");
13 | location = base + splatPath;
14 | }
15 | if (
16 | !location.startsWith("/") &&
17 | !location.startsWith("http://") &&
18 | !location.startsWith("https://")
19 | ) {
20 | location = "/" + location;
21 | }
22 | return redirect(location, { status });
23 | }
24 | }
25 | return null;
26 | }
27 |
--------------------------------------------------------------------------------
/app/modules/redirects/.server/get-redirects.ts:
--------------------------------------------------------------------------------
1 | import redirectsFileContents from "../../../../_redirects?raw";
2 |
3 | export type Redirect = [string, string, boolean, number?];
4 | let redirects: null | Redirect[] = null;
5 |
6 | /**
7 | * Reads and caches the redirects file.
8 | *
9 | * @param relativePath path of the redirects file relative to process.cwd(),
10 | * defaults to `_redirects`
11 | * @returns string
12 | */
13 | export async function getRedirects() {
14 | if (redirects) {
15 | return redirects;
16 | }
17 |
18 | console.log("Reading redirects file");
19 | redirects = redirectsFileContents
20 | .split("\n")
21 | .reduce((redirects, line: string) => {
22 | if (line.startsWith("#") || line.trim() === "") {
23 | return redirects;
24 | }
25 |
26 | let code = 302;
27 | let splat = false;
28 | let [from, to, maybeCode] = line.split(/\s+/);
29 | if (maybeCode) {
30 | code = parseInt(maybeCode, 10);
31 | }
32 | // super basic support for splats
33 | if (from.endsWith("/*")) {
34 | from = from.slice(0, -2);
35 | splat = true;
36 | }
37 | redirects.push([from, to, splat, code]);
38 |
39 | return redirects;
40 | }, [] as Redirect[]);
41 |
42 | return redirects;
43 | }
44 |
--------------------------------------------------------------------------------
/app/modules/redirects/.server/index.ts:
--------------------------------------------------------------------------------
1 | import { type unstable_MiddlewareFunction } from "react-router";
2 | import { checkUrl } from "./check-url";
3 | import { getRedirects } from "./get-redirects";
4 |
5 | /**
6 | * Super basic redirects handling with a redirects file.
7 | *
8 | * ```
9 | * # from to status
10 | * /cheese /taco 302
11 | *
12 | * # very, very, very basic support for trailing splat
13 | * /docs/* /api/*
14 | * ```
15 | *
16 | * @param request Web Fetch Request to possibly redirect
17 | */
18 | export const handleRedirects: unstable_MiddlewareFunction<
19 | void | Response
20 | > = async ({ request }) => {
21 | let redirects = await getRedirects();
22 | let url = new URL(request.url);
23 | let response = await checkUrl(url.pathname, redirects);
24 | if (response) throw response;
25 | };
26 |
--------------------------------------------------------------------------------
/app/modules/remix-seo/index.ts:
--------------------------------------------------------------------------------
1 | export { getSeo } from "./seo";
2 |
--------------------------------------------------------------------------------
/app/modules/remix-seo/seo.test.ts:
--------------------------------------------------------------------------------
1 | import { getSeo } from "./seo";
2 |
3 | describe("getSeo", () => {
4 | it("provides default stuff", () => {
5 | let seo = getSeo({});
6 | expect(seo({})).toMatchInlineSnapshot(`
7 | [
8 | [],
9 | [],
10 | ]
11 | `);
12 | });
13 |
14 | it("provides a default title", () => {
15 | let seo = getSeo({ defaultTitle: "Test default title" });
16 | expect(seo({})).toMatchInlineSnapshot(`
17 | [
18 | [
19 | {
20 | "title": "Test default title",
21 | },
22 | ],
23 | [],
24 | ]
25 | `);
26 | });
27 |
28 | it("overrides the title", () => {
29 | let DEFAULT_TITLE = "default title";
30 | let TITLE = "real title";
31 | let seo = getSeo({ defaultTitle: DEFAULT_TITLE });
32 | let [meta] = seo({ title: TITLE });
33 | let titleMeta = meta.find((m) => "title" in m && m.title != null);
34 | expect("title" in titleMeta! ? titleMeta.title : null).toBe(TITLE);
35 | });
36 |
37 | it("defaults the description for everybody who who hates the normal description", () => {
38 | let seo = getSeo({});
39 | let [meta] = seo({ description: "Heyooo" });
40 | expect(meta).toMatchInlineSnapshot(`
41 | [
42 | {
43 | "content": "Heyooo",
44 | "name": "description",
45 | },
46 | {
47 | "content": "Heyooo",
48 | "name": "og:description",
49 | },
50 | ]
51 | `);
52 | });
53 |
54 | it("adds the host to the images", () => {
55 | let seo = getSeo({ host: "test://example.com" });
56 | let [meta] = seo({
57 | openGraph: { images: [{ url: "/beef.jpg", alt: "beef!" }] },
58 | twitter: { image: { url: "/beef.jpg", alt: "beef!" } },
59 | });
60 | expect(meta).toMatchInlineSnapshot(`
61 | [
62 | {
63 | "content": "test://example.com/beef.jpg",
64 | "name": "twitter:image",
65 | },
66 | {
67 | "content": "beef!",
68 | "name": "twitter:image:alt",
69 | },
70 | {
71 | "content": "summary",
72 | "name": "twitter:card",
73 | },
74 | {
75 | "content": "test://example.com/beef.jpg",
76 | "name": "og:image",
77 | },
78 | {
79 | "content": "beef!",
80 | "name": "og:image:alt",
81 | },
82 | ]
83 | `);
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/app/modules/stats/index.tsx:
--------------------------------------------------------------------------------
1 | import LRUCache from "lru-cache";
2 | import { octokit } from "../gh-docs/.server/github";
3 |
4 | export interface Stats {
5 | label: string;
6 | svgId: string;
7 | count: number;
8 | }
9 |
10 | interface StatCounts {
11 | npmDownloads: number;
12 | githubContributors: number;
13 | githubStars: number;
14 | githubDependents: number;
15 | }
16 |
17 | declare global {
18 | var statCountsCache: LRUCache;
19 | }
20 |
21 | global.statCountsCache ??= new LRUCache({
22 | // let statCountsCache = new LRUCache({
23 | max: 3,
24 | // ttl: process.env.NO_CACHE ? 1 : 1000 * 60 * 60, // 1 hour
25 | ttl: 1000 * 60 * 60, // 1 hour
26 | allowStale: true,
27 | noDeleteOnFetchRejection: true,
28 | fetchMethod: async () => {
29 | console.log("Fetching fresh stats");
30 | let [npmDownloads, githubContributors, githubStars, githubDependents] =
31 | await Promise.all([
32 | fetchNpmDownloads(),
33 | fetchGithubContributors(),
34 | fetchGithubStars(),
35 | fetchGithubDependents(),
36 | ]);
37 | return {
38 | npmDownloads,
39 | githubContributors,
40 | githubStars,
41 | githubDependents,
42 | };
43 | },
44 | });
45 |
46 | export async function getStats(): Promise {
47 | let cacheKey = "ONE_STATS_KEY_TO_RULE_THEM_ALL";
48 | let statCounts = await statCountsCache.fetch(cacheKey);
49 |
50 | return [
51 | {
52 | count: statCounts.npmDownloads,
53 | label: "Downloads on npm",
54 | svgId: "stat-download",
55 | },
56 | {
57 | count: statCounts.githubContributors,
58 | label: "Contributors on GitHub",
59 | svgId: "stat-users",
60 | },
61 | {
62 | count: statCounts.githubStars,
63 | label: "Stars on GitHub",
64 | svgId: "stat-star",
65 | },
66 | {
67 | count: statCounts.githubDependents,
68 | label: "Dependents on GitHub",
69 | svgId: "stat-box",
70 | },
71 | ];
72 | }
73 |
74 | /**
75 | * Haven't figured out a good way to get this yet, so it's hard-coded from:
76 | * https://github.com/remix-run/react-router/network/dependents?package_id=UGFja2FnZS00OTM0MDEzMDg%3D
77 | * @returns {Number}
78 | */
79 | async function fetchGithubDependents() {
80 | return 3597612;
81 | }
82 |
83 | /**
84 | * Fetch public stats from npm
85 | * https://github.com/npm/registry/blob/master/docs/download-counts.md
86 | *
87 | * @returns {Number}
88 | */
89 | async function fetchNpmDownloads() {
90 | try {
91 | // You can only get stats for 18 months at a time
92 | // But doing date math is hard, so we'll just do 1 year at a time
93 | // Earliest date for data is 2015-01-10, so we start there
94 | // Hacky? Sure, but it doesn't break until like 2036 so we're good
95 | const currentYear = new Date().getFullYear();
96 | let urls = [];
97 | let year = 2015;
98 | let day = 10;
99 | while (year <= currentYear) {
100 | urls.push(
101 | `https://api.npmjs.org/downloads/point/${year}-01-${day}:${
102 | year + 1
103 | }-01-${day}/react-router`,
104 | );
105 | year++;
106 | day++;
107 | }
108 | const queryData = await Promise.all(
109 | urls.map((url) => fetch(url).then((res) => res.json())),
110 | );
111 | const allTimeDownloads = queryData.reduce(
112 | (acc, { downloads = 0 }) => acc + downloads,
113 | 0,
114 | );
115 | return allTimeDownloads;
116 | } catch (e) {
117 | // If this fails for some reason, just return a hard-coded value fetched
118 | // on June 17, 2022
119 | console.error(
120 | "Failed to fetch stats for npm downloads. Falling back to a hard-coded value",
121 | e,
122 | );
123 | return 844617220;
124 | }
125 | }
126 |
127 | /**
128 | * Fetch the number of contributors from a header in the GitHub API
129 | * https://stackoverflow.com/questions/44347339/github-api-how-efficiently-get-the-total-contributors-amount-per-repository/60458265
130 | * @returns {Number}
131 | */
132 | async function fetchGithubContributors() {
133 | try {
134 | const { headers } = await octokit.rest.repos.listContributors({
135 | owner: "remix-run",
136 | repo: "react-router",
137 | anon: "true",
138 | per_page: 1,
139 | });
140 | const link = headers.link || "";
141 | const matches = link.match(/rel="next".*&page=(\d*)/);
142 | if (!(matches && matches[1])) {
143 | throw Error("Unable to find the number of contributors using a regex.");
144 | }
145 | return Number(matches[1]);
146 | } catch (e) {
147 | console.error(
148 | "Failed to fetch stats for GitHub contributors. Falling back to a hard-coded value",
149 | e,
150 | );
151 | // Return a hard-coded value retrieved manually on Jun 15, 2022
152 | return 737;
153 | }
154 | }
155 |
156 | /**
157 | * Fetch the number of stars from Github
158 | * @returns {Number}
159 | */
160 | async function fetchGithubStars() {
161 | try {
162 | const {
163 | data: { stargazers_count },
164 | } = await octokit.rest.repos.get({
165 | owner: "remix-run",
166 | repo: "react-router",
167 | });
168 | return stargazers_count;
169 | } catch (e) {
170 | console.log(
171 | "Failed to fetch stats for GitHub stars. Falling back to a hard-coded value",
172 | e,
173 | );
174 | // Return hard-coded value retrieved Jun 15, 2022
175 | return 47245;
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/app/pages/brand.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import type { Route } from "./+types/brand";
3 |
4 | export const meta: Route.MetaFunction = () => {
5 | return [{ title: "React Router Assets and Branding Guidelines" }];
6 | };
7 |
8 | const BRAND_DIR = "/_brand/React Router Brand Assets";
9 |
10 | export default function Brand() {
11 | return (
12 |
13 |
14 | React Router Brand
15 |
16 |
17 | These assets are provided for use in situations like articles and video
18 | tutorials.
19 |
20 | Trademark Usage Agreement
21 |
The React Router name and logos are trademarks of Shopify Inc.
22 |
23 | You may not use the React Router name or logos in any way that could
24 | mistakenly imply any official connection with or endorsement of Shopify
25 | Inc. Any use of the React Router name or logos in a manner that could
26 | cause customer confusion is not permitted.
27 |
28 |
29 | Additionally, you may not use our trademarks for t-shirts, stickers, or
30 | other merchandise without explicit written consent.
31 |
32 |
33 | Download Assets
34 |
35 | You can download a zip file containing all the React Router brand
36 | assets:
37 |