# Content reference A content repo is the site. This is everything it may say. ``` site.yaml branding, language, mail aggregates.yaml the state machine of every bucket needs.yaml what the site is for (read by iris, never by portal) questions/ index.yaml the front page, served at / about.yaml served at /about review/ _section.yaml applies to everything under /review index.yaml served at /review [record].yaml serves /review/ ``` Nothing here is required except a `questions/` tree with something in it. Portal's lint refuses content it cannot serve honestly, at boot, on every hot reload, and in CI through `iris check`. ## site.yaml ```yaml title: Vestmark kommune # the browser title and the header wordmark: /site/logo.svg # an image instead of the title wordmark_invert: true # flip it in dark mode wordmark_height: 2.4 # rem lang: no # the interface language: en or no stylesheet: /site/theme.css # served same-origin from the repo favicon: /site/icon.svg hero: { kind: module, module: hero.js } # or { kind: plain } mail: from: Vestmark kommune reply_to: post@vestmark.example invite: { subject: "...", body: "..." } # rewords the built-in invite mail ``` `mail.from` is required as soon as any state declares a mail. ## A question One YAML file is one page. ```yaml id: /apply # defaults to the file's path name: Apply to build description: What we need from you. qualifies: case_officers # a Kanidm group may open this page requires_chain: /start # or: an answer they must already have given followup: true # keep it out of the nav until earned responsible: { name: Solveig, contact: post@vestmark.example } # or a group's: no account is made for the inbox, and any signed-in # member of the group may suggest a change to the page responsible: { name: The board, contact: board@vestmark.example, group: board } event: # this page announces an occasion starts: 2026-10-14T18:00 duration: 2h place: The town hall place: # this page is about somewhere lat: 59.8938 lon: 11.0332 zoom: 15 name: Tomter alternatives: [ ... ] ``` `qualifies`, `requires_chain` and `responsible` are inherited from the nearest `_section.yaml`. `event` makes a page an occasion with a time; `place` makes it a place, drawing a still map under the question and opening any `type: location` field on the page there rather than on the whole world. ## An alternative One choice. Never two actions behind one heading: if two things can be done, they are two alternatives. ```yaml alternatives: - name: Send the drawings description: We answer within twelve weeks. action: /sent # the page to go to; /shape/{field} templates record_as: applications # the bucket the answer lands in disabled: false consequence: [What happens next] encouragements: [It takes two minutes] images: [/site/drawing.png] features: [ ... ] ``` Three things an alternative may do instead of, or beside, recording an answer: ```yaml self_transition: # the person moves their own record bucket: applications to: complete from: [open, incomplete] # defaults to the initial state alone label: Send them invite: { group: case_officers } # onboard someone into Kanidm ``` `self_transition.from` is what keeps a person in dialogue with their case: an applicant written to for a missing drawing answers from `incomplete`, and may withdraw from wherever the case has got to. Omitted, it is the initial state alone, which is what a one-shot unsubscribe link means. Each state listed becomes its own access rule, and the lint refuses a `from -> to` the state machine does not declare. An `invite` needs a required `name` and `email` on the same alternative, and the inviting group must be allowed to invite that group by the policy. ## A feature What an alternative offers, explained. A feature that is only layout is a feature wasted: each one says something true about the choice. ```yaml features: - name: What you need description: The case number from the letter. icon: document color: "#8ec2c0" requirements: [ ... ] # form fields resource: { ... } # live data ``` ## A requirement (a form field) ```yaml requirements: - name: email # snake_case; this is the stored key label: Your email placeholder: you@example.no type: email optional: false value: "{key}" # a preset; a dynamic page's segment fills it ``` `value: "{user.email}"` or `"{user.name}"` is the signed-in person's own, and a field it fills is carried rather than asked: someone the site knows is not asked who they are. A visitor gets the field, empty. `type` is one of: | type | what it is | |---|---| | `text` | the default | | `email`, `tel`, `date`, `number`, … | anything else is passed straight through as the HTML input type | | `textarea` | several lines | | `prosekit` | a rich-text editor | | `select` | one choice, or `multiple: true` for any number | | `file` | an upload, with `accept:` and `multiple:` | | `location` | a pin on a map, stored as `{lat, lon, zoom}` | | `record` | the record this one belongs to | | `gesture`, `voice` | a drawn stroke or a recording, through a redoal relay | | `hidden` | a carrier value, no label row | | `module` | a widget the content repo ships | A `select` takes either inline `options: [...]` or a `resource:` to draw them from, with `id_field:` naming each item's stable id. A field may load its value from a resource when a sibling changes: ```yaml - name: contents type: textarea bind: field: path # the sibling to watch resource: { source: { kind: url, url: "https://git.example/raw/{path}" } } ``` ### type: record How one case belongs to another — an objection to the application it objects to, an appeal to the decision it appeals. ```yaml - name: case type: record of: applications # the bucket the other record is in value: "{key}" # arrives on the link, on a dynamic page ``` Nobody types one of these: the stored value is the parent's record id, which is a chain hash. It arrives the way it was given out, so the field carries rather than asks, and renders hidden. A submission naming a record that is not there is refused, so a reference in a stored record is one that resolved at least once. The other half is a listing that asks for the children: ```yaml resource: source: { kind: kv, bucket: objections } requires_group: case_officers where: { field: case, equals: "{key}" } ``` And the way there from a list: `link` puts a link on every listed record to a page about it, with `{id}` for the record's id and `{field}` for any of its fields. A notice on a public board links to `/oppslag/{id}`, the dynamic page `/oppslag/[key]` whose form carries the notice's id back in with `value: "{key}"`: ```yaml resource: source: { kind: kv, bucket: oppslag } public: true link: { to: "/oppslag/{id}", label: Jeg kan låne ut eller låne denne } ``` ## A resource Live data, declared by content and read by the server. What a resource reads is never a client-supplied parameter. ```yaml resource: source: { kind: kv, bucket: applications } key: abc123 # one record instead of the list public: true # or requires_group: case_officers states: [approved, refused] # only these; empty means all where: { field: case, equals: "{key}" } jq: ".[] | {name, url: .html_url}" empty: Nothing decided yet. link: { to: "/oppslag/{id}", label: Answer this } # a page per record transitions: # the desk's buttons - { from: complete, to: on_hearing, label: Send it out } ``` `source.kind` is one of `kv`, `gitea_starred`, `gitea_releases`, `url`. A spec with neither `public: true` nor `requires_group` is unreachable: reads fail closed, and mutations always need a group whatever `public` says. `states` and `where` narrow a listing. They matter most for a register that is public by law — public about what was decided, not about what was withdrawn — while the desk working the same bucket is a different spec and still sees all of it. A record that has no such field never matches: "everything that did not say" is not something a page can ask for by accident. ## needs.yaml What the site is for, as a checkable spec: who wants what, which bucket the answer must land in, which group carries it to a finished state. The runtime never reads it. `iris check` proves the site meets it, and `iris replay` runs cases through the real engine.