Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add document-level validator #157

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All new rewrite exclusively for Sanity Studio v3
- [Querying translations](#querying-translations)
- [Querying with GROQ](#querying-with-groq)
- [Querying with GraphQL](#querying-with-graphql)
- [Validations](#validations)
- [Note on document quotas](#note-on-document-quotas)
- [Documentation](#documentation)
- [License](#license)
Expand Down Expand Up @@ -227,6 +228,26 @@ query GetTranslations($id: ID!) {
}
```

## Validations

This package also provides a document-level validation function to check whether any references point to content in a different language:

```js
import {defineType} from 'sanity'
import {referenceLanguageValidator} from '@sanity/document-internationalization'

export const article = defineType({
title: 'Article',
name: 'article',
type: 'document',
// ..rest of the schema definition
validation: (rule) =>
// You can decide whether this will trigger a warning
// or an error in the Studio
rule.custom(referenceLanguageValidator).warning(),
})
```

## Note on document quotas

In previous versions of this plugin, translations were stored as an array of references on the actual documents. This required a base language, lead to messy transaction histories and made deleting documents difficult.
Expand Down
5,415 changes: 3,393 additions & 2,022 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@
"@sanity/ui": "^1.2.2",
"@sanity/uuid": "^3.0.1",
"sanity-plugin-internationalized-array": "^1.6.0",
"sanity-plugin-utils": "^1.6.2"
"sanity-plugin-utils": "^1.6.2",
"traverse": "^0.6.7"
},
"devDependencies": {
"@commitlint/cli": "^17.4.4",
Expand All @@ -65,6 +66,7 @@
"@sanity/semantic-release-preset": "^4.0.1",
"@types/react": "^18.0.27",
"@types/styled-components": "^5.1.26",
"@types/traverse": "^0.6.32",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"eslint": "^8.33.0",
Expand All @@ -83,7 +85,7 @@
"react-dom": "^18",
"react-is": "^18",
"rimraf": "^4.1.2",
"sanity": "^3.3.1",
"sanity": "^3.16.2",
"semantic-release": "^20.1.0",
"typescript": "^4.9.5"
},
Expand Down
5 changes: 5 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ export const DEFAULT_CONFIG: PluginConfigContext = {
metadataFields: [],
apiVersion: API_VERSION,
}

/**
* Used to add plugin configuration on `window`
*/
export const PLUGIN_CONFIG = Symbol('@sanity/document-internationalization')
9 changes: 9 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/* eslint-disable no-unused-vars */
import type {PLUGIN_CONFIG} from './constants'
import type {PluginConfig} from './types'

export declare global {
interface Window {
[PLUGIN_CONFIG]: Readonly<Required<PluginConfig>>
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export {useDocumentInternationalizationContext} from './components/DocumentInter
export {DocumentInternationalizationMenu} from './components/DocumentInternationalizationMenu'
export {documentInternationalization} from './plugin'
export * from './types'
export * from './validators'
12 changes: 11 additions & 1 deletion src/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import BulkPublish from './components/BulkPublish'
import {DocumentInternationalizationProvider} from './components/DocumentInternationalizationContext'
import {DocumentInternationalizationMenu} from './components/DocumentInternationalizationMenu'
import OptimisticallyStrengthen from './components/OptimisticallyStrengthen'
import {API_VERSION, DEFAULT_CONFIG, METADATA_SCHEMA_NAME} from './constants'
import {
API_VERSION,
DEFAULT_CONFIG,
METADATA_SCHEMA_NAME,
PLUGIN_CONFIG,
} from './constants'
import metadata from './schema/translation/metadata'
import {PluginConfig, TranslationReference} from './types'

Expand All @@ -29,6 +34,11 @@ export const documentInternationalization = definePlugin<PluginConfig>(
)
}

// Set configuration on global object, to use elsewhere
Object.defineProperty(window, PLUGIN_CONFIG, {
value: Object.freeze(pluginConfig),
})

return {
name: '@sanity/document-internationalization',

Expand Down
1 change: 1 addition & 0 deletions src/validators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {referenceLanguageValidator} from './referenceLanguageValidator'
101 changes: 101 additions & 0 deletions src/validators/referenceLanguageValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
type CustomValidator,
type Id,
isKeyedObject,
isReference,
type Path,
type SanityDocument,
} from 'sanity'
import {reduce} from 'traverse'

import {API_VERSION, PLUGIN_CONFIG} from '../constants'

// Extend the declaration with an additional property
declare module 'traverse' {
// eslint-disable-next-line no-unused-vars
interface TraverseContext {
sanityPath: Path
}
}

export const referenceLanguageValidator: CustomValidator<
SanityDocument | undefined
> = async (document, {getClient}) => {
const languageField = window[PLUGIN_CONFIG].languageField

if (!(document && document[languageField])) {
return true
}

const client = getClient({apiVersion: API_VERSION}).withConfig({
perspective: 'previewDrafts',
})

// Traverse the document to find any references
// Can't use `@sanity/mutator` because we need
// to collect array element `_key`s for validation targets 👇🏻
const referencePathsById: Map<Id, Path[]> = reduce(
document,
// eslint-disable-next-line no-shadow
function (referencePathsById, value) {
try {
// Manually track validation path
if (this.isRoot) {
this.sanityPath = this.path
} else if (
// When encountering an array element, we need to get
// its `_key` to use in the validation path
this.parent &&
Array.isArray(this.parent.node) &&
isKeyedObject(value)
) {
this.sanityPath = this.parent!.sanityPath.concat({_key: value._key})
} else {
this.sanityPath = this.parent!.sanityPath.concat(this.path.at(-1)!)
}

if (isReference(value)) {
const {_ref: id} = value

if (referencePathsById.has(id)) {
const referencePaths = referencePathsById.get(id)!

// WARNING: mutating in place
referencePaths.push(this.sanityPath)
} else {
referencePathsById.set(id, [this.sanityPath])
}

// Don't need to traverse the reference's children
this.block()
}
} catch (error) {
console.error(error)
}

return referencePathsById
},
new Map<Id, Path[]>()
)

const references = referencePathsById.size
? await client.fetch(`*[_id in $ids]{ _id, locale }`, {
ids: Array.from(referencePathsById.keys()),
})
: []

const paths = (Array.isArray(references) ? references : [])
.filter(
(reference) =>
languageField in reference &&
reference[languageField] != document[languageField]
)
.flatMap((reference) => referencePathsById.get(reference._id) ?? [])

return paths.length
? {
message: 'Document contains references to other languages',
paths,
}
: true
}