Forms
Forms as one declarative definition stored in the database, one renderer and one server pipeline: single page or stepped, conditional fields, file uploads, captcha, access control, and stored or email-only submissions.
@frigear-nu/module-kit, @frigear-nu/auth,
@frigear-nu/users, @frigear-nu/mcp, @frigear-nu/notify
(email delivery; the host picks the driver), @frigear-nu/ui and @nuxthub/core with KV (rate
limiting) and blob (uploads) enabled.
Optional@frigear-nu/menu (menu entries) and @frigear-nu/admin (the admin
pages live in its shell, so they only exist when it is installed). The pages follow the
UX patterns and are built from the shared components of @frigear-nu/ui.Design
- A form is data. The definition is JSON in the
formstable, so editors can change fields without a deploy. - One compiler.
buildFormSchema(definition, answers)turns a definition into a zod schema. The client uses it per step, the server for the whole form, so both sides agree on what is required. - Submissions are snapshots. Each submission stores the definition (and its
version) it was answered against, so old submissions keep rendering after the form changes. - Storing is optional.
storage: 'none'never persists answers: they are only emitted throughforms:submitted(and emailed). - Delivery never loses a stored submission. The row is written first; delivery errors are recorded on it and can be retried from the admin.
- Staff access is the host's decision. There is no shared permission system; see Authorization.
- Forms are never deleted.
DELETEarchives, so submissions always keep their form.
Quick setup
export default defineNuxtConfig({
forms: {
settings: {
pages: { form: true, admin: true, adminForm: true, adminSubmissions: true, adminSubmission: true },
},
},
})
Pages are opt-in and use @nuxt/ui. <FormRenderer slug="..." /> can also be placed in any page.
// server/plugins/forms-policy.ts - who may manage forms (denied by default)
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('forms:authorize', async (ctx) => {
ctx.allowed = await isStaff(ctx.userId)
})
})
Options
All options live under forms.
| Option | Type | Default | Purpose |
|---|---|---|---|
settings.pages.form | string | boolean | off | Public form page; must contain :slug. true = /forms/:slug. |
settings.pages.admin | string | boolean | off | Admin list. true = /admin/forms. Admin pages need @frigear-nu/admin. |
settings.pages.adminForm | string | boolean | off | Admin editor; must contain :id. true = /admin/forms/:id. |
settings.pages.adminSubmissions | string | boolean | off | Submissions of one form; must contain :id. true = /admin/forms/:id/submissions. |
settings.pages.adminSubmission | string | boolean | off | One submission; must contain :id and :submissionId. true = /admin/forms/:id/submissions/:submissionId. |
settings.rateLimit | number | 10 | Submissions per client and form per window; 0 disables rate limiting (including password-attempt limiting). |
settings.rateLimitWindowSeconds | number | 600 | Length of the window. |
settings.mcp | boolean | false | Expose this module's tools on the MCP endpoint. |
seed.enabled | boolean | false | Insert seed.forms at startup (env NUXT_FORMS_SEED_ENABLED). See Seeding and import. |
seed.forms | SeedForm[] | [] | { slug, name, status?, definition }; status defaults to draft. |
captcha.<provider>.siteKey | string | '' | Public site key; <provider> is turnstile, recaptcha or hcaptcha. |
captcha.<provider>.secretKey | string | '' | Secret key, server only. |
captcha.defaultProvider | CaptchaProvider | 'turnstile' | Provider of a captcha field that names none. |
encryptionKey | string | '' | Key for per-form secrets. Falls back to NUXT_ENCRYPTION_KEY, then the session password. |
Environment variables:
| Variable | Option |
|---|---|
NUXT_FORMS_SEED_ENABLED | seed.enabled |
NUXT_PUBLIC_FORMS_CAPTCHA_TURNSTILE_SITE_KEY, ..._RECAPTCHA_SITE_KEY, ..._HCAPTCHA_SITE_KEY | captcha.<provider>.siteKey |
NUXT_FORMS_CAPTCHA_TURNSTILE_SECRET_KEY, ..._RECAPTCHA_SECRET_KEY, ..._HCAPTCHA_SECRET_KEY | captcha.<provider>.secretKey |
NUXT_PUBLIC_FORMS_CAPTCHA_DEFAULT_PROVIDER | captcha.defaultProvider |
NUXT_FORMS_ENCRYPTION_KEY | encryptionKey |
Definition
Author definitions with defineForm() from @frigear-nu/forms/utils. These are the real types (abridged):
type FormDefinition = {
mode: 'single' | 'stepped' // 'single' has exactly one step and no stepper
storage: 'db' | 'none' // default 'db'
steps: FormStep[]
submit?: {
label?: Translated
successMessage?: Translated
redirect?: string // same-origin path
allowResubmit?: { keep: string[] } // offers "send another"; these field keys stay filled in
}
delivery?: {
email?: {
to: string[] // literal addresses; ${...} placeholders are rejected
subject?: Translated // may use {{variables}}
body?: Translated // plain text with {{variables}}, up to 10,000 characters per language
routes?: { field: string, to: Record<string, string[]> } // recipients by one field's answer, falling back to `to`
replyToField?: string // key of an email field
}
}
access?: FormAccess // default { availability: 'web', auth: 'none' }
}
type FormStep = { id: string, title?: Translated, description?: Translated, showIf?: Condition, fields: FormField[] }
type FormField = {
key: string // unique across the form; the key of the answer
type: 'text' | 'email' | 'tel' | 'number' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'file' | 'markdown' | 'captcha'
label?: Translated
description?: Translated
placeholder?: Translated
content?: Translated // markdown fields
required?: boolean
options?: { value: string, label: Translated }[] // select / radio
multiple?: boolean // file fields
autocomplete?: string // defaults: 'email' for email, 'tel' for tel ('tel-national' next to a country selector)
validation?: { min?, max?, minLength?, maxLength?, pattern?, maxFiles?, maxSize?, accept?: string[], message?: Translated }
phone?: PhoneConfig // tel fields
captcha?: CaptchaConfig // captcha fields
showIf?: Condition
}
type Condition = { field: string, op: 'eq' | 'neq' | 'in' | 'empty' | 'notEmpty' | 'truthy', value?: unknown }
type FormAccess = {
availability: 'web' | 'api' // 'api' = no public page
auth: 'none' | 'user' | 'token' | 'password'
schedule?: Schedule // default always open; see Open period
}
Translated is a string or a { da, en } map (falls back to en, then the first value).
Rules enforced when a definition is saved or seeded:
- Conditions.
showIfonly sees answers of fields earlier in the form and must reference an input field.valueis the compared value foreq/neq, a list forin, and unused forempty,notEmptyandtruthy(an alias ofnotEmpty). A hidden field's stale answer never keeps another field shown; hidden fields are not required and their answers are dropped. - Keys. Field keys start with a letter and contain letters, digits and underscores; keys and step ids are unique.
- Fields.
selectandradioneed options;markdownneedscontentand collects no answer;fileneedsstorage: 'db';captchais always required while visible. - Files.
maxSizedefaults to 10 MB,maxFilesto 10 (1 unlessmultiple). - Access.
auth: 'token'requiresavailability: 'api', because a bearer token cannot be typed into a page. - Email.
routes.fieldmust be an earlier input field andreplyToFieldan email field.
Phone fields
A tel field is one free-form input by default. phone.mode: 'country' adds a searchable country selector.
type PhoneConfig = {
mode?: 'simple' | 'country' // default 'simple'
countries?: string[] // ISO 3166-1 alpha-2, in display order; default all. Enforced on the server.
defaultCountry?: string // default 'DK' when offered, else the first offered country
}
{ key: 'phone', type: 'tel', phone: { mode: 'country', countries: ['DK', 'SE', 'NO'], defaultCountry: 'DK' } }
- The answer is one string. With a selector it is
+<dial code><number>(+4512345678; a leading trunk0is dropped, Italian numbers keep it), so submissions, emails and the API never see two values. - Country mode requires an international number of at most 15 digits (E.164) with at least 4 digits after the code.
- Helpers
composePhone,parsePhone,phoneCountriesandallPhoneCountriesare exported from@frigear-nu/forms/utils.
Open period (seasonal forms)
access.schedule limits when a form accepts answers. A yearly season whose end is before its start wraps over new
year. from is inclusive, to exclusive.
// Every year from 1 Jan 23:00 to 1 Feb 23:00 (Oslo wall-clock time).
access: {
availability: 'web',
auth: 'none',
schedule: {
type: 'yearly',
timezone: 'Europe/Oslo',
from: { month: 1, day: 1, time: '23:00' },
to: { month: 2, day: 1, time: '23:00' },
},
}
// One fixed period (either end may be left out for an open-ended window).
schedule: { type: 'range', from: '2026-03-01T10:00:00Z', to: '2026-03-15T10:00:00Z' }
Outside the period the public page shows a "closed" notice (with the next opening when there is one) and the
definition and submit endpoints answer 403 with data: { closed: true, opensAt }. isFormOpen and
nextFormOpening are exported from the utils for custom UI.
Email body and variables
Without delivery.email.body the notification lists every visible answer. The body is chosen by the submission's
locale (falling back to en); subject accepts the same variables.
delivery: {
email: {
to: ['office@example.com'],
subject: 'New message from {{ answer.name }}',
body: { en: 'Hi team,\n\n{{answer.name}} wrote via {{form.name}}:\n{{answer.message}}\n\nAll answers:\n{{answers}}' },
},
}
| Variable | Replaced with |
|---|---|
{{form.name}}, {{form.slug}}, {{form.version}} | The form's name, slug and version |
{{submission.id}} | The stored submission id (empty when storage: 'none') |
{{submission.createdAt}}, {{submission.locale}} | ISO timestamp and locale of the submission |
{{answers}} | All visible answers as label: value lines (a table in the HTML version), without captcha |
{{answer.<fieldKey>}} | One answer, formatted like the default email: option labels, joined lists, file names, yes/no per locale |
{{files}} | Names of the files stored with the submission |
{{replyTo}} | The reply-to address taken from replyToField |
- Whitespace inside the braces is ignored. Unknown variables, hidden fields and unanswered fields render empty; the
admin definition check warns about unknown variables (
unknownBodyVariables(definition)) but never fails the save. - Values are inserted once and never re-scanned, so an answer containing
{{form.name}}stays literal. - The body is plain text. The HTML version escapes every value and turns newlines into
<br>. In the subject, line breaks are collapsed so an answer cannot inject headers. - Rendering lives in
@frigear-nu/forms/utils(composeEmail,renderTemplateText,renderTemplateHtml), so the builder previews the exact result with sample answers.
Captcha
A captcha field renders Cloudflare Turnstile, Google reCAPTCHA v2 (checkbox) or hCaptcha.
type CaptchaConfig = {
provider?: 'turnstile' | 'recaptcha' | 'hcaptcha' // default: captcha.defaultProvider
siteKey?: string // public; overrides the configured site key for this field
theme?: 'auto' | 'light' | 'dark'
size?: 'normal' | 'compact'
}
{ key: 'human', type: 'captcha', captcha: { provider: 'turnstile' } }
- Keys. Site key: the field's
siteKey, else the configured one. Secret key: the form's own encrypted secret (see Per-form secrets), else the configured one, else the check fails closed. The admin builder warns when a provider has neither a fieldsiteKeynor a configured site key. - Client. The provider script loads lazily in the browser only. The token lives in the form state under the field key, the widget resets after every failed submit, and the token is never stored, delivered or shown in the preview JSON. In the admin preview and "Try it" modal the field is a stub that supplies a dummy token.
- Server. The token is verified (with the visitor's IP) against the provider's siteverify endpoint before anything is stored or delivered.
- Token access. Submissions with a valid form API token (
access.auth: 'token') skip the captcha.
| Outcome | Status |
|---|---|
| Token missing or invalid | 422 with an issue on the captcha field key |
| Provider has no secret key (fails closed) | 500 |
| Provider unreachable | 502 |
Hosts and tests can replace the network call with the forms:captcha:verify hook.
Visual builder
The admin editor (/admin/forms/:id) has Builder, Delivery & storage, Access and JSON tabs, all editing
the same unsaved definition (so they are state tabs, not routes); Save form is only enabled while it passes
validation. The heading carries the breadcrumb back to the list, the form's status badge and a ⋯ menu with
Submissions, Copy link, Duplicate form and Archive form.
- Pick Single page or Stepped (switching to single merges all steps), drag field types from the palette (or click to append), and drag fields or step headers to re-order. Every row has up/down buttons for pointer-free use; drag and drop uses the browser's native API.
- Selecting a field or step opens its settings: label, help text, placeholder, required, options, limits, its step and
a
showIfcondition on an earlier field. - Every text has a language button: plain text is the same in every language, and translating turns it into a map with
one row per locale (the host's
@nuxtjs/i18nlocales, plus any code you add). Empty languages are dropped.
Server
Submitting
POST /api/forms/:slug/submit takes JSON { answers, locale }, or multipart/form-data with an answers JSON part,
a locale part and one file:<key> part per file. The response is { ok: true, id } (id is null when
storage: 'none'). GET /api/forms/:slug returns the published definition without delivery.
| Status | Cause |
|---|---|
401 | access.auth is not satisfied (sign-in, token or password missing or wrong; data.access: true) |
403 | The form is closed (data: { closed: true, opensAt }) |
404 | Unknown or unpublished form |
422 | Validation errors in data.issues, or a failed captcha |
429 | Rate limit hit (Retry-After header) |
500 / 502 | Captcha not configured / unreachable; 502 is also returned when storage: 'none' delivery fails |
Access
A form's access decides who may read and submit it. Secrets are never part of the definition.
auth | Submitter proves access with |
|---|---|
none | Nothing |
user | A signed-in user (cookie session or a token with the forms:submit scope) |
token | Authorization: Bearer <token> or x-form-token: <token>; tokens are created per form in the admin and shown once |
password | The shared password in the x-form-password header (FORM_PASSWORD_HEADER on the client) |
- Tokens (
ffk_...) are stored as SHA-256 hashes with a display prefix,lastUsedAtandrevokedAt. - The password is stored as a scrypt hash in
form_access. With no password set the form stays closed. - Wrong password attempts count against the rate limit per client and form.
availability: 'api'only removes the public page; the API still serves the form.
Rate limiting
Submissions are counted per client IP and form in KV with a fixed window (rateLimit per rateLimitWindowSeconds).
The count is read then written, so it is not exact under concurrency, which is fine for casual abuse. Over the limit
the response is 429 with Retry-After. Wrong password attempts use the same limit and window.
Per-form secrets
Secrets that belong to one form (today the captcha secret keys) live in the form_secrets table, encrypted on the
server with AES-256-GCM (a random 12-byte IV per write, scrypt-derived key, stored as { ct, iv, tag }). They are
kept apart from the definition, so they never end up in definition versions, submission snapshots, deliveries,
imports or the public API. Values are write-only.
- Key:
encryptionKey, elseNUXT_ENCRYPTION_KEY, else the session password, so it works out of the box. Set a dedicated key in production so that changing the session password does not affect it. - Rotation: changing the key makes stored secrets unreadable. The admin API then reports
unreadable: true(the builder shows a warning) and checks fall back to the configured key; entering the secrets again replaces them. - Allowed keys:
captcha.turnstile.secretKey,captcha.recaptcha.secretKey,captcha.hcaptcha.secretKey.
Seeding and import
Nothing is inserted automatically. Two explicit ways to load definitions; both only insert slugs that do not exist yet and never overwrite existing forms:
seed: { enabled: true, forms: [...] }inserts at startup.- The admin Import button (
POST /api/forms/admin/import) takes{ forms: [{ slug, name, definition }] }and returns{ inserted, skipped }.
Admin list and actions
The list (/admin/forms) is a PageHeading with New form (a slideover) and Import JSON, a search box and
status filter, and a table whose rows open the editor. Each row's ⋯ menu offers Open, Submissions, Copy link and
Duplicate form, and Archive form (after a confirmation) in its own group. The same menu is built by
useFormActions().menu(form, { extra?, onRemoved? }), which also exposes create, clone, remove (archives),
importJson and redeliver, so a host page can offer the same actions. A submission's menu offers Open and Deliver
again. The old dashboard.account tile is gone: forms appears in the admin sidebar (admin.nav) only.
Cloning
POST /api/forms/admin/forms/:id/clone copies a form's name and definition into a new draft (slug <slug>-copy, then
-copy-2, ...). Submissions, tokens, the password and secrets are not copied.
Authorization
Admin routes require a user holding the forms:manage scope and the forms:authorize hook allowing them,
denied by default. Set ctx.allowed from ctx.userId; never read the session from ctx.event, which is empty for
bearer requests. The forms:manage and forms:pii scopes are only offered to users the policy lets in.
Auth scopes
| Scope | Allows |
|---|---|
forms:manage | Manage forms and submissions (staff), also gated by forms:authorize |
forms:submit | Submit forms as the user (and read forms that need a signed-in user) |
forms:pii | See submission answers and file names through MCP tools (also needs forms:manage) |
MCP tools
With settings.mcp: true:
| Tool | Scope | Purpose |
|---|---|---|
list-forms, get-form, submit-form | forms:submit | Browse published forms and submit as the user |
list-all-forms, get-form-definition, create-form, update-form | forms:manage | Manage forms; delivery settings are never returned or changed |
list-submissions, get-submission | forms:manage | Submissions; answers, submitter and file names need forms:pii too |
App
Pages
| Page | Default path | Option |
|---|---|---|
| Public form | /forms/:slug | pages.form |
| Admin list | /admin/forms | pages.admin |
| Admin editor | /admin/forms/:id | pages.adminForm |
| Submissions | /admin/forms/:id/submissions | pages.adminSubmissions |
| One submission | /admin/forms/:id/submissions/:submissionId | pages.adminSubmission |
Any can be a custom path string. The admin paths follow the configured Admin base.
Components
| Component | Props | Notes |
|---|---|---|
<FormRenderer> | slug?, form?, password?, preview?, locale? | Renders a published form (by slug) or a given definition. Emits submitted(id) and previewed(answers). |
<FormEmbed> | slug, title?, locale?, class? | Embeds a published form, honoring its access settings. Registered globally. |
<FormPreview> | slug, name?, definition?, contentKey? | Admin preview. |
<FormSubmissionView> | submission | Renders a submission against the definition it was answered with. |
Embedding in Markdown
FormEmbed is registered globally, so Nuxt Content (MDC) resolves it in any collection
without a wrapper. Use the block syntax: the embed renders block elements, so inline :form-embed{...} would place
them inside a paragraph.
::form-embed{slug="contact"}
::
::form-embed{slug="contact" title locale="da" class="max-w-2xl my-8"}
::
| Prop | Type | Default | Purpose |
|---|---|---|---|
slug | string | none | Required. Slug of a published form. |
title | boolean | false | Show the form's name as a heading. |
locale | string | app locale | Overrides the locale of the form's translated texts. |
class | string | none | Extra classes for the wrapper (it has not-prose). |
The embed uses GET /api/forms/:slug/access and follows the access settings:
| Form state | Renders |
|---|---|
| Open | The form, with a loading skeleton; success message or redirect handled by the renderer |
| Unknown or unpublished | A "could not be found" alert |
auth: 'user' | A sign-in prompt linking to the login page (with a redirect back) |
auth: 'password' | The password gate |
availability: 'api' or auth: 'token' | Nothing (a console warning in development names the slug) |
Composables
Auto-imported, also available from @frigear-nu/forms/composables.
useForm(slug: MaybeRefOrGetter<string>, password?: MaybeRefOrGetter<string | undefined>) // published definition
useFormAccess(slug: MaybeRefOrGetter<string>) // FormAccessInfoDto: access, signedIn, open, opensAt
useFormsLocale() / useFormsLocales() // current locale / available locales
useFormsPaths() // (name, params?) => resolved page path
useFormLink() // { enabled, url(slug), copy(slug) }
useFormStepper(...) // step navigation of the renderer
FORM_PASSWORD_HEADER // 'x-form-password'
Utils
@frigear-nu/forms/utils is pure and safe in the Vue app and in Nitro.
| Export | Purpose |
|---|---|
defineForm, parseFormDefinition, formDefinitionSchema | Author and validate definitions |
buildFormSchema, buildFieldSchema, validatedFields | Compile a definition to zod |
evaluateCondition, resolveVisibility, isFieldVisible | Conditional visibility |
resolveAccess, defaultAccess, accessSchema | Effective access of a definition |
isFormOpen, nextFormOpening | Open period |
composeEmail, renderTemplateText, renderTemplateHtml, unknownBodyVariables, availableVariables | Email rendering |
composePhone, parsePhone, phoneCountries, allPhoneCountries | Phone numbers |
resolveEmailDelivery, resolveCaptcha, renderMarkdown | Delivery recipients, captcha provider, static markdown |
The builder helpers (moveField, insertField, setAccess, ...) are exported from the same path.
Hooks
Nitro runtime hooks, registered from a server plugin with nitro.hooks.hook(name, handler).
| Hook | When | Payload |
|---|---|---|
forms:authorize | A staff route is called | { event, userId: number, allowed: boolean }; set allowed = true to let the user in |
forms:submitted | A valid submission was received (and stored unless storage: 'none') | SubmittedContext (below) |
forms:captcha:verify | A captcha token must be verified | { event, provider, token, ip, formId?, secretKey?, secretSource?: 'form' | 'env' | 'unset', valid: boolean | undefined }; set valid to decide, left undefined the provider is asked |
forms:delivered | Delivery finished (only fires when a handler delivered or one failed) | { submissionId: string | null, formSlug, status: 'delivered' | 'failed', error? } |
type SubmittedContext = {
event: H3Event
form: { id: string, slug: string, name: string, version: number, definition: FormDefinition }
answers: Record<string, unknown> // validated; file fields hold the file names
files: { fieldKey: string, filename: string, contentType: string, size: number, pathname: string | null }[]
locale: string
userId: number | null
submissionId: string | null // null when storage is 'none'
stored: boolean
delivered: boolean // set true once the handler delivered; throw to mark delivery failed
}
nitroApp.hooks.hook('forms:submitted', async (ctx) => {
await fetch('https://example.com/hook', { method: 'POST', body: JSON.stringify(ctx.answers) })
ctx.delivered = true
})
- The built-in email delivery listens to
forms:submittedand mailsdelivery.emailthrough@frigear-nu/notify. - For
storage: 'db'a failing listener setsdeliveryStatus = 'failed'(with the error) and the visitor still gets success; Deliver again in the admin re-runs the hooks. Forstorage: 'none'nothing else keeps the answers, so a failure is returned as502.
Routes
/api/forms/.... Static admin/* segments win over :slug. "Staff" means forms:manage plus the
forms:authorize policy; admin routes also accept bearer tokens with that scope.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /:slug | Form access | Published definition without delivery |
GET | /:slug/access | None | What the page needs to choose between the form, a gate and "not found" |
POST | /:slug/submit | Form access | Submit answers (rate limited) |
GET | /admin/forms | Staff | List forms with submission counts |
POST | /admin/forms | Staff | Create a form (201; 409 for a taken slug) |
GET | /admin/forms/:id | Staff | One form |
PATCH | /admin/forms/:id | Staff | Update; a changed definition bumps version |
DELETE | /admin/forms/:id | Staff | Archive (never deletes) |
POST | /admin/forms/:id/clone | Staff | Clone into a draft (201) |
GET | /admin/forms/:id/submissions | Staff | Submissions of a form |
GET / POST | /admin/forms/:id/tokens | Staff | List tokens / create one (201; the token is only in this response) |
DELETE | /admin/forms/:id/tokens/:tokenId | Staff | Revoke a token |
GET / PUT / DELETE | /admin/forms/:id/password | Staff | Whether a password is set ({ set }) / set (6-200 chars) / remove |
GET / PUT | /admin/forms/:id/secrets | Staff | Names and sources of secrets ({ keys, captcha, unreadable }) / write one { key, value } |
DELETE | /admin/forms/:id/secrets/:key | Staff | Remove one secret (400 for an unknown key) |
POST | /admin/import | Staff | Import definitions |
GET | /admin/submissions/:id | Staff | One submission |
GET | /admin/submissions/:id/files/:fileId | Staff | Download an uploaded file |
POST | /admin/submissions/:id/redeliver | Staff | Run the delivery hooks again |
Database
| Table | Purpose |
|---|---|
forms | slug, name, status (draft, published, archived), definition, version |
form_submissions | Answers, definitionSnapshot, formVersion, userId, locale, deliveryStatus (none, pending, delivered, failed) and deliveryError |
form_submission_files | Blob pathname, filename, content type and size per uploaded file |
form_access_tokens | Per-form API tokens: tokenHash, prefix, lastUsedAt, revokedAt |
form_access | One row per password-protected form: passwordHash |
form_secrets | One row per form: secrets (encrypted JSON map of namespaced keys) |
Files are only downloadable by staff. Generate and commit migrations for all six tables in every host app and fixture (see Module Kit).
Translations
- Module chrome (buttons, stepper, admin) uses
useModuleT('forms')with English fallbacks; the module contributesforms.*messages (da/en) throughregisterLocales(see Module Kit). Override any of them in your app'si18n/locales/<code>.json(see overriding module strings), for example{ "forms": { "actions": { "submit": "Send inquiry" } } }. - Key groups:
actions,step,success,error,embed,preview,field,value,status,delivery,validation,admin,schedule,access,menu,captcha,builder. - Form content lives in the definition as
{ da, en }texts. - Validation messages of the compiler's own rules use
forms.validation.*; a field overrides them withvalidation.message.