Skip to content
Open
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
95 changes: 95 additions & 0 deletions .github/skills/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
name: i18n-array-groq-query-migration
description: Detect and update legacy GROQ patterns where language is read from _key for sanity-plugin-internationalized-array when users mention v4 to v5 migration, or @sanity/document-internationalization from v5 to v6. GROQ query updates, localized arrays, or patterns like `_key == "en"` and `_key == $language`.
---

# Internationalized Array GROQ Migration

## Goal

Help users find GROQ queries that still read locale from `_key` and rewrite them safely for v5.

## When To Use

Use this skill when a user asks to:

- migrate `sanity-plugin-internationalized-array` from v4 to v5
- migrate `@sanity/document-internationalization` from v5 to v6 alongside `sanity-plugin-internationalized-array` language field changes
- find queries that still use `_key` for language lookup
<!-- - update GROQ filters like `_key == "en"` or `_key == $language` -->

## Detection Workflow

Detection commands below use `grep`. If your environment differs, use your editor's global search with equivalent patterns.

1. Search for direct language comparisons on `_key`:

```bash
grep -REn --exclude-dir=node_modules "_key[[:space:]]*==[[:space:]]*(\"[^\"]+\"|'[^']+'|\\$[A-Za-z_][A-Za-z0-9_]*)" .
```

1. Search for any localized-array filters that mention `_key`:

```bash
grep -REn --exclude-dir=node_modules "\[[^]]*_key[^]]*\]" .
```

1. Prioritize matches that look like localized-value reads, for example:

- `field[_key == ...][0].value`
- `select(...)` branches that compare `_key` to a language value

1. Check for uses of `groq` and verify if they use `_key` as the language, if it is using it, update them.

1. Explicitly check for template-interpolated language expressions and keep the same operand, for example:
- `_key == "${language}"`
- `_key == "${locale}"`

1. Review each match to avoid false positives where `_key` is used for unrelated array item identity.

## Rewrite Rules

Use the same language operand from the original query. The language operand can be a string literal (for example `"en"`), a variable (for example `$language`), or a template-interpolated expression (for example `"${language}"`).

- **Before data migration is executed (backwards compatible):**
- `_key == <languageExpr>` -> `language == <languageExpr> || _key == <languageExpr>`
- **After migration is complete:**
- `language == <languageExpr> || _key == <languageExpr>` -> `language == <languageExpr>`

## Examples

Legacy:

```groq
*[_type == "person"]{
"greeting": greeting[_key == $language][0].value
}
```

Backwards compatible:

```groq
*[_type == "person"]{
"greeting": greeting[language == $language || _key == $language][0].value
}
```

Post-migration final form:

```groq
*[_type == "person"]{
"greeting": greeting[language == $language][0].value
}
```

## Response Template

When reporting findings to a user:

1. List each query location that still uses `_key` as language source.
2. Show the exact replacement using the same language expression.
3. Label each replacement as:
- `backwards-compatible` (pre-migration), or
- `final` (post-migration complete).
4. Label each match category as `runtime`, `docs/example`, or `ambiguous`.
5. Call out any ambiguous `_key` usage that needs manual review.
49 changes: 23 additions & 26 deletions packages/energyvision/src/configs/satelliteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,80 +2,73 @@
{
id: The key for this language
title: The title as it will be labeled in Sanity studio
iso: Iso version of the language, used by Next.js localisation and react-intl
name: Name as used in the Sanity localisation feature (this one is hard to explain :sweat_smile:)
locale: The actual locale name as used by Next.js and locale folders/structure ([site]/locale)
iso: ISO locale code (e.g., "en-GB"), used for document lang field and Next.js routing
locale: Two-letter locale code (e.g., "en"), used for URL routing and next-intl config
}
*/
export type Language = {
id: string
title: string
iso: string
name: string
locale: string
}
const languages = [
{
id: 'english',
title: 'English (UK)',
iso: 'en-GB',
name: 'en_GB',
locale: 'en',
},
{
id: 'norwegian',
title: 'Norwegian',
iso: 'nb-NO',
name: 'nb_NO',
locale: 'no',
},
{
id: 'portuguese',
title: 'Portuguese (BR)',
iso: 'pt-BR',
name: 'pt_BR',
locale: 'pt',
},
{ id: 'german', title: 'German', iso: 'de-DE', name: 'de_DE', locale: 'de' },
{ id: 'german', title: 'German', iso: 'de-DE', locale: 'de' },
{
id: 'spanish-ar',
title: 'Spanish',
iso: 'es-AR',
name: 'es_AR',
locale: 'es',
},
{ id: 'polish', title: 'Polish', iso: 'pl-PL', name: 'pl_PL', locale: 'pl' },
{ id: 'polish', title: 'Polish', iso: 'pl-PL', locale: 'pl' },
{
id: 'japanese',
title: 'Japanese',
iso: 'ja-JP',
name: 'ja_JP',
locale: 'ja',
},
{ id: 'korean', title: 'Korean', iso: 'ko-KR', name: 'ko_KR', locale: 'ko' },
{ id: 'welsh', title: 'Welsh', iso: 'cy-CY', name: 'cy_CY', locale: 'cy' },
{ id: 'korean', title: 'Korean', iso: 'ko-KR', locale: 'ko' },
{ id: 'welsh', title: 'Welsh', iso: 'cy-CY', locale: 'cy' },
]

