├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── index.ts ├── robotstxt │ ├── index.ts │ └── utils.ts ├── sitemap │ ├── index.ts │ └── utils.ts └── types │ └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # Go to https://editorconfig.org/ to download the plugin for your editor. 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | # Markdown files 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: '🐛 Bug report' 2 | description: Create a report to help us improve 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for reporting an issue :pray:. 8 | 9 | This issue tracker is for reporting bugs found in `remix-seo` (https://github.com/balavishnuvj/remix-seo). 10 | If you have a question about how to achieve something and are struggling, please post a question 11 | inside of `remix-seo` Discussions tab: https://github.com/balavishnuvj/remix-seo/discussions 12 | 13 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: 14 | - `remix-seo` Issues tab: https://github.com/balavishnuvj/remix-seo/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc 15 | - `remix-seo` closed issues tab: https://github.com/balavishnuvj/remix-seo/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed 16 | - `remix-seo` Discussions tab: https://github.com/balavishnuvj/remix-seo/discussions 17 | 18 | The more information you fill in, the better the community can help you. 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Describe the bug 23 | description: Provide a clear and concise description of the challenge you are running into. 24 | validations: 25 | required: true 26 | - type: input 27 | id: link 28 | attributes: 29 | label: Your Example Website or App 30 | description: | 31 | Which website or app were you using when the bug happened? 32 | Note: 33 | - Your bug will may get fixed much faster if we can run your code and it doesn't have dependencies other than the `remix-seo` npm package. 34 | - To create a shareable code example you can use Stackblitz (https://stackblitz.com/). Please no localhost URLs. 35 | - Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve. 36 | placeholder: | 37 | e.g. https://stackblitz.com/edit/...... OR Github Repo 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: steps 42 | attributes: 43 | label: Steps to Reproduce the Bug or Issue 44 | description: Describe the steps we have to take to reproduce the behavior. 45 | placeholder: | 46 | 1. Go to '...' 47 | 2. Click on '....' 48 | 3. Scroll down to '....' 49 | 4. See error 50 | validations: 51 | required: true 52 | - type: textarea 53 | id: expected 54 | attributes: 55 | label: Expected behavior 56 | description: Provide a clear and concise description of what you expected to happen. 57 | placeholder: | 58 | As a user, I expected ___ behavior but i am seeing ___ 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: screenshots_or_videos 63 | attributes: 64 | label: Screenshots or Videos 65 | description: | 66 | If applicable, add screenshots or a video to help explain your problem. 67 | For more information on the supported file image/file types and the file size limits, please refer 68 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files 69 | placeholder: | 70 | You can drag your video or image files inside of this editor ↓ 71 | - type: textarea 72 | id: platform 73 | attributes: 74 | label: Platform 75 | value: | 76 | - OS: [e.g. macOS, Windows, Linux] 77 | - Browser: [e.g. Chrome, Safari, Firefox] 78 | - Version: [e.g. 91.1] 79 | validations: 80 | required: true 81 | - type: textarea 82 | id: additional 83 | attributes: 84 | label: Additional context 85 | description: Add any other context about the problem here. 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🤔 Feature Requests & Questions 4 | url: https://github.com/balavishnuvj/remix-seo/discussions 5 | about: Please ask and answer questions here. 6 | - name: 💬 Remix Discord Channel 7 | url: https://rmx.as/discord 8 | about: Interact with other people using Remix 📀 9 | - name: 💬 New Updates (Twitter) 10 | url: https://twitter.com/remix_run 11 | about: Stay up to date with Remix news on twitter 12 | - name: 🍿 Remix YouTube Channel 13 | url: https://rmx.as/youtube 14 | about: Are you a techlead or wanting to learn more about Remix in depth? Checkout the Remix YouTube Channel 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | build -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !build -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Balavishnu V J 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix SEO 2 | 3 | Collection of SEO utilities like sitemap, robots.txt, etc. for a [Remix](https://remix.run/) application. 4 | 5 | # Features 6 | 7 | - Generate Sitemap 8 | - Generate Robots.txt 9 | 10 | # Installation 11 | 12 | To use it, install it from npm (or yarn): 13 | 14 | ```sh 15 | npm install @balavishnuvj/remix-seo 16 | ``` 17 | 18 | # Usage 19 | 20 | For all miscellaneous routes in root like `/robots.txt`, `/sitemap.xml`. We can create a single function to handle all of them instead polluting our `routes` folder. 21 | 22 | For that, lets create a file called `otherRootRoutes.server.ts` (file could be anything, make sure it is import only in server by ending with`.server.{ts|js}`) 23 | 24 | ```ts 25 | // otherRootRoutes.server.ts 26 | 27 | import { EntryContext } from "remix"; 28 | 29 | type Handler = ( 30 | request: Request, 31 | remixContext: EntryContext 32 | ) => Promise | null; 33 | 34 | export const otherRootRoutes: Record = {}; 35 | 36 | export const otherRootRouteHandlers: Array = [ 37 | ...Object.entries(otherRootRoutes).map(([path, handler]) => { 38 | return (request: Request, remixContext: EntryContext) => { 39 | if (new URL(request.url).pathname !== path) return null; 40 | return handler(request, remixContext); 41 | }; 42 | }), 43 | ]; 44 | ``` 45 | 46 | and import this file in your `entry.server.tsx` 47 | 48 | ```diff 49 | import { renderToString } from "react-dom/server"; 50 | import { RemixServer } from "remix"; 51 | import type { EntryContext } from "remix"; 52 | +import { otherRootRouteHandlers } from "./otherRootRoutes.server"; 53 | 54 | export default async function handleRequest( 55 | request: Request, 56 | responseStatusCode: number, 57 | responseHeaders: Headers, 58 | remixContext: EntryContext 59 | ) { 60 | + for (const handler of otherRootRouteHandlers) { 61 | + const otherRouteResponse = await handler(request, remixContext); 62 | + if (otherRouteResponse) return otherRouteResponse; 63 | + } 64 | let markup = renderToString( 65 | 66 | ); 67 | 68 | responseHeaders.set("Content-Type", "text/html"); 69 | 70 | return new Response("" + markup, { 71 | status: responseStatusCode, 72 | headers: responseHeaders, 73 | }); 74 | } 75 | ``` 76 | 77 | ## Sitemap 78 | 79 | To generate sitemap, `@balavishnuvj/remix-seo` would need context of all your routes. 80 | 81 | If you have already created a file to handle all root routes. If not, [check above](#usage) 82 | 83 | Add config for your sitemap 84 | 85 | ```ts 86 | import { EntryContext } from "remix"; 87 | import { generateSitemap } from "@balavishnuvj/remix-seo"; 88 | 89 | type Handler = ( 90 | request: Request, 91 | remixContext: EntryContext 92 | ) => Promise | null; 93 | 94 | export const otherRootRoutes: Record = { 95 | "/sitemap.xml": async (request, remixContext) => { 96 | return generateSitemap(request, remixContext, { 97 | siteUrl: "https://balavishnuvj.com", 98 | }); 99 | }, 100 | }; 101 | 102 | export const otherRootRouteHandlers: Array = [ 103 | ...Object.entries(otherRootRoutes).map(([path, handler]) => { 104 | return (request: Request, remixContext: EntryContext) => { 105 | if (new URL(request.url).pathname !== path) return null; 106 | 107 | return handler(request, remixContext); 108 | }; 109 | }), 110 | ]; 111 | ``` 112 | 113 | `generateSitemap` takes three params `request`, `EntryContext`, and `SEOOptions`. 114 | 115 | ### Configuration 116 | 117 | - `SEOOptions` lets you configure the sitemap 118 | 119 | ```ts 120 | export type SEOOptions = { 121 | siteUrl: string; // URL where the site is hosted, eg. https://balavishnuvj.com 122 | headers?: HeadersInit; // Additional headers 123 | /* 124 | eg: 125 | headers: { 126 | "Cache-Control": `public, max-age=${60 * 5}`, 127 | }, 128 | */ 129 | }; 130 | ``` 131 | 132 | - To not generate sitemap for a route 133 | 134 | ```ts 135 | // in your routes/url-that-doesnt-need-sitemap 136 | import { SEOHandle } from "@balavishnuvj/remix-seo"; 137 | 138 | export let loader: LoaderFunction = ({ request }) => { 139 | /**/ 140 | }; 141 | 142 | export const handle: SEOHandle = { 143 | getSitemapEntries: () => null, 144 | }; 145 | ``` 146 | 147 | - To generate sitemap for dynamic routes 148 | 149 | ```ts 150 | // routes/blog/$blogslug.tsx 151 | 152 | export const handle: SEOHandle = { 153 | getSitemapEntries: async (request) => { 154 | const blogs = await db.blog.findMany(); 155 | return blogs.map((blog) => { 156 | return { route: `/blog/${blog.slug}`, priority: 0.7 }; 157 | }); 158 | }, 159 | }; 160 | ``` 161 | 162 | ## Robots 163 | 164 | You can add this part of the root routes as did above[check above](#usage). Or else you can create a new file in your `routes` folder with `robots[.txt].ts` 165 | 166 | To generate `robots.txt` 167 | 168 | ```ts 169 | generateRobotsTxt([ 170 | { type: "sitemap", value: "https://balavishnuvj.com/sitemap.xml" }, 171 | { type: "disallow", value: "/admin" }, 172 | ]); 173 | ``` 174 | 175 | `generateRobotsTxt` takes two arguments. 176 | 177 | First one is array of `policies` 178 | 179 | ```ts 180 | export type RobotsPolicy = { 181 | type: "allow" | "disallow" | "sitemap" | "crawlDelay" | "userAgent"; 182 | value: string; 183 | }; 184 | ``` 185 | 186 | and second parameter `RobotsConfig` is for additional configuration 187 | 188 | ```ts 189 | export type RobotsConfig = { 190 | appendOnDefaultPolicies?: boolean; // If default policies should used 191 | /* 192 | Default policy 193 | const defaultPolicies: RobotsPolicy[] = [ 194 | { 195 | type: "userAgent", 196 | value: "*", 197 | }, 198 | { 199 | type: "allow", 200 | value: "/", 201 | }, 202 | ]; 203 | */ 204 | headers?: HeadersInit; // Additional headers 205 | /* 206 | eg: 207 | headers: { 208 | "Cache-Control": `public, max-age=${60 * 5}`, 209 | }, 210 | */ 211 | }; 212 | ``` 213 | 214 | If you are using single function to create both `sitemap` and `robots.txt` 215 | 216 | ```ts 217 | import { EntryContext } from "remix"; 218 | import { generateRobotsTxt, generateSitemap } from "@balavishnuvj/remix-seo"; 219 | 220 | type Handler = ( 221 | request: Request, 222 | remixContext: EntryContext 223 | ) => Promise | null; 224 | 225 | export const otherRootRoutes: Record = { 226 | "/sitemap.xml": async (request, remixContext) => { 227 | return generateSitemap(request, remixContext, { 228 | siteUrl: "https://balavishnuvj.com", 229 | headers: { 230 | "Cache-Control": `public, max-age=${60 * 5}`, 231 | }, 232 | }); 233 | }, 234 | "/robots.txt": async () => { 235 | return generateRobotsTxt([ 236 | { type: "sitemap", value: "https://balavishnuvj.com/sitemap.xml" }, 237 | { type: "disallow", value: "/admin" }, 238 | ]); 239 | }, 240 | }; 241 | 242 | export const otherRootRouteHandlers: Array = [ 243 | ...Object.entries(otherRootRoutes).map(([path, handler]) => { 244 | return (request: Request, remixContext: EntryContext) => { 245 | if (new URL(request.url).pathname !== path) return null; 246 | 247 | return handler(request, remixContext); 248 | }; 249 | }), 250 | ]; 251 | ``` 252 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@balavishnuvj/remix-seo", 3 | "version": "1.0.1", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@balavishnuvj/remix-seo", 9 | "version": "1.0.1", 10 | "license": "MIT", 11 | "dependencies": { 12 | "lodash.isequal": "^4.5.0" 13 | }, 14 | "devDependencies": { 15 | "@types/lodash.isequal": "^4.5.6", 16 | "@types/node": "^17.0.13", 17 | "typescript": "^4.5.5" 18 | }, 19 | "peerDependencies": { 20 | "@remix-run/react": "^1.0.0 || ^2.0.0", 21 | "@remix-run/server-runtime": "^1.0.0 || ^2.0.0" 22 | } 23 | }, 24 | "node_modules/@babel/runtime": { 25 | "version": "7.16.7", 26 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz", 27 | "integrity": "sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==", 28 | "peer": true, 29 | "dependencies": { 30 | "regenerator-runtime": "^0.13.4" 31 | }, 32 | "engines": { 33 | "node": ">=6.9.0" 34 | } 35 | }, 36 | "node_modules/@remix-run/react": { 37 | "version": "1.1.3", 38 | "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-1.1.3.tgz", 39 | "integrity": "sha512-1vYwRi4qQGJLByZR15o47TRZ3/xT3+vhM97UCPJPu4GtzbPldNYLuQlVmAzWWjGuQmSqmwWv818U/+U7rke18g==", 40 | "peer": true, 41 | "dependencies": { 42 | "react-router-dom": "^6.2.1" 43 | }, 44 | "peerDependencies": { 45 | "react": ">=16.8", 46 | "react-dom": ">=16.8" 47 | } 48 | }, 49 | "node_modules/@remix-run/server-runtime": { 50 | "version": "1.1.3", 51 | "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.1.3.tgz", 52 | "integrity": "sha512-Gpl57d42CVqx1gDD+kjjUx7PZqjZgw4i1TWpH37AqOCCBTrNBb2lcl2D0oAZ2ot+wLOT9rDbvIU74GzayDUalw==", 53 | "peer": true, 54 | "dependencies": { 55 | "@types/cookie": "^0.4.0", 56 | "cookie": "^0.4.1", 57 | "jsesc": "^3.0.1", 58 | "react-router-dom": "^6.2.1", 59 | "set-cookie-parser": "^2.4.8", 60 | "source-map": "^0.7.3" 61 | }, 62 | "peerDependencies": { 63 | "react": ">=16.8", 64 | "react-dom": ">=16.8" 65 | } 66 | }, 67 | "node_modules/@types/cookie": { 68 | "version": "0.4.1", 69 | "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", 70 | "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", 71 | "peer": true 72 | }, 73 | "node_modules/@types/lodash": { 74 | "version": "4.14.178", 75 | "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", 76 | "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==", 77 | "dev": true 78 | }, 79 | "node_modules/@types/lodash.isequal": { 80 | "version": "4.5.6", 81 | "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz", 82 | "integrity": "sha512-Ww4UGSe3DmtvLLJm2F16hDwEQSv7U0Rr8SujLUA2wHI2D2dm8kPu6Et+/y303LfjTIwSBKXB/YTUcAKpem/XEg==", 83 | "dev": true, 84 | "dependencies": { 85 | "@types/lodash": "*" 86 | } 87 | }, 88 | "node_modules/@types/node": { 89 | "version": "17.0.13", 90 | "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.13.tgz", 91 | "integrity": "sha512-Y86MAxASe25hNzlDbsviXl8jQHb0RDvKt4c40ZJQ1Don0AAL0STLZSs4N+6gLEO55pedy7r2cLwS+ZDxPm/2Bw==", 92 | "dev": true 93 | }, 94 | "node_modules/cookie": { 95 | "version": "0.4.1", 96 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", 97 | "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", 98 | "peer": true, 99 | "engines": { 100 | "node": ">= 0.6" 101 | } 102 | }, 103 | "node_modules/history": { 104 | "version": "5.2.0", 105 | "resolved": "https://registry.npmjs.org/history/-/history-5.2.0.tgz", 106 | "integrity": "sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==", 107 | "peer": true, 108 | "dependencies": { 109 | "@babel/runtime": "^7.7.6" 110 | } 111 | }, 112 | "node_modules/js-tokens": { 113 | "version": "4.0.0", 114 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 115 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 116 | "peer": true 117 | }, 118 | "node_modules/jsesc": { 119 | "version": "3.0.2", 120 | "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", 121 | "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", 122 | "peer": true, 123 | "bin": { 124 | "jsesc": "bin/jsesc" 125 | }, 126 | "engines": { 127 | "node": ">=6" 128 | } 129 | }, 130 | "node_modules/lodash.isequal": { 131 | "version": "4.5.0", 132 | "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", 133 | "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" 134 | }, 135 | "node_modules/loose-envify": { 136 | "version": "1.4.0", 137 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 138 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 139 | "peer": true, 140 | "dependencies": { 141 | "js-tokens": "^3.0.0 || ^4.0.0" 142 | }, 143 | "bin": { 144 | "loose-envify": "cli.js" 145 | } 146 | }, 147 | "node_modules/object-assign": { 148 | "version": "4.1.1", 149 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 150 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", 151 | "peer": true, 152 | "engines": { 153 | "node": ">=0.10.0" 154 | } 155 | }, 156 | "node_modules/react": { 157 | "version": "17.0.2", 158 | "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", 159 | "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", 160 | "peer": true, 161 | "dependencies": { 162 | "loose-envify": "^1.1.0", 163 | "object-assign": "^4.1.1" 164 | }, 165 | "engines": { 166 | "node": ">=0.10.0" 167 | } 168 | }, 169 | "node_modules/react-dom": { 170 | "version": "17.0.2", 171 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", 172 | "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", 173 | "peer": true, 174 | "dependencies": { 175 | "loose-envify": "^1.1.0", 176 | "object-assign": "^4.1.1", 177 | "scheduler": "^0.20.2" 178 | }, 179 | "peerDependencies": { 180 | "react": "17.0.2" 181 | } 182 | }, 183 | "node_modules/react-router": { 184 | "version": "6.2.1", 185 | "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.2.1.tgz", 186 | "integrity": "sha512-2fG0udBtxou9lXtK97eJeET2ki5//UWfQSl1rlJ7quwe6jrktK9FCCc8dQb5QY6jAv3jua8bBQRhhDOM/kVRsg==", 187 | "peer": true, 188 | "dependencies": { 189 | "history": "^5.2.0" 190 | }, 191 | "peerDependencies": { 192 | "react": ">=16.8" 193 | } 194 | }, 195 | "node_modules/react-router-dom": { 196 | "version": "6.2.1", 197 | "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.1.tgz", 198 | "integrity": "sha512-I6Zax+/TH/cZMDpj3/4Fl2eaNdcvoxxHoH1tYOREsQ22OKDYofGebrNm6CTPUcvLvZm63NL/vzCYdjf9CUhqmA==", 199 | "peer": true, 200 | "dependencies": { 201 | "history": "^5.2.0", 202 | "react-router": "6.2.1" 203 | }, 204 | "peerDependencies": { 205 | "react": ">=16.8", 206 | "react-dom": ">=16.8" 207 | } 208 | }, 209 | "node_modules/regenerator-runtime": { 210 | "version": "0.13.9", 211 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", 212 | "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", 213 | "peer": true 214 | }, 215 | "node_modules/scheduler": { 216 | "version": "0.20.2", 217 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", 218 | "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", 219 | "peer": true, 220 | "dependencies": { 221 | "loose-envify": "^1.1.0", 222 | "object-assign": "^4.1.1" 223 | } 224 | }, 225 | "node_modules/set-cookie-parser": { 226 | "version": "2.4.8", 227 | "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.4.8.tgz", 228 | "integrity": "sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg==", 229 | "peer": true 230 | }, 231 | "node_modules/source-map": { 232 | "version": "0.7.3", 233 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", 234 | "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", 235 | "peer": true, 236 | "engines": { 237 | "node": ">= 8" 238 | } 239 | }, 240 | "node_modules/typescript": { 241 | "version": "4.5.5", 242 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", 243 | "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", 244 | "dev": true, 245 | "bin": { 246 | "tsc": "bin/tsc", 247 | "tsserver": "bin/tsserver" 248 | }, 249 | "engines": { 250 | "node": ">=4.2.0" 251 | } 252 | } 253 | }, 254 | "dependencies": { 255 | "@babel/runtime": { 256 | "version": "7.16.7", 257 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz", 258 | "integrity": "sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==", 259 | "peer": true, 260 | "requires": { 261 | "regenerator-runtime": "^0.13.4" 262 | } 263 | }, 264 | "@remix-run/react": { 265 | "version": "1.1.3", 266 | "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-1.1.3.tgz", 267 | "integrity": "sha512-1vYwRi4qQGJLByZR15o47TRZ3/xT3+vhM97UCPJPu4GtzbPldNYLuQlVmAzWWjGuQmSqmwWv818U/+U7rke18g==", 268 | "peer": true, 269 | "requires": { 270 | "react-router-dom": "^6.2.1" 271 | } 272 | }, 273 | "@remix-run/server-runtime": { 274 | "version": "1.1.3", 275 | "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.1.3.tgz", 276 | "integrity": "sha512-Gpl57d42CVqx1gDD+kjjUx7PZqjZgw4i1TWpH37AqOCCBTrNBb2lcl2D0oAZ2ot+wLOT9rDbvIU74GzayDUalw==", 277 | "peer": true, 278 | "requires": { 279 | "@types/cookie": "^0.4.0", 280 | "cookie": "^0.4.1", 281 | "jsesc": "^3.0.1", 282 | "react-router-dom": "^6.2.1", 283 | "set-cookie-parser": "^2.4.8", 284 | "source-map": "^0.7.3" 285 | } 286 | }, 287 | "@types/cookie": { 288 | "version": "0.4.1", 289 | "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", 290 | "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", 291 | "peer": true 292 | }, 293 | "@types/lodash": { 294 | "version": "4.14.178", 295 | "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", 296 | "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==", 297 | "dev": true 298 | }, 299 | "@types/lodash.isequal": { 300 | "version": "4.5.6", 301 | "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz", 302 | "integrity": "sha512-Ww4UGSe3DmtvLLJm2F16hDwEQSv7U0Rr8SujLUA2wHI2D2dm8kPu6Et+/y303LfjTIwSBKXB/YTUcAKpem/XEg==", 303 | "dev": true, 304 | "requires": { 305 | "@types/lodash": "*" 306 | } 307 | }, 308 | "@types/node": { 309 | "version": "17.0.13", 310 | "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.13.tgz", 311 | "integrity": "sha512-Y86MAxASe25hNzlDbsviXl8jQHb0RDvKt4c40ZJQ1Don0AAL0STLZSs4N+6gLEO55pedy7r2cLwS+ZDxPm/2Bw==", 312 | "dev": true 313 | }, 314 | "cookie": { 315 | "version": "0.4.1", 316 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", 317 | "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", 318 | "peer": true 319 | }, 320 | "history": { 321 | "version": "5.2.0", 322 | "resolved": "https://registry.npmjs.org/history/-/history-5.2.0.tgz", 323 | "integrity": "sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==", 324 | "peer": true, 325 | "requires": { 326 | "@babel/runtime": "^7.7.6" 327 | } 328 | }, 329 | "js-tokens": { 330 | "version": "4.0.0", 331 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 332 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 333 | "peer": true 334 | }, 335 | "jsesc": { 336 | "version": "3.0.2", 337 | "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", 338 | "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", 339 | "peer": true 340 | }, 341 | "lodash.isequal": { 342 | "version": "4.5.0", 343 | "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", 344 | "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" 345 | }, 346 | "loose-envify": { 347 | "version": "1.4.0", 348 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 349 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 350 | "peer": true, 351 | "requires": { 352 | "js-tokens": "^3.0.0 || ^4.0.0" 353 | } 354 | }, 355 | "object-assign": { 356 | "version": "4.1.1", 357 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 358 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", 359 | "peer": true 360 | }, 361 | "react": { 362 | "version": "17.0.2", 363 | "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", 364 | "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", 365 | "peer": true, 366 | "requires": { 367 | "loose-envify": "^1.1.0", 368 | "object-assign": "^4.1.1" 369 | } 370 | }, 371 | "react-dom": { 372 | "version": "17.0.2", 373 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", 374 | "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", 375 | "peer": true, 376 | "requires": { 377 | "loose-envify": "^1.1.0", 378 | "object-assign": "^4.1.1", 379 | "scheduler": "^0.20.2" 380 | } 381 | }, 382 | "react-router": { 383 | "version": "6.2.1", 384 | "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.2.1.tgz", 385 | "integrity": "sha512-2fG0udBtxou9lXtK97eJeET2ki5//UWfQSl1rlJ7quwe6jrktK9FCCc8dQb5QY6jAv3jua8bBQRhhDOM/kVRsg==", 386 | "peer": true, 387 | "requires": { 388 | "history": "^5.2.0" 389 | } 390 | }, 391 | "react-router-dom": { 392 | "version": "6.2.1", 393 | "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.1.tgz", 394 | "integrity": "sha512-I6Zax+/TH/cZMDpj3/4Fl2eaNdcvoxxHoH1tYOREsQ22OKDYofGebrNm6CTPUcvLvZm63NL/vzCYdjf9CUhqmA==", 395 | "peer": true, 396 | "requires": { 397 | "history": "^5.2.0", 398 | "react-router": "6.2.1" 399 | } 400 | }, 401 | "regenerator-runtime": { 402 | "version": "0.13.9", 403 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", 404 | "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", 405 | "peer": true 406 | }, 407 | "scheduler": { 408 | "version": "0.20.2", 409 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", 410 | "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", 411 | "peer": true, 412 | "requires": { 413 | "loose-envify": "^1.1.0", 414 | "object-assign": "^4.1.1" 415 | } 416 | }, 417 | "set-cookie-parser": { 418 | "version": "2.4.8", 419 | "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.4.8.tgz", 420 | "integrity": "sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg==", 421 | "peer": true 422 | }, 423 | "source-map": { 424 | "version": "0.7.3", 425 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", 426 | "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", 427 | "peer": true 428 | }, 429 | "typescript": { 430 | "version": "4.5.5", 431 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", 432 | "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", 433 | "dev": true 434 | } 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@balavishnuvj/remix-seo", 3 | "version": "1.0.1", 4 | "description": "Collection of SEO utilities like sitemap, robots.txt, etc. for a Remix Application", 5 | "main": "./build/index.js", 6 | "private": false, 7 | "scripts": { 8 | "prepare": "tsc --project tsconfig.json", 9 | "start": "tsc -w --project tsconfig.json", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/balavishnuvj/remix-seo.git" 15 | }, 16 | "keywords": [ 17 | "remix", 18 | "sitemap", 19 | "sitemap.xml", 20 | "seo", 21 | "robot.txt" 22 | ], 23 | "author": "Balavishnu V J (https://balavishnuvj.com)", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/balavishnuvj/remix-seo/issues" 27 | }, 28 | "homepage": "https://github.com/balavishnuvj/remix-seo#readme", 29 | "devDependencies": { 30 | "@types/lodash.isequal": "^4.5.6", 31 | "@types/node": "^17.0.13", 32 | "typescript": "^4.5.5" 33 | }, 34 | "peerDependencies": { 35 | "@remix-run/react": "^1.0.0 || ^2.0.0", 36 | "@remix-run/server-runtime": "^1.0.0 || ^2.0.0" 37 | }, 38 | "dependencies": { 39 | "lodash.isequal": "^4.5.0" 40 | }, 41 | "types": "./build/index.d.ts" 42 | } 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { generateSitemap } from "./sitemap"; 2 | export { generateRobotsTxt } from "./robotstxt"; 3 | export { SEOHandle } from "./types"; 4 | -------------------------------------------------------------------------------- /src/robotstxt/index.ts: -------------------------------------------------------------------------------- 1 | import { RobotsPolicy, RobotsConfig } from "../types"; 2 | import { getRobotsText } from "./utils"; 3 | 4 | const defaultPolicies: RobotsPolicy[] = [ 5 | { 6 | type: "userAgent", 7 | value: "*", 8 | }, 9 | { 10 | type: "allow", 11 | value: "/", 12 | }, 13 | ]; 14 | 15 | export async function generateRobotsTxt( 16 | policies: RobotsPolicy[] = [], 17 | { appendOnDefaultPolicies = true, headers }: RobotsConfig = {} 18 | ) { 19 | const policiesToUse = appendOnDefaultPolicies 20 | ? [...defaultPolicies, ...policies] 21 | : policies; 22 | const robotText = await getRobotsText(policiesToUse); 23 | const bytes = new TextEncoder().encode(robotText).byteLength; 24 | 25 | return new Response(robotText, { 26 | headers: { 27 | ...headers, 28 | "Content-Type": "text/plain", 29 | "Content-Length": String(bytes), 30 | }, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/robotstxt/utils.ts: -------------------------------------------------------------------------------- 1 | import { RobotsPolicy } from "../types"; 2 | 3 | const typeTextMap = { 4 | userAgent: "User-agent", 5 | allow: "Allow", 6 | disallow: "Disallow", 7 | sitemap: "Sitemap", 8 | crawlDelay: "Crawl-delay", 9 | }; 10 | 11 | export function getRobotsText(policies: RobotsPolicy[]): string { 12 | return policies.reduce((acc, policy) => { 13 | const { type, value } = policy; 14 | return `${acc}${typeTextMap[type]}: ${value}\n`; 15 | }, ""); 16 | } 17 | -------------------------------------------------------------------------------- /src/sitemap/index.ts: -------------------------------------------------------------------------------- 1 | import { EntryContext } from "@remix-run/server-runtime"; 2 | import { SEOOptions } from "../types"; 3 | import { getSitemapXml } from "./utils"; 4 | 5 | export async function generateSitemap( 6 | request: Request, 7 | remixEntryContent: EntryContext, 8 | options: SEOOptions 9 | ) { 10 | const { siteUrl, headers } = options; 11 | const sitemap = await getSitemapXml(request, remixEntryContent, { 12 | siteUrl, 13 | }); 14 | const bytes = new TextEncoder().encode(sitemap).byteLength 15 | 16 | return new Response(sitemap, { 17 | headers: { 18 | ...headers, 19 | "Content-Type": "application/xml", 20 | "Content-Length": String(bytes), 21 | }, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/sitemap/utils.ts: -------------------------------------------------------------------------------- 1 | // This is adapted from https://github.com/kentcdodds/kentcdodds.com 2 | 3 | import { EntryContext } from "@remix-run/server-runtime"; 4 | import isEqual from "lodash.isequal"; 5 | import { SEOHandle, SitemapEntry } from "../types"; 6 | 7 | type Options = { 8 | siteUrl: string; 9 | }; 10 | 11 | function typedBoolean( 12 | value: T 13 | ): value is Exclude { 14 | return Boolean(value); 15 | } 16 | 17 | function removeTrailingSlash(s: string) { 18 | return s.endsWith("/") ? s.slice(0, -1) : s; 19 | } 20 | 21 | async function getSitemapXml( 22 | request: Request, 23 | remixContext: EntryContext, 24 | options: Options 25 | ) { 26 | const { siteUrl } = options; 27 | 28 | function getEntry({ 29 | route, 30 | lastmod, 31 | changefreq, 32 | priority = 0.7, 33 | }: SitemapEntry) { 34 | return ` 35 | 36 | ${siteUrl}${route} 37 | ${lastmod ? `${lastmod}` : ""} 38 | ${changefreq ? `${changefreq}` : ""} 39 | ${typeof priority === "number" ? `${priority}` : ""} 40 | 41 | `.trim(); 42 | } 43 | 44 | const rawSitemapEntries = ( 45 | await Promise.all( 46 | Object.entries(remixContext.routeModules).map(async ([id, mod]) => { 47 | if (id === "root") return; 48 | 49 | const handle = mod.handle as SEOHandle | undefined; 50 | if (handle?.getSitemapEntries) { 51 | return handle.getSitemapEntries(request); 52 | } 53 | 54 | // exclude resource routes from the sitemap 55 | // (these are an opt-in via the getSitemapEntries method) 56 | if (!("default" in mod)) return; 57 | 58 | const manifestEntry = remixContext.manifest.routes[id]; 59 | if (!manifestEntry) { 60 | console.warn(`Could not find a manifest entry for ${id}`); 61 | return; 62 | } 63 | let parentId = manifestEntry.parentId; 64 | let parent = parentId ? remixContext.manifest.routes[parentId] : null; 65 | 66 | let path; 67 | if (manifestEntry.path) { 68 | path = removeTrailingSlash(manifestEntry.path); 69 | } else if (manifestEntry.index) { 70 | path = ""; 71 | } else { 72 | return; 73 | } 74 | 75 | while (parent) { 76 | // the root path is '/', so it messes things up if we add another '/' 77 | const parentPath = parent.path 78 | ? removeTrailingSlash(parent.path) 79 | : ""; 80 | path = `${parentPath}/${path}`; 81 | parentId = parent.parentId; 82 | parent = parentId ? remixContext.manifest.routes[parentId] : null; 83 | } 84 | 85 | // we can't handle dynamic routes, so if the handle doesn't have a 86 | // getSitemapEntries function, we just 87 | if (path.includes(":")) return; 88 | if (id === "root") return; 89 | 90 | const entry: SitemapEntry = { route: removeTrailingSlash(path) }; 91 | return entry; 92 | }) 93 | ) 94 | ) 95 | .flatMap((z) => z) 96 | .filter(typedBoolean); 97 | 98 | const sitemapEntries: Array = []; 99 | for (const entry of rawSitemapEntries) { 100 | const existingEntryForRoute = sitemapEntries.find( 101 | (e) => e.route === entry.route 102 | ); 103 | if (existingEntryForRoute) { 104 | if (!isEqual(existingEntryForRoute, entry)) { 105 | console.warn( 106 | `Duplicate route for ${entry.route} with different sitemap data`, 107 | { entry, existingEntryForRoute } 108 | ); 109 | } 110 | } else { 111 | sitemapEntries.push(entry); 112 | } 113 | } 114 | 115 | return ` 116 | 117 | 122 | ${sitemapEntries.map((entry) => getEntry(entry)).join("")} 123 | 124 | `.trim(); 125 | } 126 | 127 | export { getSitemapXml }; 128 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type SitemapEntry = { 2 | route: string; 3 | lastmod?: string; 4 | changefreq?: 5 | | "always" 6 | | "hourly" 7 | | "daily" 8 | | "weekly" 9 | | "monthly" 10 | | "yearly" 11 | | "never"; 12 | priority?: 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0; 13 | }; 14 | 15 | export type SEOHandle = { 16 | getSitemapEntries?: ( 17 | request: Request 18 | ) => 19 | | Promise | null> 20 | | Array 21 | | null; 22 | }; 23 | 24 | export type SEOOptions = { 25 | siteUrl: string; 26 | headers?: HeadersInit; 27 | }; 28 | 29 | export type RobotsPolicy = { 30 | type: "allow" | "disallow" | "sitemap" | "crawlDelay" | "userAgent"; 31 | value: string; 32 | }; 33 | 34 | export type RobotsConfig = { 35 | appendOnDefaultPolicies?: boolean; 36 | headers?: HeadersInit; 37 | }; 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2019" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "react-jsx" /* Specify what JSX code is generated. */, 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./build" /* Specify an output folder for all emitted files. */, 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "exclude": ["node_modules"], 102 | "include": ["src/**/*.ts", "src/**/*.tsx"] 103 | } 104 | --------------------------------------------------------------------------------