├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── lefthook.yml ├── package-lock.json ├── package.config.ts ├── package.json ├── sanity.json ├── src ├── component.ts ├── components │ ├── CustomLinkInput.tsx │ ├── Link.tsx │ ├── LinkInput.tsx │ └── LinkTypeInput.tsx ├── helpers.ts ├── helpers │ ├── generateHref.ts │ ├── getLinkText.ts │ ├── requiredLinkField.ts │ └── typeGuards.ts ├── index.ts ├── linkField.tsx └── types.ts ├── tsconfig.dist.json ├── tsconfig.json ├── tsconfig.settings.json └── v2-incompatible.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | insert_final_newline = true 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | max_line_length = 80 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .eslintrc.js 3 | commitlint.config.js 4 | dist 5 | lint-staged.config.js 6 | package.config.ts 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true, 6 | }, 7 | "extends": [ 8 | "sanity", 9 | "sanity/typescript", 10 | "sanity/react", 11 | "plugin:react-hooks/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:react/jsx-runtime", 14 | ], 15 | "rules": { 16 | "no-nested-ternary": "off", 17 | "no-shadow": "off", 18 | "react/jsx-no-bind": "off", 19 | "@typescript-eslint/explicit-module-boundary-types": "off", 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI & Release 3 | 4 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string 5 | run-name: >- 6 | ${{ 7 | inputs.release && inputs.test && format('Build {0} ➤ Test ➤ Publish to NPM', github.ref_name) || 8 | inputs.release && !inputs.test && format('Build {0} ➤ Skip Tests ➤ Publish to NPM', github.ref_name) || 9 | github.event_name == 'workflow_dispatch' && inputs.test && format('Build {0} ➤ Test', github.ref_name) || 10 | github.event_name == 'workflow_dispatch' && !inputs.test && format('Build {0} ➤ Skip Tests', github.ref_name) || 11 | '' 12 | }} 13 | 14 | on: 15 | # Build on pushes branches that have a PR (including drafts) 16 | pull_request: 17 | # Build on commits pushed to branches without a PR if it's in the allowlist 18 | push: 19 | branches: [main] 20 | # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow 21 | workflow_dispatch: 22 | inputs: 23 | test: 24 | description: Run tests 25 | required: true 26 | default: true 27 | type: boolean 28 | release: 29 | description: Release new version 30 | required: true 31 | default: false 32 | type: boolean 33 | 34 | concurrency: 35 | # On PRs builds will cancel if new pushes happen before the CI completes, as it defines `github.head_ref` and gives it the name of the branch the PR wants to merge into 36 | # Otherwise `github.run_id` ensures that you can quickly merge a queue of PRs without causing tests to auto cancel on any of the commits pushed to main. 37 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 38 | cancel-in-progress: true 39 | 40 | permissions: 41 | contents: read # for checkout 42 | 43 | jobs: 44 | build: 45 | runs-on: ubuntu-latest 46 | name: Lint & Build 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: actions/setup-node@v4 50 | with: 51 | cache: npm 52 | node-version: lts/* 53 | - run: npm clean-install 54 | # Linting can be skipped 55 | - run: npm run lint --if-present 56 | if: github.event.inputs.test != 'false' 57 | - run: npm run check:format --if-present 58 | if: github.event.inputs.test != 'false' 59 | # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early 60 | - run: npm run prepublishOnly --if-present 61 | 62 | test: 63 | needs: build 64 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main 65 | if: github.event.inputs.test != 'false' 66 | runs-on: ${{ matrix.os }} 67 | name: Node.js ${{ matrix.node }} / ${{ matrix.os }} 68 | strategy: 69 | # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture 70 | fail-fast: false 71 | matrix: 72 | # Run the testing suite on each major OS with the latest LTS release of Node.js 73 | os: [macos-latest, ubuntu-latest, windows-latest] 74 | node: [lts/*] 75 | # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner 76 | include: 77 | - os: ubuntu-latest 78 | # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life 79 | node: lts/-1 80 | - os: ubuntu-latest 81 | # Test the actively developed version that will become the latest LTS release next October 82 | node: current 83 | steps: 84 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 85 | - name: Set git to use LF 86 | if: matrix.os == 'windows-latest' 87 | run: | 88 | git config --global core.autocrlf false 89 | git config --global core.eol lf 90 | - uses: actions/checkout@v4 91 | - uses: actions/setup-node@v4 92 | with: 93 | cache: npm 94 | node-version: ${{ matrix.node }} 95 | - run: npm install 96 | - run: npm test --if-present 97 | 98 | release: 99 | permissions: 100 | contents: write # to be able to publish a GitHub release 101 | issues: write # to be able to comment on released issues 102 | pull-requests: write # to be able to comment on released pull requests 103 | id-token: write # to enable use of OIDC for npm provenance 104 | needs: [build, test] 105 | # only run if opt-in during workflow_dispatch 106 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 107 | runs-on: ubuntu-latest 108 | name: Semantic release 109 | steps: 110 | - uses: actions/checkout@v4 111 | with: 112 | # Need to fetch entire commit history to 113 | # analyze every commit since last release 114 | fetch-depth: 0 115 | - uses: actions/setup-node@v4 116 | with: 117 | cache: npm 118 | node-version: lts/* 119 | - run: npm clean-install 120 | - run: npm audit signatures 121 | # Branches that will release new versions are defined in .releaserc.json 122 | - run: npx semantic-release 123 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 124 | # e.g. git tags were pushed but it exited before `npm publish` 125 | if: always() 126 | env: 127 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 128 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # IntelliJ 46 | .idea 47 | *.iml 48 | 49 | # Cache 50 | .cache 51 | 52 | # Yalc 53 | .yalc 54 | yalc.lock 55 | 56 | # npm package zips 57 | *.tgz 58 | 59 | # Compiled plugin 60 | dist -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | yarn.lock 4 | package-lock.json 5 | README.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [1.4.0](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.3.2...v1.4.0) (2025-01-27) 9 | 10 | ### Features 11 | 12 | - added preview and icon fields ([#23](https://github.com/winteragency/sanity-plugin-link-field/issues/23)) ([7591560](https://github.com/winteragency/sanity-plugin-link-field/commit/759156035b0e773bdacfe72bad0885e097362d44)) 13 | 14 | ## [1.3.2](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.3.1...v1.3.2) (2024-06-07) 15 | 16 | ### Reverts 17 | 18 | - Revert "fix: set default type to "internal" if it's somehow missing (#10)" (#16) ([43d4cc0](https://github.com/winteragency/sanity-plugin-link-field/commit/43d4cc0ee647c1fea8843386761a220a920ff0f6)), closes [#10](https://github.com/winteragency/sanity-plugin-link-field/issues/10) [#16](https://github.com/winteragency/sanity-plugin-link-field/issues/16) 19 | 20 | ## [1.3.1](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.3.0...v1.3.1) (2024-06-07) 21 | 22 | ### Bug Fixes 23 | 24 | - allow initiating plugin with no options object ([#15](https://github.com/winteragency/sanity-plugin-link-field/issues/15)) ([ad969ff](https://github.com/winteragency/sanity-plugin-link-field/commit/ad969ff9e7d836ae328055248748d2b8e93024d2)) 25 | 26 | ## [1.3.0](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.2.2...v1.3.0) (2024-06-04) 27 | 28 | ### Features 29 | 30 | - allow hrefResolver to return UrlObject instead of string ([cdc90d9](https://github.com/winteragency/sanity-plugin-link-field/commit/cdc90d98022f15f09eb6b65ead0040573d07d255)) 31 | 32 | ## [1.2.2](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.2.1...v1.2.2) (2024-06-04) 33 | 34 | ### Bug Fixes 35 | 36 | - regression from 1.2.1 when internal links cannot be resolved ([2e0b0eb](https://github.com/winteragency/sanity-plugin-link-field/commit/2e0b0eb39ab6d52465d0d77df153f72537346156)) 37 | 38 | ## [1.2.1](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.2.0...v1.2.1) (2024-06-04) 39 | 40 | ### Bug Fixes 41 | 42 | - avoid double leading slashes in default href resolver ([b9fa67a](https://github.com/winteragency/sanity-plugin-link-field/commit/b9fa67ac94c587eac141c0a5e784d8792e2b6040)) 43 | - correct dependencies in package.json to avoid 3rd party code in bundle (=smaller Studio bundle) ([b87f200](https://github.com/winteragency/sanity-plugin-link-field/commit/b87f200b7b4512b6431caac0f8f00518fcf0e42d)) 44 | - properly include URL parameters and anchors for internal links ([7707a13](https://github.com/winteragency/sanity-plugin-link-field/commit/7707a135290896ffe5d06c5a198c55051f4db24a)) 45 | 46 | ## [1.2.0](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.1.3...v1.2.0) (2024-05-31) 47 | 48 | ### Features 49 | 50 | - allow passing filter options to the reference field, and add the option to use weak references ([2d441a7](https://github.com/winteragency/sanity-plugin-link-field/commit/2d441a7f9fcb0fd053407f242d5ed2b58813629a)) 51 | 52 | ## [1.1.3](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.1.2...v1.1.3) (2024-05-06) 53 | 54 | ### Bug Fixes 55 | 56 | - set default type to "internal" if it's somehow missing ([#10](https://github.com/winteragency/sanity-plugin-link-field/issues/10)) ([4f9ec69](https://github.com/winteragency/sanity-plugin-link-field/commit/4f9ec698d309447e99586dc887824ae0e488e601)) 57 | 58 | ## [1.1.2](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.1.1...v1.1.2) (2024-05-06) 59 | 60 | ### Bug Fixes 61 | 62 | - don't crash when interacting with empty link field ([#9](https://github.com/winteragency/sanity-plugin-link-field/issues/9)) ([cca4a6f](https://github.com/winteragency/sanity-plugin-link-field/commit/cca4a6f534ed5dd67089e27bc70970da8424af25)) 63 | 64 | ## [1.1.1](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.1.0...v1.1.1) (2024-04-22) 65 | 66 | ### Bug Fixes 67 | 68 | - adjust types throughout and add missing ones ([09cadb4](https://github.com/winteragency/sanity-plugin-link-field/commit/09cadb4c31d19f4b8d7e97e47fe6f0be53b7d1d7)) 69 | - don't crash when there's no type selected ([#6](https://github.com/winteragency/sanity-plugin-link-field/issues/6)) ([7b15fe8](https://github.com/winteragency/sanity-plugin-link-field/commit/7b15fe805d9e6dbfc3351ffc8773ae50c2681989)) 70 | 71 | ## [1.1.0](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.0.1...v1.1.0) (2024-04-19) 72 | 73 | ### Features 74 | 75 | - add option to include a "text" field for the link ([#4](https://github.com/winteragency/sanity-plugin-link-field/issues/4)) ([568df92](https://github.com/winteragency/sanity-plugin-link-field/commit/568df927779cadc67f8ed58360da755ecf2bfd26)) 76 | 77 | ### Bug Fixes 78 | 79 | - make input field as wide as its parent ([#3](https://github.com/winteragency/sanity-plugin-link-field/issues/3)) ([ce90410](https://github.com/winteragency/sanity-plugin-link-field/commit/ce90410cafcd89229189bda6a1797dfc9b832a58)) 80 | - **types:** ensure `blank` property is not available on `PhoneLink` and `EmailLink` interfaces ([2eb09f6](https://github.com/winteragency/sanity-plugin-link-field/commit/2eb09f64c422cf45cbfa7acde080dc0788dab1a0)) 81 | 82 | ## [1.0.1](https://github.com/winteragency/sanity-plugin-link-field/compare/v1.0.0...v1.0.1) (2024-04-17) 83 | 84 | ### Bug Fixes 85 | 86 | - show loading spinner while fetching custom link type options ([f9d30f2](https://github.com/winteragency/sanity-plugin-link-field/commit/f9d30f26166dba90f62a5b305264ae1280eec67f)) 87 | 88 | ## 1.0.0 (2024-04-17) 89 | 90 | ### Features 91 | 92 | - initial version ([d6b855d](https://github.com/winteragency/sanity-plugin-link-field/commit/d6b855dca442e0cb142d145fc436c281b2c9abdc)) 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Winter Agency 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 | # 🔗 sanity-plugin-link-field 2 | 3 | [![Latest Stable Version](https://img.shields.io/npm/v/sanity-plugin-link-field.svg)](https://www.npmjs.com/package/sanity-plugin-link-field) [![Weekly Downloads](https://img.shields.io/npm/dw/sanity-plugin-link-field?style=flat-square)](https://npm-stat.com/charts.html?package=sanity-plugin-link-field) 4 | [![License](https://img.shields.io/github/license/winteragency/sanity-plugin-link-field.svg)](https://github.com/winteragency/sanity-plugin-link-field) [![Made by Winter](https://img.shields.io/badge/made%20by-Winter-blue.svg)](https://winteragency.se) 5 | 6 | A custom Link field (and associated React component) that allows editors to easily create internal and external links, as well as `mailto` and `tel`-links, all using the same intuitive UI. 7 | 8 | 9 | 10 | ## 🔌 Installation 11 | 12 | ```sh 13 | npm install sanity-plugin-link-field 14 | ``` 15 | 16 | ## 🗒️ Setup 17 | 18 | ### 1. Configure the plugin 19 | 20 | Add the plugin to your `sanity.config.ts`: 21 | 22 | ```ts 23 | // sanity.config.ts 24 | import {defineConfig} from 'sanity' 25 | import {linkField} from 'sanity-plugin-link-field' 26 | 27 | export default defineConfig({ 28 | //... 29 | plugins: [linkField()], 30 | }) 31 | ``` 32 | 33 | This will enable the new `link` field type. By default, it will allow internal links to point to any document of the type `page`. You can adjust this according to your needs by using the `linkableSchemaTypes` option: 34 | 35 | ```ts 36 | // ... 37 | export default defineConfig({ 38 | //... 39 | plugins: [ 40 | linkField({ 41 | linkableSchemaTypes: ['page', 'product', 'article'], 42 | }), 43 | ], 44 | }) 45 | ``` 46 | 47 | If you set it to an empty array, the internal link option will be hidden entirely for all link fields. 48 | 49 | > [!TIP] 50 | > See [Options](#-options) for all the plugin level options you can set. 51 | 52 | ### 2. Add the field to your schema 53 | 54 | You can now use the `link` type throughout your schema: 55 | 56 | ```ts 57 | // mySchema.ts 58 | import {defineField, defineType} from 'sanity' 59 | 60 | export const mySchema = defineType({ 61 | // ... 62 | fields: [ 63 | // ... 64 | defineField({ 65 | name: 'link', 66 | title: 'Link', 67 | type: 'link', 68 | }), 69 | ], 70 | }) 71 | ``` 72 | 73 | Editors will be able to switch between internal links (using native references in Sanity), external links (for linking to other websites) as well as e-mail (`mailto`) and phone (`tel`) links: 74 | 75 | The link object also includes additional fields for adding custom URL parameters and/or URL fragments to the end of an internal or external link. This can be used to add UTM campaign tracking or link to specific sections of a page, respectively. If you use the provided `Link` component, these will be handled automatically on the frontend. 76 | 77 | link-field 78 | 79 | You can also choose to enable an additional input field for setting the link's text/label: 80 | 81 | ```ts 82 | defineField({ 83 | name: 'link', 84 | title: 'Link', 85 | type: 'link', 86 | options: { 87 | enableText: true 88 | } 89 | }) 90 | ``` 91 | 92 | Screenshot 2024-04-19 at 15 45 30 93 | 94 | 95 | > [!TIP] 96 | > See [Options](#-options) for all the field level options you can set. 97 | 98 | ### 3. Making a required link field 99 | 100 | Since the link field is just an object field internally, the normal `.required()` validator _will not work_. Instead, the plugin includes a helper to properly validate a link field and make it required: 101 | 102 | ```ts 103 | import {requiredLinkField} from 'sanity-plugin-link-field' 104 | 105 | // ... 106 | defineField({ 107 | name: 'link', 108 | title: 'Link', 109 | type: 'link', 110 | validation: (rule) => rule.custom((field) => requiredLinkField(field)), 111 | }) 112 | ``` 113 | 114 | ### 4. Rendering links on the frontend 115 | 116 | #### Spreading internal links 117 | 118 | In order to render internal links in your frontend, you need to add a projection to your groq query so that the relevant fields (such as the `slug`) are included from the linked documents: 119 | 120 | ```groq 121 | *[_type == "page" && slug.current == $slug][0] { 122 | // ... 123 | link { 124 | ..., 125 | internalLink->{_type,slug,title} 126 | }, 127 | } 128 | ``` 129 | 130 | #### Rendering links 131 | 132 | How you render your links is up to you and will depend on your frontend framework of choice, as well as how you manage slugs/pathnames in your project. 133 | This plugin does include a simple React component to render the link correctly regardless of its type: 134 | 135 | ```tsx 136 | import {Link} from 'sanity-plugin-link-field/component' 137 | 138 | import {resolveHref} from '@/lib/sanity/sanity.links' 139 | 140 | // ... 141 | resolveHref(internalLink?._type, internalLink?.slug?.current)} 144 | > 145 | This is my link 146 | 147 | // ... 148 | ``` 149 | 150 | Notice the `hrefResolver` property. This is a callback used to resolve the `href` for internal links, and will differ depending on how your project is set up. The example above uses a `resolveHref` function defined elsewhere that will return the correct path depending on the document type and `slug`. 151 | 152 | If a `hrefResolver` is not provided, the component will naively attempt to look at the `slug` property of the linked document and generate a `href` like so: `/${link.internalLink.slug?.current}`. This will of course only work on the off chance that your documents all have a `slug` property (like if you're using [this approach to managing slugs](https://www.simeongriggs.dev/nextjs-sanity-slug-patterns)). 153 | 154 | Regardless of how you choose to manage slugs for internal links, the component will automatically handle external links, add `target="_blank"` as needed, and add `mailto:` to e-mail links as well as `tel:` to phone links. For `tel:` links, it will strip any spaces in the phone number since these are not allowed in such links. Additionally, it will render [the link's text label (if enabled)](#field-level), or try and fall back to a good textual representation of the link if one hasn't been passed to the component (using the `children` property). 155 | 156 | #### Using `next/link` or similar framework specific components 157 | 158 | If you're using Next.js, you'll want to use `next/link` for routing. In this case, the `Link` component accepts an `as` property: 159 | 160 | ```tsx 161 | import {default as NextLink} from 'next/link' 162 | import {Link} from 'sanity-plugin-link-field/component' 163 | 164 | // ... 165 | 166 | This is my link 167 | 168 | // ... 169 | ``` 170 | 171 | To avoid having to remember to do this every time, you could create a convenience component in your project like so: 172 | 173 | ```tsx 174 | import { default as NextLink } from 'next/link'; 175 | import { Link as SanityLink, type LinkProps } from 'sanity-plugin-link-field/component'; 176 | 177 | export function Link(props: LinkProps) { 178 | return ; 179 | } 180 | ``` 181 | 182 | You can then use it throughout your project: 183 | 184 | ```tsx 185 | import {Link} from '@/components/Link' 186 | 187 | // ... 188 | This is my link 189 | // ... 190 | ``` 191 | 192 | #### Using with TypeScript 193 | 194 | The plugin exports a type called `LinkValue` that you can use for your link fields. 195 | 196 | ### 5. Using with Portable Text 197 | 198 | As with any other field, the link field can be used in a Portable Text editor by adding it as an annotation, eg: 199 | 200 | ```ts 201 | defineArrayMember({ 202 | type: 'block', 203 | marks: { 204 | annotations: [ 205 | // ... 206 | { 207 | name: 'link', 208 | title: 'Link', 209 | type: 'link', 210 | }, 211 | ], 212 | }, 213 | }) 214 | ``` 215 | 216 | In this example, the built-in link annotation in Sanity will be replaced with a much more user-friendly and powerful link selector. If you want to keep the built-in link annotation as well, you can use a different `name`, such as `customLink`, in your own annotation. 217 | 218 | link-in-portable-text 219 | 220 | You will need to adjust your groq queries to spread internal links: 221 | 222 | ```groq 223 | content[] { 224 | ..., 225 | markDefs[]{ 226 | ..., 227 | _type == "link" => { 228 | ..., 229 | internalLink->{_type,slug,title} 230 | } 231 | } 232 | } 233 | ``` 234 | 235 | Finally, you'll need to adjust your frontend rendering logic to handle these links, something along the lines of: 236 | 237 | ```ts 238 | marks: { 239 | link: ({ children, value }) => ( 240 | 244 | {children} 245 | 246 | ) 247 | } 248 | ``` 249 | 250 | ## ⚙️ Advanced 251 | 252 | ### Custom link types 253 | 254 | In addition to the built-in link types, it's possible to define a set of custom link types for the user to choose from. This can be used to allow users to link to pre-defined routes that do not exist in Sanity, such as hardcoded routes in your frontend application or dynamic routes loaded from an external system. 255 | 256 | To enable this feature, simply define your custom link types using the `customLinkTypes` property when initializing the plugin: 257 | 258 | ```ts 259 | // sanity.config.ts 260 | import {defineConfig} from 'sanity' 261 | import {linkField} from 'sanity-plugin-link-field' 262 | 263 | export default defineConfig({ 264 | //... 265 | plugins: [ 266 | linkField({ 267 | customLinkTypes: [ 268 | { 269 | title: 'Archive Page', 270 | value: 'archive', 271 | icon: OlistIcon, 272 | description: 'Link to an archive page.', 273 | options: [ 274 | { 275 | title: 'Blog', 276 | value: '/blog', 277 | }, 278 | { 279 | title: 'News', 280 | value: '/news', 281 | }, 282 | ], 283 | }, 284 | ], 285 | }), 286 | ], 287 | }) 288 | ``` 289 | 290 | The "Archive Page" type will now show up as an option when editing a link field, and selecting it will present the user with a dropdown menu with the available routes: 291 | 292 | 293 | 294 | You can also provide a callback for the `options` parameter to load the available options dynamically. The callback will receive the current document, the path to the link field being edited, as well as the current user: 295 | 296 | ```ts 297 | // ... 298 | customLinkTypes: [ 299 | // Load movies from external system 300 | { 301 | title: 'Movie', 302 | value: 'movie', 303 | icon: FilmIcon, 304 | description: 'Link to a movie from the cinema system.', 305 | options: async (document, fieldPath, user) => { 306 | // Do a fetch request here to get available movies from an API route 307 | 308 | // ... 309 | 310 | return options; 311 | } 312 | ] 313 | ``` 314 | 315 | #### Rendering custom links on the frontend 316 | 317 | Custom link objects have the following structure in the schema, where `url` will be the `value` of the user-selected option: 318 | 319 | ```ts 320 | { 321 | _type: 'link', 322 | blank: false, 323 | type: 'myType' 324 | value: 'myCustomValue' 325 | } 326 | ``` 327 | 328 | How you handle this on the frontend is up to you; you can either pass the `value` directly to your `` as its `href` or do any other processing you like with it; it's just a string value. 329 | 330 | If you're using the built-in `Link` component, it will handle custom links just like external links, and use the `value` as the `href`. It will also add any custom parameters or anchors configured by the user, if enabled. 331 | 332 | ## 🔧 Options 333 | 334 | ### Plugin level 335 | 336 | When configuring the plugin in `sanity.config.ts`, these are the global options you can set. These will affect all link fields throughout your Studio. 337 | 338 | | Option | Default Value | Description | 339 | | ------------- | ------------- | ------------- | 340 | | linkableSchemaTypes | `['page']` | An array of schema types that should be allowed in internal links. | 341 | | weakReferences | `false` | Make internal links use [weak references](https://www.sanity.io/docs/reference-type#f45f659e7b28) | 342 | | referenceFilterOptions | `undefined` | Custom [filter options](https://www.sanity.io/docs/reference-type#1ecd78ab1655) passed to the reference input component for internal links. Use it to filter the documents that should be available for linking, eg. by locale. | 343 | | descriptions | *See [linkField.tsx](https://github.com/winteragency/sanity-plugin-link-field/blob/main/src/linkField.tsx)* | Override the descriptions of the different subfields. | 344 | | enableLinkParameters | `true` | Whether the user should be able to set custom URL parameters for internal and external links. | 345 | | enableAnchorLinks | `true` | Whether the user should be able to set custom anchors (URL fragments) for internal and external links. | 346 | | customLinkTypes | `[]` | Any custom link types that should be available in the dropdown. This can be used to allow users to link to pre-defined routes that don't exist within Sanity, such as hardcoded routes in the frontend application, or dynamic content that is pulled in from an external system. See [Custom link types](#custom-link-types) | 347 | 348 | ### Field level 349 | 350 | For each individual link field you add to your schema, you can set these options: 351 | 352 | | Option | Default Value | Description | 353 | | ------------- | ------------- | ------------- | 354 | | enableText | `false` | Whether the link should include an optional field for setting the link text/label. If enabled, this will be available on the resulting link object under the `.text` property. | 355 | | textLabel | `Text` | The label for the text input field, if enabled using the `enableText` option. | 356 | 357 | ## 🔏 License 358 | 359 | [MIT](LICENSE) © [Winter Agency](https://winteragency.se) 360 | 361 | ## 🧪 Develop & test 362 | 363 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) 364 | with default configuration for build & watch scripts. 365 | 366 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) 367 | on how to run this plugin with hotreload in the studio. 368 | 369 | ### Release new version 370 | 371 | Run ["CI & Release" workflow](https://github.com/winteragency/sanity-plugin-link-field/actions/workflows/main.yml). 372 | Make sure to select the main branch and check "Release new version". 373 | 374 | Semantic release will only release on configured branches, so it is safe to run release on any branch. 375 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | commit-msg: 2 | parallel: true 3 | commands: 4 | lint-commit-msg: 5 | run: npx -y commitlint --edit 6 | 7 | pre-commit: 8 | parallel: true 9 | commands: 10 | prettier: 11 | run: npx -y prettier --check {staged_files} 12 | types: 13 | glob: '*.{ts,tsx}' 14 | run: npm run check:types 15 | lint: 16 | glob: 'src/**/*.{js,jsx,ts,tsx}' 17 | run: npx -y eslint {staged_files} 18 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | dist: 'dist', 5 | tsconfig: 'tsconfig.dist.json', 6 | 7 | // Remove this block to enable strict export validation 8 | extract: { 9 | rules: { 10 | 'ae-forgotten-export': 'off', 11 | 'ae-incompatible-release-tags': 'off', 12 | 'ae-internal-missing-underscore': 'off', 13 | 'ae-missing-release-tag': 'off', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-plugin-link-field", 3 | "version": "1.4.0", 4 | "description": "A custom Link field for Sanity Studio", 5 | "keywords": [ 6 | "sanity", 7 | "sanity-plugin", 8 | "links", 9 | "portable-text" 10 | ], 11 | "homepage": "https://github.com/winteragency/sanity-plugin-link-field#readme", 12 | "bugs": { 13 | "url": "https://github.com/winteragency/sanity-plugin-link-field/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+ssh://git@github.com/winteragency/sanity-plugin-link-field.git" 18 | }, 19 | "license": "MIT", 20 | "author": "Winter Agency ", 21 | "sideEffects": false, 22 | "type": "commonjs", 23 | "exports": { 24 | ".": { 25 | "source": "./src/index.ts", 26 | "import": "./dist/index.mjs", 27 | "default": "./dist/index.js" 28 | }, 29 | "./helpers": { 30 | "source": "./src/helpers.ts", 31 | "import": "./dist/helpers.mjs", 32 | "default": "./dist/helpers.js" 33 | }, 34 | "./component": { 35 | "source": "./src/component.ts", 36 | "import": "./dist/component.mjs", 37 | "default": "./dist/component.js" 38 | }, 39 | "./package.json": "./package.json" 40 | }, 41 | "main": "./dist/index.js", 42 | "types": "./dist/index.d.ts", 43 | "typesVersions": { 44 | "*": { 45 | "helpers": [ 46 | "./dist/index.d.ts" 47 | ], 48 | "component": [ 49 | "./dist/index.d.ts" 50 | ] 51 | } 52 | }, 53 | "files": [ 54 | "dist", 55 | "sanity.json", 56 | "src", 57 | "v2-incompatible.js" 58 | ], 59 | "scripts": { 60 | "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean", 61 | "format": "prettier --write --cache --ignore-unknown .", 62 | "link-watch": "plugin-kit link-watch", 63 | "lint": "eslint .", 64 | "prepublishOnly": "npm run build", 65 | "prepare": "lefthook install", 66 | "watch": "pkg-utils watch --strict", 67 | "check:types": "tsc --pretty --noEmit", 68 | "check:format": "prettier --check .", 69 | "fix:format": "prettier --write .", 70 | "fix:lint": "eslint src/ --fix --quiet" 71 | }, 72 | "dependencies": { 73 | "@sanity/icons": "^2.11.8", 74 | "@sanity/incompatible-plugin": "^1.0.4", 75 | "@sanity/ui": "^2.1.3", 76 | "lucide-react": "^0.368.0", 77 | "styled-components": "^6.1.11" 78 | }, 79 | "devDependencies": { 80 | "@commitlint/cli": "^19.2.2", 81 | "@commitlint/config-conventional": "^19.2.2", 82 | "@sanity/pkg-utils": "^6.6.6", 83 | "@sanity/plugin-kit": "^4.0.4", 84 | "@sanity/semantic-release-preset": "^4.1.7", 85 | "@types/react": "^18.2.79", 86 | "@typescript-eslint/eslint-plugin": "^7.7.0", 87 | "@typescript-eslint/parser": "^7.7.0", 88 | "eslint": "^8.0.0", 89 | "eslint-config-prettier": "^9.1.0", 90 | "eslint-config-sanity": "^7.1.2", 91 | "eslint-plugin-prettier": "^5.1.3", 92 | "eslint-plugin-react": "^7.34.1", 93 | "eslint-plugin-react-hooks": "^4.6.0", 94 | "lefthook": "^1.6.10", 95 | "prettier": "^3.2.5", 96 | "prettier-plugin-packagejson": "^2.5.0", 97 | "react": "^18.2.0", 98 | "react-dom": "^18.2.0", 99 | "sanity": "^3.37.2", 100 | "typescript": "^5.4.5" 101 | }, 102 | "peerDependencies": { 103 | "react": "^18", 104 | "sanity": "^3" 105 | }, 106 | "engines": { 107 | "node": ">=18" 108 | }, 109 | "lint-staged": { 110 | "*.js": "eslint --cache --fix" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/component.ts: -------------------------------------------------------------------------------- 1 | export {Link, type LinkProps} from './components/Link' 2 | -------------------------------------------------------------------------------- /src/components/CustomLinkInput.tsx: -------------------------------------------------------------------------------- 1 | import {Select, Spinner} from '@sanity/ui' 2 | import {useEffect, useState} from 'react' 3 | import {SanityDocument, set, type StringInputProps, useFormValue, useWorkspace} from 'sanity' 4 | import styled from 'styled-components' 5 | 6 | import {CustomLinkType, CustomLinkTypeOptions, LinkValue} from '../types' 7 | 8 | const OptionsSpinner = styled(Spinner)` 9 | margin-left: 0.5rem; 10 | ` 11 | 12 | /** 13 | * Custom input component used for custom link types. 14 | * Renders a dropdown with the available options for the custom link type. 15 | */ 16 | export function CustomLinkInput( 17 | props: StringInputProps & { 18 | customLinkTypes: CustomLinkType[] 19 | }, 20 | ) { 21 | const workspace = useWorkspace() 22 | const document = useFormValue([]) as SanityDocument 23 | const linkValue = useFormValue(props.path.slice(0, -1)) as LinkValue | null 24 | const [options, setOptions] = useState(null) 25 | 26 | const customLinkType = props.customLinkTypes.find((type) => type.value === linkValue!.type) 27 | 28 | useEffect(() => { 29 | if (customLinkType) { 30 | if (Array.isArray(customLinkType?.options)) { 31 | setOptions(customLinkType.options) 32 | } else { 33 | customLinkType 34 | .options(document, props.path, workspace.currentUser) 35 | .then((options) => setOptions(options)) 36 | } 37 | } 38 | // eslint-disable-next-line react-hooks/exhaustive-deps 39 | }, [customLinkType, props.path, workspace.currentUser]) 40 | 41 | return options ? ( 42 | 56 | ) : ( 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, {type ElementType, type ForwardedRef, forwardRef} from 'react' 2 | 3 | import {generateHref} from '../helpers/generateHref' 4 | import {getLinkText} from '../helpers/getLinkText' 5 | import {isCustomLink, isEmailLink, isPhoneLink} from '../helpers/typeGuards' 6 | import {InternalLink, LinkValue} from '../types' 7 | 8 | type LinkProps = { 9 | link?: LinkValue 10 | as?: ElementType 11 | hrefResolver?: (link: InternalLink) => string 12 | } & Omit, 'href' | 'target'> 13 | 14 | const Link = forwardRef( 15 | ( 16 | {link, as: Component = 'a', hrefResolver, children, ...props}: LinkProps, 17 | ref: ForwardedRef, 18 | ) => { 19 | if (!link) { 20 | return null 21 | } 22 | 23 | // If no link text is provided, try and find a fallback 24 | if (!children) { 25 | // eslint-disable-next-line no-param-reassign 26 | children = getLinkText(link) 27 | } 28 | 29 | return ( 30 | 40 | {children} 41 | 42 | ) 43 | }, 44 | ) 45 | 46 | Link.displayName = 'Link' 47 | 48 | export {Link, type LinkProps} 49 | -------------------------------------------------------------------------------- /src/components/LinkInput.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Flex, Stack, Text} from '@sanity/ui' 2 | import {type FieldMember, FormFieldValidationStatus, ObjectInputMember} from 'sanity' 3 | import styled from 'styled-components' 4 | 5 | import {isCustomLink} from '../helpers/typeGuards' 6 | import {LinkInputProps} from '../types' 7 | 8 | const ValidationErrorWrapper = styled(Box)` 9 | contain: size; 10 | margin-bottom: 6px; 11 | margin-left: auto; 12 | margin-right: 12px; 13 | ` 14 | 15 | const FullWidthStack = styled(Stack)` 16 | width: 100%; 17 | ` 18 | 19 | /** 20 | * Custom input component for the link object. 21 | * Nicely renders the type and link fields next to each other, with the 22 | * description and any validation errors for the link field below them. 23 | * 24 | * The rest of the fields ("blank" and "advanced") are rendered as usual. 25 | */ 26 | export function LinkInput(props: LinkInputProps) { 27 | const [textField, typeField, linkField, ...otherFields] = props.members as FieldMember[] 28 | const {options} = props.schemaType 29 | 30 | const { 31 | field: { 32 | validation: linkFieldValidation, 33 | schemaType: {description: linkFieldDescription}, 34 | }, 35 | } = linkField 36 | 37 | const description = 38 | // If a custom link type is used, use its description if it has one. 39 | props.value && isCustomLink(props.value) 40 | ? props.customLinkTypes.find((type) => type.value === props.value?.type)?.description 41 | : // Fallback to the description of the current link type field. 42 | linkFieldDescription 43 | 44 | const renderProps = { 45 | renderAnnotation: props.renderAnnotation, 46 | renderBlock: props.renderBlock, 47 | renderField: props.renderField, 48 | renderInlineBlock: props.renderInlineBlock, 49 | renderInput: props.renderInput, 50 | renderItem: props.renderItem, 51 | renderPreview: props.renderPreview, 52 | } 53 | 54 | return ( 55 | 56 | {/* Render the text field if enabled */} 57 | {options?.enableText && ( 58 | 71 | )} 72 | 73 | 74 | {/* Render a label for the link field if there's also a text field enabled. */} 75 | {/* If there's no text field, the label here is irrelevant */} 76 | {options?.enableText && ( 77 | 78 | Link 79 | 80 | )} 81 | 82 | 83 | {/* Render the type field (without its label) */} 84 | 97 | 98 | 99 | {/* Render the input for the selected type of link (withouts its label) */} 100 | 113 | 114 | {/* Render any validation errors for the link field */} 115 | {linkFieldValidation.length > 0 && ( 116 | 117 | 122 | 123 | )} 124 | 125 | 126 | 127 | {/* Render the description of the selected link field, if any */} 128 | {description && ( 129 | 130 | {description} 131 | 132 | )} 133 | 134 | 135 | {/* Render the rest of the fields as usual */} 136 | {otherFields.map((field) => ( 137 | 138 | ))} 139 | 140 | ) 141 | } 142 | -------------------------------------------------------------------------------- /src/components/LinkTypeInput.tsx: -------------------------------------------------------------------------------- 1 | import {ChevronDownIcon} from '@sanity/icons' 2 | import {Button, Menu, MenuButton, MenuItem} from '@sanity/ui' 3 | import {AtSignIcon, GlobeIcon, LinkIcon, PhoneIcon} from 'lucide-react' 4 | import {set, type StringInputProps} from 'sanity' 5 | import styled from 'styled-components' 6 | 7 | import {CustomLinkType, LinkFieldPluginOptions, LinkType} from '../types' 8 | 9 | const defaultLinkTypes: LinkType[] = [ 10 | {title: 'Internal', value: 'internal', icon: LinkIcon}, 11 | {title: 'URL', value: 'external', icon: GlobeIcon}, 12 | {title: 'Email', value: 'email', icon: AtSignIcon}, 13 | {title: 'Phone', value: 'phone', icon: PhoneIcon}, 14 | ] 15 | 16 | const LinkTypeButton = styled(Button)` 17 | height: 35px; 18 | 19 | svg.lucide { 20 | width: 1rem; 21 | height: 1rem; 22 | } 23 | ` 24 | 25 | const LinkTypeMenuItem = styled(MenuItem)` 26 | svg.lucide { 27 | width: 1rem; 28 | height: 1rem; 29 | } 30 | ` 31 | 32 | /** 33 | * Custom input component for the "type" field on the link object. 34 | * Renders a button with an icon and a dropdown menu to select the link type. 35 | */ 36 | export function LinkTypeInput({ 37 | value, 38 | onChange, 39 | customLinkTypes = [], 40 | linkableSchemaTypes, 41 | }: StringInputProps & { 42 | customLinkTypes?: CustomLinkType[] 43 | linkableSchemaTypes: LinkFieldPluginOptions['linkableSchemaTypes'] 44 | }) { 45 | const linkTypes = [ 46 | // Disable internal links if not enabled for any schema types 47 | ...defaultLinkTypes.filter( 48 | ({value}) => value !== 'internal' || linkableSchemaTypes?.length > 0, 49 | ), 50 | ...customLinkTypes, 51 | ] 52 | 53 | const selectedType = linkTypes.find((type) => type.value === value) || linkTypes[0] 54 | 55 | return ( 56 | 66 | } 67 | id="link-type" 68 | menu={ 69 | 70 | {linkTypes.map((type) => ( 71 | { 76 | onChange(set(type.value)) 77 | }} 78 | /> 79 | ))} 80 | 81 | } 82 | /> 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export { 2 | isCustomLink, 3 | isEmailLink, 4 | isExternalLink, 5 | isInternalLink, 6 | isPhoneLink, 7 | } from './helpers/typeGuards' 8 | -------------------------------------------------------------------------------- /src/helpers/generateHref.ts: -------------------------------------------------------------------------------- 1 | import {type UrlObject} from 'url' 2 | 3 | import {InternalLink, LinkValue} from '../types' 4 | import {isCustomLink, isEmailLink, isExternalLink, isPhoneLink} from './typeGuards' 5 | 6 | export const generateHref = { 7 | internal: (link: LinkValue, hrefResolver?: (link: InternalLink) => string | UrlObject) => { 8 | const internalLink = link as InternalLink 9 | const resolvedHref = 10 | internalLink.internalLink && hrefResolver ? hrefResolver(internalLink) : undefined 11 | 12 | // Support UrlObjects, e.g. from Next.js 13 | if (typeof resolvedHref === 'object' && 'pathname' in resolvedHref) { 14 | resolvedHref.hash = internalLink.anchor?.replace(/^#/, '') 15 | 16 | if (internalLink.parameters) { 17 | const params = new URLSearchParams(internalLink.parameters) 18 | const resolvedParams = new URLSearchParams(resolvedHref.query?.toString()) 19 | 20 | for (const [key, value] of params.entries()) { 21 | resolvedParams.set(key, value) 22 | } 23 | 24 | resolvedHref.query = resolvedParams.toString() 25 | } 26 | 27 | return resolvedHref 28 | } 29 | 30 | let href = 31 | resolvedHref || 32 | (internalLink.internalLink?.slug?.current 33 | ? `/${internalLink.internalLink.slug.current.replace(/^\//, '')}` 34 | : undefined) 35 | 36 | if (href && typeof href === 'string') { 37 | href += (internalLink.parameters?.trim() || '') + (internalLink.anchor?.trim() || '') 38 | } 39 | 40 | return href || '#' 41 | }, 42 | external: (link: LinkValue) => 43 | isExternalLink(link) && link.url 44 | ? link.url.trim() + (link.parameters?.trim() || '') + (link.anchor?.trim() || '') 45 | : '#', 46 | email: (link: LinkValue) => 47 | isEmailLink(link) && link.email ? `mailto:${link.email.trim()}` : '#', 48 | phone: (link: LinkValue) => 49 | isPhoneLink(link) && link.phone 50 | ? // Tel links cannot contain spaces 51 | `tel:${link.phone?.replace(/\s+/g, '').trim()}` 52 | : '#', 53 | custom: (link: LinkValue) => 54 | isCustomLink(link) && link.value 55 | ? link.value.trim() + (link.parameters?.trim() || '') + (link.anchor?.trim() || '') 56 | : '#', 57 | } 58 | -------------------------------------------------------------------------------- /src/helpers/getLinkText.ts: -------------------------------------------------------------------------------- 1 | import {LinkValue} from '../types' 2 | import {isCustomLink, isEmailLink, isExternalLink, isInternalLink, isPhoneLink} from './typeGuards' 3 | 4 | /** 5 | * Get the text to display for the given link. 6 | */ 7 | export const getLinkText = (link: LinkValue): string => 8 | link.text || 9 | (isInternalLink(link) 10 | ? // Naively try to get the title or slug of the internal link 11 | link.internalLink?.title || link.internalLink?.slug?.current 12 | : isExternalLink(link) 13 | ? link.url 14 | : isPhoneLink(link) 15 | ? link.phone 16 | : isEmailLink(link) 17 | ? link.email 18 | : isCustomLink(link) 19 | ? link.value 20 | : undefined) || 21 | '#' 22 | -------------------------------------------------------------------------------- /src/helpers/requiredLinkField.ts: -------------------------------------------------------------------------------- 1 | import type {CustomValidatorResult} from 'sanity' 2 | 3 | import type {LinkValue} from '../types' 4 | import {isCustomLink, isEmailLink, isExternalLink, isInternalLink, isPhoneLink} from './typeGuards' 5 | 6 | /** 7 | * Helper to create a required link field. 8 | */ 9 | export const requiredLinkField = (field: unknown): CustomValidatorResult => { 10 | const link = field as LinkValue 11 | 12 | if (!link || !link.type) { 13 | return 'Link is required' 14 | } 15 | 16 | if (isInternalLink(link) && !link.internalLink) { 17 | return { 18 | message: 'Link is required', 19 | path: 'internalLink', 20 | } 21 | } 22 | 23 | if (isExternalLink(link) && !link.url) { 24 | return { 25 | message: 'URL is required', 26 | path: 'url', 27 | } 28 | } 29 | 30 | if (isEmailLink(link) && !link.email) { 31 | return { 32 | message: 'E-mail is required', 33 | path: 'email', 34 | } 35 | } 36 | 37 | if (isPhoneLink(link) && !link.phone) { 38 | return { 39 | message: 'Phone is required', 40 | path: 'phone', 41 | } 42 | } 43 | 44 | if (isCustomLink(link) && !link.value) { 45 | return { 46 | message: 'Value is required', 47 | path: 'value', 48 | } 49 | } 50 | 51 | return true 52 | } 53 | -------------------------------------------------------------------------------- /src/helpers/typeGuards.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CustomLink, 3 | EmailLink, 4 | ExternalLink, 5 | InternalLink, 6 | LinkValue, 7 | PhoneLink, 8 | } from '../types' 9 | 10 | export const isInternalLink = (link: LinkValue): link is InternalLink => link.type === 'internal' 11 | 12 | export const isExternalLink = (link: LinkValue): link is ExternalLink => link.type === 'external' 13 | 14 | export const isEmailLink = (link: LinkValue): link is EmailLink => link.type === 'email' 15 | 16 | export const isPhoneLink = (link: LinkValue): link is PhoneLink => link.type === 'phone' 17 | 18 | export const isCustomLink = (link: LinkValue): link is CustomLink => 19 | !['internal', 'external', 'email', 'phone'].includes(link.type) 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {requiredLinkField} from './helpers/requiredLinkField' 2 | export { 3 | isCustomLink, 4 | isEmailLink, 5 | isExternalLink, 6 | isInternalLink, 7 | isPhoneLink, 8 | } from './helpers/typeGuards' 9 | export {linkField} from './linkField' 10 | export type { 11 | CustomizableLink, 12 | CustomLink, 13 | CustomLinkType, 14 | CustomLinkTypeOptions, 15 | EmailLink, 16 | ExternalLink, 17 | InternalLink, 18 | LinkFieldOptions, 19 | LinkFieldPluginOptions, 20 | LinkInputProps, 21 | LinkSchemaType, 22 | LinkType, 23 | LinkValue, 24 | PhoneLink, 25 | } from './types' 26 | -------------------------------------------------------------------------------- /src/linkField.tsx: -------------------------------------------------------------------------------- 1 | import {defineField, definePlugin, defineType, type ObjectInputProps} from 'sanity' 2 | 3 | import {CustomLinkInput} from './components/CustomLinkInput' 4 | import {LinkInput} from './components/LinkInput' 5 | import {LinkTypeInput} from './components/LinkTypeInput' 6 | import {isCustomLink} from './helpers/typeGuards' 7 | import type {LinkFieldPluginOptions, LinkValue} from './types' 8 | 9 | /** 10 | * A plugin that adds a custom Link field for creating internal and external links, 11 | * as well as `mailto` and `tel`-links, all using the same intuitive UI. 12 | * 13 | * @param options - Options for the plugin. See {@link LinkFieldPluginOptions} 14 | * 15 | * @example Minimal example 16 | * ```ts 17 | * // sanity.config.ts 18 | * import { defineConfig } from 'sanity' 19 | * import { linkField } from 'sanity-plugin-link-field' 20 | * 21 | * export default defineConfig(( 22 | * // ... 23 | * plugins: [ 24 | * linkField() 25 | * ] 26 | * }) 27 | * 28 | * // mySchema.ts 29 | * import { defineField, defineType } from 'sanity'; 30 | * 31 | * export const mySchema = defineType({ 32 | * // ... 33 | * fields: [ 34 | * // ... 35 | * defineField({ 36 | * name: 'link', 37 | * title: 'Link', 38 | * type: 'link' 39 | * }), 40 | * ] 41 | *}); 42 | * ``` 43 | */ 44 | export const linkField = definePlugin((opts) => { 45 | const { 46 | linkableSchemaTypes = ['page'], 47 | weakReferences = false, 48 | referenceFilterOptions, 49 | descriptions = { 50 | internal: 'Link to another page or document on the website.', 51 | external: 'Link to an absolute URL to a page on another website.', 52 | email: 'Link to send an e-mail to the given address.', 53 | phone: 'Link to call the given phone number.', 54 | advanced: 'Optional. Add anchor links and custom parameters.', 55 | parameters: 'Optional. Add custom parameters to the URL, such as UTM tags.', 56 | anchor: 'Optional. Add an anchor to link to a specific section on the page.', 57 | }, 58 | enableLinkParameters = true, 59 | enableAnchorLinks = true, 60 | customLinkTypes = [], 61 | icon, 62 | preview, 63 | } = opts || {} 64 | 65 | const linkType = defineType({ 66 | name: 'link', 67 | title: 'Link', 68 | type: 'object', 69 | icon, 70 | preview, 71 | fieldsets: [ 72 | { 73 | name: 'advanced', 74 | title: 'Advanced', 75 | description: descriptions.advanced, 76 | options: { 77 | collapsible: true, 78 | collapsed: true, 79 | }, 80 | }, 81 | ], 82 | fields: [ 83 | defineField({ 84 | name: 'text', 85 | type: 'string', 86 | description: descriptions.text, 87 | }), 88 | 89 | defineField({ 90 | name: 'type', 91 | type: 'string', 92 | initialValue: 'internal', 93 | validation: (Rule) => Rule.required(), 94 | components: { 95 | input: (props) => LinkTypeInput({customLinkTypes, linkableSchemaTypes, ...props}), 96 | }, 97 | }), 98 | 99 | // Internal 100 | defineField({ 101 | name: 'internalLink', 102 | type: 'reference', 103 | to: linkableSchemaTypes.map((type) => ({ 104 | type, 105 | })), 106 | weak: weakReferences, 107 | options: { 108 | disableNew: true, 109 | ...referenceFilterOptions, 110 | }, 111 | description: descriptions?.internal, 112 | hidden: ({parent}) => !!parent?.type && parent?.type !== 'internal', 113 | }), 114 | 115 | // External 116 | defineField({ 117 | name: 'url', 118 | type: 'url', 119 | description: descriptions?.external, 120 | validation: (rule) => 121 | rule.uri({ 122 | allowRelative: true, 123 | scheme: ['https', 'http'], 124 | }), 125 | hidden: ({parent}) => parent?.type !== 'external', 126 | }), 127 | 128 | // E-mail 129 | defineField({ 130 | name: 'email', 131 | type: 'email', 132 | description: descriptions?.email, 133 | hidden: ({parent}) => parent?.type !== 'email', 134 | }), 135 | 136 | // Phone 137 | defineField({ 138 | name: 'phone', 139 | type: 'string', 140 | description: descriptions?.phone, 141 | validation: (rule) => 142 | rule.custom((value, context) => { 143 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 144 | if (!value || (context.parent as any)?.type !== 'phone') { 145 | return true 146 | } 147 | 148 | return ( 149 | (new RegExp(/^\+?[0-9\s-]*$/).test(value) && 150 | !value.startsWith('-') && 151 | !value.endsWith('-')) || 152 | 'Must be a valid phone number' 153 | ) 154 | }), 155 | hidden: ({parent}) => parent?.type !== 'phone', 156 | }), 157 | 158 | // Custom 159 | defineField({ 160 | name: 'value', 161 | type: 'string', 162 | description: descriptions?.external, 163 | hidden: ({parent}) => !parent || !isCustomLink(parent as LinkValue), 164 | components: { 165 | input: (props) => CustomLinkInput({customLinkTypes, ...props}), 166 | }, 167 | }), 168 | 169 | // New tab 170 | defineField({ 171 | title: 'Open in new window', 172 | name: 'blank', 173 | type: 'boolean', 174 | initialValue: false, 175 | description: descriptions.blank, 176 | hidden: ({parent}) => parent?.type === 'email' || parent?.type === 'phone', 177 | }), 178 | 179 | // Parameters 180 | ...(enableLinkParameters || enableAnchorLinks 181 | ? [ 182 | ...(enableLinkParameters 183 | ? [ 184 | defineField({ 185 | title: 'Parameters', 186 | name: 'parameters', 187 | type: 'string', 188 | description: descriptions.parameters, 189 | validation: (rule) => 190 | rule.custom((value, context) => { 191 | if ( 192 | !value || 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | (context.parent as any)?.type === 'email' || 195 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 196 | (context.parent as any)?.type === 'phone' 197 | ) { 198 | return true 199 | } 200 | 201 | if (value.indexOf('?') !== 0) { 202 | return 'Must start with ?; eg. ?utm_source=example.com&utm_medium=referral' 203 | } 204 | 205 | if (value.length === 1) { 206 | return 'Must contain at least one parameter' 207 | } 208 | 209 | return true 210 | }), 211 | hidden: ({parent}) => parent?.type === 'email' || parent?.type === 'phone', 212 | fieldset: 'advanced', 213 | }), 214 | ] 215 | : []), 216 | 217 | // Anchor 218 | ...(enableAnchorLinks 219 | ? [ 220 | defineField({ 221 | title: 'Anchor', 222 | name: 'anchor', 223 | type: 'string', 224 | description: descriptions.anchor, 225 | validation: (rule) => 226 | rule.custom((value, context) => { 227 | if ( 228 | !value || 229 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 230 | (context.parent as any)?.type === 'email' || 231 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 232 | (context.parent as any)?.type === 'phone' 233 | ) { 234 | return true 235 | } 236 | 237 | if (value.indexOf('#') !== 0) { 238 | return 'Must start with #; eg. #page-section-1' 239 | } 240 | 241 | if (value.length === 1) { 242 | return 'Must contain at least one character' 243 | } 244 | 245 | return ( 246 | new RegExp(/^([-?/:@._~!$&'()*+,;=a-zA-Z0-9]|%[0-9a-fA-F]{2})*$/).test( 247 | value.replace(/^#/, ''), 248 | ) || 'Invalid URL fragment' 249 | ) 250 | }), 251 | hidden: ({parent}) => parent?.type === 'email' || parent?.type === 'phone', 252 | fieldset: 'advanced', 253 | }), 254 | ] 255 | : []), 256 | ] 257 | : []), 258 | ], 259 | components: { 260 | input: (props: ObjectInputProps) => 261 | LinkInput({customLinkTypes, ...props, value: props.value as LinkValue}), 262 | }, 263 | }) 264 | 265 | return { 266 | name: 'link-field', 267 | schema: { 268 | types: [linkType], 269 | }, 270 | } 271 | }) 272 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {ComponentType} from 'react' 2 | import type { 3 | BaseSchemaDefinition, 4 | CurrentUser, 5 | ObjectInputProps, 6 | ObjectSchemaType, 7 | Path, 8 | PreviewConfig, 9 | ReferenceFilterOptions, 10 | SanityDocument, 11 | } from 'sanity' 12 | 13 | export interface CustomizableLink { 14 | parameters?: string 15 | anchor?: string 16 | blank?: boolean 17 | } 18 | 19 | export interface InternalLink extends CustomizableLink { 20 | type: 'internal' 21 | internalLink?: { 22 | _type: string 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | [key: string]: any 25 | } 26 | } 27 | 28 | export interface ExternalLink extends CustomizableLink { 29 | type: 'external' 30 | url?: string 31 | } 32 | 33 | export interface EmailLink { 34 | type: 'email' 35 | email?: string 36 | } 37 | 38 | export interface PhoneLink { 39 | type: 'phone' 40 | phone?: string 41 | } 42 | 43 | export interface CustomLink extends CustomizableLink { 44 | type: string 45 | value?: string 46 | } 47 | 48 | export type LinkValue = { 49 | _key?: string 50 | _type?: 'link' 51 | text?: string 52 | } & (InternalLink | ExternalLink | EmailLink | PhoneLink | CustomLink) 53 | 54 | export interface LinkType { 55 | title: string 56 | value: string 57 | icon: ComponentType 58 | } 59 | 60 | export interface CustomLinkTypeOptions { 61 | title: string 62 | value: string 63 | } 64 | 65 | export interface CustomLinkType extends LinkType { 66 | options: 67 | | CustomLinkTypeOptions[] 68 | | (( 69 | document: SanityDocument, 70 | fieldPath: Path, 71 | user: CurrentUser | null, 72 | ) => Promise) 73 | description?: string 74 | } 75 | 76 | /** 77 | * Global options for the link field plugin 78 | * 79 | * @todo: Should be overridable on the field level 80 | */ 81 | export interface LinkFieldPluginOptions { 82 | /** 83 | * An array of schema types that should be allowed in internal links. 84 | * @defaultValue ['page'] 85 | */ 86 | linkableSchemaTypes: string[] 87 | 88 | /** 89 | * Custom filter options passed to the reference input component for internal links. 90 | * Use it to filter the documents that should be available for linking, eg. by locale. 91 | * 92 | * @see https://www.sanity.io/docs/reference-type#1ecd78ab1655 93 | * @defaultValue undefined 94 | */ 95 | referenceFilterOptions?: ReferenceFilterOptions 96 | 97 | /** 98 | * Make internal links use weak references 99 | * @see https://www.sanity.io/docs/reference-type#f45f659e7b28 100 | * @defaultValue false 101 | */ 102 | weakReferences?: boolean 103 | 104 | /** Override the descriptions of the different subfields. */ 105 | descriptions?: { 106 | internal?: string 107 | external?: string 108 | email?: string 109 | phone?: string 110 | text?: string 111 | blank?: string 112 | advanced?: string 113 | parameters?: string 114 | anchor?: string 115 | } 116 | 117 | /** 118 | * Whether the user should be able to set custom URL parameters for internal and external links. 119 | * @defaultValue true 120 | */ 121 | enableLinkParameters?: boolean 122 | 123 | /** 124 | * Whether the user should be able to set custom anchors (URL fragments) for internal and external links. 125 | * @defaultValue true 126 | */ 127 | enableAnchorLinks?: boolean 128 | 129 | /** 130 | * Any custom link types that should be available in the dropdown. 131 | * 132 | * This can be used to allow users to link to pre-defined routes that don't exist within Sanity, 133 | * such as hardcoded routes in the frontend application, or dynamic content that is pulled in 134 | * from an external system. 135 | * 136 | * The options can be either an array of objects, or a function that, given the current document 137 | * and link field, returns an array of options. This can be used to dynamically fetch options. 138 | * 139 | * @defaultValue [] 140 | * 141 | * @example 142 | * ```ts 143 | * customLinkTypes: [ 144 | * { 145 | * title: 'Archive Page', 146 | * value: 'archive', 147 | * icon: OlistIcon, 148 | * options: [ 149 | * { 150 | * title: 'Blog', 151 | * value: '/blog' 152 | * }, 153 | * { 154 | * title: 'News', 155 | * value: '/news' 156 | * } 157 | * ] 158 | * } 159 | * ] 160 | * ``` 161 | */ 162 | customLinkTypes?: CustomLinkType[] 163 | 164 | icon?: BaseSchemaDefinition['icon'] 165 | 166 | preview?: PreviewConfig 167 | } 168 | 169 | /** 170 | * Options for an individual link field 171 | */ 172 | export interface LinkFieldOptions { 173 | /** 174 | * Whether the link should include an optional field for setting the link text/label. 175 | * @defaultValue false 176 | */ 177 | enableText?: boolean 178 | 179 | /** 180 | * The label for the text input field, if enabled using the `enableText` option. 181 | * @defaultValue Text 182 | */ 183 | textLabel?: string 184 | } 185 | 186 | export type LinkSchemaType = Omit & { 187 | options?: LinkFieldOptions 188 | } 189 | 190 | export type LinkInputProps = ObjectInputProps & { 191 | customLinkTypes: CustomLinkType[] 192 | } 193 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": [ 5 | "./src/**/__fixtures__", 6 | "./src/**/__mocks__", 7 | "./src/**/*.test.ts", 8 | "./src/**/*.test.tsx" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./package.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "./dist", 5 | 6 | "target": "esnext", 7 | "jsx": "preserve", 8 | "module": "preserve", 9 | "moduleResolution": "bundler", 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "moduleDetection": "force", 13 | "strict": true, 14 | "allowSyntheticDefaultImports": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "isolatedModules": true, 18 | 19 | // Don't emit by default, pkg-utils will ignore this when generating .d.ts files 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /v2-incompatible.js: -------------------------------------------------------------------------------- 1 | const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin') 2 | const {name, version, sanityExchangeUrl} = require('./package.json') 3 | 4 | export default showIncompatiblePluginDialog({ 5 | name: name, 6 | versions: { 7 | v3: version, 8 | v2: undefined, 9 | }, 10 | sanityExchangeUrl, 11 | }) 12 | --------------------------------------------------------------------------------