/**
* @type {Record<string, string>}
*/
export const newsSlug: Record<string, string> = {
en_GB: 'news',
nb_NO: 'nyheter',
pt_BR: 'noticias',
pl_PL: 'aktualnosci',
de_DE: 'aktuelles',
ja_JP: 'news',
ko_KR: 'news',
cy_CY: 'newyddion',
'en-GB': 'news',
'nb-NO': 'nyheter',
'pt-BR': 'noticias',
'pl-PL': 'aktualnosci',
'de-DE': 'aktuelles',
'ja-JP': 'news',
'ko-KR': 'news',
'cy-CY': 'newyddion',
}

/**
* @type {Record<string, string>}
*/
export const magazineSlug: Record<string, string> = {
en_GB: 'magazine',
nb_NO: 'magasin',
'en-GB': 'magazine',
'nb-NO': 'magasin',
}

/*
Expand Down Expand Up @@ -187,9 +180,7 @@ const websiteDomains: Partial<
/**
* @returns {{
* id: string
* title: string
* iso: string
* name: string
* locale: string
* }[]}
*/
Expand All @@ -206,7 +197,7 @@ const logAndFallback = (dataset: DatasetsKeys) => {
}

export const localNewsTags: Record<string, string[]> = {
en_GB: ['ev', 'uk', 'us'],
'en-GB': ['ev', 'uk', 'us'],
}

