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 definitionssrc/schema/valueSchema.ts- runtime value shapessrc/schema/baseSchema.ts- translatable record helpers, supported languages
Common field properties
Every field definition extends fieldDefinitionBaseSchema:
{ 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. |
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). 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.
{ 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.
{ 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.
{ 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.
{ 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,lowercaseanddecamelizemirror@sindresorhus/slugifyopens in a new tab and shape what counts as canonical.separatormust be empty or one of the URL-safe marks-_.~(RFC 3986 unreserved). Changing any of these is a major change (releases.md). ofFieldDefinitions- an ordered list of sibling field-definition ids the editor uses to auto-generate the slug, for example atitlefield. 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.
decamelizesplits camelCase before slugifying, somyProductbecomesmy-productand a hand-entered camelCase value is rejected as non-canonical. Run input through the sharedslug()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() |
{ fieldType: 'datetime', valueType: 'string', defaultValue: null, // or a valid ISO datetime // ... common properties}Numeric (value type: number)
All number fields force isUnique: false.
number
{ 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).
{ 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).
{ 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).
{ 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.
asset
References Assets (files in the project’s media library).
{ 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.
{ 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.
{ 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 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.
{ 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
minandmaxare set,minmust be ≤max. Trees that violate the configuredmin/maxtop-level block count are rejected at write time. features.taskListItems: truerequiresfeatures.lists: true.defaultValue(when non-null) must satisfy the field’sfeaturesconfiguration - a default tree can’t contain node types the features map disables.entryReferencenodes inside an entry’s tree are checked structurally againstofCollectionsat the schema layer (their claimedcollectionIdmust be in the allowed list, when non-empty).assetReferencenodes are checked againstofAssetMimeTypesat the service layer (Core reads the referenced Asset’smimeTypeand 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.
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
slugfield withlowercase: true.
- Nulls never collide. Unset /
nulllanguage slots are not indexed, so any number of Entries may leave a unique field empty. - On collision, create / update throws a
ConflictCoreErrorwhosecauseis an array ofUniqueValueConflictobjects (collectionId,fieldDefinitionId,fieldSlug,language,value,conflictingEntryId), collecting every violation at once. - No auto-resolution. Core does not suffix a colliding value (there is no
-2fallback). Because slug and unique values are author-supplied, the caller retries with a different value, using thevalueandconflictingEntryIdon 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_collisionentry issues you resolve the same way as other field-definition changes (seeschema-changes.md). Each issue carriesvalue,languageandconflictingEntryId, mirroringUniqueValueConflict, so an editor can show what clashed. - Scope. Uniqueness is single-field and Collection-scoped. There is no compound / multi-field uniqueness, and
isUnique/slugare not allowed inside Components (seefeatures.md).
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.
{ 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
labelanddescriptionare admin metadata, so like a field definition’s they must carry every language the Project supports. Anulldescription 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:
// 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
contentarray 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
valuesare still translatable through their own field definitions (string,number,boolean, andreferencevalues 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’sfieldDefinitionsarray acceptsFieldDefinitiononly - notFieldDefinitionOrGroup. - Components cannot contain groups. Only Collections accept
FieldDefinitionOrGroup. Components acceptFieldDefinitiondirectly. - Dynamic fields cannot recursively nest dynamic fields. A Component referenced by a
dynamicfield cannot itself contain adynamicfield that loops back. Circular references are detected at schema-generation time insrc/schema/schemaFromFieldDefinition.ts. - No standalone
listfield type. Repeating structured groups are modelled asdynamic+ 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:clientproduces a TypeScript client whose translatable values are typed asRecord<ProjectLanguage, T>. - Astro -
@elek-io/core/astroexports 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 opens in a new tab for the package’s entry points.
See also
markdown-content.md- rendering, serialization, and security formarkdownfield valuesschema-changes.md- how editing field definitions cascades into existing Entrieserror-handling.md-CoreErrorand validation error patterns