Related: CDN & Caching · Publish Flow · Auth Tokens
A Space is the top-level workspace and the root of all data in Localess. Every resource (content, translation, schema, asset, task, token) belongs to exactly one Space.
Firestore: spaces/{spaceId}
Key properties:
locales— list of supported locales (e.g.[{ id: 'en' }, { id: 'de' }])localeFallback— the default locale used when a requested locale has no dataoverview— aggregated counts and sizes (denormalized for dashboard display)
A Schema defines the structure of a Content document — it is the content type definition.
Firestore: spaces/{spaceId}/schemas/{schemaId}
Three schema types:
| Type | Purpose |
|---|---|
ROOT |
Top-level page schema (used as the root of a Content document) |
NODE |
Reusable nested component (embedded inside ROOT or other NODEs) |
ENUM |
A fixed set of named values (used in option/select fields) |
Field kinds: TEXT, TEXTAREA, RICH_TEXT, MARKDOWN, NUMBER, COLOR, DATE, DATETIME, BOOLEAN, OPTION, OPTIONS, SCHEMA (single node), SCHEMAS (array of nodes), LINK, REFERENCE, REFERENCES, ASSET, ASSETS.
New spaces can be created from a template that seeds a ready-made set of schemas — see Admin → Spaces.
A Content is either a FOLDER (organisational) or a DOCUMENT (actual page/entry).
Firestore: spaces/{spaceId}/contents/{contentId}
Storage: spaces/{spaceId}/contents/{contentId}/{locale}.json ← published
spaces/{spaceId}/contents/{contentId}/draft/{locale}.json ← draft
spaces/{spaceId}/contents/cache.json ← cv pointer
Key properties on a ContentDocument:
schema— references a Schema by IDslug— URL-safe segment for this nodefullSlug— full path from root (e.g.blog/2024/my-post)parentSlug— parent's fullSlug (used for tree queries)data— the content payload (typed by the Schema)assets,links,references— IDs of related resources for resolution
One data payload holds every locale. Which key a value lives under depends on the locale:
| locale | key | example |
|---|---|---|
| default | the bare field name | title |
| any other | {fieldName}_i18n_{localeId} |
title_i18n_de |
Three consequences follow, and all of them are load-bearing:
The default locale is the fallback value. It sits in the bare key precisely so a reader can ask
for _i18n_<locale> and fall back to it when the translation is missing. extractContent() — the
publish/serve path, in both shared/utils/content.ts and functions/src/services/content.service.ts —
does exactly that, which is why an untranslated field still serves content rather than a blank.
The editor deliberately does not fall back. extractSchemaContent() returns the locale's own
value, empty if absent, so an author can see what is still untranslated. The default value is shown
as the input's placeholder instead — visible, but not mistaken for a real translation.
default is a storage sentinel, not a language. CONTENT_DEFAULT_LOCALE.id is the literal
string default, and availableLocales rewrites the space's localeFallback to it (labelled
"English (Default)"). So the id that identifies the bare key is never a language code. Anything
talking to a translation provider must resolve it first — see toProviderLocale() in
locale.model.ts and Contents → AI translation.
_i18n_ is reserved. Schema field names are rejected if they contain it, on both sides:
CommonValidator.SCHEMA_FIELD_NAME_TRANSLATION in the UI and a refine in
functions/src/models/schema.zod.ts. A field called title_i18n_de would be indistinguishable
from a German translation of title.
Not every field kind can be translatable. translatable lives on SchemaFieldTranslatable,
mixed into every kind except REFERENCE, REFERENCES, SCHEMA and SCHEMAS — their value is
shared by every locale, and nested blocks translate their own fields. Read it through
isFieldTranslatable() (in both schema.model.ts files), never field.translatable: it also
ignores a leftover flag that schemas pushed through the API before the push started stripping it
can still carry. A non-translatable field is read-only outside the default locale; reference and
asset pickers keep their controls enabled to show the shared value but take [locked], and
EditDocumentSchemaComponent never writes a non-translatable field back from another locale.
Only _-prefixed keys are internal. A block's identity lives in _id and _schema, and those
two are the only reserved field names, so schema is an ordinary field name. Blocks stored before
_schema existed carry the schema id under a legacy schema key instead. It is never served:
extractContent() in functions/ reads it only as a fallback (contentSchemaId()), and the
editor's normalizeContent() moves it to _schema on load, so the next save migrates the block.
Any code that reads or writes a localised value applies the table above — writing the default to
title_i18n_defaultproduces a key nothing reads, and reading the default from that key finds nothing.
The rule is enforced by tests at every site that applies it, so a regression fails the build rather than reaching the API:
| site | guarded by |
|---|---|
| serve/publish (functions) | services/content.service.test.ts |
| serve/publish (frontend) | shared/utils/content.spec.ts → extractContent |
| editor form ← data | shared/utils/content.spec.ts → extractSchemaContent |
| editor form → data | edit-document-schema.component.spec.ts → writing form values back to data |
| whole-document translation | shared/utils/content.spec.ts → collectTranslatableFields |
| per-field translation | markdown-editor / rich-text-editor specs |
previewField |
edit-document-schema.component.spec.ts → previewText |
reserved _i18n_ in field names |
schema.validator.spec.ts (UI), schema.zod.test.ts (functions) |
See Publish Flow for how drafts become published JSON files.
A Translation is a key/value localisation entry. It is not tied to a Schema — it is a flat key store for UI strings.
Firestore: spaces/{spaceId}/translations/{translationId}
Storage: spaces/{spaceId}/translations/{locale}.json ← published
spaces/{spaceId}/translations/draft/{locale}.json ← draft
spaces/{spaceId}/translations/cache.json ← cv pointer
Three translation types:
| Type | Structure |
|---|---|
STRING |
Single string per locale |
PLURAL |
Locale-keyed plural forms |
ARRAY |
Array of strings per locale |
An Asset is either a FOLDER or a FILE stored in Firebase Storage.
Firestore: spaces/{spaceId}/assets/{assetId}
Storage: spaces/{spaceId}/assets/{assetId}/original ← raw file
The CDN endpoint (/api/v1/spaces/:spaceId/assets/:assetId) supports:
?w=<px>— resize images on-the-fly via Sharp?thumbnail=true— extract first frame of animated GIF/WebP or video thumbnail/api/v1/spaces/:spaceId/assets/:assetId/download— the stored bytes asContent-Disposition: attachment(the old?downloadflag was removed and now returns400);/originalserves them inline when the type is safe to render; HTML, XML and unknown types are always served as attachments (see assets.md)
Assets are referenced from Content documents via
ASSET/ASSETSschema fields.
A Task is a background job (e.g. bulk publish, import, export). Tasks are queued in Firestore and executed by Firebase Functions.
Firestore: spaces/{spaceId}/tasks/{taskId}
An API token grants programmatic access to the public CDN API. See Auth Tokens for the full permission model.
spaces/
{spaceId}/
contents/
{contentId}
translations/
{translationId}
schemas/
{schemaId}
assets/
{assetId}
tasks/
{taskId}
logs/
{logId}
tokens/
{tokenId}
webhooks/
{webhookId}
logs/
{logId}
When a Space is deleted,
firestoreService.recursiveDelete()removes the space document and all nested subcollections in one call. When a Content document is deleted,firestoreService.recursiveDelete()removes the content document and any nested subcollections. Child folder contents (sibling documents referencing the folder viaparentSlug) are cascade-deleted via theonContentDeletetrigger.
{ "_id": "…", "_schema": "page", "title": "Hello", // default locale "title_i18n_de": "Hallo", // German "title_i18n_fr": "Bonjour" // French }