# Fields

Reference for elek.io Core's field system: how field definitions are shaped, what each field type does, and how the resulting Entry values are stored. Field definitions describe the shape of Entry data. Collections and Components own these definitions, and the `dynamic` field type composes Components into reusable, polymorphic blocks.

## Layered model

Two type axes describe every field:

- **Value type** (`string` | `number` | `boolean` | `reference` | `component` | `mdast`) - how data is stored at runtime in the Entry JSON file.
- **Field type** (18 in total) - layered on top of the value type to add validation, constraints, and editor semantics.

A `select` field, for example, stores either a `string` or `number` value depending on its variant. A `dynamic` field stores a `component` value (an array of Component instances).

Schemas live in:

- `src/schema/fieldSchema.ts` - field definitions
- `src/schema/valueSchema.ts` - runtime value shapes
- `src/schema/baseSchema.ts` - translatable record helpers, supported languages

## Common field properties

Every field definition extends `fieldDefinitionBaseSchema`:

```typescript
{
  id: string,             // UUID, caller-supplied
  slug: string,           // 1-128 chars, lowercase + hyphens, unique within parent
  label: { [lang]: string },              // translatable display name
  description: { [lang]: string } | null, // optional translatable help text
  isRequired: boolean,    // forced true for `toggle` and `range`
  isDisabled: boolean,    // editor read-only
  isUnique: boolean,      // string value types only; forced false elsewhere, always true for slug
  inputWidth: '12' | '6' | '4' | '3',     // 12-column grid (full / half / third / quarter)
}
```

