├── .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 | [](https://www.npmjs.com/package/sanity-plugin-link-field) [](https://npm-stat.com/charts.html?package=sanity-plugin-link-field)
4 | [](https://github.com/winteragency/sanity-plugin-link-field) [](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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------