├── .eslintrc.js ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── build.js ├── jest.config.js ├── jest.esbuild.js ├── package.json ├── scripts ├── injectFnComments.js └── postbuild.sh ├── src ├── fields │ ├── block.js │ ├── image.js │ └── slug.js ├── index.js └── lib │ ├── blockPreview.js │ ├── blockValidator.js │ ├── decodeAssetId.js │ ├── decodeAssetId.test.js │ ├── fieldsHelper.js │ ├── imageValidator.js │ ├── imageValidator.test.js │ ├── noDuplicateRefs.js │ ├── startCase.js │ ├── startCase.test.js │ ├── urlJoin.js │ ├── urlJoin.test.js │ ├── urlValidator.js │ └── urlValidator.test.js ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "standard", 4 | "eslint:recommended", 5 | "plugin:import/errors", 6 | "plugin:prettier/recommended", 7 | ], 8 | rules: { 9 | "prettier/prettier": "warn", 10 | }, 11 | env: { 12 | jest: true, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | dist/ 4 | index.js 5 | index.js.map 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "proseWrap": "always", 5 | "endOfLine": "lf" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Corey Ward 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 Pills 2 | 3 | [![Latest version](https://img.shields.io/npm/v/sanity-pills?label=version&color=brightGreen&logo=npm)](https://www.npmjs.com/package/sanity-pills) 4 | ![Dependency status](https://img.shields.io/librariesio/release/npm/sanity-pills) 5 | [![Open issues](https://img.shields.io/github/issues/coreyward/sanity-pills)](https://github.com/coreyward/sanity-pills/issues) 6 | 7 | A collection of utilities formulated to provide positive experiences in the 8 | Sanity Studio. 9 | 10 | > Note: these pills are non-prescription—use them as they fit your needs and 11 | > ignore them as they don’t! 12 | 13 | ## Pills 14 | 15 | Documentation on each of the utilities provided follow. 16 | 17 | ### Authoring fields with objects 18 | 19 | The `fields` helper allows authoring of field schemas in a more succinct, 20 | scannable manner. By way of example: 21 | 22 | ```js 23 | import { fields } from "sanity-pills" 24 | 25 | { 26 | name: "example", 27 | type: "object", 28 | fields: fields({ 29 | // a required string 30 | name: { required: true }, 31 | 32 | // an optional number 33 | age: { 34 | type: "number", 35 | }, 36 | 37 | // a string that is only required when `age` is below 18 38 | guardianName: { 39 | required: ({ parent }) => parent.age < 18, 40 | }, 41 | }), 42 | } 43 | ``` 44 | 45 | This pill will use the object key as the name of the field, and if no title is 46 | provided in the value definition, a title will be inferred from the name 47 | (similar to default Sanity Studio behavior, less the warnings). 48 | 49 | This behaves more or less expected, converting the object into an array with the 50 | one notable callout being that `required` relies on the `validation` property on 51 | the field and specifying `validation` explicitly will override the `required` 52 | functionality (i.e. Sanity Pills will not merge them). 53 | 54 | ### Validated image fields 55 | 56 | There are no built-in validators for image dimensions in the Sanity Studio, but 57 | these are often valuable. It is straightforward enough to parse the image 58 | dimensions out of the image `_id` field, but Sanity Pills can handle this for 59 | you. 60 | 61 | ```js 62 | import { createImageField, fields } from "sanity-pills" 63 | 64 | export default { 65 | name: "example", 66 | type: "document", 67 | fields: fields({ 68 | headshot: createImageField({ 69 | validations: { 70 | required: true, 71 | minWidth: 500, 72 | minHeight: 500, 73 | }, 74 | warnings: { 75 | minWidth: 1000, 76 | minHeight: 1000, 77 | }, 78 | }), 79 | }), 80 | } 81 | ``` 82 | 83 | This example illustrates a required headshot image that will be invalid unless 84 | the original image dimensions are at minimum 500x500. It also shows a warning 85 | message suggesting that the best results would be achieved with an image of 86 | 1000x1000 when either of the original dimensions is smaller. 87 | 88 | This pill also supports validations of `maxWidth` and `maxHeight`, though they 89 | are less likely to be required. 90 | 91 | On a related note, Sanity Pills also exposes the function used to parse a Sanity 92 | asset ID as `decodeAssetId`. It's documented later in this document. 93 | 94 | ### Slugs representing URL paths 95 | 96 | If you happen to want your slugs to be valid URL paths, complete with the 97 | initial slash and a trailing slash, possibly with a required prefix, while 98 | supporting the “generate” button, you might appreciate this pill—it does just 99 | that. 100 | 101 | It enforces the following rules: 102 | 103 | 1. Slugs are required 104 | 2. Slugs must start with `/` 105 | 3. Slugs must end with `/` 106 | 4. The first non-slash character has to be a letter, except when the slug is 107 | `/404/` 108 | 5. Slugs have to be lowercase alphanumeric characters, plus hyphens and forward 109 | slashes 110 | 6. If the `prefix` option is supplied to `createSlugField`, the slug must begin 111 | with `//` 112 | 113 | Here's are a couple examples: 114 | 115 | ```js 116 | import { createSlugField, fields } from "sanity-pills" 117 | 118 | export default { 119 | name: "example", 120 | type: "document", 121 | fields: fields({ 122 | // optional string field 123 | name: {}, 124 | 125 | // slug with a “generate” button that sources from name 126 | slug: createSlugField({ source: "name" }), 127 | 128 | // produces a prefixed URL like /blog/hello/ and validates 129 | // that the prefixes is included as expected 130 | scopedSlug: createSlugField({ 131 | prefix: "blog", 132 | source: "name", 133 | }), 134 | }), 135 | } 136 | ``` 137 | 138 | You can use an async function for `source` (it's passed through unchanged), but 139 | for now `prefix` only supports static string values. 140 | 141 | Oh, and if you want to roll your own slug but want a handy `slugify` routine, 142 | you can use `createSlug` from the Sanity Pills package. E.g.: 143 | 144 | ```js 145 | import { createSlug } from "sanity-pills" 146 | 147 | export default { 148 | // … 149 | slug: { 150 | type: "slug", 151 | options: { 152 | source: "name", 153 | slugify: createSlug, 154 | }, 155 | required: true, 156 | }, 157 | } 158 | ``` 159 | 160 | ### Validating block content 161 | 162 | Portable Text is a powerful way for editors to author non-trivial, rich data 163 | structures in a platform agnostic way, but it's easy to wind up with 164 | poor-quality content represented impeccably. As an array of nested blocks, 165 | enforcing common things like no trailing whitespace, links always having 166 | associated URLS, or even just that real content is provided can be cumbersome. 167 | 168 | This pill makes validating Portable Text fields easier by including common 169 | validation patterns out of the box and supporting custom extensions. 170 | 171 | Sanity Pills ships with two built in block validators ready to use: `all` and 172 | `optional`. Both of these enforce the following validations, and `all` also 173 | enforces the presence of a value for the field. 174 | 175 | 1. **No empty blocks**: an empty paragraph, for example 176 | 2. **No newlines**: prevents single blocks (rendered as `

` tags) by default 177 | from containing newlines (rendered as `
` tags) 178 | 3. **No terminating whitespace**: disallows spaces at the beginning or end of a 179 | block 180 | 4. **No missing links**: links must have a valid `href` property 181 | 5. **No unstyled blocks**: each block needs a `style` set (e.g. `normal` or 182 | `h1`) 183 | 6. **No stacked marks**: disallows having text that is bold and italic or italic 184 | and strikethrough, etc. while allowing stacking of custom marks 185 | 7. **No marks on headings**: disallows any marks to be used on blocks with a 186 | style starting with `h` 187 | 188 | If you’re happy with this list, you can use either of the default block 189 | validators like so: 190 | 191 | ```js 192 | import { defaultBlockValidators } from "sanity-pills" 193 | 194 | { 195 | type: "array", 196 | of: [{ type: "block" }], 197 | validation: defaultBlockValidators.all, // or .optional 198 | } 199 | ``` 200 | 201 | It's entirely likely that this very opinionated set of validations is not 202 | entirely suitable for your use case, or that you need to add an additional 203 | custom validator to the list. Not to worry, that's possible like so: 204 | 205 | ```js 206 | import { createBlockValidator } from "sanity-pills" 207 | 208 | const yourValidator = createBlockValidator({ 209 | // enable a couple of the built in validations 210 | noEmptyBlocks: true, 211 | validateLinks: true, 212 | 213 | // add a custom validation that only allows heading blocks 214 | noTextAllowed: (blocks) => { 215 | const errorPaths = (blocks || []) 216 | .filter( 217 | (block) => 218 | block._type === "block" && 219 | block.style.match(/^h[1-6]$/) 220 | ) 221 | .map((block, index) => [{ _key: block._key }] || [index]) 222 | 223 | return ( 224 | errorPaths.length === 0 || { 225 | message: "Must be styled as a heading", 226 | paths: errorPaths, 227 | } 228 | ) 229 | } 230 | }) 231 | 232 | // use your validator like so 233 | { 234 | type: "array", 235 | of: [{ type: "block" }], 236 | validation: yourValidator, 237 | } 238 | ``` 239 | 240 | ### Using Portable Text in a preview 241 | 242 | Since block content is stored as an array, you can't use it directly when 243 | customizing previews. Instead you have to convert it to a string, but ignore 244 | everything that isn't readily convertible to a string. That's what 245 | `blockPreview` does: 246 | 247 | ```js 248 | import { blockPreview } from "sanity-pills" 249 | 250 | export default { 251 | // … 252 | preview: { 253 | select: { 254 | title: "title", 255 | copy: "copy", 256 | }, 257 | prepare: ({ title, copy }) => ({ 258 | title, 259 | subtitle: blockPreview(copy), 260 | }), 261 | }, 262 | } 263 | ``` 264 | 265 | ### Parsing an asset ID 266 | 267 | Sanity assigns stable, informative IDs for asset uploads, including the format 268 | and, for images, the dimensions of the original file. These can be easily parsed 269 | using the `decodeAssetId` export from Sanity Pills. 270 | 271 | ```js 272 | import { decodeAssetId } from "sanity-pills" 273 | 274 | const { 275 | dimensions: { width, height }, 276 | format, 277 | } = decodeAssetId(someImageAssetId) 278 | ``` 279 | 280 | ### Preventing duplicates in arrays of references 281 | 282 | Arrays of references are pretty common, and the usual cases for them typically 283 | only expect a single instance of any selected document. This wee routine 284 | enhances the experience of choosing references by removing any documents that 285 | are already in the array. 286 | 287 | ```js 288 | import { noDuplicateRefs } from "sanity-pills" 289 | 290 | const field = { 291 | type: "array", 292 | of: [ 293 | { 294 | type: "reference", 295 | to: [{ type: "someDocumentType" }], 296 | options: { 297 | filter: noDuplicateRefs, 298 | }, 299 | }, 300 | ], 301 | } 302 | ``` 303 | 304 | ### Joining path segments into a slash-delimited URL 305 | 306 | Typical path joining routine that prevents doubling up slashes but won't remove 307 | doubled slashes in a string you pass in. 308 | 309 | ```js 310 | import { urlJoin } from "sanity-pills" 311 | 312 | urlJoin("/foo/", "/bar/") // #=> "/foo/bar/" 313 | urlJoin("foo", "bar") // #=> "foo/bar" 314 | urlJoin("/f/o/o/", "/b/a/r/") // #=> "/f/o/o/b/a/r/" 315 | urlJoin("/", "/", "/", "/", "/") // #=> "/" 316 | ``` 317 | 318 | ## License 319 | 320 | Copyright ©2022 Corey Ward. Available under the [MIT License](https://github.com/coreyward/sanity-pills/blob/master/LICENSE). 321 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild") 2 | 3 | const sharedConfig = { 4 | entryPoints: ["./src/index.js"], 5 | external: ["slugify"], 6 | bundle: true, 7 | minify: true, 8 | } 9 | 10 | // Build CommonJS version 11 | esbuild 12 | .build({ 13 | ...sharedConfig, 14 | format: "cjs", 15 | platform: "node", 16 | target: ["node12"], 17 | outdir: "dist/cjs", 18 | }) 19 | .catch(() => process.exit(1)) 20 | 21 | // Build ES modules version 22 | esbuild 23 | .build({ 24 | ...sharedConfig, 25 | format: "esm", 26 | target: ["es2020"], 27 | splitting: true, 28 | outdir: "dist/mjs", 29 | }) 30 | .catch(() => process.exit(1)) 31 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.(t|j)sx?$": "esbuild-jest-transform", 4 | }, 5 | testEnvironment: "jest-environment-node", 6 | } 7 | -------------------------------------------------------------------------------- /jest.esbuild.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | target: "es2017", 3 | format: "cjs", 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-pills", 3 | "description": "A collection of utilities and helpers for the Sanity.io Studio.", 4 | "version": "2.0.0", 5 | "author": "Corey Ward ", 6 | "license": "MPL-2.0", 7 | "repository": "https://github.com/coreyward/sanity-pills", 8 | "main": "dist/cjs/index.js", 9 | "module": "dist/mjs/index.js", 10 | "types": "dist/index.d.ts", 11 | "exports": { 12 | ".": { 13 | "import": "./dist/mjs/index.js", 14 | "require": "./dist/cjs/index.js" 15 | } 16 | }, 17 | "files": [ 18 | "/dist" 19 | ], 20 | "sideEffects": false, 21 | "dependencies": { 22 | "@sanity/types": "^3.8.2", 23 | "slugify": "^1.4.6" 24 | }, 25 | "devDependencies": { 26 | "@size-limit/preset-small-lib": "^8.2.4", 27 | "esbuild": "^0.17.11", 28 | "esbuild-jest-transform": "^1.1.0", 29 | "eslint": "^8.22.0", 30 | "eslint-config-prettier": "^8.5.0", 31 | "eslint-config-standard": "^17.0.0", 32 | "eslint-plugin-i": "^2.28.0", 33 | "eslint-plugin-n": "^15.2.5", 34 | "eslint-plugin-node": "^11.1.0", 35 | "eslint-plugin-prettier": "^4.2.1", 36 | "eslint-plugin-promise": "^6.0.0", 37 | "jest": "^29.5.0", 38 | "prettier": "^2.7.1", 39 | "size-limit": "^8.2.4", 40 | "typescript": "^5.0.0" 41 | }, 42 | "scripts": { 43 | "prebuild": "tsc", 44 | "build": "node ./build.js", 45 | "postbuild": "node ./scripts/injectFnComments.js && ./scripts/postbuild.sh", 46 | "prepublish": "yarn run build", 47 | "size": "size-limit", 48 | "test": "jest" 49 | }, 50 | "size-limit": [ 51 | { 52 | "path": "dist/mjs/index.js", 53 | "limit": "10 KB" 54 | }, 55 | { 56 | "path": "dist/cjs/index.js", 57 | "limit": "15 KB" 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /scripts/injectFnComments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script injects JSDoc comments from the JS source files into the type 3 | * definition files. This is necessary because the type definition files 4 | * generated by TypeScript do not include JSDoc comments. 5 | * 6 | * @see https://github.com/microsoft/TypeScript/issues/14619 7 | * 8 | * The strategy is a bit hacky, but straightforward: 9 | * 10 | * 1. Recursively walk the output folder looking for .d.ts files 11 | * 2. For each .d.ts file, find the corresponding .js file 12 | * 3. Read the type definition file and identify function declarations that do 13 | * not have JSDoc comments 14 | * 4. Read the .js file and find the corresponding function declarations that 15 | * have JSDoc comments 16 | * 5. Extract matched comments and strip redundant information about types 17 | * 6. Inject the comments into the type definition file 18 | * 19 | * This has some shortcomings. There is no actual parsing or static analysis 20 | * going on, so it's possible that some functions or comments will be missed. 21 | * It's also plausible that something matches unexpectedly and breaks the type 22 | * file. Since the output folder is generated anyways, these are hopefully easy 23 | * to remedy issues. 24 | * 25 | * NOTES: 26 | * - You may need to alter the source-file identification. For my purposes, 27 | * substituting `.js` in place of `.d.ts` was all that was needed. If you 28 | * have `.jsx` source files, you will need to change that. 29 | * - This will NOT work with TypeScript source files in a majority of cases. 30 | * 31 | */ 32 | 33 | const fs = require("fs") 34 | const path = require("path") 35 | 36 | const srcFolder = path.join(__dirname, "../src") 37 | const outputFolder = path.join(__dirname, "../dist") 38 | 39 | const getFiles = (dir, done) => { 40 | let results = [] 41 | fs.readdirSync(dir).forEach((file) => { 42 | file = path.join(dir, file) 43 | const stat = fs.statSync(file) 44 | if (stat && stat.isDirectory()) { 45 | results = results.concat(getFiles(file, done)) 46 | } else { 47 | results.push(file) 48 | } 49 | }) 50 | return results 51 | } 52 | 53 | getFiles(outputFolder).forEach((file) => { 54 | if (!file.endsWith(".d.ts")) return 55 | 56 | const relativePath = path.relative(outputFolder, file) 57 | const srcFile = path.join(srcFolder, relativePath).replace(".d.ts", ".js") 58 | 59 | if (fs.existsSync(srcFile)) { 60 | injectComments(srcFile, file) 61 | } 62 | }) 63 | 64 | console.log("Done.") 65 | 66 | function injectComments(srcFile, typeFile) { 67 | const fileData = fs.readFileSync(srcFile, "utf8") 68 | const typeData = fs.readFileSync(typeFile, "utf8") 69 | 70 | const functionDeclarationMatches = Array.from( 71 | typeData.matchAll( 72 | /(? { 78 | const functionDefinitionLinePattern = `(?:export (?:default )?)?(?:const|let|var|function) ${functionName}[^a-zA-Z0-9.]` 79 | const pattern = new RegExp( 80 | `(\\/\\*\\*\\s*\n([^*]|(\\*(?!\\/)))*\\*\\/)\n${functionDefinitionLinePattern}` 81 | ) 82 | 83 | const match = fileData.match(pattern) 84 | 85 | const jsDocComment = match?.[1] 86 | 87 | if (jsDocComment) { 88 | output = output.replace( 89 | matchedText, 90 | [jsDocComment, matchedText].join("\n") 91 | ) 92 | } 93 | 94 | return output 95 | }, 96 | typeData 97 | ) 98 | 99 | if (output !== typeData) { 100 | console.log(`Updating ${typeFile}`) 101 | fs.writeFileSync(typeFile, output) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /scripts/postbuild.sh: -------------------------------------------------------------------------------- 1 | cat >dist/cjs/package.json <dist/mjs/package.json < 39 | defineField({ 40 | type: "array", 41 | of: [ 42 | { 43 | type: "block", 44 | styles, 45 | marks, 46 | lists, 47 | }, 48 | ...of, 49 | ].map(defineArrayMember), 50 | validation: required 51 | ? defaultBlockValidator.all 52 | : defaultBlockValidator.optional, 53 | ...overrides, 54 | }) 55 | -------------------------------------------------------------------------------- /src/fields/image.js: -------------------------------------------------------------------------------- 1 | import { defineField } from "@sanity/types" 2 | import { 3 | buildImageValidator, 4 | getWarningValidators, 5 | validators, 6 | } from "../lib/imageValidator" 7 | 8 | export const imageField = defineField({ 9 | type: "image", 10 | options: { 11 | accept: "image/*", 12 | hotspot: true, 13 | }, 14 | }) 15 | 16 | /** 17 | * Image factory params 18 | * @typedef {object} CreateImageParams 19 | * @property {string} [name] 20 | * @property {string | import("react").ReactElement} [description] 21 | * @property {import("@sanity/types").ImageOptions} [options] 22 | * @property {string} [fieldset] 23 | * @property {import("../lib/imageValidator").ImageValidationOptions} [validations] 24 | * @property {import("../lib/imageValidator").ImageValidationOptions} [warnings] 25 | */ 26 | 27 | /** 28 | * Creates an image field with validation 29 | * 30 | * @param {CreateImageParams} params 31 | * @returns {import("@sanity/types").ImageDefinition} 32 | */ 33 | export const createImageField = ({ 34 | validations: { required, ...validations } = {}, 35 | warnings = {}, 36 | ...overrides 37 | }) => 38 | defineField({ 39 | ...imageField, 40 | validation: (Rule) => 41 | [ 42 | required && Rule.required(), 43 | Object.keys(validations).length && 44 | Rule.custom(buildImageValidator(validations, validators)), 45 | Object.keys(warnings).length && 46 | Rule.custom( 47 | buildImageValidator(warnings, getWarningValidators()) 48 | ).warning(), 49 | ].filter(Boolean), 50 | ...overrides, 51 | }) 52 | -------------------------------------------------------------------------------- /src/fields/slug.js: -------------------------------------------------------------------------------- 1 | import slugify from "slugify" 2 | import { urlJoin } from "../lib/urlJoin.js" 3 | import { defineField } from "@sanity/types" 4 | 5 | /** 6 | * Create a url-friendly slug with a limited character set. 7 | * 8 | * @param {string} input 9 | */ 10 | export const createSlug = (input) => 11 | slugify(input, { lower: true, remove: /[^a-zA-Z0-9 -]/g }) 12 | 13 | export const slugField = defineField({ 14 | title: "Slug", 15 | name: "slug", 16 | type: "slug", 17 | validation: (Rule) => 18 | Rule.custom( 19 | ({ current: slug } = {}) => validateFormat(slug) || "Invalid formatting" 20 | ), 21 | options: { 22 | slugify: (source) => urlJoin("/", createSlug(source), "/"), 23 | }, 24 | description: 25 | "The `slug` becomes the path of the published page on the website. It will be appended to the domain name automatically.", 26 | }) 27 | 28 | /** 29 | * Generate a slug-type Sanity field with validation 30 | * 31 | * @param {object} options 32 | * @param {string} options.source - Name of the document field to use as a source for the slug generator 33 | * @param {string} [options.prefix] - Require the slug to begin with a specific path 34 | * @param {string} [options.name] - Name of the slug field 35 | * @param {string} [options.title] - Title of the slug field 36 | * @param {function} [options.slugify] - Slugging function 37 | * @param {function} [options.validation] - Validation function 38 | * @returns {object} Sanity field definition 39 | */ 40 | export const createSlugField = ({ prefix, validation, ...options }) => { 41 | prefix = urlJoin("/", prefix, "/") 42 | 43 | return defineField({ 44 | ...slugField, 45 | options: { 46 | ...slugField.options, 47 | 48 | slugify: (source) => urlJoin(prefix, createSlug(source), "/"), 49 | 50 | ...options, 51 | }, 52 | validation: (Rule) => 53 | [ 54 | Rule.required().custom(({ current: value } = {}) => { 55 | if (!value) return "Slug is required" 56 | if (!validateFormat(value)) return "Invalid formatting" 57 | if (!value.startsWith(prefix)) 58 | return `This document type must be under ${prefix}` 59 | 60 | return true 61 | }), 62 | validation && validation(Rule), 63 | ].filter(Boolean), 64 | }) 65 | } 66 | 67 | const validateFormat = (slug) => { 68 | if (!slug) return false 69 | switch (slug.length) { 70 | case 1: 71 | return slug === "/" 72 | 73 | case 2: 74 | return false 75 | 76 | case 3: 77 | return /\/[a-z]\//.test(slug) 78 | 79 | default: 80 | return /^\/([a-z][a-z0-9/-]*[a-z0-9]|404)\/$/.test(slug) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Fields 2 | export { imageField, createImageField } from "./fields/image" 3 | export { slugField, createSlugField, createSlug } from "./fields/slug" 4 | 5 | // Block stuff 6 | export { blockPreview } from "./lib/blockPreview" 7 | export { createBlockField } from "./fields/block" 8 | export { 9 | createBlockValidator, 10 | defaultBlockValidator, 11 | blockValidations, 12 | } from "./lib/blockValidator" 13 | 14 | // Utilities 15 | export { decodeAssetId } from "./lib/decodeAssetId" 16 | export { fields } from "./lib/fieldsHelper" 17 | export { urlJoin } from "./lib/urlJoin" 18 | export { noDuplicateRefs } from "./lib/noDuplicateRefs" 19 | export { startCase, isCamelCase } from "./lib/startCase" 20 | export * from "./lib/urlValidator" 21 | -------------------------------------------------------------------------------- /src/lib/blockPreview.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Reduces a portable text field to a string for use in Studio previews. 3 | * 4 | * @param {object[]} [content] Portable Text content 5 | * @returns {string} Text content 6 | */ 7 | export const blockPreview = (content) => { 8 | if (!Array.isArray(content) || content.length === 0) return null 9 | 10 | return content.reduce( 11 | (text, { _type, children }) => 12 | _type === "block" 13 | ? text + 14 | " " + 15 | children 16 | .filter((child) => child._type === "span") 17 | .map((span) => span.text) 18 | .join("") 19 | : text, 20 | "" 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/blockValidator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validator-generator for Portable Text fields 3 | * 4 | * @param {Object} options 5 | * @param {Boolean} options.required Require a value to be set in this field? 6 | * @param {Boolean} options.noEmptyBlocks Prevent zero-length blocks? 7 | * @param {Boolean} options.validateLinks Ensure links have required attributes? 8 | * @param {Boolean} options.styleRequired Ensure blocks have an associated style? 9 | * @param {Boolean} options.noStackedMarks Disallow stacked standard marks (e.g. bold + italic), but allow custom marks to stack 10 | * @param {Boolean} options.noNewlines Prevent newlines inside of block content 11 | * @param {Boolean} options.noTerminatingWhitespace Prevent preceding or trailing whitespace 12 | */ 13 | export const createBlockValidator = (options) => { 14 | const { required, ...customValidators } = options 15 | 16 | return (Rule) => 17 | [ 18 | required && Rule.required(), 19 | ...Object.entries(customValidators) 20 | .filter(([, value]) => value) 21 | .map(([name, value]) => 22 | Rule.custom( 23 | typeof value === "boolean" ? blockValidations[name] : value 24 | ) 25 | ), 26 | ].filter(Boolean) 27 | } 28 | 29 | export const defaultBlockValidator = { 30 | all: createBlockValidator({ 31 | required: true, 32 | noEmptyBlocks: true, 33 | noStackedMarks: true, 34 | styleRequired: true, 35 | validateLinks: true, 36 | noNewlines: true, 37 | noTerminatingWhitespace: true, 38 | noMarksOnHeadings: true, 39 | }), 40 | 41 | optional: createBlockValidator({ 42 | noEmptyBlocks: true, 43 | noStackedMarks: true, 44 | styleRequired: true, 45 | validateLinks: true, 46 | noNewlines: true, 47 | noTerminatingWhitespace: true, 48 | noMarksOnHeadings: true, 49 | }), 50 | } 51 | 52 | export const blockValidations = { 53 | // https://www.sanity.io/docs/validation#validating-children-9e69d5db6f72 54 | noEmptyBlocks: (blocks) => { 55 | const offendingPaths = (blocks || []) 56 | .filter( 57 | (block) => 58 | block._type === "block" && 59 | block.children.every( 60 | (span) => span._type === "span" && span.text.trim() === "" 61 | ) 62 | ) 63 | .map((block, index) => [{ _key: block._key }] || [index]) 64 | 65 | return ( 66 | offendingPaths.length === 0 || { 67 | message: "Paragraph cannot be empty", 68 | paths: offendingPaths, 69 | } 70 | ) 71 | }, 72 | 73 | // Prevent newlines inside of block content 74 | noNewlines: (blocks) => { 75 | const offendingPaths = (blocks || []) 76 | .filter( 77 | (block) => 78 | block._type === "block" && 79 | !block.level && // don't apply to indented content list items 80 | block.children.some( 81 | (span) => span._type === "span" && span.text.includes("\n") 82 | ) 83 | ) 84 | .map((block, index) => [{ _key: block._key }] || [index]) 85 | 86 | return ( 87 | offendingPaths.length === 0 || { 88 | message: "Text cannot contain arbitrary newlines", 89 | paths: offendingPaths, 90 | } 91 | ) 92 | }, 93 | 94 | // Prevent preceding or trailing whitespace 95 | noTerminatingWhitespace: (blocks) => { 96 | const offendingPaths = (blocks || []) 97 | .filter(({ _type, children }) => { 98 | if (_type !== "block") return false 99 | 100 | const { 0: first, length, [length - 1]: last } = children || [] 101 | 102 | return first?.text.startsWith(" ") || last?.text.endsWith(" ") 103 | }) 104 | .map((block, index) => [{ _key: block._key }] || [index]) 105 | 106 | return ( 107 | offendingPaths.length === 0 || { 108 | message: "Blocks cannot start or end with whitespace", 109 | paths: offendingPaths, 110 | } 111 | ) 112 | }, 113 | 114 | // Links without href attributes 115 | validateLinks: (blocks) => { 116 | const errorPaths = (blocks || []) 117 | .filter( 118 | (block) => 119 | block._type === "block" && 120 | block.markDefs.some( 121 | (def) => 122 | def._type === "link" && !(def.href && def.href.trim() !== "") 123 | ) 124 | ) 125 | .map((block) => [{ _key: block._key }]) 126 | 127 | return ( 128 | errorPaths.length === 0 || { 129 | message: "Links must have a url set", 130 | paths: errorPaths, 131 | } 132 | ) 133 | }, 134 | 135 | // Ensure all blocks have a `style` 136 | styleRequired: (blocks) => { 137 | const emptyPaths = (blocks || []) 138 | .filter((block) => block._type === "block" && !block.style) 139 | .map((block, index) => [{ _key: block._key }] || [index]) 140 | 141 | return ( 142 | emptyPaths.length === 0 || { 143 | message: "Must have a style selected", 144 | paths: emptyPaths, 145 | } 146 | ) 147 | }, 148 | 149 | // Disallow stacked standard marks (e.g. bold + italic), but allow custom marks to stack 150 | noStackedMarks: (blocks) => { 151 | const errorPaths = (blocks || []) 152 | .filter( 153 | (block) => 154 | block._type === "block" && 155 | block.children.some( 156 | (span) => 157 | span.marks.filter((mark) => standardMarks.includes(mark)).length > 158 | 1 159 | ) 160 | ) 161 | .map((block) => [{ _key: block._key }]) 162 | 163 | return ( 164 | errorPaths.length === 0 || { 165 | message: 166 | "Text cannot have multiple marks applied (e.g. bold and italic)", 167 | paths: errorPaths, 168 | } 169 | ) 170 | }, 171 | 172 | noMarksOnHeadings: (blocks) => { 173 | const errorPaths = (blocks || []) 174 | .filter( 175 | (block) => 176 | block._type === "block" && 177 | block.style.startsWith("h") && 178 | block.children.some((span) => span.marks.length) 179 | ) 180 | .map((block) => [{ _key: block._key }]) 181 | 182 | return ( 183 | errorPaths.length === 0 || { 184 | message: "Headings cannot have marks applied (e.g. bold, links, etc.)", 185 | paths: errorPaths, 186 | } 187 | ) 188 | }, 189 | } 190 | 191 | const standardMarks = ["strong", "em", "underline", "del", "code"] 192 | -------------------------------------------------------------------------------- /src/lib/decodeAssetId.js: -------------------------------------------------------------------------------- 1 | const pattern = /^(?:image|file)-([a-f\d]+)-(?:(\d+x\d+)-)?(\w+)$/ 2 | 3 | /** 4 | * @typedef {object} AssetDimensions 5 | * @property {number} width The width of the asset 6 | * @property {number} height The height of the asset 7 | * 8 | * @typedef {object} AssetProperties 9 | * @property {string} assetId The Sanity asset ID 10 | * @property {AssetDimensions} [dimensions] The dimensions of the asset (if applicable) 11 | * @property {string} format The format/extension of the asset 12 | */ 13 | 14 | /** 15 | * Decode a Sanity asset ID into its parts. 16 | * 17 | * @param {string} id The Sanity asset ID 18 | * @returns {AssetProperties} The decoded asset ID 19 | */ 20 | export const decodeAssetId = (id) => { 21 | const [, assetId, dimensions, format] = pattern.exec(id) 22 | const [width, height] = dimensions 23 | ? dimensions.split("x").map((v) => parseInt(v, 10)) 24 | : [] 25 | 26 | return { 27 | assetId, 28 | dimensions: { width, height }, 29 | format, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/decodeAssetId.test.js: -------------------------------------------------------------------------------- 1 | import { decodeAssetId } from "./decodeAssetId" 2 | 3 | describe("decodeAssetId", () => { 4 | test("extracts parts from image asset ID", () => { 5 | const parsed = decodeAssetId( 6 | "image-8a03588d78e5b5645298e8dd903dcaa0dffa0e20-1162x868-png" 7 | ) 8 | expect(parsed).toEqual({ 9 | assetId: "8a03588d78e5b5645298e8dd903dcaa0dffa0e20", 10 | dimensions: { 11 | width: 1162, 12 | height: 868, 13 | }, 14 | format: "png", 15 | }) 16 | }) 17 | 18 | test("extracts parts from file asset ID", () => { 19 | const parsed = decodeAssetId( 20 | "file-a05701458c0906fda0dc85eaf49011f406fbc00f-mp4" 21 | ) 22 | expect(parsed).toEqual({ 23 | assetId: "a05701458c0906fda0dc85eaf49011f406fbc00f", 24 | dimensions: { 25 | width: undefined, 26 | height: undefined, 27 | }, 28 | format: "mp4", 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/lib/fieldsHelper.js: -------------------------------------------------------------------------------- 1 | import { defineField } from "@sanity/types" 2 | import { startCase } from "./startCase" 3 | 4 | /** 5 | * Convert object-based field definition to array-based field definition 6 | * 7 | * @param {object} fieldDefs 8 | */ 9 | export const fields = (fieldDefs) => 10 | Object.entries(fieldDefs).map(([name, { required, ...properties }]) => 11 | defineField({ 12 | title: startCase(name), 13 | type: "string", 14 | validation: required ? buildRequiredValidation(required) : null, 15 | ...properties, 16 | name, 17 | }) 18 | ) 19 | 20 | const buildRequiredValidation = (input) => 21 | typeof input === "function" 22 | ? (Rule) => 23 | Rule.custom((value, { parent, document }) => { 24 | const fieldRequired = input({ value, parent, document }) 25 | return fieldRequired && !value ? "Required" : true 26 | }) 27 | : (Rule) => Rule.required() 28 | -------------------------------------------------------------------------------- /src/lib/imageValidator.js: -------------------------------------------------------------------------------- 1 | import { decodeAssetId } from "./decodeAssetId" 2 | 3 | /** 4 | * Validation options for image fields 5 | * @typedef {object} ImageValidationOptions 6 | * @property {number} [minWidth] Minimum width in pixels 7 | * @property {number} [minHeight] Minimum height in pixels 8 | * @property {number} [maxWidth] Maximum width in pixels 9 | * @property {number} [maxHeight] Maximum height in pixels 10 | * @property {string[]} [allowedFormats] Allowed file extensions (no separator) 11 | * @property {boolean} [required] Whether the field is required 12 | */ 13 | 14 | export const buildImageValidator = 15 | (validations, selectedValidators) => (image) => { 16 | if (image && image.asset && image.asset._ref) { 17 | const { dimensions, format } = decodeAssetId(image.asset._ref) 18 | 19 | const validatorProps = { 20 | ...validations, 21 | ...dimensions, 22 | format, 23 | } 24 | 25 | for (const validation in validations) { 26 | if (!selectedValidators[validation]) { 27 | throw new Error(`Unexpected validation \`${validation}\` specified.`) 28 | } 29 | 30 | const result = selectedValidators[validation](validatorProps) 31 | if (typeof result === "string") return result 32 | } 33 | } 34 | 35 | return true 36 | } 37 | 38 | export const validators = { 39 | minWidth: ({ minWidth, width, format }) => 40 | format === "svg" || 41 | width >= minWidth || 42 | `Image must be at least ${minWidth}px wide`, 43 | minHeight: ({ minHeight, height, format }) => 44 | format === "svg" || 45 | height >= minHeight || 46 | `Image must be at least ${minHeight}px tall`, 47 | maxWidth: ({ maxWidth, width }) => 48 | width <= maxWidth || `Image must be less than ${maxWidth}px wide`, 49 | maxHeight: ({ maxHeight, height }) => 50 | height <= maxHeight || `Image must be less than ${maxHeight}px tall`, 51 | allowedFormats: ({ allowedFormats, format }) => 52 | allowedFormats.includes(format) || 53 | `Image must be in ${allowedFormats.join(" or ")} format`, 54 | } 55 | 56 | let warningValidators 57 | export const getWarningValidators = () => 58 | warningValidators || (warningValidators = createWarningValidators()) 59 | 60 | const createWarningValidators = () => 61 | Object.entries(validators).reduce((out, [name, fn]) => { 62 | out[name] = (props) => 63 | typeof fn(props) === "string" 64 | ? fn(props).replace("must", "should") + " for best results" 65 | : true 66 | return out 67 | }, {}) 68 | -------------------------------------------------------------------------------- /src/lib/imageValidator.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | buildImageValidator, 3 | validators, 4 | getWarningValidators, 5 | } from "./imageValidator" 6 | 7 | test("minWidth", () => { 8 | const validator = buildImageValidator({ minWidth: 500 }, validators) 9 | 10 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe(true) 11 | expect(validator({ asset: { _ref: "image-abc123-499x500-png" } })).toBe( 12 | "Image must be at least 500px wide" 13 | ) 14 | }) 15 | 16 | test("maxWidth", () => { 17 | const validator = buildImageValidator({ maxWidth: 500 }, validators) 18 | 19 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe(true) 20 | expect(validator({ asset: { _ref: "image-abc123-501x500-png" } })).toBe( 21 | "Image must be less than 500px wide" 22 | ) 23 | }) 24 | 25 | test("minHeight", () => { 26 | const validator = buildImageValidator({ minHeight: 500 }, validators) 27 | 28 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe(true) 29 | expect(validator({ asset: { _ref: "image-abc123-500x499-png" } })).toBe( 30 | "Image must be at least 500px tall" 31 | ) 32 | }) 33 | 34 | test("maxHeight", () => { 35 | const validator = buildImageValidator({ maxHeight: 500 }, validators) 36 | 37 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe(true) 38 | expect(validator({ asset: { _ref: "image-abc123-500x501-png" } })).toBe( 39 | "Image must be less than 500px tall" 40 | ) 41 | }) 42 | 43 | test("allowedFormats", () => { 44 | const validator = buildImageValidator( 45 | { allowedFormats: ["jpg", "png"] }, 46 | validators 47 | ) 48 | 49 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe(true) 50 | expect(validator({ asset: { _ref: "image-abc123-500x500-jpg" } })).toBe(true) 51 | expect(validator({ asset: { _ref: "image-abc123-500x500-gif" } })).toBe( 52 | "Image must be in jpg or png format" 53 | ) 54 | }) 55 | 56 | test("allowedFormats single", () => { 57 | const validator = buildImageValidator({ allowedFormats: ["svg"] }, validators) 58 | 59 | expect(validator({ asset: { _ref: "image-abc123-500x500-svg" } })).toBe(true) 60 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe( 61 | "Image must be in svg format" 62 | ) 63 | }) 64 | 65 | describe("warnings", () => { 66 | const warningValidators = getWarningValidators() 67 | 68 | test("minWidth", () => { 69 | const validator = buildImageValidator({ minWidth: 500 }, warningValidators) 70 | 71 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe( 72 | true 73 | ) 74 | expect(validator({ asset: { _ref: "image-abc123-499x500-png" } })).toBe( 75 | "Image should be at least 500px wide for best results" 76 | ) 77 | }) 78 | 79 | test("maxWidth", () => { 80 | const validator = buildImageValidator({ maxWidth: 500 }, warningValidators) 81 | 82 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe( 83 | true 84 | ) 85 | expect(validator({ asset: { _ref: "image-abc123-501x500-png" } })).toBe( 86 | "Image should be less than 500px wide for best results" 87 | ) 88 | }) 89 | 90 | test("minHeight", () => { 91 | const validator = buildImageValidator({ minHeight: 500 }, warningValidators) 92 | 93 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe( 94 | true 95 | ) 96 | expect(validator({ asset: { _ref: "image-abc123-500x499-png" } })).toBe( 97 | "Image should be at least 500px tall for best results" 98 | ) 99 | }) 100 | 101 | test("maxHeight", () => { 102 | const validator = buildImageValidator({ maxHeight: 500 }, warningValidators) 103 | 104 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe( 105 | true 106 | ) 107 | expect(validator({ asset: { _ref: "image-abc123-500x501-png" } })).toBe( 108 | "Image should be less than 500px tall for best results" 109 | ) 110 | }) 111 | 112 | test("allowedFormats", () => { 113 | const validator = buildImageValidator( 114 | { allowedFormats: ["jpg", "png"] }, 115 | warningValidators 116 | ) 117 | 118 | expect(validator({ asset: { _ref: "image-abc123-500x500-png" } })).toBe( 119 | true 120 | ) 121 | expect(validator({ asset: { _ref: "image-abc123-500x500-jpg" } })).toBe( 122 | true 123 | ) 124 | expect(validator({ asset: { _ref: "image-abc123-500x500-gif" } })).toBe( 125 | "Image should be in jpg or png format for best results" 126 | ) 127 | }) 128 | }) 129 | 130 | describe("svg allowances", () => { 131 | test("minWidth", () => { 132 | const validator = buildImageValidator({ minWidth: 500 }, validators) 133 | 134 | expect(validator({ asset: { _ref: "image-abc123-500x500-svg" } })).toBe( 135 | true 136 | ) 137 | expect(validator({ asset: { _ref: "image-abc123-499x500-svg" } })).toBe( 138 | true 139 | ) 140 | }) 141 | 142 | test("minHeight", () => { 143 | const validator = buildImageValidator({ minHeight: 500 }, validators) 144 | 145 | expect(validator({ asset: { _ref: "image-abc123-500x500-svg" } })).toBe( 146 | true 147 | ) 148 | expect(validator({ asset: { _ref: "image-abc123-500x499-svg" } })).toBe( 149 | true 150 | ) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /src/lib/noDuplicateRefs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prevents the same document from being referenced multiple times in an array 3 | * of references. Pass as the value to the `filter` option on the reference 4 | * declaration itself (not the array). 5 | * 6 | * @example 7 | * const field = { 8 | * type: "array", 9 | * of: [ 10 | * { 11 | * type: "reference", 12 | * to: [{ type: "someDocumentType" }], 13 | * options: { 14 | * filter: noDuplicateRefs, 15 | * }, 16 | * }, 17 | * ], 18 | * } 19 | */ 20 | export const noDuplicateRefs = ({ parent }) => { 21 | const existingRefs = parent.map((item) => item._ref).filter(Boolean) 22 | 23 | return existingRefs.length 24 | ? { 25 | filter: "!(_id in $ids)", 26 | params: { 27 | ids: existingRefs, 28 | }, 29 | } 30 | : true 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/startCase.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a string in camelCase, snake_case, or kebab-case to Start Case. 3 | * 4 | * @param {string} input 5 | * @returns {string} 6 | */ 7 | export const startCase = (input) => { 8 | const parts = isCamelCase(input) 9 | ? splitCamelCase(input) 10 | : splitDelimited(input) 11 | 12 | return (parts ?? [input]) 13 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 14 | .join(" ") 15 | } 16 | 17 | /** 18 | * Evaluate whether input is camelCase. 19 | * 20 | * @param {string} input 21 | * @returns {boolean} 22 | */ 23 | export const isCamelCase = (input) => 24 | /^[A-Za-z][\da-z]*[A-Z][\dA-Za-z]+$/.test(input) 25 | 26 | /** 27 | * Split camelCase on capital letters following lowercase 28 | * @param {string} input 29 | */ 30 | export const splitCamelCase = (input) => 31 | input.match(/(^[A-Za-z][\da-z]+|(?<=[a-z])([A-Z]+[\da-z]*))/g) 32 | 33 | /** 34 | * Split on hyphens, underscores, and spaces 35 | * @param {string} input 36 | */ 37 | export const splitDelimited = (input) => 38 | input 39 | .replace(/[-_\s]+/g, " ") 40 | .trim() 41 | .split(" ") 42 | -------------------------------------------------------------------------------- /src/lib/startCase.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | startCase, 3 | isCamelCase, 4 | splitCamelCase, 5 | splitDelimited, 6 | } from "./startCase" // Replace 'yourFile' with the actual file name 7 | 8 | describe("startCase", () => { 9 | it("converts camelCase to Start Case", () => { 10 | expect(startCase("helloWorld")).toBe("Hello World") 11 | expect(startCase("hiii")).toBe("Hiii") 12 | }) 13 | 14 | it("converts CamelCase to Start Case", () => { 15 | expect(startCase("HelloWorld")).toBe("Hello World") 16 | }) 17 | 18 | it("handles acronyms", () => { 19 | expect(startCase("helloWorldHTML")).toBe("Hello World HTML") 20 | }) 21 | 22 | it("converts snake_case to Start Case", () => { 23 | expect(startCase("hello_world")).toBe("Hello World") 24 | }) 25 | 26 | it("converts kebab-case to Start Case", () => { 27 | expect(startCase("hello-world")).toBe("Hello World") 28 | }) 29 | 30 | it("converts space separated to Start Case", () => { 31 | expect(startCase("hello world")).toBe("Hello World") 32 | }) 33 | }) 34 | 35 | describe("isCamelCase", () => { 36 | it("returns true for camelCase", () => { 37 | expect(isCamelCase("helloWorld")).toBe(true) 38 | expect(isCamelCase("HelloWorld")).toBe(true) 39 | }) 40 | 41 | it("returns false for not camelCase", () => { 42 | expect(isCamelCase("Hello World")).toBe(false) 43 | expect(isCamelCase("hello_world")).toBe(false) 44 | expect(isCamelCase("hello-world")).toBe(false) 45 | expect(isCamelCase("helloWorld is cool")).toBe(false) 46 | }) 47 | }) 48 | 49 | describe("splitCamelCase", () => { 50 | it("splits camelCase into words", () => { 51 | expect(splitCamelCase("helloWorld")).toEqual(["hello", "World"]) 52 | }) 53 | 54 | it("splits UpperCamelCase into words", () => { 55 | expect(splitCamelCase("HelloWorld")).toEqual(["Hello", "World"]) 56 | }) 57 | }) 58 | 59 | describe("splitDelimited", () => { 60 | it("splits snake_case into words", () => { 61 | expect(splitDelimited("hello_world")).toEqual(["hello", "world"]) 62 | }) 63 | 64 | it("splits kebab-case into words", () => { 65 | expect(splitDelimited("hello-world")).toEqual(["hello", "world"]) 66 | }) 67 | 68 | it("splits space separated into words", () => { 69 | expect(splitDelimited("hello world")).toEqual(["hello", "world"]) 70 | }) 71 | 72 | it("trims extra characters", () => { 73 | expect(splitDelimited(" hello world ")).toEqual(["hello", "world"]) 74 | expect(splitDelimited("--hello--world--")).toEqual(["hello", "world"]) 75 | expect(splitDelimited("__hello__world__")).toEqual(["hello", "world"]) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /src/lib/urlJoin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Concatenates a list of URL parts, ensuring that there is only one slash 3 | * between each part. 4 | * 5 | * @param {...string} parts 6 | * @returns {string} The joined URL 7 | */ 8 | export const urlJoin = (...parts) => 9 | parts.reduce((result, part) => { 10 | if (!part) return result 11 | 12 | const trailingSlashPresent = result.endsWith("/") 13 | const preceedingSlashPresent = part.startsWith("/") 14 | 15 | return trailingSlashPresent !== preceedingSlashPresent 16 | ? result + part 17 | : trailingSlashPresent && preceedingSlashPresent 18 | ? result + part.substring(1) 19 | : result + "/" + part 20 | }, "" + parts.shift()) 21 | -------------------------------------------------------------------------------- /src/lib/urlJoin.test.js: -------------------------------------------------------------------------------- 1 | import { urlJoin } from "./urlJoin" 2 | 3 | test("avoid double delimiter", () => { 4 | expect(urlJoin("/foo/", "/bar/")).toBe("/foo/bar/") 5 | }) 6 | 7 | test("accept trailing delimiter", () => { 8 | expect(urlJoin("/foo/", "bar/")).toBe("/foo/bar/") 9 | }) 10 | 11 | test("accept preceding delimiter", () => { 12 | expect(urlJoin("/foo", "/bar/")).toBe("/foo/bar/") 13 | }) 14 | 15 | test("inserts delimiter", () => { 16 | expect(urlJoin("/foo", "bar/")).toBe("/foo/bar/") 17 | }) 18 | 19 | test("delimiter-only args", () => { 20 | expect(urlJoin("/", "/foo/", "/", "bar", "/")).toBe("/foo/bar/") 21 | }) 22 | 23 | test("internal delimiters ignored", () => { 24 | expect(urlJoin("/f/o/o/", "/b/a/r/")).toBe("/f/o/o/b/a/r/") 25 | }) 26 | 27 | test("preserves existing double slashes", () => { 28 | expect(urlJoin("foo//", "bar")).toBe("foo//bar") 29 | }) 30 | 31 | test("does not insert preceding and trailing slashes", () => { 32 | expect(urlJoin("foo", "bar")).toBe("foo/bar") 33 | }) 34 | 35 | test("single argument returned as-is", () => { 36 | expect(urlJoin("foo")).toBe("foo") 37 | expect(urlJoin("foo/")).toBe("foo/") 38 | expect(urlJoin("/foo")).toBe("/foo") 39 | }) 40 | 41 | test("delimiters collapsed infinitely", () => { 42 | expect(urlJoin("/", "/", "/", "/", "/")).toBe("/") 43 | }) 44 | -------------------------------------------------------------------------------- /src/lib/urlValidator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Regular expression pattern matching email addresses designed to catch input 3 | * errors and mistakes more than to prevent all invalid emails. 4 | */ 5 | export const emailPattern = 6 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 7 | 8 | /** 9 | * Regular expression matching common URL fragments 10 | */ 11 | export const fragmentPattern = /^[^#]*#([a-z][a-z0-9-]*[a-z0-9]|[a-z0-9])$/i 12 | 13 | export const errors = { 14 | invalidEmail: "Email address seems invalid.", 15 | invalidExternal: 16 | "External links need to start with `https://` (e.g., `https://www.example.com/`)", 17 | invalidRelative: 18 | "Relative links should start with a forward slash (/) or fragment (#).", 19 | invalidSpaces: "Links cannot contain unencoded spaces.", 20 | invalidFragment: "The fragment portion of this link looks invalid.", 21 | } 22 | 23 | /** 24 | * Validates a provided string value as a URL. Expected to be used in 25 | * conjunction with the built in `uri` validator in the Sanity Studio. 26 | * 27 | * @param {string} value The url-like value to validate 28 | * @returns {boolean | string} Either an error message or `true` if the value is valid 29 | */ 30 | export const validateUrlValue = (value) => { 31 | if (value?.startsWith("mailto:")) { 32 | const [, emailPart] = value.split(":") 33 | if (!emailPattern.test(emailPart)) { 34 | return errors.invalidEmail 35 | } else { 36 | return true 37 | } 38 | } 39 | 40 | // Custom handling of external-looking links without a protocol 41 | if ( 42 | value && 43 | !value.startsWith("http://") && 44 | !value.startsWith("https://") && 45 | (value.startsWith("www.") || value.includes(".com")) 46 | ) { 47 | return errors.invalidExternal 48 | } 49 | 50 | if ( 51 | value && 52 | !value.startsWith("http://") && 53 | !value.startsWith("https://") && 54 | !value.startsWith("/") && 55 | !value.startsWith("#") 56 | ) { 57 | return errors.invalidRelative 58 | } 59 | 60 | if (value?.includes(" ")) { 61 | return errors.invalidSpaces 62 | } 63 | 64 | if (value?.includes("#") && !fragmentPattern.test(value)) { 65 | return errors.invalidFragment 66 | } 67 | 68 | return true 69 | } 70 | 71 | /** 72 | * Validates a required URL field value 73 | * 74 | * @param {import("@sanity/types").Rule} Rule 75 | * @returns {boolean | string} 76 | */ 77 | export const requiredUrlValidator = (Rule) => 78 | Rule.required() 79 | .uri({ 80 | allowRelative: true, 81 | scheme: ["https", "http", "mailto"], 82 | }) 83 | .custom(validateUrlValue) 84 | 85 | /** 86 | * Validates a URL field value without requiring it to be present. 87 | * 88 | * @param {import("@sanity/types").Rule} Rule 89 | * @returns {boolean | string} 90 | */ 91 | export const optionalUrlValidator = (Rule) => 92 | Rule.uri({ 93 | allowRelative: true, 94 | scheme: ["https", "http", "mailto"], 95 | }).custom(validateUrlValue) 96 | -------------------------------------------------------------------------------- /src/lib/urlValidator.test.js: -------------------------------------------------------------------------------- 1 | import { errors, validateUrlValue } from "./urlValidator" 2 | 3 | describe("empty values", () => { 4 | test("empty value", () => { 5 | expect(validateUrlValue("")).toBe(true) 6 | }) 7 | 8 | test("undefined value", () => { 9 | expect(validateUrlValue(undefined)).toBe(true) 10 | }) 11 | }) 12 | 13 | describe("garbage in, garbage out", () => { 14 | test("garbage value", () => { 15 | expect(() => { 16 | validateUrlValue(9) 17 | }).toThrow() 18 | }) 19 | }) 20 | 21 | describe("email links", () => { 22 | test("valid email link", () => { 23 | expect(validateUrlValue("mailto:example@gmail.com")).toBe(true) 24 | }) 25 | 26 | test("invalid email link", () => { 27 | expect(validateUrlValue("mailto:example@gmail")).toBe(errors.invalidEmail) 28 | }) 29 | }) 30 | 31 | describe("external links", () => { 32 | test("https external link", () => { 33 | expect(validateUrlValue("https://example.com")).toBe(true) 34 | }) 35 | 36 | test("http external link", () => { 37 | expect(validateUrlValue("http://example.com")).toBe(true) 38 | }) 39 | 40 | test("invalid external link", () => { 41 | expect(validateUrlValue("example.com")).toBe(errors.invalidExternal) 42 | expect(validateUrlValue("www.example.com")).toBe(errors.invalidExternal) 43 | }) 44 | }) 45 | 46 | describe("relative links", () => { 47 | test("relative link", () => { 48 | expect(validateUrlValue("/relative")).toBe(true) 49 | }) 50 | 51 | test("relative link with hash", () => { 52 | expect(validateUrlValue("/relative#hash")).toBe(true) 53 | }) 54 | 55 | test("relative link with query", () => { 56 | expect(validateUrlValue("/relative?query=1")).toBe(true) 57 | }) 58 | 59 | test("relative link with query and hash", () => { 60 | expect(validateUrlValue("/relative?query=1#hash")).toBe(true) 61 | }) 62 | 63 | test("invalid relative link", () => { 64 | expect(validateUrlValue("relative")).toBe(errors.invalidRelative) 65 | }) 66 | }) 67 | 68 | describe("fragment links", () => { 69 | test("valid fragment link", () => { 70 | expect(validateUrlValue("#single-hash-01")).toBe(true) 71 | }) 72 | 73 | test("invalid fragment link", () => { 74 | expect(validateUrlValue("#double#hash")).toBe(errors.invalidFragment) 75 | }) 76 | 77 | test("fragment with starting number", () => { 78 | expect(validateUrlValue("#1hash")).toBe(errors.invalidFragment) 79 | }) 80 | 81 | test("fragment with starting hyphen", () => { 82 | expect(validateUrlValue("#-dash")).toBe(errors.invalidFragment) 83 | }) 84 | 85 | test("fragment with ending hyphen", () => { 86 | expect(validateUrlValue("#dash-")).toBe(errors.invalidFragment) 87 | }) 88 | 89 | test("valid link with invalid fragment", () => { 90 | expect(validateUrlValue("https://example.com#double#hash")).toBe( 91 | errors.invalidFragment 92 | ) 93 | }) 94 | }) 95 | 96 | describe("spaces", () => { 97 | test("external link with spaces", () => { 98 | expect(validateUrlValue("https://example.com/with spaces")).toBe( 99 | errors.invalidSpaces 100 | ) 101 | }) 102 | 103 | test("relative link with spaces", () => { 104 | expect(validateUrlValue("/with spaces")).toBe(errors.invalidSpaces) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "exclude": ["node_modules", "src/**/*.test.*"], 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "outDir": "./dist", 9 | "removeComments": false, 10 | "target": "ES2020", 11 | "moduleResolution": "node" 12 | } 13 | } 14 | --------------------------------------------------------------------------------