| Property | Constraints |
| --- | --- |
| `id` | UUID. Caller-supplied (you pass one, for example via `uuid()`) - Core does not generate it. It is the stable identity used to match field definitions on update, so keep it constant. See [`schema-changes.md`](https://elek.io/en/documentation/core/schema-changes#the-golden-rule-field-definitions-are-matched-by-id). |
| `slug` | 1 to 128 chars, lowercase with hyphens, no reserved values. Must be unique within its parent Collection or Component. |
| `label` | Translatable. Must contain a non-empty string for every project-supported language at validation time. |
| `description` | Translatable or `null`. |
| `isRequired` | Forced `true` on `toggle` and `range` (those types always carry a value). |
| `isUnique` | Only meaningful on `string` value types; the schema forces `false` on `number`, `boolean`, `reference`, `dynamic`, and `mdast`, and forces `true` on `slug`. Enforced per language within a Collection (see [Uniqueness](https://elek.io/en/documentation/core/fields#uniqueness)). A unique field may not carry a non-null `defaultValue`, and this is checked on each field type's own schema, so a single field definition is rejected on its own rather than only when the Collection is assembled. |
| `inputWidth` | Layout hint for the editor's 12-column grid. `'12'` = full row, `'6'` = half, `'4'` = third, `'3'` = quarter. |

## Field type catalogue

The 18 field types are grouped by purpose. Each entry below shows the field-definition shape (in addition to the common properties above), the validation applied to the value, and a usage hint.

### Text and input (value type: `string`)

All string field types share `defaultValue: string | null`. They differ in the validation Zod applies to the value content.

#### `text`

Single-line text input with optional length bounds.

```typescript
{
  id: '...',
  slug: 'title',
  fieldType: 'text',
  valueType: 'string',
  label: { en: 'Title', de: 'Titel' },
  description: null,
  isRequired: true,
  isDisabled: false,
  isUnique: true,
  inputWidth: '12',
  min: 1,         // optional, nullable, ≥1
  max: 120,       // optional, nullable, ≥1
  defaultValue: null,
}
```

Validation: when both `min` and `max` are set, `min` must be ≤ `max`. A non-null `defaultValue` must itself satisfy the length bounds (`min` ≤ `defaultValue.length` ≤ `max`), so a definition cannot ship a default that its own values would reject.

#### `textarea`

Identical shape to `text`. Editor renders a multi-line textarea.

#### `email`, `url`, `ipv4`, `telephone`

Same base shape as `text` minus `min` / `max`. Each applies a Zod format check on the value (`z.email()`, `z.url()`, `z.ipv4()`, `z.e164()` respectively) and constrains `defaultValue` to the same format.

```typescript
{
  fieldType: 'email',
  valueType: 'string',
  defaultValue: null,    // or a valid email
  // ... common properties
}
```

#### `select` (string variant)

Dropdown over a fixed set of string options. Each option's `label` is translatable.

```typescript
{
  fieldType: 'select',
  valueType: 'string',
  options: [
    { value: 'draft',     label: { en: 'Draft',     de: 'Entwurf' } },
    { value: 'published', label: { en: 'Published', de: 'Veröffentlicht' } },
  ],
  defaultValue: 'draft',  // must be one of the option values, or null
  // ... common properties
}
```

Validation: at least one option. `defaultValue` must be `null` or match one option's `value`.

#### `slug`

A URL-friendly string that is always unique within its Collection (`isUnique` is forced `true`). Its value is validated as _already canonical_ rather than transformed: a value is accepted only when it equals its own slugified form for the field's configuration (`value === slug(value, config)`). `defaultValue` is always `null`.

```typescript
{
  fieldType: 'slug',
  valueType: 'string',
  isUnique: true,            // forced
  separator: '-',            // '' or one of the URL-safe marks - _ . ~
  lowercase: true,
  decamelize: true,
  ofFieldDefinitions: [],    // source field ids for soft auto-generation
  defaultValue: null,        // forced
  // ... common properties
}
```

- **Configuration** - `separator`, `lowercase` and `decamelize` mirror [`@sindresorhus/slugify`](https://github.com/sindresorhus/slugify) and shape what counts as canonical. `separator` must be empty or one of the URL-safe marks `-` `_` `.` `~` (RFC 3986 unreserved). Changing any of these is a major change ([`releases.md`](https://elek.io/en/documentation/core/releases)).
- **`ofFieldDefinitions`** - an ordered list of sibling field-definition ids the editor uses to auto-generate the slug, for example a `title` field. It is a _soft hint_: Core never derives or binds the value, so the stored slug stays static and user-editable.
  - Each id must reference a non-slug string field in the same Collection, not the slug field itself.
  - An empty array means the slug is entered directly.
- **`decamelize`** splits camelCase before slugifying, so `myProduct` becomes `my-product` and a hand-entered camelCase value is rejected as non-canonical. Run input through the shared `slug()` helper rather than typing the slug by hand.
- **Stability.** Because Core never recomputes the slug, editing a source field (for example renaming the `title`) does not change an existing slug. This keeps permalinks stable, so keeping a slug in sync with its sources is the editor's job, not Core's.
- Because the stored value is exact-match unique, normalization (lowercasing, separators) is what makes uniqueness meaningful for slugs. The caller (for example Desktop) slugifies input with the shared `slug()` helper before saving; a non-canonical value is rejected. An input that produces an empty slug (only whitespace, punctuation, or a non-Latin script) is rejected with a message that says so.

### Date and time (value type: `string`)

All three store an ISO-formatted string and share the `text`-like base. They differ only in the format Zod accepts.

| Field type | Value format | Schema check       |
| ---------- | ------------ | ------------------ |
| `date`     | `YYYY-MM-DD` | `z.iso.date()`     |
| `time`     | ISO time     | `z.iso.time()`     |
| `datetime` | ISO datetime | `z.iso.datetime()` |

```typescript
{
  fieldType: 'datetime',
  valueType: 'string',
  defaultValue: null,    // or a valid ISO datetime
  // ... common properties
}
```

### Numeric (value type: `number`)

All number fields force `isUnique: false`.

#### `number`

```typescript
{
  fieldType: 'number',
  valueType: 'number',
  min: 0,            // nullable
  max: 100,          // nullable
  defaultValue: 0,   // nullable
  // ... common properties
}
```

Validation: when both `min` and `max` are set, `min` must be ≤ `max`. A non-null `defaultValue` must fall within the bounds (`min` ≤ `defaultValue` ≤ `max`).

#### `range`

Slider with a required `min`, `max`, and `defaultValue`. Always required (the field type always carries a value).

```typescript
{
  fieldType: 'range',
  valueType: 'number',
  isRequired: true,    // forced
  min: 0,              // required, not nullable
  max: 100,            // required, not nullable
  defaultValue: 50,    // required, not nullable
  // ... common properties
}
```

Validation: `min` must be ≤ `max`, and the required `defaultValue` must fall within the bounds (`min` ≤ `defaultValue` ≤ `max`).

#### `select` (number variant)

Same shape as the string `select`, but option values are numbers. `min` and `max` are forced to `null` (a fixed option list ignores numeric bounds).

```typescript
{
  fieldType: 'select',
  valueType: 'number',
  options: [
    { value: 1, label: { en: 'Low',    de: 'Niedrig' } },
    { value: 2, label: { en: 'Medium', de: 'Mittel'  } },
    { value: 3, label: { en: 'High',   de: 'Hoch'    } },
  ],
  min: null,           // forced
  max: null,           // forced
  defaultValue: 2,     // must be one of the option values, or null
  // ... common properties
}
```

### Boolean (value type: `boolean`)

#### `toggle`

The only boolean field type. Always required and always has a default value (booleans are never null).

```typescript
{
  fieldType: 'toggle',
  valueType: 'boolean',
  isRequired: true,    // forced
  isUnique: false,     // forced
  defaultValue: false, // required
  // ... common properties
}
```

### Relational (value type: `reference`)

Reference fields store an array of `{ id, objectType }` references per language. Both reference field types use `min` / `max` (nullable, ≥1) to control the count: `min: 1, max: 1` for a single reference. `null`/`null` for "any number, including none". `isUnique` is forced `false`.

For how references behave beyond field configuration (the three carriers, write-time validation, and delete protection), see [`references.md`](https://elek.io/en/documentation/core/references).

#### `asset`

References Assets (files in the project's media library).

```typescript
{
  fieldType: 'asset',
  valueType: 'reference',
  min: 1,
  max: 1,        // single asset reference
  // ... common properties
}
```

#### `entry`

References Entries from one or more Collections. Polymorphic via `ofCollections`: an empty array allows any Collection. A non-empty array restricts to the listed Collections.

```typescript
{
  fieldType: 'entry',
  valueType: 'reference',
  ofCollections: ['<author-collection-uuid>'],  // empty array = any Collection
  min: 1,
  max: null,     // one or more authors
  // ... common properties
}
```

### Composite (value type: `component`)

#### `dynamic`

References one or more shared **Component** definitions. Editors add, remove, and reorder Component instances at runtime. An empty `ofComponents` array allows any Component in the project (full polymorphism). A non-empty array restricts to the listed Components.

```typescript
{
  fieldType: 'dynamic',
  valueType: 'component',
  isUnique: false,    // forced
  ofComponents: [
    '<hero-component-uuid>',
    '<gallery-component-uuid>',
    '<call-to-action-component-uuid>',
  ],
  min: 1,
  max: null,
  // ... common properties
}
```

The standalone `list` field type was removed in favour of `dynamic` + a Component. To represent a repeating set of fields, define a Component once and reference it from a `dynamic` field. See [Composing with Components](https://elek.io/en/documentation/core/fields#composing-with-components) for a worked example.

### Rich text (value type: `mdast`)

#### `markdown`

Authoring of rich body content (formatted text, headings, lists, links, code, embedded references) backed by a structured mdast (markdown abstract syntax tree). The writer experience is markdown-style. Storage is a typed JSON tree per language - not a markdown source string. This makes Asset and Entry references first-class typed nodes (`assetReference` / `entryReference`) rather than opaque URLs.

```typescript
{
  fieldType: 'markdown',
  valueType: 'mdast',
  min: 1,            // optional, nullable, ≥1 - minimum top-level block count
  max: null,         // optional, nullable, ≥1 - maximum top-level block count
  features: {
    // Block-level (8 booleans + headings array; paragraph + text are always allowed)
    headings: [2, 3, 4],     // allowed depth literals; [] disables headings
    blockquotes: true,
    lists: true,
    codeBlocks: false,
    thematicBreak: true,
    rawHtml: false,          // see Security note in markdown-content.md
    tables: true,            // GFM
    taskListItems: true,     // GFM; requires lists === true
    footnotes: false,        // GFM
    // Inline (9 booleans)
    emphasis: true,
    strong: true,
    inlineCode: true,
    externalLinks: true,     // [text](url) and <url> autolinks
    entryReferences: true,   // custom entryReference node
    externalImages: false,
    assetReferences: true,   // custom assetReference node
    strikethrough: false,    // GFM
    hardLineBreaks: false,
  },
  ofCollections: [],         // empty array = no restriction (entryReference may target any Collection); a non-empty array restricts it to the listed Collection ids
  ofAssetMimeTypes: ['image/jpeg', 'image/png'],  // empty array = no restriction (assetReference may target any MIME type); a non-empty array restricts it to the listed MIME types
  defaultValue: null,        // null or a single MdAstRoot tree applied to every language
  isUnique: false,           // forced
  // ... common properties
}
```

Validation:

- When both `min` and `max` are set, `min` must be ≤ `max`. Trees that violate the configured `min`/`max` top-level block count are rejected at write time.
- `features.taskListItems: true` requires `features.lists: true`.
- `defaultValue` (when non-null) must satisfy the field's `features` configuration - a default tree can't contain node types the features map disables.
- `entryReference` nodes inside an entry's tree are checked structurally against `ofCollections` at the schema layer (their claimed `collectionId` must be in the allowed list, when non-empty).
- `assetReference` nodes are checked against `ofAssetMimeTypes` at the service layer (Core reads the referenced Asset's `mimeType` and compares it to the allowed list).
- Reference existence (the referenced Asset / Entry actually exists on disk) is checked at write time. Dangling references are rejected.

Per-language value shape: `content: Record<ProjectLanguage, MdAstRoot | null>`. Empty markdown values are canonically `null` per language - the schema rejects empty trees and Milkdown-style empty-paragraph wrappers, forcing Desktop to normalize "effectively empty" editor state to `null` before saving.

> For details on how to render markdown values, convert to a markdown string, and handle entry/asset references in tree nodes, see [`markdown-content.md`](https://elek.io/en/documentation/core/markdown-content).

## Uniqueness

A string field with `isUnique: true` (and the always-unique `slug` type) is enforced on every Entry create and update within its Collection.

- **Per language, exact match.** A value must be unique within its own language slot, so the same value in two different languages does not collide.
  - Comparison is byte-for-byte. The schema trims a value but does not Unicode-normalize it, so two visually identical strings in different normal forms, NFC and NFD, count as distinct.
  - For case-insensitive uniqueness, or to collapse look-alikes, use a `slug` field with `lowercase: true`.
- **Nulls never collide.** Unset / `null` language slots are not indexed, so any number of Entries may leave a unique field empty.
- **On collision,** create / update throws a `Conflict` `CoreError` whose `cause` is an array of `UniqueValueConflict` objects (`collectionId`, `fieldDefinitionId`, `fieldSlug`, `language`, `value`, `conflictingEntryId`), collecting every violation at once.
- **No auto-resolution.** Core does not suffix a colliding value (there is no `-2` fallback). Because slug and unique values are author-supplied, the caller retries with a different value, using the `value` and `conflictingEntryId` on the returned conflict to decide what to change.
- **Turning a field unique** over a Collection that already has duplicates is rejected: the Collection update surfaces `unique_collision` entry issues you resolve the same way as other field-definition changes (see [`schema-changes.md`](https://elek.io/en/documentation/core/schema-changes)). Each issue carries `value`, `language` and `conflictingEntryId`, mirroring `UniqueValueConflict`, so an editor can show what clashed.
- **Scope.** Uniqueness is single-field and Collection-scoped. There is no compound / multi-field uniqueness, and `isUnique` / `slug` are not allowed inside Components (see [`features.md`](https://elek.io/en/documentation/core/features#intentional-constraints)).

Enforcement scans the Collection's Entries on each write rather than relying on a persisted index, so it stays correct for Entries brought in by a pull or merge that never passed through Core's write path.

The check is a scan-then-write rather than an atomic transaction, so it assumes a single serialized writer (the same assumption Core's Collection and Component slug uniqueness already makes). Two writes racing in parallel for the same value could both pass the scan, so a caller that issues concurrent writes to one Collection must serialize them itself.

## Field grouping

Inside a Collection, field definitions can be wrapped in a `FieldDefinitionGroup` for visual organization. Groups are presentational only - they have no effect on Entry storage or value validation, and they do not appear in the Entry JSON.

```typescript
{
  isGroup: true,
  id: '<group-uuid>',
  label: { en: 'SEO', de: 'SEO' },
  description: { en: 'Search engine metadata', de: 'Suchmaschinen-Metadaten' },
  fieldDefinitions: [
    // direct, reference, or dynamic field definitions
  ],
}
```

A Collection's `fieldDefinitions` array can mix groups and ungrouped fields. Flatten it with `flattenFieldDefinitions()` for a flat list, or `flattenFieldDefinitionsWithPaths()` when you also need each definition's position in the nested array.

Use the second one to build error paths. A flattened index does not address a grouped definition, and past the first group it no longer addresses anything at all.

Constraints:

- Groups can contain any field definition, direct, reference, dynamic and markdown alike - but not other groups.
- Components hold a flat list of fields and cannot use groups (Collections only).
- A group's own `label` and `description` are admin metadata, so like a field definition's they must carry every language the Project supports. A `null` description is allowed, a partially translated one is not.

## Composing with Components

Components are reusable bundles of field definitions. The `dynamic` field type embeds them into a Collection's Entries by reference.

A typical setup - a `Hero` Component referenced from a Page Collection's `sections` field:

```typescript
// 1. The Hero Component (created once via ComponentService.create)
const hero = {
  id: '<hero-component-uuid>',
  slug: 'hero',
  name: { en: 'Hero', de: 'Hero' },
  fieldDefinitions: [
    { fieldType: 'text', valueType: 'string', slug: 'headline' /* ... */ },
    {
      fieldType: 'asset',
      valueType: 'reference',
      slug: 'image',
      min: 1,
      max: 1 /* ... */,
    },
  ],
};

// 2. The Page Collection's `sections` field (only one of many fields shown)
const sectionsField = {
  fieldType: 'dynamic',
  valueType: 'component',
  slug: 'sections',
  ofComponents: ['<hero-component-uuid>', '<gallery-component-uuid>'],
  min: 1,
  max: null,
  // ... common properties
};

// 3. The resulting Entry value at entry.values['sections']
const sectionsValue = {
  objectType: 'value',
  valueType: 'component',
  content: [
    {
      id: '<item-uuid>',
      componentId: '<hero-component-uuid>',
      values: {
        headline: {
          objectType: 'value',
          valueType: 'string',
          content: { en: 'Welcome', de: 'Willkommen' },
        },
        image: {
          objectType: 'value',
          valueType: 'reference',
          content: {
            en: [{ id: '<asset-uuid>', objectType: 'asset' }],
            de: [{ id: '<asset-uuid>', objectType: 'asset' }],
          },
        },
      },
    },
    // ... more items, possibly with a different componentId
  ],
};
```

Two things to notice:

- **The `content` array is not per-language** - Component instances live at the Entry level, not the language level. Editors choose one ordering shared across all project languages.
- **Each item's inner `values` are still translatable** through their own field definitions (`string`, `number`, `boolean`, and `reference` values continue to carry per-language content).

## Value structure

Direct values (`string` / `number` / `boolean`) and `reference` values store per-language content, while `component` values store an ordered, language-independent array of items whose internal values are themselves translatable.

| Value Type | `content` Shape | Per-language? |
| --- | --- | --- |
| `string` | `{ [lang]: string \| null }` (non-empty trimmed strings or null) | yes |
| `number` | `{ [lang]: number \| null }` | yes |
| `boolean` | `{ [lang]: boolean }` (never null) | yes |
| `reference` | `{ [lang]: Array<{ id: UUID, objectType: 'asset' \| 'entry', collectionId?: UUID }> }` | yes |
| `component` | `ComponentItem[]` - `{ id, componentId, values: Record<slug, Value> }` | **no** (array shell is flat, nested values translate per their definitions) |
| `mdast` | `{ [lang]: MdAstRoot \| null }` (structured tree of typed mdast nodes, `null` = empty) | yes |

The full Entry shape and the `Value` union live in `src/schema/valueSchema.ts`.

## Constraints and limits

- **Groups cannot nest groups.** A `FieldDefinitionGroup`'s `fieldDefinitions` array accepts `FieldDefinition` only - not `FieldDefinitionOrGroup`.
- **Components cannot contain groups.** Only Collections accept `FieldDefinitionOrGroup`. Components accept `FieldDefinition` directly.
- **Dynamic fields cannot recursively nest dynamic fields.** A Component referenced by a `dynamic` field cannot itself contain a `dynamic` field that loops back. Circular references are detected at schema-generation time in `src/schema/schemaFromFieldDefinition.ts`.
- **No standalone `list` field type.** Repeating structured groups are modelled as `dynamic` + a Component.

## Project languages

Each Project declares its supported languages in `ProjectSettings['language']['supported']`, exposed as a non-empty `ProjectLanguages` tuple. Translatable values are validated against the project's languages - not the full universe of 24 supported language codes.

Supported language universe: `bg, cs, da, de, el, en, es, et, fi, fr, hu, it, ja, lt, lv, nl, pl, pt, ro, ru, sk, sl, sv, zh`.

## Generated client types

Two generators consume Project metadata and emit typed API client code:

- **CLI** - `elek generate:client` produces a TypeScript client whose translatable values are typed as `Record<ProjectLanguage, T>`.
- **Astro** - `@elek-io/core/astro` exports content loaders (`elekAssetsLoader()`, `elekEntriesLoader()`) that produce typed Astro collections from elek.io Entries and Assets.

Both emit the narrow `Record<ProjectLanguage, T>` type for translatable content rather than the broad `Partial<Record<SupportedLanguage, T>>` Core's static types expose. Both admit `null` in that type for a field the Collection does not require, which is what an empty language slot holds.

See the [README's Exports section](https://github.com/elek-io/core#exports) for the package's entry points.

## See also

- [`markdown-content.md`](https://elek.io/en/documentation/core/markdown-content) - rendering, serialization, and security for `markdown` field values
- [`schema-changes.md`](https://elek.io/en/documentation/core/schema-changes) - how editing field definitions cascades into existing Entries
- [`error-handling.md`](https://elek.io/en/documentation/core/error-handling) - `CoreError` and validation error patterns
