npm package: @0x30-ch/localized-field
Description: Strapi custom field for per-locale translations without full i18n localization
Author: jeanvier
Latest version: 1.0.0

# Strapi Localized Field Plugin

A Strapi 5 custom field that stores per-locale translations as JSON — without requiring full i18n content localization. Perfect for fields like SEO titles, button labels, or any text that needs translations while the entry itself stays in a single locale.

![Strapi](https://img.shields.io/badge/Strapi-5.x-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue) ![Version](https://img.shields.io/badge/version-1.0.0-brightgreen)

## Features

- **Per-Locale Translations**: Store translations for all locales in a single JSON field on a single entry
- **No Full i18n Required**: Works alongside Strapi's i18n plugin without duplicating entire entries per locale
- **Compact Locale Tabs**: Inline tab UI with status dots showing filled/empty state and a fill counter badge
- **Short & Long Text**: Choose between single-line input or textarea per field
- **Configurable Fallback**: Three strategies when a locale is missing — default locale, first available, or none
- **Automatic API Resolution**: Middleware resolves translations in content API responses based on `?locale=` query param
- **Populate All Locales**: Request all translations via `?plugins[localized-field][populateLocalization]=true`
- **Component & Dynamic Zone Support**: Works inside components, repeatable components, and dynamic zones
- **Full i18n**: All admin UI strings use `react-intl` for internationalization

## Installation

```bash
npm install @0x30-ch/localized-field
# or
yarn add @0x30-ch/localized-field
# or
pnpm add @0x30-ch/localized-field
```

## Configuration

### 1. Enable the Plugin

```typescript
// config/plugins.ts
export default () => ({
  'localized-field': {
    enabled: true,
    resolve: './node_modules/@0x30-ch/localized-field',
  },
});
```

### 2. Add the Custom Field

In the Strapi Content-Type Builder, add the **Localized string** custom field to your content type. Configure:

| Option | Values | Default | Description |
|--------|--------|---------|-------------|
| **Field type** | `Short text`, `Long text` | `Short text` | Single-line input or textarea |
| **Default locale** | Any locale code | *(auto-detected)* | Override which locale tab opens first and is used for fallback |
| **Fallback strategy** | `Default locale`, `First available`, `None` | `Default locale` | How to resolve when the requested locale has no translation |

### 3. Restart Your Server

After configuration, restart your Strapi server to apply the schema changes.

## Usage

### Admin Interface

1. **Open** any entry with a localized field
2. **Switch locale** by clicking the compact tab chips — each shows a status dot (filled = has content)
3. **Type** your translation in the input field
4. **Track progress** with the badge showing filled count (e.g., `2/4`)
5. **Save** the entry — all translations are stored as a single JSON value

### Content API

The plugin registers a global Koa middleware that automatically resolves localized fields in content API responses.

#### Resolve a Single Locale

Pass `?locale=` to get the resolved string value instead of the raw JSON object:

```bash
# Returns resolved string for French
GET /api/tags?locale=fr
```

**Response:**
```json
{
  "data": [
    {
      "id": 1,
      "label": "Bonjour"
    }
  ]
}
```

#### Get All Translations

Add the `populateLocalization` plugin param to receive all locale values:

```bash
GET /api/tags?locale=fr&plugins[localized-field][populateLocalization]=true
```

**Response:**
```json
{
  "data": [
    {
      "id": 1,
      "label": "Bonjour",
      "label_localizations": {
        "en": "Hello",
        "fr": "Bonjour",
        "de": "Hallo"
      }
    }
  ]
}
```

#### Without Locale Param

When no `?locale=` is provided, the raw JSON object is returned as-is:

```json
{
  "data": [
    {
      "id": 1,
      "label": {
        "en": "Hello",
        "fr": "Bonjour",
        "de": "Hallo"
      }
    }
  ]
}
```

### Fallback Strategies

| Strategy | Behavior |
|----------|----------|
| **Default locale** | Falls back to the configured default locale (or Strapi's i18n default) |
| **First available** | Returns the first non-empty translation found |
| **None** | Returns `null` if the requested locale has no translation |

## Data Structure

The field stores a JSON object mapping locale codes to translation strings:

```json
{
  "en": "Hello world",
  "fr": "Bonjour le monde",
  "de": "Hallo Welt"
}
```

This is stored in a single `json` column in the database — no additional tables or relations needed.

## Development

### Prerequisites

- Node.js >= 18.0.0
- Strapi 5.x
- i18n plugin enabled (for locale detection)

### Setup

```bash
git clone https://github.com/0x30-ch/strapi-plugins.git
cd strapi-plugins
pnpm install

# Start development
pnpm dev

# Build plugin
cd packages/localized-field
npm run build

# Type checking
npm run test:ts:front  # Frontend types
npm run test:ts:back   # Backend types

# Verify plugin structure
npm run verify
```

### Project Structure

```
packages/localized-field/
├── admin/                        # Frontend (React + Strapi Design System v2)
│   ├── src/
│   │   ├── components/
│   │   │   ├── LocalizedInput.tsx       # Main input with locale tabs
│   │   │   └── LocalizedFieldIcon.tsx   # Field icon for content-type builder
│   │   ├── utils/
│   │   │   └── getTranslation.ts        # i18n helper
│   │   ├── translations/
│   │   │   └── en.json                  # English translations
│   │   ├── pluginId.ts                  # Plugin identifier
│   │   └── index.ts                     # Plugin registration + custom field config
│   └── tsconfig.json
├── server/                       # Backend logic
│   ├── src/
│   │   ├── register.ts                  # Custom field server registration
│   │   ├── bootstrap.ts                 # Global middleware for API resolution
│   │   ├── utils/
│   │   │   └── localized.ts             # Core logic (collect, resolve, fallback)
│   │   ├── config/                      # Plugin configuration
│   │   ├── controllers/                 # (empty — no admin routes needed)
│   │   ├── services/                    # (empty — logic in middleware)
│   │   └── routes/                      # (empty — no custom routes)
│   └── tsconfig.json
└── package.json
```

## Architecture

### How It Works

1. **Registration**: The plugin registers a `localizedString` custom field of type `json`
2. **Admin UI**: The `LocalizedInput` component fetches available locales from `/i18n/locales` and renders compact tab chips with an input per locale
3. **Storage**: All translations are stored as a single JSON object in one database column
4. **API Resolution**: A global Koa middleware intercepts content API responses, detects localized fields in the schema, and resolves them based on the `?locale=` query parameter
5. **Deep Resolution**: The middleware recursively walks components, repeatable components, and dynamic zones to resolve nested localized fields

### Why Not Full i18n?

Strapi's built-in i18n duplicates the **entire entry** per locale. This plugin is for cases where:

- You need translations for specific fields only (SEO, labels, slugs)
- Your content structure is locale-independent (same images, same relations)
- You want to manage all translations in one place, on one entry
- You need a lightweight alternative without entry duplication

## Contributing

1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

## License

MIT License - see [LICENSE](LICENSE) file for details.

## Acknowledgments

- [Strapi](https://strapi.io/) - Headless CMS framework
- [0x30](https://github.com/0x30-ch) - Plugin author

## Support

- [Issues](https://github.com/0x30-ch/strapi-plugins/issues)
- [Documentation](https://github.com/0x30-ch/strapi-plugins/tree/main/packages/localized-field)