diff --git a/README.md b/README.md index d553cda..f09e9bd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # sanity-plugin-internationalized-array -A plugin to register array fields with a custom input component to store field values in multiple languages, queryable by using the language ID as an array `_key`. +A plugin to register array fields with a custom input component to store field values in multiple languages, queryable by the `language` field. ![Screenshot of an internationalized input](./img/internationalized-array.png) @@ -19,6 +19,7 @@ A plugin to register array fields with a custom input component to store field v - [Usage with @sanity/language-filter](#usage-with-sanitylanguage-filter) - [Shape of stored data](#shape-of-stored-data) - [Querying data](#querying-data) + - [Migrate from v3 to v4](#migrate-from-v3-to-v4) - [Migrate from objects to arrays](#migrate-from-objects-to-arrays) - [Why store localized field data like this?](#why-store-localized-field-data-like-this) - [License](#license) @@ -308,15 +309,14 @@ export default defineConfig({ enclosingType.name.startsWith('internationalizedArray') && 'kind' in member ) { - // Get last two segments of the field's path - const pathEnd = member.field.path.slice(-2) - // If the second-last segment is a _key, and the last segment is `value`, - // It's an internationalized array value - // And the array _key is the language of the field - const language = - pathEnd[1] === 'value' && isKeySegment(pathEnd[0]) - ? pathEnd[0]._key - : null + // Get the language from the member's parent value + // In v4+, language is stored in a dedicated `language` field + const parentValue = member.field.path.length >= 2 + ? member.field.document?.[member.field.path[0]]?.find( + (item: any) => item._key === member.field.path[1]?._key + ) + : null + const language = parentValue?.language return language ? selectedLanguageIds.includes(language) : false } @@ -339,25 +339,93 @@ export default defineConfig({ ## Shape of stored data -The custom input contains buttons which will add new array items with the language as the `_key` value. Data returned from this array will look like this: +The custom input contains buttons which will add new array items with a `language` field identifying the language. Data returned from this array will look like this: ```json "greeting": [ - { "_key": "en", "value": "hello" }, - { "_key": "fr", "value": "bonjour" }, + { "_key": "abc123", "language": "en", "value": "hello" }, + { "_key": "def456", "language": "fr", "value": "bonjour" } ] ``` +> **Note:** In versions prior to v4, the language ID was stored in the `_key` field. See [Migrate from v3 to v4](#migrate-from-v3-to-v4) if you're upgrading from an earlier version. + ## Querying data -Using GROQ filters you can query for a specific language key like so: +Using GROQ filters you can query for a specific language like so: ```js *[_type == "person"] { - "greeting": greeting[_key == "en"][0].value + "greeting": greeting[language == "en"][0].value } ``` +> **Migrating queries from v3:** If upgrading from v3, replace `_key == "en"` with `language == "en"` in your GROQ queries. + +## Migrate from v3 to v4 + +Version 4 changes how language identification is stored. Previously, the language ID was stored in the array item's `_key` field. Now, a dedicated `language` field is used, and `_key` contains a random identifier. + +**Before (v3):** +```json +{ "_key": "en", "value": "hello" } +``` + +**After (v4):** +```json +{ "_key": "abc123", "language": "en", "value": "hello" } +``` + +### Why this change? + +The `_key` field in Sanity arrays is meant for tracking item identity across edits, not for storing semantic data. Using it for language IDs caused issues with: +- Array reordering and diffing in the Studio +- Portable Text operations that rely on stable keys +- Edge cases when copying/pasting between documents + +### Migration steps + +1. **Take a backup first!** + ```bash + npx sanity@latest dataset export + ``` + +2. **Update the plugin** to v4 + +3. **Update your GROQ queries** to use `language` instead of `_key`: + ```js + // Before + greeting[_key == "en"][0].value + + // After + greeting[language == "en"][0].value + ``` + +4. **Run the migration script** to update existing documents: + + Edit `migrations/keyToLanguage.ts` to configure your document types and field names: + ```ts + const DOCUMENT_TYPES = ['post', 'page'] // Your document types + const FIELD_NAMES = ['title', 'description'] // Your internationalized fields + ``` + + First, run in dry-run mode to preview changes: + ```bash + npx sanity@latest exec ./migrations/keyToLanguage.ts --with-user-token + ``` + + Then set `DRY_RUN = false` and run again to apply changes. + +5. **Handle drafts and published documents** - The migration script processes all documents. Run it twice if needed: once for production, once after publishing any pending drafts. + +### Migration script details + +The migration script (`migrations/keyToLanguage.ts`): +- Processes documents in batches of 100 +- Uses optimistic locking (`ifRevisionID`) for safe concurrent execution +- Is idempotent - safe to run multiple times +- Skips items that already have a `language` field + ## Migrate from objects to arrays [See the migration script](https://github.com/sanity-io/sanity-plugin-internationalized-array/blob/main/migrations/transformObjectToArray.ts) inside `./migrations/transformObjectToArray.ts` of this Repo. diff --git a/migrations/keyToLanguage.ts b/migrations/keyToLanguage.ts new file mode 100644 index 0000000..b61b860 --- /dev/null +++ b/migrations/keyToLanguage.ts @@ -0,0 +1,292 @@ +/* eslint-disable no-console, @typescript-eslint/no-explicit-any, consistent-return */ + +import {nanoid} from 'nanoid' +import {getCliClient} from 'sanity/cli' + +// Migration script: Convert _key-based language identification to dedicated language field +// +// BEFORE (v3.x): +// "greeting": [ +// { "_key": "en", "value": "hello" }, +// { "_key": "fr", "value": "bonjour" } +// ] +// +// AFTER (v4.x): +// "greeting": [ +// { "_key": "abc123", "language": "en", "value": "hello" }, +// { "_key": "def456", "language": "fr", "value": "bonjour" } +// ] +// +// This migration: +// 1. Finds documents with internationalized array fields that lack the `language` property +// 2. Copies the `_key` value to a new `language` field +// 3. Generates a new random `_key` using nanoid +// 4. Uses optimistic locking (ifRevisionID) for safe concurrent execution +// +// The script is idempotent - it can be safely re-run multiple times. + +// ============================================================================= +// CONFIGURATION - Modify these values for your project +// ============================================================================= + +/** + * Document type(s) to migrate. Can be a single type or array of types. + * Example: 'post' or ['post', 'page', 'product'] + */ +const DOCUMENT_TYPES: string | string[] = 'post' + +/** + * Field name(s) containing internationalized arrays. + * These should match the field names in your schema that use the plugin. + * Example: 'title' or ['title', 'description', 'body'] + */ +const FIELD_NAMES: string | string[] = 'title' + +/** + * Batch size for processing documents. Lower values are safer but slower. + * Default: 100 + */ +const BATCH_SIZE = 100 + +/** + * Set to true to preview changes without applying them. + * Highly recommended to run with DRY_RUN=true first! + */ +const DRY_RUN = true + +/** + * API version for Sanity client + */ +const API_VERSION = '2024-01-01' + +// ============================================================================= +// MIGRATION LOGIC - Generally no need to modify below this line +// ============================================================================= + +const client = getCliClient({apiVersion: API_VERSION}) + +// Normalize config to arrays +const documentTypes = Array.isArray(DOCUMENT_TYPES) + ? DOCUMENT_TYPES + : [DOCUMENT_TYPES] +const fieldNames = Array.isArray(FIELD_NAMES) ? FIELD_NAMES : [FIELD_NAMES] + +/** + * Build the GROQ query to find documents needing migration. + * + * A document needs migration if: + * - It matches one of the configured document types + * - It has at least one of the configured fields defined + * - At least one array item in those fields lacks a `language` property + */ +function buildFetchQuery(): string { + // Build field existence checks + const fieldChecks = fieldNames + .map((field) => `defined(${field})`) + .join(' || ') + + // Build migration status checks - find docs where any field has items without language + const migrationChecks = fieldNames + .map((field) => `count(${field}[!defined(language)]) > 0`) + .join(' || ') + + // Build projection to fetch only the fields we need + const projection = ['_id', '_rev', ...fieldNames].join(', ') + + return `*[ + _type in $types + && (${fieldChecks}) + && (${migrationChecks}) + ][0...${BATCH_SIZE}] {${projection}}` +} + +/** + * Fetch the next batch of documents that need migration + */ +async function fetchDocuments(): Promise { + const query = buildFetchQuery() + + if (DRY_RUN) { + console.log('Query:', query) + console.log('Params:', {types: documentTypes}) + } + + return client.fetch(query, {types: documentTypes}) +} + +/** + * Transform a single array item from old format to new format + */ +function transformArrayItem(item: {_key: string; value?: unknown}): { + _key: string + language: string + value?: unknown +} { + // Copy _key to language, generate new random _key + return { + ...item, + _key: nanoid(), + language: item._key, + } +} + +/** + * Build patch operations for a single document + */ +function buildPatch(doc: any): {id: string; patch: any} | null { + const setOperations: Record = {} + + for (const fieldName of fieldNames) { + const fieldValue = doc[fieldName] + + // Skip if field doesn't exist or is empty + if (!fieldValue || !Array.isArray(fieldValue) || fieldValue.length === 0) { + continue + } + + // Check if any items need migration (lack language field) + const needsMigration = fieldValue.some((item: any) => !item.language) + + if (needsMigration) { + // Transform all items in the array + setOperations[fieldName] = fieldValue.map((item: any) => { + // Only transform items that don't already have language + if (item.language) { + return item + } + return transformArrayItem(item) + }) + } + } + + // If no fields need migration, skip this document + if (Object.keys(setOperations).length === 0) { + return null + } + + return { + id: doc._id, + patch: { + set: setOperations, + ifRevisionID: doc._rev, + }, + } +} + +/** + * Build patches for a batch of documents + */ +function buildPatches(docs: any[]): Array<{id: string; patch: any}> { + return docs + .map(buildPatch) + .filter((patch): patch is {id: string; patch: any} => patch !== null) +} + +/** + * Create a transaction from patches + */ +function createTransaction(patches: Array<{id: string; patch: any}>) { + return patches.reduce( + (tx, {id, patch}) => tx.patch(id, patch), + client.transaction() + ) +} + +/** + * Commit a transaction + */ +async function commitTransaction(tx: any): Promise { + await tx.commit() +} + +/** + * Log patch details for review + */ +function logPatches(patches: Array<{id: string; patch: any}>): void { + for (const {id, patch} of patches) { + console.log(`\n${id}:`) + for (const [field, value] of Object.entries(patch.set)) { + if (Array.isArray(value)) { + console.log(` ${field}: ${value.length} items`) + for (const item of value as any[]) { + console.log(` - _key: ${item._key}, language: ${item.language}`) + } + } + } + } +} + +/** + * Main migration loop - process batches until no more documents need migration + */ +async function migrateNextBatch(): Promise { + const documents = await fetchDocuments() + + if (documents.length === 0) { + console.log('\nāœ… No more documents to migrate!') + return + } + + console.log(`\nFound ${documents.length} documents to migrate`) + + const patches = buildPatches(documents) + + if (patches.length === 0) { + console.log( + 'No patches to apply (documents may have been migrated concurrently)' + ) + return migrateNextBatch() + } + + console.log(`Built ${patches.length} patches`) + logPatches(patches) + + if (DRY_RUN) { + console.log('\nšŸ” DRY RUN - No changes applied') + console.log('Set DRY_RUN = false to apply these changes') + return + } + + console.log('\nApplying patches...') + const transaction = createTransaction(patches) + await commitTransaction(transaction) + console.log('āœ… Batch committed successfully') + + // Continue with next batch + return migrateNextBatch() +} + +/** + * Migration entry point + */ +async function runMigration(): Promise { + console.log('='.repeat(60)) + console.log('Internationalized Array Migration: _key → language') + console.log('='.repeat(60)) + console.log('\nConfiguration:') + console.log(` Document types: ${documentTypes.join(', ')}`) + console.log(` Field names: ${fieldNames.join(', ')}`) + console.log(` Batch size: ${BATCH_SIZE}`) + console.log(` Dry run: ${DRY_RUN}`) + console.log('') + + if (DRY_RUN) { + console.log('āš ļø DRY RUN MODE - No changes will be applied') + console.log( + ' Review the output and set DRY_RUN = false to apply changes\n' + ) + } + + await migrateNextBatch() + + console.log(`\n${'='.repeat(60)}`) + console.log('Migration complete') + console.log('='.repeat(60)) +} + +// Run the migration +runMigration().catch((err) => { + console.error('\nāŒ Migration failed:', err.message) + console.error(err) + process.exit(1) +}) diff --git a/package-lock.json b/package-lock.json index c670dd3..d34ec0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@sanity/ui": "^3.1.11", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", + "nanoid": "^5.0.7", "suspend-react": "0.1.3" }, "devDependencies": { @@ -5848,6 +5849,25 @@ "rxjs": "^7.0.0" } }, + "node_modules/@sanity/bifur-client/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/@sanity/browserslist-config": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@sanity/browserslist-config/-/browserslist-config-1.0.5.tgz", @@ -6478,6 +6498,24 @@ "node": ">=14.0.0" } }, + "node_modules/@sanity/client/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/@sanity/client/node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -7715,25 +7753,6 @@ "node": ">=18" } }, - "node_modules/@sanity/mutate/node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "node_modules/@sanity/mutator": { "version": "3.99.0", "resolved": "https://registry.npmjs.org/@sanity/mutator/-/mutator-3.99.0.tgz", @@ -21153,9 +21172,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -21164,10 +21183,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -25562,6 +25581,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/preferred-pm": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/preferred-pm/-/preferred-pm-4.1.1.tgz", @@ -28224,6 +28262,25 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sanity/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/sanity/node_modules/npm-run-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", @@ -32394,6 +32451,25 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vite/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/vite/node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index be7d4fb..f79a5de 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@sanity/ui": "^3.1.11", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", + "nanoid": "^5.0.7", "suspend-react": "0.1.3" }, "devDependencies": { diff --git a/src/components/DocumentAddButtons.tsx b/src/components/DocumentAddButtons.tsx index 4ac98f7..f7ad2d1 100644 --- a/src/components/DocumentAddButtons.tsx +++ b/src/components/DocumentAddButtons.tsx @@ -1,4 +1,5 @@ import {Box, Stack, Text, useToast} from '@sanity/ui' +import {nanoid} from 'nanoid' import React, {useCallback} from 'react' import { FormInsertPatch, @@ -106,7 +107,7 @@ export default function DocumentAddButtons( return } const alreadyTranslated = documentsToTranslation.filter( - (translation) => translation?._key === languageId + (translation) => translation?.language === languageId ) const removeDuplicates = documentsToTranslation.reduce< DocumentsToTranslate[] @@ -149,7 +150,8 @@ export default function DocumentAddButtons( const insertValue = insert( [ { - _key: languageId, + _key: nanoid(), + language: languageId, _type: toTranslate._type, value: initialValue, // Use the determined initial value instead of undefined }, diff --git a/src/components/InternationalizedArray.tsx b/src/components/InternationalizedArray.tsx index 3b0d2e7..5f48abd 100644 --- a/src/components/InternationalizedArray.tsx +++ b/src/components/InternationalizedArray.tsx @@ -150,7 +150,7 @@ export default function InternationalizedArray( // This would also strip out values that don't have a language as the key const updatedValue = value .reduce((acc, v) => { - const newIndex = languages.findIndex((l) => l.id === v?._key) + const newIndex = languages.findIndex((l) => l.id === v?.language) if (newIndex > -1) { acc[newIndex] = v @@ -175,14 +175,14 @@ export default function InternationalizedArray( return true } - return value?.every((v) => languages.find((l) => l?.id === v?._key)) + return value?.every((v) => languages.find((l) => l?.id === v?.language)) }, [value, languages]) // Check languages are in the correct order const languagesInUse = useMemo( () => languages && languages.length > 1 - ? languages.filter((l) => value?.find((v) => v._key === l.id)) + ? languages.filter((l) => value?.find((v) => v.language === l.id)) : [], [languages, value] ) @@ -194,7 +194,9 @@ export default function InternationalizedArray( return value .map((v, vIndex) => - vIndex === languagesInUse.findIndex((l) => l.id === v._key) ? null : v + vIndex === languagesInUse.findIndex((l) => l.id === v.language) + ? null + : v ) .filter(Boolean) }, [value, languagesInUse]) diff --git a/src/components/InternationalizedInput.tsx b/src/components/InternationalizedInput.tsx index 0f89e46..4620fdb 100644 --- a/src/components/InternationalizedInput.tsx +++ b/src/components/InternationalizedInput.tsx @@ -24,6 +24,8 @@ import {useInternationalizedArrayContext} from './InternationalizedArrayContext' export type InternationalizedValue = { _type: string _key: string + /** The language identifier (e.g., 'en', 'fr'). This is the semantic identifier. */ + language: string value: string } @@ -144,11 +146,11 @@ export default function InternationalizedInput( useInternationalizedArrayContext() const languageKeysInUse = useMemo( - () => parentValue?.map((v) => v._key) ?? [], + () => parentValue?.map((v) => v.language) ?? [], [parentValue] ) const keyIsValid = languages?.length - ? languages.find((l) => l.id === value._key) + ? languages.find((l) => l.id === value.language) : false // Changes the key of this item, ideally to a valid language @@ -164,7 +166,7 @@ export default function InternationalizedInput( return } - onChange([set(languageId, ['_key'])]) + onChange([set(languageId, ['language'])]) }, [onChange, value, languages] ) @@ -178,13 +180,13 @@ export default function InternationalizedInput( return } - const language = languages.find((l) => l.id === value._key) + const language = languages.find((l) => l.id === value.language) const languageTitle: string = keyIsValid && language ? getLanguageDisplay(languageDisplay, language.title, language.id) : '' - const isDefault = defaultLanguages.includes(value._key) + const isDefault = defaultLanguages.includes(value.language) const removeButton = (