/**
Expand Down Expand Up @@ -234,3 +225,9 @@ export const getAllDomainUrls = () => {
return Object.values(websiteDomains).map(dataset => dataset.url)
//return Object.keys(datasets).map((dataset) => websiteDomains[dataset]?.url)
}

/**
* Converts ISO locale code to Sanity schema field name format
* Example: "en-GB" → "en_GB", "nb-NO" → "nb_NO"
*/
export const isoToSchemaName = (iso: string): string => iso.replace('-', '_')
2 changes: 1 addition & 1 deletion search/common/language.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('Language function tests', () => {
const sut = languageFromIso

describe('With english iso code', () => {
const english = { internalCode: 'en_GB', isoCode: 'en-GB' }
const english = { internalCode: 'en-GB', isoCode: 'en-GB' }
const res = sut('en-GB')
it('english language object is returned', () => {
E.foldW(
Expand Down
4 changes: 2 additions & 2 deletions search/common/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export type Language = {
isoCode: string
}

const english = { internalCode: 'en_GB', isoCode: 'en-GB' }
const norwegian = { internalCode: 'nb_NO', isoCode: 'nb-NO' }
const english = { internalCode: 'en-GB', isoCode: 'en-GB' }
const norwegian = { internalCode: 'nb-NO', isoCode: 'nb-NO' }

type LanguageMappingsType = Language[]
const languageMappings: LanguageMappingsType = [english, norwegian]
Expand Down
2 changes: 1 addition & 1 deletion studio/actions/CustomDuplicateAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function createCustomDuplicateAction(originalAction: DocumentActionCompon
return null
}

if (lang == defaultLanguage.name) {
if (lang == defaultLanguage.iso) {
// allow duplicate action only on base language
originalResult.onHandle && originalResult.onHandle()
} else {
Expand Down
2 changes: 1 addition & 1 deletion studio/actions/customDelete/DeleteTranslationAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const DeleteTranslationAction: DocumentActionComponent = (props) => {

const doc = props.draft || props.published
const documentLanguage = doc ? doc[languageField] : null
const isDefaultLanguageDocument = defaultLanguage.name === documentLanguage
const isDefaultLanguageDocument = defaultLanguage.iso === documentLanguage

const { id: documentId } = props
const [isDialogOpen, setDialogOpen] = useState(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default function DeleteTranslationDialog(props: DeleteTranslationDialogPr

return (
<Stack space={4}>
{translations && translations.length > 0 && doc.lang === defaultLanguage.name ? (
{translations && translations.length > 0 && doc.lang === defaultLanguage.iso ? (
<>
<Text>Delete this document and its translations?</Text>
{translatedDocs.length > 0 &&
Expand Down
2 changes: 1 addition & 1 deletion studio/helpers/referenceFilters.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SanityDocument } from 'sanity'
import { defaultLanguage } from '../languages'

export const langOrDefault = (lang: string | unknown) => lang || defaultLanguage.name
export const langOrDefault = (lang: string | unknown) => lang || defaultLanguage.iso

export const filterByLang = ({ document }: { document: SanityDocument }) => ({
filter: `lang == $lang`,
Expand Down
15 changes: 7 additions & 8 deletions studio/initialValueTemplates.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { Template } from 'sanity'
import { defaultLanguage, languages } from './languages'
import { defaultLanguage, isoToSchemaName, languages } from './languages'
import textSnippets from './schemas/textSnippets'
import { magazineSlug, newsSlug } from './sitesConfig'
import { Flags } from './src/lib/datasetHelpers'

const ParentRoutesTemplates: Template<any, any>[] = languages.map(
({ name, title }) => ({
id: `parent-route-${name}`,
({ iso, title }) => ({
id: `parent-route-${iso}`,
title: `Parent route - ${title}`,
schemaType: `route_${name}`,
schemaType: `route_${isoToSchemaName(iso)}`,
parameters: [{ name: 'parentId', type: 'string' }],
value: (params: Record<string, unknown>) => ({
parent: { _type: 'reference', _ref: params.parentId },
Expand All @@ -24,8 +23,8 @@ const TextSnippetsTemplates: Template<any, any>[] = Object.keys(
schemaType: `textSnippet`,
parameters: [{ name: 'defaultValue', type: 'string' }],
value: (params: Record<string, unknown>) => {
const fields = languages.map(({ name }) => ({
[name]: params.defaultValue,
const fields = languages.map(({ iso }) => ({
[isoToSchemaName(iso)]: params.defaultValue,
}))
return Object.assign({}, ...fields)
},
Expand Down Expand Up @@ -94,7 +93,7 @@ const localNewsWithTagTemplate: Template<any, any> = {
],
value: (params: Record<string, unknown>) => ({
localNewsTag: params.localNewsTag,
lang: params.lang || defaultLanguage.name,
lang: params.lang || defaultLanguage.iso,
}),
}

Expand Down
2 changes: 2 additions & 0 deletions studio/languages.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
defaultWebLanguage,
getLanguages,
isoToSchemaName,
} from '@energyvision/shared/satelliteConfig'
import { dataset } from './sanity.client'

export { isoToSchemaName }
export const languages = getLanguages(dataset)

export const defaultLanguage = languages[0]
Expand Down
16 changes: 16 additions & 0 deletions studio/migrations/i18n-doc-v6/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { migrateToLanguageField } from 'sanity-plugin-internationalized-array/migrations'

// Migrates translation.metadata documents from the old format where language
// was stored in `_key` to the new format with a dedicated `language` field.
// Required for @sanity/document-internationalization v6 compatibility.
//
// Steps:
// 1. Backup: pnpm sanity dataset export <dataset>
// 2. Dry run: pnpm sanity migration run i18n-doc-v6 --project=<PROJECT_ID> --dataset=<DATASET>
// 3. Migrate: pnpm sanity migration run i18n-doc-v6 --project=<PROJECT_ID> --dataset=<DATASET> --no-dry-run
// 4. After confirming data is correct, simplify GROQ queries:
// Remove `|| _key == $lang` fallbacks from translations[] filters.

const DOCUMENT_TYPES: string[] = ['translation.metadata']

export default migrateToLanguageField(DOCUMENT_TYPES)
52 changes: 52 additions & 0 deletions studio/migrations/lang-underscore-to-hyphen/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { at, defineMigration, set } from 'sanity/migrate'

/**
* Migrates all documents' `lang` field from underscore format (e.g. "en_GB")
* to ISO hyphen format (e.g. "en-GB").
*
* Also updates the `language` field inside `translation.metadata` translations
* arrays, which was added by the i18n-doc-v6 migration.
*
* Steps:
* 1. Backup: pnpm sanity dataset export <dataset>
* 2. Dry run: pnpm sanity migration run lang-underscore-to-hyphen --project=<PROJECT_ID> --dataset=<DATASET>
* 3. Migrate: pnpm sanity migration run lang-underscore-to-hyphen --project=<PROJECT_ID> --dataset=<DATASET> --no-dry-run
*
* Run against every dataset:
* global, global-development, brazil, germany, argentina, poland,
* japan, southkorea, celticsea, sponsorship, equinorfunds, storage, secret
*/

const LANG_REGEX = /^[a-z]{2}_[A-Z]{2}$/

const toIso = (lang: string) => lang.replace('_', '-')

export default defineMigration({
title: 'lang underscore → hyphen (en_GB → en-GB)',

migrate: {
document(doc, _context) {
const patches = []

// 1. Update top-level `lang` field on any document that has it
if (typeof doc.lang === 'string' && LANG_REGEX.test(doc.lang)) {
patches.push(at('lang', set(toIso(doc.lang))))
}

// 2. Update `language` field inside translation.metadata translations array
if (doc._type === 'translation.metadata' && Array.isArray(doc.translations)) {
;(doc.translations as Array<{ _key: string; language?: string }>).forEach((item, index) => {
if (typeof item.language === 'string' && LANG_REGEX.test(item.language)) {
patches.push(at(`translations[${index}].language`, set(toIso(item.language))))
}
// Also update _key if it matches the old format
if (typeof item._key === 'string' && LANG_REGEX.test(item._key)) {
patches.push(at(`translations[${index}]._key`, set(toIso(item._key))))
}
})
}

return patches.length > 0 ? patches : undefined
},
},
})
Loading