├── .eslintrc.json
├── .gitignore
├── .nojekyll
├── 404.html
├── CNAME
├── LICENSE
├── README.md
├── build
├── bundle.js
└── bundle.js.map
├── favicon
├── green-grid-144-168-192-180x180.png
├── green-grid-144-168-192-512x512.png
├── green-grid-144-168-192.svg
└── site.webmanifest
├── index.html
├── package-lock.json
├── package.json
├── robots.txt
├── sitemap.txt
├── src
├── App.tsx
├── components
│ ├── Breadcrumbs.tsx
│ ├── ExampleComponent.tsx
│ ├── ExampleTwoDeepComponent.tsx
│ ├── Home.tsx
│ ├── PageNotFound.tsx
│ └── SitemapLinkGenerator.tsx
├── index.tsx
├── stitches.config.ts
└── ui
│ ├── Button.tsx
│ ├── DarkModeButton.tsx
│ ├── GitHubIconLink.tsx
│ ├── InteractiveLink.tsx
│ └── Paragraph.tsx
├── tsconfig.json
└── webpack.config.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "react-app"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directory
2 | node_modules
3 |
4 | # Transpilied files generated by typescript
5 | generated
6 |
7 | # Files marked with gitigx
8 | *gitigx*
9 |
--------------------------------------------------------------------------------
/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rafgraph/spa-github-pages/4b898016074be230db5196111c7b3ad866914764/.nojekyll
--------------------------------------------------------------------------------
/404.html:
--------------------------------------------------------------------------------
1 | <!DOCTYPE html>
2 | <html>
3 | <head>
4 | <meta charset="utf-8">
5 | <title>Single Page Apps for GitHub Pages</title>
6 | <script type="text/javascript">
7 | // Single Page Apps for GitHub Pages
8 | // MIT License
9 | // https://github.com/rafgraph/spa-github-pages
10 | // This script takes the current url and converts the path and query
11 | // string into just a query string, and then redirects the browser
12 | // to the new url with only a query string and hash fragment,
13 | // e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
14 | // https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
15 | // Note: this 404.html file must be at least 512 bytes for it to work
16 | // with Internet Explorer (it is currently > 512 bytes)
17 |
18 | // If you're creating a Project Pages site and NOT using a custom domain,
19 | // then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
20 | // This way the code will only replace the route part of the path, and not
21 | // the real directory in which the app resides, for example:
22 | // https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
23 | // https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
24 | // Otherwise, leave pathSegmentsToKeep as 0.
25 | var pathSegmentsToKeep = 0;
26 |
27 | var l = window.location;
28 | l.replace(
29 | l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') +
30 | l.pathname.split('/').slice(0, 1 + pathSegmentsToKeep).join('/') + '/?/' +
31 | l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
32 | (l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
33 | l.hash
34 | );
35 |
36 | </script>
37 | </head>
38 | <body>
39 | </body>
40 | </html>
41 |
--------------------------------------------------------------------------------
/CNAME:
--------------------------------------------------------------------------------
1 | spa-github-pages.rafgraph.dev
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Rafael Pedicini
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 | # Single Page Apps for GitHub Pages
2 |
3 | [Demo app][demoapp]
4 |
5 | This is a lightweight solution for deploying single page apps with [GitHub Pages][ghpagesoverview]. You can easily deploy a [React][react] single page app with [React Router][reactrouter] `<BrowserRouter />`, like the one in the [demo app][demoapp], or a single page app built with any frontend library or framework.
6 |
7 | #### Why it's necessary
8 |
9 | GitHub Pages doesn't natively support single page apps. When there is a fresh page load for a url like `example.tld/foo`, where `/foo` is a frontend route, the GitHub Pages server returns 404 because it knows nothing of `/foo`.
10 |
11 | #### How it works
12 |
13 | When the GitHub Pages server gets a request for a path defined with frontend routes, e.g. `example.tld/foo`, it returns a custom `404.html` page. The [custom `404.html` page contains a script][404html] that takes the current url and converts the path and query string into just a query string, and then redirects the browser to the new url with only a query string and hash fragment. For example, `example.tld/one/two?a=b&c=d#qwe`, becomes `example.tld/?/one/two&a=b~and~c=d#qwe`.
14 |
15 | The GitHub Pages server receives the new request, e.g. `example.tld/?/...`, ignores the query string and returns the `index.html` file, which has a [script that checks for a redirect in the query string][indexhtmlscript] before the single page app is loaded. If a redirect is present it is converted back into the correct url and added to the browser's history with `window.history.replaceState(...)`, but the browser won't attempt to load the new url. When the [single page app is loaded][indexhtmlspa] further down in the `index.html` file, the correct url will be waiting in the browser's history for the single page app to route accordingly. (Note that these redirects are only needed with fresh page loads, and not when navigating within the single page app once it's loaded).
16 |
17 | ## Usage instructions
18 |
19 | _For general information on using GitHub Pages please see [Getting Started with GitHub Pages][ghpagesbasics], note that pages can be [User, Organization or Project Pages][ghpagestypes]_
20 |
21 |
22 | **Basic instructions** - there are two things you need from this repo for your single page app to run on GitHub Pages.
23 |
24 | 1. Copy over the [`404.html`][404html] file to your repo as is
25 | - Note that if you are setting up a Project Pages site and not using a [custom domain][customdomain] (i.e. your site's address is `username.github.io/repo-name`), then you need to set [`pathSegmentsToKeep` to `1` in the `404.html` file][pathsegmentstokeep] in order to keep `/repo-name` in the path after the redirect. If you are using React Router you'll need to tell it to use the `repo-name` as the `basename`, for example `<BrowserRouter basename="/repo-name" />`.
26 | 2. Copy the [redirect script][indexhtmlscript] in the `index.html` file and add it to your `index.html` file - Note that the redirect script must be placed _before_ your single page app script in your `index.html` file.
27 |
28 |
29 | **Detailed instructions** - using this repo as a boilerplate for a React single page app hosted with GitHub Pages. Note that this boilerplate is written in TypeScript but is setup to accept JavaScript files as well. It was previously written in JS and if you prefer a JS only boilerplate you can use [version 6][spa-github-pages-v6].
30 |
31 | 1. Clone this repo (`$ git clone https://github.com/rafgraph/spa-github-pages.git`)
32 | 2. Delete the `.git` directory (`cd` into the `spa-github-pages` directory and run `$ rm -rf .git`)
33 | 3. Instantiate the repository
34 | - If you're using this boilerplate as a new repository
35 | - `$ git init` in the `spa-github-pages` directory, and then `$ git add .` and `$ git commit -m "Add SPA for GitHub Pages boilerplate"` to initialize a fresh repository
36 | - If this will be a Project Pages site, then change the branch name from `main` to `gh-pages` (`$ git branch -m gh-pages`), if this will be a User or Organization Pages site, then leave the branch name as `main`
37 | - Create an empty repo on GitHub.com (don't add a readme, gitignore or license), and add it as a remote to the local repo (`$ git remote add origin <your-new-github-repo-url>`)
38 | - Feel free to rename the local `spa-github-pages` directory to anything you want (e.g. `your-project-name`)
39 | - If you're adding this boilerplate as the `gh-pages` branch of an existing repository
40 | - Create and checkout a new orphaned branch named `gh-pages` for your existing repo (`$ git checkout --orphan gh-pages`), note that the `gh-pages` branch won't appear in the list of branches until you make your first commit
41 | - Delete all of the files and directories (except the `.git` directory) from the directory of your existing repo (`$ git rm -rf .`)
42 | - Copy all of the files and directories (including hidden dot files) from the cloned `spa-github-pages` directory into your project's now empty directory (`$ mv path/to/spa-github-pages/{.[!.],}* path/to/your-projects-directory`)
43 | - `$ git add .` and `$ git commit -m "Add SPA for GitHub Pages boilerplate"` to instantiate the `gh-pages` branch
44 | 4. Set up a custom domain (optional) - see GitHub Pages instructions for [setting up a custom domain][customdomain]
45 | - Update the [`CNAME` file][cnamefile] with your custom domain, don't include `https://`, but do include a subdomain if desired, e.g. `www` or `your-subdomain`
46 | - Update your `CNAME` and/or `A` record with your DNS provider
47 | - Run `$ dig your-subdomain.your-domain.tld` to make sure it's set up properly with your DNS (don't include `https://`)
48 | 5. Set up without using a custom domain (optional)
49 | - Delete the [`CNAME` file][cnamefile]
50 | - If you are creating a User or Organization Pages site, then that's all you need to do
51 | - If you are creating a Project Pages site, (i.e. your site's address is `username.github.io/repo-name`):
52 | - Set [`pathSegmentsToKeep` to `1` in the `404.html` file][pathsegmentstokeep] in order to keep `/repo-name` in the path after the redirect
53 | - Add your `repo-name` to the absolute path of assets in `index.html`, change the [bundle.js src][indexhtmlspa] to `"/repo-name/build/bundle.js"`
54 | - In React Router set the `basename` to `/repo-name` [here][browserrouter] like `<BrowserRouter basename="/repo-name" />`
55 | - In the [start script][startscript] in `package.json` replace `--open` with `--open-page repo-name`
56 | - In `webpack.config.js`:
57 | - Add `repo-name` to the [`publicPath`][webpackpublicpath] like `publicPath: '/repo-name/build/'`
58 | - Change the [`historyApiFallback rewrites`][webpackdevrewrites] to `rewrites: [{ from: /\/repo-name\/[^?]/, to: '/404.html' }]`
59 | 6. Run `$ npm install` to install React and other dependencies, and then run `$ npm run build` to update the build
60 | 7. `$ git add .` and `$ git commit -m "Update boilerplate for use with my domain"` and then push to GitHub (`$ git push origin gh-pages` for Project Pages or `$ git push origin main` for User or Organization Pages) - the example site should now be live on your domain
61 | 8. Create your own site
62 | - Write your own React components, create your own routes, and add your own style
63 | - Note that the example site is styled with [Stitches][stitches] and uses [React Interactive][reactinteractive] for the links and other interactive components.
64 | - Change the [title in `index.html`][indexhtmltitle] and the [title in `404.html`][404htmltitle] to your site's title
65 | - Remove the [favicon links][favicon] from the header of `index.html` and the [`favicon` directory][favicondir].
66 | - Update or delete [`robots.txt`][robots] and [`sitemap.txt`][sitemap] as you see fit (see SEO section below for more info)
67 | - Change the readme, license and package.json as you see fit
68 | - For testing changes locally see development environment info below
69 | - To publish your changes to GitHub Pages run `$ npm run build` (this runs `webpack -p` for [production][webpackproduction]) to update the build, then `$ git commit` and `$ git push` to make your changes live
70 |
71 | **Serving from the `/docs` folder on the `main` branch** - alternatively you can serve your site from the `/docs` folder instead of the root folder while your source code remains in the root folder.
72 |
73 | 1. After following the previous set of instructions for using this repo as a boilerplate, create a `/docs` folder in the root and move `index.html`, `404.html` and the `/build` folder into `/docs`
74 | 2. Add `--content-base docs/` to the [start script][startscript] in `package.json`
75 | 3. In `webpack.config.js` change the [output path][webpackoutputpath] to `` path: `${__dirname}/docs/build`, ``
76 | 4. On GitHub in your repo settings select the `/docs` folder as the source for GitHub Pages
77 |
78 | #### Development environment
79 |
80 | I have included `webpack-dev-server` for testing changes locally. It can be accessed by running `$ npm start` (details below). Note that `webpack-dev-server` automatically creates a new bundle whenever the source files change and serves the bundle from memory, so you'll never see the bundle as a file saved to disk.
81 |
82 | - `$ npm start` runs the [start script][startscript] in `package.json`, which runs the command `$ webpack-dev-server --host 0.0.0.0 --disable-host-check --open`
83 | - `--host 0.0.0.0 --disable-host-check` is so you can access the site on your local network from other devices at `http://[YOUR COMPUTER'S IP ADDRESS]:8080`
84 | - `--open` will open automatically open the site in your browser
85 | - `webpack-dev-server` will serve `index.html` at `http://localhost:8080` (port `8080` is the default). Note that you must load the `index.html` from the server and not just open it directly in the browser or the scripts won't load.
86 |
87 | #### SEO
88 |
89 | When I first created this solution in 2016 Google treated the redirect in `404.html` the same as a 301 redirect and indexed pages without issue. Around 2019 Google changed their algorithm and no longer follows redirects in `404.html`. In order to have all the pages on your site indexed by Google you need to create a `robots.txt` and `sitemap.txt` file to let Google know what pages exist. The [`robots.txt`][robots] file needs to contain the location of the sitemap, and the [`sitemap.txt`][sitemap] file needs to contain the redirect links for each page of your site so the crawler doesn't get a 404 response when it requests the page. To make this easier I created a [sitemap link generator][sitemaplinkgenerator] that transforms normal links into redirect links to use in the sitemap. I have done this for the demo site (this repo) and you can see the [pages indexed here][googlesitesearch]. Note that since Google is no longer associating the redirect links with the real paths, incoming links from other sites won't help your site's page rank. If you are creating a site where page rank on generic search terms is important, then I'd suggest looking for another solution. Some options are using GitHub Pages with a static site generator like [Gatsby][gatsby] which generates an `html` file for each page as part of its build process, or hosting your single page app on a service that has native support for spas, like [Netlify][netlify].
90 |
91 | #### Miscellaneous
92 |
93 | - The `.nojekyll` file in this repo turns off Jekyll for GitHub Pages
94 | - One of the great things about the GitHub Pages CDN is that all files are automatically compressed with gzip, so no need to worry about compressing your JavaScript, HTML or CSS files for production
95 |
96 | <!-- links to within repo -->
97 |
98 | [indexhtmltitle]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/index.html#L6
99 | [favicon]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/index.html#L13
100 | [indexhtmlscript]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/index.html#L21-L42
101 | [indexhtmlspa]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/index.html#L49
102 | [404html]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/404.html
103 | [404htmltitle]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/404.html#L5
104 | [pathsegmentstokeep]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/404.html#L25
105 | [browserrouter]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/src/index.tsx#L8
106 | [webpackoutputpath]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/webpack.config.js#L6
107 | [webpackpublicpath]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/webpack.config.js#L7
108 | [webpackdevrewrites]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/webpack.config.js#L48
109 | [startscript]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/package.json#L7
110 | [cnamefile]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/CNAME
111 | [favicondir]: https://github.com/rafgraph/spa-github-pages/tree/gh-pages/favicon
112 | [robots]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/robots.txt
113 | [sitemap]: https://github.com/rafgraph/spa-github-pages/blob/gh-pages/sitemap.txt
114 | [spa-github-pages-v6]: https://github.com/rafgraph/spa-github-pages/tree/v6.0.0
115 |
116 | <!-- links to github docs -->
117 |
118 | [ghpagesoverview]: https://pages.github.com/
119 | [ghpagesbasics]: https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/getting-started-with-github-pages
120 | [ghpagestypes]: https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/about-github-pages#types-of-github-pages-sites
121 | [customdomain]: https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site
122 |
123 | <!-- other links -->
124 |
125 | [demoapp]: https://spa-github-pages.rafgraph.dev
126 | [sitemaplinkgenerator]: https://spa-github-pages.rafgraph.dev/sitemap-link-generator
127 | [react]: https://github.com/facebook/react
128 | [reactrouter]: https://github.com/ReactTraining/react-router
129 | [webpackproduction]: https://webpack.js.org/guides/production-build/#the-automatic-way
130 | [stitches]: https://stitches.dev/
131 | [reactinteractive]: https://github.com/rafgraph/react-interactive
132 | [googlesitesearch]: https://www.google.com/search?q=site%3Aspa-github-pages.rafgraph.dev
133 | [gatsby]: https://github.com/gatsbyjs/gatsby
134 | [netlify]: https://www.netlify.com/blog/2020/04/07/creating-better-more-predictable-redirect-rules-for-spas
135 |
--------------------------------------------------------------------------------
/favicon/green-grid-144-168-192-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rafgraph/spa-github-pages/4b898016074be230db5196111c7b3ad866914764/favicon/green-grid-144-168-192-180x180.png
--------------------------------------------------------------------------------
/favicon/green-grid-144-168-192-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rafgraph/spa-github-pages/4b898016074be230db5196111c7b3ad866914764/favicon/green-grid-144-168-192-512x512.png
--------------------------------------------------------------------------------
/favicon/green-grid-144-168-192.svg:
--------------------------------------------------------------------------------
1 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
2 | <g fill="#009000">
3 | <path d="M0,12 h4 v4 h-4 z"/>
4 | <path d="M6,12 h4 v4 h-4 z"/>
5 | <path d="M12,12 h4 v4 h-4 z"/>
6 | </g>
7 | <g fill="#00A800">
8 | <path d="M0,6 h4 v4 h-4 z"/>
9 | <path d="M6,6 h4 v4 h-4 z"/>
10 | <path d="M12,6 h4 v4 h-4 z"/>
11 | </g>
12 | <g fill="#00C000">
13 | <path d="M0,0 h4 v4 h-4 z"/>
14 | <path d="M6,0 h4 v4 h-4 z"/>
15 | <path d="M12,0 h4 v4 h-4 z"/>
16 | </g>
17 | </svg>
--------------------------------------------------------------------------------
/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spa-github-pages",
3 | "icons": [
4 | {
5 | "src": "/favicon/green-grid-144-168-192-512x512.png",
6 | "sizes": "512x512",
7 | "type": "image/png"
8 | }
9 | ],
10 | "theme_color": "#000000",
11 | "background_color": "#007800",
12 | "display": "browser"
13 | }
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 | <!DOCTYPE html>
2 | <html>
3 | <head>
4 | <meta charset="utf-8">
5 | <meta name="viewport" content="width=device-width, initial-scale=1">
6 | <title>Single Page Apps for GitHub Pages</title>
7 | <meta name="description" content="Lightweight solution for deploying single page apps with GitHub Pages.">
8 |
9 | <style>
10 | html { background-color: rgb(0, 120, 0); }
11 | </style>
12 |
13 | <!-- favicon -->
14 | <link rel="icon" type=”image/svg+xml” href="/favicon/green-grid-144-168-192.svg">
15 | <link rel="alternate icon" type="image/png" href="/favicon/green-grid-144-168-192-512x512.png">
16 | <link rel="apple-touch-icon" href="/favicon/green-grid-144-168-192-180x180.png">
17 | <link rel="manifest" href="/favicon/site.webmanifest">
18 | <meta name="theme-color" content="#000000">
19 |
20 | <!-- Start Single Page Apps for GitHub Pages -->
21 | <script type="text/javascript">
22 | // Single Page Apps for GitHub Pages
23 | // MIT License
24 | // https://github.com/rafgraph/spa-github-pages
25 | // This script checks to see if a redirect is present in the query string,
26 | // converts it back into the correct url and adds it to the
27 | // browser's history using window.history.replaceState(...),
28 | // which won't cause the browser to attempt to load the new url.
29 | // When the single page app is loaded further down in this file,
30 | // the correct url will be waiting in the browser's history for
31 | // the single page app to route accordingly.
32 | (function(l) {
33 | if (l.search[1] === '/' ) {
34 | var decoded = l.search.slice(1).split('&').map(function(s) {
35 | return s.replace(/~and~/g, '&')
36 | }).join('?');
37 | window.history.replaceState(null, null,
38 | l.pathname.slice(0, -1) + decoded + l.hash
39 | );
40 | }
41 | }(window.location))
42 | </script>
43 | <!-- End Single Page Apps for GitHub Pages -->
44 |
45 | </head>
46 | <body>
47 | <div id="root"></div>
48 | <!-- single page app in bundle.js -->
49 | <script src="/build/bundle.js"></script>
50 | </body>
51 | </html>
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spa-github-pages",
3 | "version": "6.1.0",
4 | "private": true,
5 | "description": "Single Page Apps for GitHub Pages",
6 | "scripts": {
7 | "start": "webpack-dev-server --host 0.0.0.0 --disable-host-check --open",
8 | "build": "webpack -p"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/rafgraph/spa-github-pages.git"
13 | },
14 | "author": "Rafael Pedicini <rafael@rafgraph.dev>",
15 | "license": "MIT",
16 | "dependencies": {
17 | "@radix-ui/react-icons": "^1.0.3",
18 | "@stitches/react": "^0.1.9",
19 | "prop-types": "^15.7.2",
20 | "react": "^17.0.2",
21 | "react-dom": "^17.0.2",
22 | "react-interactive": "^1.1.0",
23 | "react-router-dom": "^5.2.0",
24 | "use-dark-mode": "^2.3.1"
25 | },
26 | "devDependencies": {
27 | "@types/react": "^17.0.4",
28 | "@types/react-dom": "^17.0.3",
29 | "@types/react-router-dom": "^5.1.7",
30 | "@typescript-eslint/eslint-plugin": "^4.22.0",
31 | "@typescript-eslint/parser": "^4.22.0",
32 | "eslint": "^7.25.0",
33 | "eslint-config-react-app": "^6.0.0",
34 | "eslint-plugin-import": "^2.22.1",
35 | "eslint-plugin-jsx-a11y": "^6.4.1",
36 | "eslint-plugin-react": "^7.23.2",
37 | "eslint-plugin-react-hooks": "^4.2.0",
38 | "husky": "^4.3.8",
39 | "lint-staged": "^10.5.4",
40 | "prettier": "^2.2.1",
41 | "source-map-loader": "^1.1.3",
42 | "terser-webpack-plugin": "^4.2.3",
43 | "ts-loader": "^8.2.0",
44 | "typescript": "^4.2.4",
45 | "webpack": "^4.46.0",
46 | "webpack-cli": "^3.3.12",
47 | "webpack-dev-server": "^3.11.0"
48 | },
49 | "prettier": {
50 | "trailingComma": "all",
51 | "singleQuote": true
52 | },
53 | "husky": {
54 | "hooks": {
55 | "pre-commit": "lint-staged"
56 | }
57 | },
58 | "lint-staged": {
59 | "src/**/*": "prettier --write --ignore-unknown"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/robots.txt:
--------------------------------------------------------------------------------
1 | Sitemap: https://spa-github-pages.rafgraph.dev/sitemap.txt
--------------------------------------------------------------------------------
/sitemap.txt:
--------------------------------------------------------------------------------
1 | https://spa-github-pages.rafgraph.dev/
2 | https://spa-github-pages.rafgraph.dev/?/example
3 | https://spa-github-pages.rafgraph.dev/?/example/two-deep&field1=foo~and~field2=bar#boom!
4 | https://spa-github-pages.rafgraph.dev/?/sitemap-link-generator
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Switch, Route } from 'react-router-dom';
3 | import { DarkModeButton } from './ui/DarkModeButton';
4 | import { GitHubIconLink } from './ui/GitHubIconLink';
5 | import { globalCss, styled } from './stitches.config';
6 | import { Home } from './components/Home';
7 | import { ExampleComponent } from './components/ExampleComponent';
8 | import { ExampleTwoDeepComponent } from './components/ExampleTwoDeepComponent';
9 | import { SitemapLinkGenerator } from './components/SitemapLinkGenerator';
10 | import { PageNotFound } from './components/PageNotFound';
11 | import { Breadcrumbs } from './components/Breadcrumbs';
12 |
13 | const AppContainer = styled('div', {
14 | maxWidth: '540px',
15 | padding: '12px 15px 25px',
16 | margin: '0 auto',
17 | });
18 |
19 | const HeaderContainer = styled('header', {
20 | display: 'flex',
21 | justifyContent: 'space-between',
22 | marginBottom: '18px',
23 | });
24 |
25 | const H1 = styled('h1', {
26 | fontSize: '26px',
27 | marginRight: '16px',
28 | });
29 |
30 | const HeaderIconContainer = styled('span', {
31 | width: '78px',
32 | display: 'inline-flex',
33 | justifyContent: 'space-between',
34 | gap: '12px',
35 | });
36 |
37 | const BreadcrumbsNav = styled('nav', {
38 | margin: '18px 0',
39 | });
40 |
41 | export const App: React.VFC = () => {
42 | globalCss();
43 |
44 | return (
45 | <AppContainer>
46 | <HeaderContainer>
47 | <H1>Single Page Apps for GitHub Pages</H1>
48 | <HeaderIconContainer>
49 | <DarkModeButton />
50 | <GitHubIconLink
51 | href="https://github.com/rafgraph/spa-github-pages"
52 | title="GitHub repository for SPA GitHub Pages"
53 | />
54 | </HeaderIconContainer>
55 | </HeaderContainer>
56 |
57 | <BreadcrumbsNav>
58 | <Breadcrumbs />
59 | </BreadcrumbsNav>
60 |
61 | <Switch>
62 | <Route exact path="/" component={Home} />
63 | <Route exact path="/example" component={ExampleComponent} />
64 | <Route
65 | exact
66 | path="/example/two-deep"
67 | component={ExampleTwoDeepComponent}
68 | />
69 | <Route
70 | exact
71 | path="/sitemap-link-generator"
72 | component={SitemapLinkGenerator}
73 | />
74 | <Route component={PageNotFound} />
75 | </Switch>
76 | </AppContainer>
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/src/components/Breadcrumbs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Route, RouteComponentProps } from 'react-router-dom';
3 | import { InteractiveLink } from '../ui/InteractiveLink';
4 |
5 | interface breadCrumbTitlesInterface {
6 | [key: string]: string | undefined;
7 | }
8 |
9 | const breadCrumbTitles: breadCrumbTitlesInterface = {
10 | '': 'Home',
11 | example: 'Example',
12 | 'two-deep': 'Two Deep',
13 | 'sitemap-link-generator': 'Sitemap Link Generator',
14 | };
15 |
16 | const BreadcrumbsItem: React.VFC<RouteComponentProps> = ({ match }) => {
17 | const path =
18 | match.url.length > 1 && match.url[match.url.length - 1] === '/'
19 | ? match.url.slice(0, -1)
20 | : match.url;
21 |
22 | const title = breadCrumbTitles[path.split('/').slice(-1)[0]];
23 | const to = title === undefined ? '/' : path;
24 |
25 | return (
26 | <span>
27 | <InteractiveLink to={to}>{title || 'Page Not Found'}</InteractiveLink>
28 | {!match.isExact && title && ' / '}
29 | {title && (
30 | <Route
31 | path={`${match.url === '/' ? '' : match.url}/:path`}
32 | component={BreadcrumbsItem}
33 | />
34 | )}
35 | </span>
36 | );
37 | };
38 |
39 | export const Breadcrumbs: React.VFC = () => (
40 | <Route path="/" component={BreadcrumbsItem} />
41 | );
42 |
--------------------------------------------------------------------------------
/src/components/ExampleComponent.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { InteractiveLink } from '../ui/InteractiveLink';
3 | import { P } from '../ui/Paragraph';
4 |
5 | export const ExampleComponent: React.VFC = () => (
6 | <div>
7 | <P>
8 | This is an example page. Refresh the page or copy/paste the url to test
9 | out the redirect functionality (this same page should load after the
10 | redirect).
11 | </P>
12 | <InteractiveLink to="/example/two-deep?field1=foo&field2=bar#boom!">
13 | Example two deep with query and hash
14 | </InteractiveLink>
15 | </div>
16 | );
17 |
--------------------------------------------------------------------------------
/src/components/ExampleTwoDeepComponent.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { RouteComponentProps } from 'react-router-dom';
3 | import { InteractiveLink } from '../ui/InteractiveLink';
4 | import { P } from '../ui/Paragraph';
5 | import { styled } from '../stitches.config';
6 |
7 | const StyledLi = styled('li', {
8 | paddingLeft: '18px',
9 | textIndent: '-15px',
10 | margin: '4px 0',
11 | listStyle: 'none',
12 | });
13 |
14 | interface LiProps {
15 | children: React.ReactText;
16 | }
17 | const Li: React.VFC<LiProps> = ({ children }) => (
18 | <StyledLi>
19 | <span style={{ paddingRight: '7px' }}>–</span>
20 | {children}
21 | </StyledLi>
22 | );
23 |
24 | const LineContainer = styled('div', {
25 | margin: '20px 0',
26 | });
27 |
28 | export const ExampleTwoDeepComponent: React.VFC<RouteComponentProps> = ({
29 | location,
30 | }) => {
31 | const queryPresent = location.search !== '';
32 | const hashPresent = location.hash !== '';
33 |
34 | function queryStringTitle() {
35 | if (queryPresent) return 'The query string field-value pairs are:';
36 | return 'No query string in the url';
37 | }
38 |
39 | function hashFragmentTitle() {
40 | if (hashPresent) return 'The hash fragment is:';
41 | return 'No hash fragment in the url';
42 | }
43 |
44 | function linkToShowQueryAndOrHash() {
45 | if (queryPresent && hashPresent) return null;
46 |
47 | const queryString = queryPresent
48 | ? location.search
49 | : '?field1=foo&field2=bar';
50 | const hashFragment = hashPresent ? location.hash : '#boom!';
51 |
52 | let linkText = '';
53 | if (queryPresent && !hashPresent) linkText = 'Show with hash fragment';
54 | if (!queryPresent && hashPresent) linkText = 'Show with query string';
55 | if (!queryPresent && !hashPresent)
56 | linkText = 'Show with query string and hash fragment';
57 |
58 | return (
59 | <LineContainer>
60 | <InteractiveLink to={`/example/two-deep${queryString}${hashFragment}`}>
61 | {linkText}
62 | </InteractiveLink>
63 | </LineContainer>
64 | );
65 | }
66 |
67 | function parseQueryString() {
68 | if (!queryPresent) return [];
69 | return location.search
70 | .replace('?', '')
71 | .split('&')
72 | .map((fvPair) => fvPair.split('='))
73 | .map((pair) => [pair[0], pair.slice(1).join('=')]);
74 | }
75 |
76 | return (
77 | <div>
78 | <P>
79 | This is an example page with query string and hash fragment. Refresh the
80 | page or copy/paste the url to test out the redirect functionality (this
81 | same page should load after the redirect).
82 | </P>
83 | <LineContainer>
84 | <div>{queryStringTitle()}</div>
85 | <ul>
86 | {parseQueryString().map((pair, index) => (
87 | <Li
88 | key={`${pair[0]}${pair[1]}${index}`}
89 | >{`${pair[0]}: ${pair[1]}`}</Li>
90 | ))}
91 | </ul>
92 | </LineContainer>
93 | <LineContainer>
94 | <div>{hashFragmentTitle()}</div>
95 | <ul>{hashPresent && <Li>{location.hash.slice(1)}</Li>}</ul>
96 | </LineContainer>
97 | {linkToShowQueryAndOrHash()}
98 | </div>
99 | );
100 | };
101 |
--------------------------------------------------------------------------------
/src/components/Home.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { InteractiveLink } from '../ui/InteractiveLink';
3 | import { P } from '../ui/Paragraph';
4 | import { styled } from '../stitches.config';
5 |
6 | const LinkContainer = styled('span', {
7 | display: 'block',
8 | margin: '8px 0',
9 | });
10 |
11 | const RepoReadmeLink: React.VFC = () => (
12 | <InteractiveLink href="https://github.com/rafgraph/spa-github-pages#readme">
13 | repo readme
14 | </InteractiveLink>
15 | );
16 |
17 | export const Home: React.VFC = () => (
18 | <div>
19 | <P>
20 | This is an example single page app built with React and React Router
21 | using <code>BrowserRouter</code>. Navigate with the links below and
22 | refresh the page or copy/paste the url to test out the redirect
23 | functionality deployed to overcome GitHub Pages incompatibility with
24 | single page apps (like this one).
25 | </P>
26 | <P>
27 | Please see the <RepoReadmeLink /> for instructions on how to use this
28 | boilerplate to deploy your own single page app using GitHub Pages.
29 | </P>
30 | <P>
31 | <LinkContainer>
32 | <InteractiveLink to="/example">Example page</InteractiveLink>
33 | </LinkContainer>
34 | <LinkContainer>
35 | <InteractiveLink to="/example/two-deep?field1=foo&field2=bar#boom!">
36 | Example two deep with query and hash
37 | </InteractiveLink>
38 | </LinkContainer>
39 | </P>
40 | <P>
41 | <InteractiveLink to="/sitemap-link-generator">
42 | Sitemap Link Generator
43 | </InteractiveLink>
44 | </P>
45 | </div>
46 | );
47 |
--------------------------------------------------------------------------------
/src/components/PageNotFound.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { P } from '../ui/Paragraph';
3 |
4 | interface PageNotFoundProps {
5 | location: { pathname: 'string' };
6 | }
7 |
8 | export const PageNotFound: React.VFC<PageNotFoundProps> = ({ location }) => (
9 | <P>
10 | Page not found - the path, <code>{location.pathname}</code>, did not match
11 | any React Router routes.
12 | </P>
13 | );
14 |
--------------------------------------------------------------------------------
/src/components/SitemapLinkGenerator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as CSS from 'csstype';
3 | import { Interactive } from 'react-interactive';
4 | import { InteractiveLink } from '../ui/InteractiveLink';
5 | import { P } from '../ui/Paragraph';
6 | import { styled } from '../stitches.config';
7 |
8 | const InteractiveInput = styled(Interactive.Input, {
9 | lineHeight: '1.4',
10 | backgroundColor: '$formElementsBackground',
11 | padding: '1px 5px',
12 | border: '1px solid $highContrast',
13 | borderRadius: '4px',
14 | '&.focus': {
15 | borderColor: '$green',
16 | boxShadow: '0 0 0 1px $colors$green',
17 | },
18 | '&.focusFromKey': {
19 | borderColor: '$purple',
20 | boxShadow: '0 0 0 1px $colors$purple',
21 | },
22 | });
23 |
24 | export const SitemapLinkGenerator: React.VFC = () => {
25 | const [url, setUrl] = React.useState('');
26 | const [segments, setSegments] = React.useState('0');
27 | let sitemapLink;
28 |
29 | try {
30 | const l = new URL(url);
31 | const pathSegmentsToKeep = parseInt(segments);
32 |
33 | // redirect script from 404.html
34 | sitemapLink =
35 | l.protocol +
36 | '//' +
37 | l.hostname +
38 | (l.port ? ':' + l.port : '') +
39 | l.pathname
40 | .split('/')
41 | .slice(0, 1 + pathSegmentsToKeep)
42 | .join('/') +
43 | '/?/' +
44 | l.pathname
45 | .slice(1)
46 | .split('/')
47 | .slice(pathSegmentsToKeep)
48 | .join('/')
49 | .replace(/&/g, '~and~') +
50 | (l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
51 | l.hash;
52 | } catch {}
53 |
54 | return (
55 | <div>
56 | <P>
57 | Use this to generate sitemap links for your site. Search engines
58 | don't like 404s so you need to create a sitemap with the redirect
59 | path for each page instead of the normal path. For more info see the{' '}
60 | <InteractiveLink href="https://github.com/rafgraph/spa-github-pages#seo">
61 | readme
62 | </InteractiveLink>
63 | .
64 | </P>
65 | <P>
66 | <label>
67 | <span style={{ marginRight: '10px' }}>
68 | <code>pathSegmentsToKeep</code> (set in <code>404.html</code>):
69 | </span>
70 | <InteractiveInput
71 | css={{ width: '40px' }}
72 | type="number"
73 | min="0"
74 | step="1"
75 | onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
76 | setSegments(e.target.value)
77 | }
78 | value={segments}
79 | />
80 | </label>
81 | </P>
82 | <P>
83 | <label>
84 | Page URL:
85 | <InteractiveInput
86 | type="text"
87 | onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
88 | setUrl(e.target.value)
89 | }
90 | value={url}
91 | css={{ width: '100%' }}
92 | />
93 | </label>
94 | </P>
95 | <P>
96 | <span style={{ display: 'block' }}>
97 | Redirect link to use in your sitemap:
98 | </span>
99 | <span>{sitemapLink || 'Please enter a valid URL'}</span>
100 | </P>
101 | </div>
102 | );
103 | };
104 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { App } from './App';
5 |
6 | ReactDOM.render(
7 | <React.StrictMode>
8 | <BrowserRouter>
9 | <App />
10 | </BrowserRouter>
11 | </React.StrictMode>,
12 | document.getElementById('root'),
13 | );
14 |
--------------------------------------------------------------------------------
/src/stitches.config.ts:
--------------------------------------------------------------------------------
1 | import { createCss, StitchesCss } from '@stitches/react';
2 |
3 | export const stitchesConfig = createCss({
4 | theme: {
5 | colors: {
6 | pageBackground: 'rgb(240,240,240)',
7 | backgroundContrast: 'rgb(216,216,216)',
8 | highContrast: 'rgb(0,0,0)',
9 | lowContrast: 'rgb(128,128,128)',
10 | formElementsBackground: 'rgb(250,250,250)',
11 | red: 'hsl(0,100%,50%)',
12 | orange: 'hsl(30,100%,50%)',
13 | yellow: 'hsl(51,100%,40%)',
14 | green: 'hsl(120,100%,33%)', // same as rgb(0,168,0)
15 | blue: 'hsl(240,100%,50%)',
16 | purple: 'hsl(270,100%,60%)',
17 | },
18 | fonts: {
19 | mono: 'monospace',
20 | },
21 | },
22 | });
23 |
24 | export type CSS = StitchesCss<typeof stitchesConfig>;
25 |
26 | export const {
27 | styled,
28 | theme,
29 | keyframes,
30 | global: createGlobalCss,
31 | } = stitchesConfig;
32 |
33 | export const darkThemeClass = theme({
34 | colors: {
35 | pageBackground: 'rgb(32,32,32)',
36 | backgroundContrast: 'rgb(64,64,64)',
37 | highContrast: 'rgb(192,192,192)',
38 | lowContrast: 'rgb(136,136,136)',
39 | formElementsBackground: 'rgb(20,20,20)',
40 | red: 'hsl(0,100%,50%)',
41 | orange: 'hsl(30,90%,50%)',
42 | yellow: 'hsl(60,88%,50%)',
43 | green: 'hsl(120,85%,42%)',
44 | blue: 'hsl(210,100%,60%)',
45 | purple: 'hsl(270,85%,60%)',
46 | },
47 | });
48 |
49 | export const globalCss = createGlobalCss({
50 | // unset all styles on interactive elements
51 | 'button, input, select, textarea, a, area': {
52 | all: 'unset',
53 | },
54 | // normalize behavior on all elements
55 | '*, *::before, *::after, button, input, select, textarea, a, area': {
56 | margin: 0,
57 | border: 0,
58 | padding: 0,
59 | boxSizing: 'inherit',
60 | font: 'inherit',
61 | fontWeight: 'inherit',
62 | textAlign: 'inherit',
63 | lineHeight: 'inherit',
64 | wordBreak: 'inherit',
65 | color: 'inherit',
66 | background: 'transparent',
67 | outline: 'none',
68 | WebkitTapHighlightColor: 'transparent',
69 | },
70 | // set base styles for the app
71 | body: {
72 | color: '$highContrast',
73 | fontFamily: 'system-ui, Helvetica Neue, sans-serif',
74 | // use word-break instead of "overflow-wrap: anywhere" because of Safari support
75 | wordBreak: 'break-word',
76 | WebkitFontSmoothing: 'antialiased',
77 | MozOsxFontSmoothing: 'grayscale',
78 | fontSize: '16px',
79 | boxSizing: 'border-box',
80 | textSizeAdjust: 'none',
81 | },
82 | code: {
83 | fontFamily: '$mono',
84 | },
85 | // pass down height: 100% to the #root div
86 | 'body, html': {
87 | height: '100%',
88 | },
89 | '#root': {
90 | minHeight: '100%',
91 | backgroundColor: '$pageBackground',
92 | },
93 | });
94 |
--------------------------------------------------------------------------------
/src/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | import { Interactive } from 'react-interactive';
2 | import { styled } from '../stitches.config';
3 |
4 | export const Button = styled(Interactive.Button, {
5 | color: '$highContrast',
6 | '&.hover, &.active': {
7 | color: '$green',
8 | borderColor: '$green',
9 | },
10 | '&.disabled': {
11 | opacity: 0.5,
12 | },
13 | variants: {
14 | focus: {
15 | outline: {
16 | '&.focusFromKey': {
17 | outline: '2px solid $colors$purple',
18 | outlineOffset: '2px',
19 | },
20 | },
21 | boxShadow: {
22 | '&.focusFromKey': {
23 | boxShadow: '0 0 0 2px $colors$purple',
24 | },
25 | },
26 | boxShadowOffset: {
27 | '&.focusFromKey': {
28 | boxShadow:
29 | '0 0 0 2px $colors$pageBackground, 0 0 0 4px $colors$purple',
30 | },
31 | },
32 | },
33 | },
34 | defaultVariants: {
35 | focus: 'boxShadowOffset',
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/src/ui/DarkModeButton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { SunIcon } from '@radix-ui/react-icons';
3 | import useDarkMode from 'use-dark-mode';
4 | import { Button } from './Button';
5 | import { darkThemeClass } from '../stitches.config';
6 |
7 | interface DarkModeButtonProps {
8 | css?: React.ComponentProps<typeof Button>['css'];
9 | }
10 |
11 | export const DarkModeButton: React.VFC<DarkModeButtonProps> = ({
12 | css,
13 | ...props
14 | }) => {
15 | // put a try catch around localStorage so this app will work in codesandbox
16 | // when the user blocks third party cookies in chrome,
17 | // which results in a security error when useDarkMode tries to access localStorage
18 | // see https://github.com/codesandbox/codesandbox-client/issues/5397
19 | let storageProvider: any = null;
20 | try {
21 | storageProvider = localStorage;
22 | } catch {}
23 | const darkMode = useDarkMode(undefined, {
24 | classNameDark: darkThemeClass,
25 | storageProvider,
26 | });
27 |
28 | // add color-scheme style to <html> element
29 | // so document scroll bars will have native dark mode styling
30 | React.useEffect(() => {
31 | if (darkMode.value === true) {
32 | // @ts-ignore because colorScheme type not added yet
33 | document.documentElement.style.colorScheme = 'dark';
34 | } else {
35 | // @ts-ignore
36 | document.documentElement.style.colorScheme = 'light';
37 | }
38 | }, [darkMode.value]);
39 |
40 | return (
41 | <Button
42 | {...props}
43 | onClick={darkMode.toggle}
44 | focus="boxShadow"
45 | css={{
46 | width: '36px',
47 | height: '36px',
48 | padding: '3px',
49 | margin: '-3px',
50 | borderRadius: '50%',
51 | // cast as any b/c of Stitches bug: https://github.com/modulz/stitches/issues/407
52 | ...(css as any),
53 | }}
54 | title="Toggle dark mode"
55 | aria-label="Toggle dark mode"
56 | >
57 | <SunIcon width="30" height="30" />
58 | </Button>
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/src/ui/GitHubIconLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Interactive } from 'react-interactive';
3 | import { GitHubLogoIcon } from '@radix-ui/react-icons';
4 | import { Button } from './Button';
5 |
6 | interface GitHubIconLinkProps {
7 | href?: string;
8 | title?: string;
9 | newWindow?: boolean;
10 | css?: React.ComponentProps<typeof Button>['css'];
11 | }
12 |
13 | export const GitHubIconLink: React.VFC<GitHubIconLinkProps> = ({
14 | newWindow = true,
15 | css,
16 | title,
17 | ...props
18 | }) => (
19 | <Button
20 | {...props}
21 | as={Interactive.A}
22 | title={title}
23 | aria-label={title}
24 | target={newWindow ? '_blank' : undefined}
25 | rel={newWindow ? 'noopener noreferrer' : undefined}
26 | focus="boxShadow"
27 | css={{
28 | display: 'inline-block',
29 | width: '36px',
30 | height: '36px',
31 | padding: '3px',
32 | margin: '-3px',
33 | borderRadius: '50%',
34 | // cast as any b/c of Stitches bug: https://github.com/modulz/stitches/issues/407
35 | ...(css as any),
36 | }}
37 | >
38 | <GitHubLogoIcon
39 | width="30"
40 | height="30"
41 | // scale up the svg icon because it doesn't fill the view box
42 | // see: https://github.com/radix-ui/icons/issues/73
43 | style={{ transform: 'scale(1.1278)' }}
44 | />
45 | </Button>
46 | );
47 |
--------------------------------------------------------------------------------
/src/ui/InteractiveLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Interactive, InteractiveExtendableProps } from 'react-interactive';
3 | import { Link } from 'react-router-dom';
4 | import { styled } from '../stitches.config';
5 |
6 | type LinkUnionProps =
7 | | (InteractiveExtendableProps<typeof Link> & { href?: never })
8 | | (InteractiveExtendableProps<'a'> & { to?: never; replace?: never });
9 |
10 | // if there is a `to` prop then render a React Router <Link>,
11 | // otherwise render a regular anchor tag <a>
12 | const LinkUnion = React.forwardRef<HTMLAnchorElement, LinkUnionProps>(
13 | (props, ref) => {
14 | // React Router's <Link> component doesn't have a disabled state
15 | // so when disabled always render as="a" and remove router specific props
16 | const As = props.to && !props.disabled ? Link : 'a';
17 | let passThroughProps = props;
18 | if (props.disabled) {
19 | const { to, replace, ...propsWithoutRouterProps } = props;
20 | passThroughProps = propsWithoutRouterProps;
21 | }
22 |
23 | return <Interactive {...passThroughProps} as={As} ref={ref} />;
24 | },
25 | );
26 |
27 | export const InteractiveLink = styled(LinkUnion, {
28 | color: '$highContrast',
29 |
30 | // can't use shorthand for textDecoration because of bug in Safari v14
31 | // textDecoration: 'underline $colors$green dotted from-font',
32 | textDecorationLine: 'underline',
33 | textDecorationStyle: 'dotted',
34 | textDecorationColor: '$green',
35 | textDecorationThickness: 'from-font',
36 |
37 | // padding used to provide offset for boxShadow used in focus styles
38 | // margin undoes padding for page layout so boxShadow works like outline
39 | padding: '2px 3px',
40 | margin: '-2px -3px',
41 | // this is the main reason to use boxShadow instead of outline for focus styles,
42 | // with outline can only have square corners,
43 | // with boxShadow can use borderRadius to soften the corners
44 | borderRadius: '3px',
45 |
46 | '&.hover, &.mouseActive': {
47 | textDecorationColor: '$green',
48 | textDecorationStyle: 'solid',
49 | },
50 | '&.touchActive, &.keyActive': {
51 | color: '$green',
52 | textDecorationColor: '$green',
53 | textDecorationStyle: 'solid',
54 | },
55 | '&.focusFromKey': {
56 | boxShadow: '0 0 0 2px $colors$purple',
57 | },
58 | });
59 |
--------------------------------------------------------------------------------
/src/ui/Paragraph.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '../stitches.config';
2 |
3 | export const P = styled('p', {
4 | margin: '20px 0',
5 | lineHeight: '1.4',
6 | });
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES5",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "allowJs": true,
7 | "jsx": "react",
8 | "sourceMap": true,
9 | "outDir": "./generated",
10 | "strict": true,
11 | "noImplicitReturns": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "forceConsistentCasingInFileNames": true
14 | },
15 | "include": ["./src/**.*"],
16 | }
17 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const TerserPlugin = require('terser-webpack-plugin');
2 |
3 | module.exports = {
4 | entry: `${__dirname}/src/index.tsx`,
5 | output: {
6 | path: `${__dirname}/build`,
7 | publicPath: '/build/',
8 | filename: 'bundle.js',
9 | },
10 |
11 | // generate different source maps for dev and production
12 | devtool: process.argv.indexOf('-p') === -1 ? 'eval-source-map' : 'source-map',
13 |
14 | resolve: {
15 | extensions: ['.ts', '.tsx', '.js'],
16 | },
17 |
18 | module: {
19 | rules: [
20 | // use ts-loader for ts and js files so all files are converted to es5
21 | { test: /\.(tsx?|js)$/, exclude: /node_modules/, loader: 'ts-loader' },
22 | { test: /\.js$/, loader: 'source-map-loader' },
23 | ],
24 | },
25 |
26 | // required because the defaults for webpack -p don't remove multiline comments
27 | optimization:
28 | process.argv.indexOf('-p') === -1
29 | ? {}
30 | : {
31 | minimize: true,
32 | minimizer: [
33 | new TerserPlugin({
34 | terserOptions: {
35 | output: {
36 | comments: false,
37 | },
38 | },
39 | extractComments: false,
40 | }),
41 | ],
42 | },
43 |
44 | // to mimic GitHub Pages serving 404.html for all paths
45 | // and test spa-github-pages redirect in dev
46 | devServer: {
47 | historyApiFallback: {
48 | rewrites: [{ from: /\//, to: '/404.html' }],
49 | },
50 | },
51 | };
52 |
--------------------------------------------------------------------------------