├── .nvmrc ├── .eslintignore ├── .prettierignore ├── .prettierrc ├── .eslintrc ├── .gitignore ├── test ├── tsconfig.json └── seo.test.ts ├── .editorconfig ├── vite.config.ts ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.yml └── workflows │ └── test.yml ├── tsconfig.json ├── LICENSE ├── README.md ├── package.json ├── tsup.config.ts └── src └── index.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.15 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist/**/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist/**/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "tabWidth": 2 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["chance", "chance/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | *.log* 4 | dist 5 | node_modules 6 | /coverage 7 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vite/client"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, configDefaults } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "node", 6 | coverage: { 7 | include: ["packages/*/**/*.test.{ts,tsx,js,jsx}"], 8 | exclude: [...configDefaults.exclude], 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🤔 Feature Requests & Questions 4 | url: https://github.com/chaance/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://www.youtube.com/channel/UC_9cztXyAZCli9Cky6NWWwQ 14 | about: Are you a techlead or wanting to learn more about Remix in depth? Checkout the Remix YouTube Channel 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["."], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ES2020", 8 | "target": "ES2020", 9 | "allowJs": true, 10 | "checkJs": true, 11 | "emitDeclarationOnly": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "downlevelIteration": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "inlineSources": false, 18 | "isolatedModules": true, 19 | "jsx": "react-jsx", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": false, 22 | "skipLibCheck": true, 23 | "strict": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-2023, Chance Strickland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | - v0.2 8 | paths-ignore: 9 | - "LICENSE" 10 | - "**/*.md" 11 | pull_request: 12 | paths-ignore: 13 | - "LICENSE" 14 | - "**/*.md" 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | name: ⚡ Test 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: ⬇️ Checkout repo 26 | uses: actions/checkout@v3 27 | 28 | - name: ⎔ Setup node 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version-file: ".nvmrc" 32 | 33 | - name: 📥 Install deps 34 | uses: pnpm/action-setup@v2 35 | with: 36 | version: 7 37 | run_install: | 38 | - recursive: true 39 | args: [--frozen-lockfile, --strict-peer-dependencies] 40 | - args: [--global, prettier, typescript] 41 | 42 | - name: 🛠️ Build 43 | run: pnpm build 44 | 45 | - name: ⚡ Run vitest 46 | run: TEST_BUILD=true pnpm test:coverage 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `remix-seo` 2 | 3 | A package for easily managing SEO metadata tags in [Remix](https://remix.run). 4 | 5 | WIP! 6 | 7 | ## Usage 8 | 9 | ```tsx 10 | // app/seo.ts 11 | import { initSeo } from "remix-seo"; 12 | export const { getSeo, getSeoMeta, getSeoLinks } = initSeo({ 13 | // Pass any SEO defaults for your site here. 14 | // If individual routes do not provide their own meta and link tags, 15 | // the tags generated by the defaults will be used. 16 | title: "The Awesome Store", 17 | titleTemplate: "%s | The Awesome Store", 18 | description: "The most awesome store on planet Earth.", 19 | }); 20 | 21 | // app/root.tsx 22 | import { getSeo } from "~/seo"; 23 | let [seoMeta, seoLinks] = getSeo(); 24 | 25 | export let meta = () => ({ ...seoMeta, { whatever: "cool" } }); 26 | export let links = () => [ ...seoLinks, { rel: "stylesheet", href: "/sick-styles.css" } ]; 27 | 28 | // app/routes/some-route.tsx 29 | import { getSeo, getSeoMeta, getSeoLinks } from "~/seo"; 30 | 31 | // No need for route data? Get meta and links in one call. 32 | let [seoMeta, seoLinks] = getSeo({ 33 | title: "About us", 34 | description: "We are great!", 35 | }); 36 | 37 | // SEO depends on route data? 38 | // call getSeoMeta and getSeoLinks individually in the relevant function. 39 | export let meta = ({ context }) => { 40 | let seoMeta = getSeoMeta({ 41 | title: `Welcome ${context.name}` 42 | }); 43 | return { 44 | ...seoMeta, 45 | }; 46 | }; 47 | export let links = ({ context }) => { 48 | let seoLinks = getSeoLinks({ 49 | title: `Welcome ${context.name}` 50 | }); 51 | return [...seoLinks]; 52 | }; 53 | ``` 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-seo", 3 | "version": "0.1.0", 4 | "description": "A package for easily managing SEO metadata tags in Remix.", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "src", 11 | "README.md", 12 | "LICENSE" 13 | ], 14 | "scripts": { 15 | "build": "tsup", 16 | "test": "vitest", 17 | "test:coverage": "vitest run --coverage" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "remix", 22 | "remix-run", 23 | "seo" 24 | ], 25 | "author": "Chance Strickland ", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/chaance/remix-seo" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/chaance/remix-seo/issues" 32 | }, 33 | "license": "MIT", 34 | "dependencies": { 35 | "just-merge": "^3.2.0" 36 | }, 37 | "devDependencies": { 38 | "@remix-run/dev": "1.14.3", 39 | "@remix-run/node": "1.14.3", 40 | "@remix-run/react": "1.14.3", 41 | "@types/node": "^18.15.10", 42 | "@types/react": "^18.0.29", 43 | "@typescript-eslint/eslint-plugin": "^5.56.0", 44 | "@typescript-eslint/parser": "^5.56.0", 45 | "@vitest/coverage-c8": "^0.29.7", 46 | "eslint": "^8.36.0", 47 | "eslint-config-chance": "^2.0.2", 48 | "eslint-import-resolver-node": "^0.3.7", 49 | "eslint-import-resolver-typescript": "^3.5.3", 50 | "eslint-plugin-import": "^2.27.5", 51 | "prettier": "^2.8.7", 52 | "react": "^18.2.0", 53 | "react-dom": "^18.2.0", 54 | "tsup": "^6.7.0", 55 | "typescript": "^5.0.2", 56 | "vite": "^4.2.1", 57 | "vitest": "^0.29.7" 58 | }, 59 | "peerDependencies": { 60 | "@remix-run/react": ">=1.0.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { defineConfig } from "tsup"; 4 | // import pkgJson from "./package.json"; 5 | 6 | let pkgJson: { name: string; version: string } = (() => { 7 | let fileContents = fs.readFileSync( 8 | path.join(process.cwd(), "package.json"), 9 | "utf8" 10 | ); 11 | return JSON.parse(fileContents); 12 | })(); 13 | 14 | let { name: packageName, version: packageVersion } = pkgJson; 15 | 16 | export default defineConfig((options) => { 17 | let entry = ["src/index.ts"]; 18 | let external = ["react", "react-dom"]; 19 | let target = "es2020" as const; 20 | let banner = createBanner({ 21 | author: "Chance Strickland", 22 | creationYear: 2022, 23 | license: "MIT", 24 | packageName, 25 | version: packageVersion, 26 | }); 27 | 28 | return [ 29 | // cjs.dev.js 30 | { 31 | entry, 32 | format: "cjs", 33 | sourcemap: true, 34 | external, 35 | banner: { js: banner }, 36 | target, 37 | }, 38 | 39 | // esm + d.ts 40 | { 41 | entry, 42 | format: "esm", 43 | sourcemap: true, 44 | external, 45 | banner: { js: banner }, 46 | target, 47 | dts: { banner }, 48 | }, 49 | ]; 50 | }); 51 | 52 | function createBanner({ 53 | packageName, 54 | version, 55 | author, 56 | license, 57 | creationYear, 58 | }: { 59 | packageName: string; 60 | version: string; 61 | author: string; 62 | license: string; 63 | creationYear: string | number; 64 | }) { 65 | let currentYear = new Date().getFullYear(); 66 | let year = 67 | currentYear === Number(creationYear) 68 | ? currentYear 69 | : `${creationYear}-${currentYear}`; 70 | 71 | return `/** 72 | * ${packageName} v${version} 73 | * 74 | * Copyright (c) ${year}, ${author} 75 | * 76 | * This source code is licensed under the ${license} license found in the 77 | * LICENSE file in the root directory of this source tree. 78 | * 79 | * @license ${license} 80 | */ 81 | `; 82 | } 83 | -------------------------------------------------------------------------------- /.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/chaance/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`'s Discussions tab: https://github.com/chaance/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`'s Issues tab: https://github.com/chaance/remix-seo/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc 15 | - `remix-seo`'s closed issues tab: https://github.com/chaance/remix-seo/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed 16 | - `remix-seo`'s Discussions tab: https://github.com/chaance/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 | -------------------------------------------------------------------------------- /test/seo.test.ts: -------------------------------------------------------------------------------- 1 | import { initSeo as _initSeo } from "../src/index"; 2 | import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 3 | 4 | let initSeo = _initSeo; 5 | if (import.meta.env.TEST_BUILD) { 6 | try { 7 | initSeo = require("../dist/index").initSeo; 8 | } catch (_) { 9 | initSeo = _initSeo; 10 | } 11 | } 12 | 13 | describe("init without default options", () => { 14 | let { getSeo, getSeoLinks, getSeoMeta } = initSeo(); 15 | 16 | describe("getSeo", () => { 17 | describe("without an SEO config", () => { 18 | it("returns meta with *only* directives for bots", () => { 19 | let [meta] = getSeo(); 20 | expect(meta).toEqual({ 21 | googlebot: "index,follow", 22 | robots: "index,follow", 23 | }); 24 | }); 25 | it("returns no links", () => { 26 | let [, links] = getSeo(); 27 | expect(links).toEqual([]); 28 | }); 29 | }); 30 | 31 | it("returns the correct title tags", () => { 32 | let [meta, links] = getSeo({ title: "Cheese and Crackers" }); 33 | expect(meta).toEqual({ 34 | title: "Cheese and Crackers", 35 | "og:title": "Cheese and Crackers", 36 | googlebot: "index,follow", 37 | robots: "index,follow", 38 | }); 39 | expect(links).toEqual([]); 40 | }); 41 | 42 | it("fucks", () => { 43 | let seo = getSeo({ 44 | title: "Best website ever", 45 | description: "This is a really great website ya dork", 46 | titleTemplate: "%s | Cool", 47 | twitter: { 48 | image: { 49 | url: "https://somewhere.com/fake-path.jpg", 50 | alt: "fake!", 51 | }, 52 | }, 53 | bypassTemplate: false, 54 | robots: { 55 | noIndex: true, 56 | noFollow: true, 57 | }, 58 | canonical: "https://somewhere.com", 59 | facebook: { 60 | appId: "12345", 61 | }, 62 | openGraph: { 63 | siteName: "Best website ever, yeah!", 64 | url: "https://somewhere.com", 65 | images: [ 66 | { 67 | url: "https://somewhere.com/fake-path.jpg", 68 | alt: "fake!", 69 | height: 200, 70 | type: "jpg", 71 | }, 72 | ], 73 | }, 74 | }); 75 | 76 | // meta 77 | expect(seo[0]).toMatchInlineSnapshot(` 78 | { 79 | "description": "This is a really great website ya dork", 80 | "fb:app_id": "12345", 81 | "googlebot": "noindex,nofollow", 82 | "og:description": "This is a really great website ya dork", 83 | "og:image": "https://somewhere.com/fake-path.jpg", 84 | "og:image:alt": "fake!", 85 | "og:image:height": "200", 86 | "og:image:type": "jpg", 87 | "og:site_name": "Best website ever, yeah!", 88 | "og:title": "Best website ever | Cool", 89 | "og:url": "https://somewhere.com", 90 | "robots": "noindex,nofollow", 91 | "title": "Best website ever | Cool", 92 | "twitter:card": "summary", 93 | "twitter:description": "This is a really great website ya dork", 94 | "twitter:image": "https://somewhere.com/fake-path.jpg", 95 | "twitter:image:alt": "fake!", 96 | "twitter:title": "Best website ever | Cool", 97 | } 98 | `); 99 | 100 | // links 101 | expect(seo[1]).toMatchInlineSnapshot(` 102 | [ 103 | { 104 | "href": "https://somewhere.com", 105 | "rel": "canonical", 106 | }, 107 | ] 108 | `); 109 | }); 110 | }); 111 | 112 | describe("getSeoMeta", () => { 113 | describe("without an SEO config", () => { 114 | it("returns an object with *only* directives for bots", () => { 115 | let meta = getSeoMeta(); 116 | expect(meta).toEqual({ 117 | googlebot: "index,follow", 118 | robots: "index,follow", 119 | }); 120 | }); 121 | }); 122 | }); 123 | 124 | describe("getSeoLinks", () => { 125 | describe("without an SEO config", () => { 126 | it("returns an empty array", () => { 127 | let links = getSeoLinks(); 128 | expect(links).toEqual([]); 129 | }); 130 | }); 131 | }); 132 | }); 133 | 134 | describe("init with default options", () => { 135 | let { getSeo, getSeoMeta, getSeoLinks } = initSeo({ 136 | title: "Cheese and Crackers", 137 | description: "A great website about eating delicious cheese and crackers.", 138 | titleTemplate: "%s | Cheese and Crackers", 139 | canonical: "https://somewhere-a.com", 140 | }); 141 | 142 | describe("getSeo", () => { 143 | describe("without an SEO config", () => { 144 | let [meta, links] = getSeo(); 145 | it("returns meta based on default config", () => { 146 | expect(meta).toEqual({ 147 | title: "Cheese and Crackers | Cheese and Crackers", 148 | "og:title": "Cheese and Crackers | Cheese and Crackers", 149 | description: 150 | "A great website about eating delicious cheese and crackers.", 151 | "og:description": 152 | "A great website about eating delicious cheese and crackers.", 153 | googlebot: "index,follow", 154 | robots: "index,follow", 155 | }); 156 | }); 157 | it("returns links based on default config", () => { 158 | expect(links).toEqual([ 159 | { 160 | rel: "canonical", 161 | href: "https://somewhere-a.com", 162 | }, 163 | ]); 164 | }); 165 | }); 166 | 167 | it("overrides the title tags", () => { 168 | let [meta, links] = getSeo({ title: "About us" }); 169 | expect(meta).toEqual({ 170 | title: "About us | Cheese and Crackers", 171 | "og:title": "About us | Cheese and Crackers", 172 | description: 173 | "A great website about eating delicious cheese and crackers.", 174 | "og:description": 175 | "A great website about eating delicious cheese and crackers.", 176 | googlebot: "index,follow", 177 | robots: "index,follow", 178 | }); 179 | expect(links).toEqual([ 180 | { 181 | rel: "canonical", 182 | href: "https://somewhere-a.com", 183 | }, 184 | ]); 185 | }); 186 | 187 | it("a page does not overwrite the initial default config", () => { 188 | let [meta, links] = getSeo({ 189 | title: "About us", 190 | twitter: { card: "summary_large_image" }, 191 | }); 192 | 193 | expect(meta).toEqual({ 194 | title: "About us | Cheese and Crackers", 195 | "og:title": "About us | Cheese and Crackers", 196 | description: 197 | "A great website about eating delicious cheese and crackers.", 198 | "og:description": 199 | "A great website about eating delicious cheese and crackers.", 200 | "twitter:card": "summary_large_image", 201 | "twitter:description": 202 | "A great website about eating delicious cheese and crackers.", 203 | "twitter:title": "About us | Cheese and Crackers", 204 | googlebot: "index,follow", 205 | robots: "index,follow", 206 | }); 207 | 208 | expect(links).toEqual([ 209 | { 210 | rel: "canonical", 211 | href: "https://somewhere-a.com", 212 | }, 213 | ]); 214 | let [newMeta] = getSeo(); 215 | expect(newMeta).toEqual({ 216 | title: "Cheese and Crackers | Cheese and Crackers", 217 | "og:title": "Cheese and Crackers | Cheese and Crackers", 218 | description: 219 | "A great website about eating delicious cheese and crackers.", 220 | "og:description": 221 | "A great website about eating delicious cheese and crackers.", 222 | googlebot: "index,follow", 223 | robots: "index,follow", 224 | }); 225 | }); 226 | }); 227 | 228 | describe("getSeoMeta", () => { 229 | describe("without an SEO config", () => { 230 | let meta = getSeoMeta(); 231 | it("returns meta based on default config", () => { 232 | expect(meta).toEqual({ 233 | title: "Cheese and Crackers | Cheese and Crackers", 234 | "og:title": "Cheese and Crackers | Cheese and Crackers", 235 | description: 236 | "A great website about eating delicious cheese and crackers.", 237 | "og:description": 238 | "A great website about eating delicious cheese and crackers.", 239 | googlebot: "index,follow", 240 | robots: "index,follow", 241 | }); 242 | }); 243 | }); 244 | 245 | it("overrides the title tags", () => { 246 | let meta = getSeoMeta({ title: "About us" }); 247 | expect(meta).toEqual({ 248 | title: "About us | Cheese and Crackers", 249 | "og:title": "About us | Cheese and Crackers", 250 | description: 251 | "A great website about eating delicious cheese and crackers.", 252 | "og:description": 253 | "A great website about eating delicious cheese and crackers.", 254 | googlebot: "index,follow", 255 | robots: "index,follow", 256 | }); 257 | }); 258 | }); 259 | 260 | describe("getSeoLinks", () => { 261 | describe("without an SEO config", () => { 262 | let links = getSeoLinks(); 263 | it("returns links based on default config", () => { 264 | expect(links).toEqual([ 265 | { 266 | rel: "canonical", 267 | href: "https://somewhere-a.com", 268 | }, 269 | ]); 270 | }); 271 | }); 272 | 273 | it("overrides the default canonical link", () => { 274 | let links = getSeoLinks({ canonical: "https://somewhere-b.com" }); 275 | expect(links).toEqual([ 276 | { 277 | rel: "canonical", 278 | href: "https://somewhere-b.com", 279 | }, 280 | ]); 281 | }); 282 | }); 283 | }); 284 | 285 | describe("init with default options based on route data", () => { 286 | let { getSeo, getSeoMeta, getSeoLinks } = initSeo({ 287 | title: "Cheese and Crackers", 288 | description: "A great website about eating delicious cheese and crackers.", 289 | titleTemplate: "%s | Cheese and Crackers", 290 | canonical: "https://somewhere-a.com", 291 | }); 292 | 293 | describe("getSeo", () => { 294 | describe("without an SEO config", () => { 295 | let [meta, links] = getSeo(); 296 | it("returns meta based on default config", () => { 297 | expect(meta).toEqual({ 298 | title: "Cheese and Crackers | Cheese and Crackers", 299 | "og:title": "Cheese and Crackers | Cheese and Crackers", 300 | description: 301 | "A great website about eating delicious cheese and crackers.", 302 | "og:description": 303 | "A great website about eating delicious cheese and crackers.", 304 | googlebot: "index,follow", 305 | robots: "index,follow", 306 | }); 307 | }); 308 | it("returns links based on default config", () => { 309 | expect(links).toEqual([ 310 | { 311 | rel: "canonical", 312 | href: "https://somewhere-a.com", 313 | }, 314 | ]); 315 | }); 316 | }); 317 | 318 | it("overrides the title tags", () => { 319 | let [meta, links] = getSeo({ title: "About us" }); 320 | expect(meta).toEqual({ 321 | title: "About us | Cheese and Crackers", 322 | "og:title": "About us | Cheese and Crackers", 323 | description: 324 | "A great website about eating delicious cheese and crackers.", 325 | "og:description": 326 | "A great website about eating delicious cheese and crackers.", 327 | googlebot: "index,follow", 328 | robots: "index,follow", 329 | }); 330 | expect(links).toEqual([ 331 | { 332 | rel: "canonical", 333 | href: "https://somewhere-a.com", 334 | }, 335 | ]); 336 | }); 337 | }); 338 | 339 | describe("getSeoMeta", () => { 340 | describe("without an SEO config", () => { 341 | let meta = getSeoMeta(); 342 | it("returns meta based on default config", () => { 343 | expect(meta).toEqual({ 344 | title: "Cheese and Crackers | Cheese and Crackers", 345 | "og:title": "Cheese and Crackers | Cheese and Crackers", 346 | description: 347 | "A great website about eating delicious cheese and crackers.", 348 | "og:description": 349 | "A great website about eating delicious cheese and crackers.", 350 | googlebot: "index,follow", 351 | robots: "index,follow", 352 | }); 353 | }); 354 | }); 355 | 356 | it("overrides the title tags", () => { 357 | let meta = getSeoMeta({ title: "About us" }); 358 | expect(meta).toEqual({ 359 | title: "About us | Cheese and Crackers", 360 | "og:title": "About us | Cheese and Crackers", 361 | description: 362 | "A great website about eating delicious cheese and crackers.", 363 | "og:description": 364 | "A great website about eating delicious cheese and crackers.", 365 | googlebot: "index,follow", 366 | robots: "index,follow", 367 | }); 368 | }); 369 | }); 370 | 371 | describe("getSeoLinks", () => { 372 | describe("without an SEO config", () => { 373 | let links = getSeoLinks(); 374 | it("returns links based on default config", () => { 375 | expect(links).toEqual([ 376 | { 377 | rel: "canonical", 378 | href: "https://somewhere-a.com", 379 | }, 380 | ]); 381 | }); 382 | }); 383 | 384 | it("overrides the default canonical link", () => { 385 | let links = getSeoLinks({ canonical: "https://somewhere-b.com" }); 386 | expect(links).toEqual([ 387 | { 388 | rel: "canonical", 389 | href: "https://somewhere-b.com", 390 | }, 391 | ]); 392 | }); 393 | }); 394 | }); 395 | 396 | describe("twitter config", () => { 397 | let warn = console.warn; 398 | beforeEach(() => { 399 | console.warn = vi.fn(); 400 | }); 401 | 402 | afterEach(() => { 403 | console.warn = warn; 404 | }); 405 | 406 | let { getSeo } = initSeo({ 407 | title: "Cheese and Crackers", 408 | description: "A great website about eating delicious cheese and crackers.", 409 | }); 410 | 411 | it("warns when an invalid URL is provided to twitter:image", () => { 412 | getSeo({ 413 | twitter: { 414 | image: { 415 | url: "/fake-path.jpg", 416 | alt: "fake!", 417 | }, 418 | }, 419 | }); 420 | expect(console.warn).toHaveBeenCalledTimes(1); 421 | }); 422 | 423 | it("warns when alt text isn't provided to twitter:image", () => { 424 | getSeo({ 425 | twitter: { 426 | // @ts-expect-error 427 | image: { 428 | url: "https://somewhere.com/fake-path.jpg", 429 | }, 430 | }, 431 | }); 432 | expect(console.warn).toHaveBeenCalledTimes(1); 433 | }); 434 | 435 | it("does not warn when a valid URL is provided to twitter:image", () => { 436 | getSeo({ 437 | twitter: { 438 | image: { 439 | url: "https://somewhere.com/fake-path.jpg", 440 | alt: "fake!", 441 | }, 442 | }, 443 | }); 444 | expect(console.warn).not.toHaveBeenCalled(); 445 | }); 446 | 447 | it("warns when an invalid card type is passed", () => { 448 | getSeo({ 449 | twitter: { 450 | // @ts-expect-error 451 | card: "poop", 452 | }, 453 | }); 454 | expect(console.warn).toHaveBeenCalledTimes(1); 455 | }); 456 | 457 | describe("when card is set to 'app'", () => { 458 | it("warns when image meta is provided", () => { 459 | getSeo({ 460 | twitter: { 461 | card: "app", 462 | app: { 463 | name: "Hello", 464 | url: { 465 | iPhone: "https://a.com", 466 | iPad: "https://b.com", 467 | googlePlay: "https://c.com", 468 | }, 469 | id: { 470 | iPhone: "1", 471 | iPad: "2", 472 | googlePlay: "3", 473 | }, 474 | }, 475 | image: { 476 | url: "https://somewhere.com/fake-path.jpg", 477 | alt: "fake!", 478 | }, 479 | }, 480 | }); 481 | expect(console.warn).toHaveBeenCalledTimes(1); 482 | }); 483 | 484 | it("warns when player meta is provided", () => { 485 | getSeo({ 486 | twitter: { 487 | card: "app", 488 | app: { 489 | name: "Hello", 490 | url: { 491 | iPhone: "https://a.com", 492 | iPad: "https://b.com", 493 | googlePlay: "https://c.com", 494 | }, 495 | id: { 496 | iPhone: "1", 497 | iPad: "2", 498 | googlePlay: "3", 499 | }, 500 | }, 501 | player: { 502 | url: "https://somewhere.com/fake-path.jpg", 503 | }, 504 | }, 505 | }); 506 | expect(console.warn).toHaveBeenCalledTimes(1); 507 | }); 508 | 509 | it("ignores image meta", () => { 510 | let [meta] = getSeo({ 511 | twitter: { 512 | card: "app", 513 | app: { 514 | name: "Hello", 515 | url: { 516 | iPhone: "https://a.com", 517 | iPad: "https://b.com", 518 | googlePlay: "https://c.com", 519 | }, 520 | id: { 521 | iPhone: "1", 522 | iPad: "2", 523 | googlePlay: "3", 524 | }, 525 | }, 526 | image: { 527 | url: "https://somewhere.com/fake-path.jpg", 528 | alt: "fake!", 529 | }, 530 | }, 531 | }); 532 | expect(meta["twitter:image"]).toBe(undefined); 533 | }); 534 | 535 | it("ignores player meta", () => { 536 | let [meta] = getSeo({ 537 | twitter: { 538 | card: "app", 539 | app: { 540 | name: "Hello", 541 | url: { 542 | iPhone: "https://a.com", 543 | iPad: "https://b.com", 544 | googlePlay: "https://c.com", 545 | }, 546 | id: { 547 | iPhone: "1", 548 | iPad: "2", 549 | googlePlay: "3", 550 | }, 551 | }, 552 | player: { 553 | url: "https://somewhere.com/fake-path.jpg", 554 | }, 555 | }, 556 | }); 557 | expect(meta["twitter:player"]).toBe(undefined); 558 | }); 559 | }); 560 | 561 | describe("when player metadata is provided", () => { 562 | it("warns on invalid card type", () => { 563 | getSeo({ 564 | twitter: { 565 | card: "summary", 566 | player: { 567 | url: "https://somewhere.com/fake-path.mp4", 568 | }, 569 | }, 570 | }); 571 | expect(console.warn).toHaveBeenCalledTimes(1); 572 | }); 573 | 574 | it("sets card type to 'player' if none is provided", () => { 575 | let [meta] = getSeo({ 576 | twitter: { 577 | player: { 578 | url: "https://somewhere.com/fake-path.mp4", 579 | }, 580 | }, 581 | }); 582 | expect(meta["twitter:card"]).toEqual("player"); 583 | }); 584 | }); 585 | 586 | describe("when app metadata is provided", () => { 587 | it("sets card type to 'app' if none is provided", () => { 588 | let [meta] = getSeo({ 589 | twitter: { 590 | app: { 591 | name: "Hello", 592 | url: { 593 | iPhone: "https://a.com", 594 | iPad: "https://b.com", 595 | googlePlay: "https://c.com", 596 | }, 597 | id: { 598 | iPhone: "1", 599 | iPad: "2", 600 | googlePlay: "3", 601 | }, 602 | }, 603 | }, 604 | }); 605 | expect(meta["twitter:card"]).toEqual("app"); 606 | }); 607 | }); 608 | }); 609 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { HtmlMetaDescriptor, HtmlLinkDescriptor } from "@remix-run/react"; 2 | import merge from "just-merge"; 3 | 4 | /** 5 | * A function for setting default SEO meta for Remix sites. 6 | * 7 | * @param defaultConfig - The default configuration object. Each of the returned 8 | * functions will merge their own config with the default config when called on 9 | * a specific route. 10 | * @returns An object with three methods to use for getting SEO link and meta 11 | * tags on the site's routes. 12 | */ 13 | export function initSeo(defaultConfig?: SeoConfig): { 14 | getSeo: SeoFunction; 15 | getSeoMeta: SeoMetaFunction; 16 | getSeoLinks: SeoLinksFunction; 17 | } { 18 | const getSeo: SeoFunction = ( 19 | cfg?: SeoConfig | ((routeArgs?: RouteArgs) => SeoConfig), 20 | routeArgs?: RouteArgs 21 | ): [HtmlMetaDescriptor, HtmlLinkDescriptor[]] => { 22 | let config = resolveConfig(defaultConfig, cfg, routeArgs); 23 | let meta = getMeta(config, routeArgs); 24 | let links = getLinks(config, routeArgs); 25 | return [meta, links]; 26 | }; 27 | 28 | const getSeoMeta: SeoMetaFunction = ( 29 | cfg?: SeoConfig | ((routeArgs?: RouteArgs) => SeoConfig), 30 | routeArgs?: RouteArgs 31 | ): HtmlMetaDescriptor => { 32 | let config = resolveConfig(defaultConfig, cfg, routeArgs); 33 | let meta = getMeta(config, routeArgs); 34 | return meta; 35 | }; 36 | 37 | const getSeoLinks: SeoLinksFunction = ( 38 | cfg?: SeoConfig | ((routeArgs?: RouteArgs) => SeoConfig), 39 | routeArgs?: RouteArgs 40 | ): HtmlLinkDescriptor[] => { 41 | let config = resolveConfig(defaultConfig, cfg, routeArgs); 42 | let links = getLinks(config, routeArgs); 43 | return links; 44 | }; 45 | 46 | return { 47 | getSeo, 48 | getSeoMeta, 49 | getSeoLinks, 50 | }; 51 | } 52 | 53 | function getMeta(config: SeoConfig, arg: any) { 54 | let meta: HtmlMetaDescriptor = {}; 55 | let title = getSeoTitle(config); 56 | let { 57 | canonical, 58 | description, 59 | facebook, 60 | omitGoogleBotMeta = false, 61 | openGraph, 62 | robots = {}, 63 | twitter, 64 | } = config; 65 | 66 | if (title) { 67 | meta.title = title; 68 | } 69 | 70 | if (description) { 71 | meta.description = description; 72 | } 73 | 74 | // Robots 75 | let { 76 | maxImagePreview, 77 | maxSnippet, 78 | maxVideoPreview, 79 | noArchive, 80 | noFollow, 81 | noImageIndex, 82 | noIndex, 83 | noSnippet, 84 | noTranslate, 85 | unavailableAfter, 86 | } = robots; 87 | 88 | let robotsParams = [ 89 | noArchive && "noarchive", 90 | noImageIndex && "noimageindex", 91 | noSnippet && "nosnippet", 92 | noTranslate && `notranslate`, 93 | maxImagePreview && `max-image-preview:${maxImagePreview}`, 94 | maxSnippet && `max-snippet:${maxSnippet}`, 95 | maxVideoPreview && `max-video-preview:${maxVideoPreview}`, 96 | unavailableAfter && `unavailable_after:${unavailableAfter}`, 97 | ]; 98 | 99 | let robotsParam = 100 | (noIndex ? "noindex" : "index") + "," + (noFollow ? "nofollow" : "follow"); 101 | 102 | for (let param of robotsParams) { 103 | if (param) { 104 | robotsParam += `,${param}`; 105 | } 106 | } 107 | 108 | meta.robots = robotsParam; 109 | if (!omitGoogleBotMeta) { 110 | meta.googlebot = meta.robots; 111 | } 112 | 113 | // OG: Twitter 114 | if (twitter) { 115 | if (twitter.title || title) { 116 | meta["twitter:title"] = twitter.title || title; 117 | } 118 | 119 | if (twitter.description || openGraph?.description || description) { 120 | meta["twitter:description"] = 121 | twitter.description || openGraph?.description || description!; 122 | } 123 | 124 | if (twitter.card) { 125 | let cardType = validateTwitterCard(twitter); 126 | if (cardType) { 127 | meta["twitter:card"] = cardType; 128 | } 129 | } 130 | 131 | if (twitter.site) { 132 | meta["twitter:site"] = 133 | typeof twitter.site === "object" ? twitter.site.id : twitter.site; 134 | } 135 | 136 | if (twitter.creator) { 137 | meta["twitter:creator"] = 138 | typeof twitter.creator === "object" 139 | ? twitter.creator.id 140 | : twitter.creator; 141 | } 142 | 143 | if (hasTwitterImageMeta(twitter)) { 144 | warnIfInvalidUrl( 145 | twitter.image.url, 146 | `The twitter:image tag must be a valid, absolute URL. Relative paths will not work as expected. Check the config's \`twitter.image.url\` value.` 147 | ); 148 | meta["twitter:image"] = twitter.image.url; 149 | if (twitter.image!.alt) { 150 | meta["twitter:image:alt"] = twitter.image.alt; 151 | } else { 152 | warn( 153 | "A Twitter image should use alt text that describes the image. This is important for users who are visually impaired. Please add a text value to the `alt` key of the `twitter.image` config option to dismiss this warning." 154 | ); 155 | } 156 | } 157 | 158 | if (hasTwitterPlayerMeta(twitter)) { 159 | if (twitter.player.url) { 160 | warnIfInvalidUrl( 161 | twitter.player.url, 162 | `The twitter:player tag must be a valid, absolute URL. Relative paths will not work as expected. Check the config's \`twitter.player.url\` value.` 163 | ); 164 | meta["twitter:player"] = twitter.player.url; 165 | } 166 | 167 | if (twitter.player.stream) { 168 | warnIfInvalidUrl( 169 | twitter.player.stream, 170 | `The twitter:player:stream tag must be a valid, absolute URL. Relative paths will not work as expected. Check the config's \`twitter.player.stream\` value.` 171 | ); 172 | meta["twitter:player:stream"] = twitter.player.stream; 173 | } 174 | 175 | if (twitter.player.height) { 176 | meta["twitter:player:height"] = twitter.player.height.toString(); 177 | } 178 | 179 | if (twitter.player.width) { 180 | meta["twitter:player:height"] = twitter.player.width.toString(); 181 | } 182 | } 183 | 184 | if (hasTwitterAppMeta(twitter)) { 185 | const twitterDevices = ["iPhone", "iPad", "googlePlay"] as const; 186 | 187 | if (typeof twitter.app.name === "object") { 188 | for (const device of twitterDevices) { 189 | if (twitter.app.name[device]) { 190 | meta[`twitter:app:name:${device.toLowerCase()}`] = 191 | twitter.app.name[device]!; 192 | } 193 | } 194 | } else { 195 | meta["twitter:app:name:iphone"] = twitter.app.name; 196 | meta["twitter:app:name:ipad"] = twitter.app.name; 197 | meta["twitter:app:name:googleplay"] = twitter.app.name; 198 | } 199 | 200 | if (typeof twitter.app.id === "object") { 201 | for (const device of twitterDevices) { 202 | if (twitter.app.id[device]) { 203 | meta[`twitter:app:id:${device.toLowerCase()}`] = 204 | twitter.app.id[device]!; 205 | } 206 | } 207 | } 208 | 209 | if (typeof twitter.app.url === "object") { 210 | for (const device of twitterDevices) { 211 | if (twitter.app.url[device]) { 212 | meta[`twitter:app:url:${device.toLowerCase()}`] = 213 | twitter.app.url[device]!; 214 | } 215 | } 216 | } 217 | } 218 | 219 | if (!meta["twitter:card"]) { 220 | if (hasTwitterPlayerMeta(twitter)) { 221 | meta["twitter:card"] = "player"; 222 | } else if (hasTwitterAppMeta(twitter)) { 223 | meta["twitter:card"] = "app"; 224 | } else if (hasTwitterImageMeta(twitter)) { 225 | meta["twitter:card"] = "summary"; 226 | } 227 | } 228 | } 229 | 230 | // OG: Facebook 231 | if (facebook) { 232 | if (facebook.appId) { 233 | meta["fb:app_id"] = facebook.appId; 234 | } 235 | } 236 | 237 | // OG: Other stuff 238 | if (openGraph?.title || config.title) { 239 | meta["og:title"] = openGraph?.title || title; 240 | } 241 | 242 | if (openGraph?.description || description) { 243 | meta["og:description"] = openGraph?.description || description!; 244 | } 245 | 246 | if (openGraph) { 247 | if (openGraph.url || canonical) { 248 | if (openGraph.url) { 249 | warnIfInvalidUrl( 250 | openGraph.url, 251 | `The og:url tag must be a valid, absolute URL. Relative paths will not work as expected. Check the config's \`openGraph.url\` value.` 252 | ); 253 | } 254 | if (canonical) { 255 | warnIfInvalidUrl( 256 | canonical, 257 | `The og:url tag must be a valid, absolute URL. Relative paths will not work as expected. Check the config's \`canonical\` value.` 258 | ); 259 | } 260 | 261 | meta["og:url"] = openGraph.url || canonical!; 262 | } 263 | 264 | if (openGraph.type) { 265 | const ogType = openGraph.type.toLowerCase(); 266 | 267 | meta["og:type"] = ogType; 268 | 269 | if (ogType === "profile" && openGraph.profile) { 270 | if (openGraph.profile.firstName) { 271 | meta["profile:first_name"] = openGraph.profile.firstName; 272 | } 273 | 274 | if (openGraph.profile.lastName) { 275 | meta["profile:last_name"] = openGraph.profile.lastName; 276 | } 277 | 278 | if (openGraph.profile.username) { 279 | meta["profile:username"] = openGraph.profile.username; 280 | } 281 | 282 | if (openGraph.profile.gender) { 283 | meta["profile:gender"] = openGraph.profile.gender; 284 | } 285 | } else if (ogType === "book" && openGraph.book) { 286 | if (openGraph.book.authors && openGraph.book.authors.length) { 287 | for (let author of openGraph.book.authors) { 288 | if (Array.isArray(meta["book:author"])) { 289 | meta["book:author"].push(author); 290 | } else { 291 | meta["book:author"] = [author]; 292 | } 293 | } 294 | } 295 | 296 | if (openGraph.book.isbn) { 297 | meta["book:isbn"] = openGraph.book.isbn; 298 | } 299 | 300 | if (openGraph.book.releaseDate) { 301 | meta["book:release_date"] = openGraph.book.releaseDate; 302 | } 303 | 304 | if (openGraph.book.tags && openGraph.book.tags.length) { 305 | for (let tag of openGraph.book.tags) { 306 | if (Array.isArray(meta["book:tag"])) { 307 | meta["book:tag"].push(tag); 308 | } else { 309 | meta["book:tag"] = [tag]; 310 | } 311 | } 312 | } 313 | } else if (ogType === "article" && openGraph.article) { 314 | if (openGraph.article.publishedTime) { 315 | meta["article:published_time"] = openGraph.article.publishedTime; 316 | } 317 | 318 | if (openGraph.article.modifiedTime) { 319 | meta["article:modified_time"] = openGraph.article.modifiedTime; 320 | } 321 | 322 | if (openGraph.article.expirationTime) { 323 | meta["article:expiration_time"] = openGraph.article.expirationTime; 324 | } 325 | 326 | if (openGraph.article.authors && openGraph.article.authors.length) { 327 | for (let author of openGraph.article.authors) { 328 | if (Array.isArray(meta["article:author"])) { 329 | meta["article:author"].push(author); 330 | } else { 331 | meta["article:author"] = [author]; 332 | } 333 | } 334 | } 335 | 336 | if (openGraph.article.section) { 337 | meta["article:section"] = openGraph.article.section; 338 | } 339 | 340 | if (openGraph.article.tags && openGraph.article.tags.length) { 341 | for (let tag of openGraph.article.tags) { 342 | if (Array.isArray(meta["article:tag"])) { 343 | meta["article:tag"].push(tag); 344 | } else { 345 | meta["article:tag"] = [tag]; 346 | } 347 | } 348 | } 349 | } else if ( 350 | (ogType === "video.movie" || 351 | ogType === "video.episode" || 352 | ogType === "video.tv_show" || 353 | ogType === "video.other") && 354 | openGraph.video 355 | ) { 356 | if (openGraph.video.actors && openGraph.video.actors.length) { 357 | for (let actor of openGraph.video.actors) { 358 | if (actor.profile) { 359 | meta["video:actor"] = actor.profile; 360 | } 361 | 362 | if (actor.role) { 363 | meta["video:actor:role"] = actor.role; 364 | } 365 | } 366 | } 367 | 368 | if (openGraph.video.directors && openGraph.video.directors.length) { 369 | for (let director of openGraph.video.directors) { 370 | meta["video:director"] = director; 371 | } 372 | } 373 | 374 | if (openGraph.video.writers && openGraph.video.writers.length) { 375 | for (let writer of openGraph.video.writers) { 376 | meta["video:writer"] = writer; 377 | } 378 | } 379 | 380 | if (openGraph.video.duration) { 381 | meta["video:duration"] = openGraph.video.duration.toString(); 382 | } 383 | 384 | if (openGraph.video.releaseDate) { 385 | meta["video:release_date"] = openGraph.video.releaseDate; 386 | } 387 | 388 | if (openGraph.video.tags && openGraph.video.tags.length) { 389 | for (let tag of openGraph.video.tags) { 390 | meta["video:tag"] = tag; 391 | } 392 | } 393 | 394 | if (openGraph.video.series) { 395 | meta["video:series"] = openGraph.video.series; 396 | } 397 | } 398 | } 399 | 400 | if (openGraph.images && openGraph.images.length) { 401 | for (let image of openGraph.images) { 402 | warnIfInvalidUrl( 403 | image.url, 404 | `The og:image tag must be a valid, absolute URL. Relative paths will not work as expected. Check each \`url\` value in the config's \`openGraph.images\` array.` 405 | ); 406 | meta["og:image"] = image.url; 407 | if (image.alt) { 408 | meta["og:image:alt"] = image.alt; 409 | } else { 410 | warn( 411 | "OpenGraph images should use alt text that describes the image. This is important for users who are visually impaired. Please add a text value to the `alt` key of all `openGraph.images` config options to dismiss this warning." 412 | ); 413 | } 414 | 415 | if (image.secureUrl) { 416 | warnIfInvalidUrl( 417 | image.secureUrl, 418 | `The og:image:secure_url tag must be a valid, absolute URL. Relative paths will not work as expected. Check each \`secureUrl\` value in the config's \`openGraph.images\` array.` 419 | ); 420 | meta["og:image:secure_url"] = image.secureUrl.toString(); 421 | } 422 | 423 | if (image.type) { 424 | meta["og:image:type"] = image.type.toString(); 425 | } 426 | 427 | if (image.width) { 428 | meta["og:image:width"] = image.width.toString(); 429 | } 430 | 431 | if (image.height) { 432 | meta["og:image:height"] = image.height.toString(); 433 | } 434 | } 435 | } 436 | 437 | if (openGraph.videos && openGraph.videos.length) { 438 | for (let video of openGraph.videos) { 439 | warnIfInvalidUrl( 440 | video.url, 441 | `The og:video tag must be a valid, absolute URL. Relative paths will not work as expected. Check each \`url\` value in the config's \`openGraph.videos\` array.` 442 | ); 443 | meta["og:video"] = video.url; 444 | if (video.alt) { 445 | meta["og:video:alt"] = video.alt; 446 | } 447 | 448 | if (video.secureUrl) { 449 | warnIfInvalidUrl( 450 | video.secureUrl, 451 | `The og:video:secure_url tag must be a valid, absolute URL. Relative paths will not work as expected. Check each \`secureUrl\` value in the config's \`openGraph.videos\` array.` 452 | ); 453 | meta["og:video:secure_url"] = video.secureUrl.toString(); 454 | } 455 | 456 | if (video.type) { 457 | meta["og:video:type"] = video.type.toString(); 458 | } 459 | 460 | if (video.width) { 461 | meta["og:video:width"] = video.width.toString(); 462 | } 463 | 464 | if (video.height) { 465 | meta["og:video:height"] = video.height.toString(); 466 | } 467 | } 468 | } 469 | 470 | if (openGraph.locale) { 471 | meta["og:locale"] = openGraph.locale; 472 | } 473 | 474 | if (openGraph.siteName) { 475 | meta["og:site_name"] = openGraph.siteName; 476 | } 477 | } 478 | 479 | return meta; 480 | } 481 | 482 | function getLinks(config: SeoConfig, arg: any): HtmlLinkDescriptor[] { 483 | let links: HtmlLinkDescriptor[] = []; 484 | let { canonical, mobileAlternate, languageAlternates = [] } = config; 485 | 486 | if (canonical) { 487 | warnIfInvalidUrl( 488 | canonical, 489 | `The canonical link tag must have an \`href\` with a valid, absolute URL. Relative paths will not work as expected. Check the config's \`canonical\` value.` 490 | ); 491 | links.push({ 492 | rel: "canonical", 493 | href: canonical, 494 | }); 495 | } 496 | 497 | // 498 | if (mobileAlternate) { 499 | if (!mobileAlternate.media || !mobileAlternate.href) { 500 | warn( 501 | "`mobileAlternate` requires both the `media` and `href` attributes for it to generate the correct link tags. This config setting currently has no effect. Either add the missing keys or remove `mobileAlternate` from your config to dismiss this warning." + 502 | // TODO: See if we can find a better description of this tag w/o all 503 | // the marketing junk. MDN is a bit scant here. 504 | "\n\nSee https://www.contentkingapp.com/academy/link-rel/#mobile-lok for a description of the tag this option generates." 505 | ); 506 | } else { 507 | links.push({ 508 | rel: "alternate", 509 | media: mobileAlternate.media, 510 | href: mobileAlternate.href, 511 | }); 512 | } 513 | } 514 | 515 | if (languageAlternates.length > 0) { 516 | for (let languageAlternate of languageAlternates) { 517 | if (!languageAlternate.hrefLang || !languageAlternate.href) { 518 | warn( 519 | "Items in `languageAlternates` requires both the `hrefLang` and `href` attributes for it to generate the correct link tags. One of your items in this config setting is missing an attribute and was skipped. Either add the missing keys or remove the incomplete object from the `languageAlternate` key in your config to dismiss this warning." + 520 | // TODO: See if we can find a better description of this tag w/o all 521 | // the marketing junk. MDN is a bit scant here. 522 | "\n\nSee https://www.contentkingapp.com/academy/link-rel/#hreflang-look-like for a description of the tag this option generates." 523 | ); 524 | } else { 525 | links.push({ 526 | rel: "alternate", 527 | hrefLang: languageAlternate.hrefLang, 528 | href: languageAlternate.href, 529 | }); 530 | } 531 | } 532 | } 533 | 534 | return links; 535 | } 536 | 537 | export default initSeo; 538 | 539 | function getSeoTitle(config: SeoConfig): string { 540 | let bypassTemplate = config.bypassTemplate || false; 541 | let templateTitle = config.titleTemplate || ""; 542 | let updatedTitle = ""; 543 | if (config.title) { 544 | updatedTitle = config.title; 545 | if (templateTitle && !bypassTemplate) { 546 | updatedTitle = templateTitle.replace(/%s/g, () => updatedTitle); 547 | } 548 | } else if (config.defaultTitle) { 549 | updatedTitle = config.defaultTitle; 550 | } 551 | return updatedTitle; 552 | } 553 | 554 | function warn(message: string): void { 555 | if (typeof console !== "undefined") console.warn("remix-seo: " + message); 556 | try { 557 | // This error is thrown as a convenience so you can more easily 558 | // find the source for a warning that appears in the console by 559 | // enabling "pause on exceptions" in your JavaScript debugger. 560 | throw new Error("remix-seo: " + message); 561 | } catch (e) {} 562 | } 563 | 564 | function warnIfInvalidUrl(str: string, message: string) { 565 | try { 566 | new URL(str); 567 | } catch (_) { 568 | if (typeof console !== "undefined") console.warn("remix-seo: " + message); 569 | } 570 | } 571 | 572 | function validateTwitterCard( 573 | twitter: TwitterMeta 574 | ): TwitterCardType | undefined { 575 | if (!twitter.card) { 576 | return; 577 | } 578 | 579 | if ( 580 | !["app", "player", "summary", "summary_large_image"].includes(twitter.card) 581 | ) { 582 | warn(`An invalid Twitter card was provided to the config and will be ignored. Make sure that \`twitter.card\` is set to one of the following: 583 | - "app" 584 | - "player" 585 | - "summary" 586 | - "summary_large_image" 587 | 588 | Read more: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup`); 589 | return; 590 | } 591 | 592 | if (hasTwitterAppMeta(twitter)) { 593 | if (twitter.card !== "app") { 594 | warn(`An Twitter card type of \`${twitter.card}\` was provided to a config with app metadata. Twitter app cards must use a \`twitter:card\` value of \`"app"\`, so the app metadata will be ignored. Fix the \`twitter.card\` value or remove the \`twitter.app\` config to dismiss this warning. 595 | 596 | Read more: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup`); 597 | // @ts-ignore 598 | delete twitter.app; 599 | } else { 600 | if (hasTwitterImageMeta(twitter)) { 601 | warn(`The Twitter app card type does not support the twitter:image metadata provided in your config. Remove the \`twitter.image\` config to dismiss this warning. 602 | 603 | Read more: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup`); 604 | // @ts-ignore 605 | delete twitter.image; 606 | } 607 | 608 | if (hasTwitterPlayerMeta(twitter)) { 609 | warn(`The Twitter app card type does not support the twitter:player metadata provided in your config. Remove the \`twitter.player\` config to dismiss this warning. 610 | 611 | Read more: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup`); 612 | // @ts-ignore 613 | delete twitter.player; 614 | } 615 | 616 | return "app"; 617 | } 618 | } 619 | 620 | if (hasTwitterPlayerMeta(twitter)) { 621 | if (twitter.card !== "player") { 622 | warn(`An Twitter card type of \`${twitter.card}\` was provided to a config with player metadata. Twitter player cards must use a \`twitter:card\` value of \`"player"\`, so the player metadata will be ignored. Fix the \`twitter.card\` value or remove the \`twitter.player\` config to dismiss this warning. 623 | 624 | Read more: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup`); 625 | // @ts-ignore 626 | delete twitter.player; 627 | } else { 628 | return "player"; 629 | } 630 | } 631 | 632 | if ( 633 | hasTwitterImageMeta(twitter) && 634 | !["summary", "summary_large_image", "player"].includes(twitter.card) 635 | ) { 636 | if (twitter.card !== "player") { 637 | warn(`An Twitter card type of \`${twitter.card}\` was provided to a config with image metadata. Cards that support image metadata are: 638 | - "summary" 639 | - "summary_large_image" 640 | - "player" 641 | 642 | The image metadata will be ignored. Fix the \`twitter.card\` value or remove the \`twitter.image\` config to dismiss this warning. 643 | 644 | Read more: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup`); 645 | // @ts-ignore 646 | delete twitter.image; 647 | } 648 | } 649 | 650 | return twitter.card as TwitterCardType; 651 | } 652 | 653 | function hasTwitterAppMeta(twitter: TwitterMeta): twitter is TwitterMeta & { 654 | app: { name: Required } & TwitterAppMeta; 655 | } { 656 | return !!(twitter.app && twitter.app.name); 657 | } 658 | 659 | function hasTwitterPlayerMeta(twitter: TwitterMeta): twitter is TwitterMeta & { 660 | player: TwitterPlayerMeta; 661 | } { 662 | return !!(twitter.player && (twitter.player.url || twitter.player.stream)); 663 | } 664 | 665 | function hasTwitterImageMeta(twitter: TwitterMeta): twitter is TwitterMeta & { 666 | image: { url: Required } & TwitterImageMeta; 667 | } { 668 | return !!(twitter.image && twitter.image.url); 669 | } 670 | 671 | function resolveConfig( 672 | defaultConfig: SeoConfig | undefined, 673 | localConfig: SeoConfig | ((routeArgs?: RouteArgs) => SeoConfig) | undefined, 674 | routeArgs: RouteArgs | undefined 675 | ) { 676 | let config: SeoConfig = 677 | typeof localConfig === "function" 678 | ? localConfig(routeArgs) 679 | : localConfig || {}; 680 | 681 | config = defaultConfig ? merge({}, defaultConfig, config) : config; 682 | 683 | return config; 684 | } 685 | 686 | interface FacebookMeta { 687 | appId?: string; 688 | } 689 | 690 | interface LanguageAlternate { 691 | hrefLang: string; 692 | href: string; 693 | } 694 | 695 | interface MobileAlternate { 696 | media: string; 697 | href: string; 698 | } 699 | 700 | interface OpenGraphArticle { 701 | authors?: string[]; 702 | expirationTime?: string; 703 | modifiedTime?: string; 704 | publishedTime?: string; 705 | section?: string; 706 | tags?: string[]; 707 | } 708 | 709 | interface OpenGraphBook { 710 | authors?: string[]; 711 | isbn?: string; 712 | releaseDate?: string; 713 | tags?: string[]; 714 | } 715 | 716 | interface OpenGraphMedia { 717 | alt: string; 718 | height?: number; 719 | secureUrl?: string; 720 | type?: string; 721 | url: string; 722 | width?: number; 723 | } 724 | 725 | interface OpenGraphMeta { 726 | article?: OpenGraphArticle; 727 | book?: OpenGraphBook; 728 | defaultImageHeight?: number; 729 | defaultImageWidth?: number; 730 | description?: string; 731 | images?: OpenGraphMedia[]; 732 | locale?: string; 733 | profile?: OpenGraphProfile; 734 | siteName?: string; 735 | title?: string; 736 | type?: string; 737 | url?: string; 738 | video?: OpenGraphVideo; 739 | videos?: OpenGraphMedia[]; 740 | } 741 | 742 | interface OpenGraphProfile { 743 | firstName?: string; 744 | lastName?: string; 745 | gender?: string; 746 | username?: string; 747 | } 748 | 749 | interface OpenGraphVideo { 750 | actors?: OpenGraphVideoActors[]; 751 | directors?: string[]; 752 | duration?: number; 753 | releaseDate?: string; 754 | series?: string; 755 | tags?: string[]; 756 | writers?: string[]; 757 | } 758 | 759 | interface OpenGraphVideoActors { 760 | profile: string; 761 | role?: string; 762 | } 763 | 764 | /** 765 | * @see https://developers.google.com/search/docs/advanced/robots/robots_meta_tag 766 | */ 767 | interface RobotsOptions { 768 | /** 769 | * Set the maximum size of an image preview for this page in a search results. 770 | * 771 | * If false, Google may show an image preview of the default size. 772 | * 773 | * Accepted values are: 774 | * 775 | * - **none:** No image preview is to be shown. 776 | * - **standard:** A default image preview may be shown. 777 | * - **large:** A larger image preview, up to the width of the viewport, may 778 | * be shown. 779 | * 780 | * This applies to all forms of search results (such as Google web search, 781 | * Google Images, Discover, Assistant). However, this limit does not apply in 782 | * cases where a publisher has separately granted permission for use of 783 | * content. For instance, if the publisher supplies content in the form of 784 | * in-page structured data (such as AMP and canonical versions of an article) 785 | * or has a license agreement with Google, this setting will not interrupt 786 | * those more specific permitted uses. 787 | * 788 | * If you don't want Google to use larger thumbnail images when their AMP 789 | * pages and canonical version of an article are shown in Search or Discover, 790 | * provide a value of `"standard"` or `"none"`. 791 | */ 792 | maxImagePreview?: "none" | "standard" | "large"; 793 | /** 794 | * The maximum of number characters to use as a textual snippet for a search 795 | * result. (Note that a URL may appear as multiple search results within a 796 | * search results page.) 797 | * 798 | * This does **not** affect image or video previews. This applies to all forms 799 | * of search results (such as Google web search, Google Images, Discover, 800 | * Assistant). However, this limit does not apply in cases where a publisher 801 | * has separately granted permission for use of content. For instance, if the 802 | * publisher supplies content in the form of in-page structured data or has a 803 | * license agreement with Google, this setting does not interrupt those more 804 | * specific permitted uses. This directive is ignored if no parseable value is 805 | * specified. 806 | * 807 | * Special values: 808 | * - 0: No snippet is to be shown. Equivalent to nosnippet. 809 | * - 1: Google will choose the snippet length that it believes is most 810 | * effective to help users discover your content and direct users to your 811 | * site. 812 | * 813 | * To specify that there's no limit on the number of characters that can be 814 | * shown in the snippet, `maxSnippet` should be set to `-1`. 815 | */ 816 | maxSnippet?: number; 817 | /** 818 | * The maximum number of seconds for videos on this page to show in search 819 | * results. 820 | * 821 | * If false, Google may show a video snippet in search results and will decide 822 | * how long the preview may be. 823 | * 824 | * Special values: 825 | * 826 | * - 0: At most, a static image may be used, in accordance to the 827 | * `maxImagePreview` setting. 828 | * - 1: There is no limit. 829 | * 830 | * This applies to all forms of search results (at Google: web search, Google 831 | * Images, Google Videos, Discover, Assistant). 832 | */ 833 | maxVideoPreview?: number; 834 | /** 835 | * Do not show a cached link in search results. 836 | * 837 | * If false, Google may generate a cached page and users may access it through 838 | * the search results. 839 | */ 840 | noArchive?: boolean; 841 | /** 842 | * Do not follow the links on this page. 843 | * 844 | * If false, Google may use the links on the page to discover those linked 845 | * pages. 846 | * 847 | * @see https://developers.google.com/search/docs/advanced/guidelines/qualify-outbound-links 848 | */ 849 | noFollow?: boolean; 850 | /** 851 | * Do not index images on this page. 852 | * 853 | * If false, images on the page may be indexed and shown in search results. 854 | */ 855 | noImageIndex?: boolean; 856 | /** 857 | * Do not show this page, media, or resource in search results. 858 | * 859 | * If false, the page, media, or resource may be indexed and shown in search 860 | * results. 861 | */ 862 | noIndex?: boolean; 863 | /** 864 | * Do not show a text snippet or video preview in the search results for this 865 | * page. A static image thumbnail (if available) may still be visible, when it 866 | * results in a better user experience. This applies to all forms of search 867 | * results (at Google: web search, Google Images, Discover). 868 | * 869 | * If false, Google may generate a text snippet and video preview based on 870 | * information found on the page. 871 | */ 872 | noSnippet?: boolean; 873 | /** 874 | * Do not offer translation of this page in search results. 875 | * 876 | * If false, Google may show a link next to the result to help users view 877 | * translated content on your page. 878 | */ 879 | noTranslate?: boolean; 880 | /** 881 | * Do not show this page in search results after the specified date/time. 882 | * 883 | * The date/time must be specified in a widely adopted format including, but 884 | * not limited to [RFC 822](http://www.ietf.org/rfc/rfc0822.txt), [RFC 885 | * 850](http://www.ietf.org/rfc/rfc0850.txt), and [ISO 886 | * 8601](https://www.iso.org/iso-8601-date-and-time-format.html). The 887 | * directive is ignored if no valid date/time is specified. 888 | * 889 | * By default there is no expiration date for content. 890 | */ 891 | unavailableAfter?: string; 892 | } 893 | 894 | /** 895 | * @see https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup 896 | */ 897 | interface TwitterMeta { 898 | /** 899 | * The card type. Used with all cards. 900 | */ 901 | card?: TwitterCardType; 902 | /** 903 | * The @username of content creator, which may be different than the @username 904 | * of the site itself. Used with `summary_large_image` cards. 905 | */ 906 | creator?: string | { id: string }; 907 | /** 908 | * Description of content (maximum 200 characters). Used with `summary`, 909 | * `summary_large_image`, and `player` cards. 910 | */ 911 | description?: string; 912 | /** 913 | * The @username of the website. Used with `summary`, `summary_large_image`, 914 | * `app`, and `player` cards 915 | */ 916 | site?: string | { id: string }; 917 | /** 918 | * Title of content (max 70 characters). Used with `summary`, `summary_large_image`, and `player` cards 919 | */ 920 | title?: string; 921 | /** 922 | * The image to use in the card. Images must be less than 5MB in size. JPG, 923 | * PNG, WEBP and GIF formats are supported. Only the first frame of an 924 | * animated GIF will be used. SVG is not supported. Used with `summary`, 925 | * `summary_large_image`, and `player` cards. 926 | */ 927 | image?: TwitterImageMeta; 928 | /** 929 | * The video player to use in the card. Used with the `player` card. 930 | */ 931 | player?: TwitterPlayerMeta; 932 | /** 933 | * Meta used with the `app` card. 934 | */ 935 | app?: TwitterAppMeta; 936 | } 937 | 938 | type TwitterCardType = "app" | "player" | "summary" | "summary_large_image"; 939 | 940 | interface TwitterImageMeta { 941 | /** 942 | * The URL of the image to use in the card. This must be an absolute URL, 943 | * *not* a relative path. 944 | */ 945 | url: string; 946 | /** 947 | * A text description of the image conveying the essential nature of an image 948 | * to users who are visually impaired. Maximum 420 characters. 949 | */ 950 | alt: string; 951 | } 952 | 953 | interface TwitterPlayerMeta { 954 | /** 955 | * The URL to the player iframe. This must be an absolute URL, *not* a 956 | * relative path. 957 | */ 958 | url: string; 959 | /** 960 | * The URL to raw video or audio stream. This must be an absolute URL, *not* a 961 | * relative path. 962 | */ 963 | stream?: string; 964 | /** 965 | * Height of the player iframe in pixels. 966 | */ 967 | height?: number; 968 | /** 969 | * Width of the player iframe in pixels. 970 | */ 971 | width?: number; 972 | } 973 | 974 | interface TwitterAppMeta { 975 | name: string | { iPhone?: string; iPad?: string; googlePlay?: string }; 976 | id: { iPhone?: string; iPad?: string; googlePlay?: string }; 977 | url: { iPhone?: string; iPad?: string; googlePlay?: string }; 978 | } 979 | 980 | export interface SeoConfig { 981 | bypassTemplate?: boolean; 982 | canonical?: string; 983 | defaultTitle?: string; 984 | description?: string; 985 | facebook?: FacebookMeta; 986 | languageAlternates?: LanguageAlternate[]; 987 | mobileAlternate?: MobileAlternate; 988 | omitGoogleBotMeta?: boolean; 989 | openGraph?: OpenGraphMeta; 990 | robots?: RobotsOptions; 991 | title?: string; 992 | titleTemplate?: string; 993 | twitter?: TwitterMeta; 994 | } 995 | 996 | // TODO: Use Remix/RR types 997 | interface RouteArgs { 998 | data: any; 999 | parentsData: RouteData; 1000 | params: Params; // Params; 1001 | location: Location; 1002 | } 1003 | type RouteData = any; 1004 | type Params = any & T; 1005 | type Location = any; 1006 | 1007 | interface SeoBaseFunction { 1008 | (config?: SeoConfig): Return; 1009 | ( 1010 | config: SeoConfig | ((routeArgs?: RouteArgs) => SeoConfig), 1011 | routeArgs: RouteArgs 1012 | ): Return; 1013 | } 1014 | 1015 | export interface SeoFunction 1016 | extends SeoBaseFunction<[HtmlMetaDescriptor, HtmlLinkDescriptor[]]> {} 1017 | 1018 | export interface SeoMetaFunction extends SeoBaseFunction {} 1019 | 1020 | export interface SeoLinksFunction 1021 | extends SeoBaseFunction {} 1022 | --------------------------------------------------------------------------------