Files
portal/docs/content-reference.md
T
Bendik LynghaugandClaude Opus 5 43a2f3a069
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m4s
Document files at the site root
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:31:21 +02:00

14 KiB

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/<anything>

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

title: Vestmark kommune          # the browser title and the header
wordmark: /site/logo.svg         # an image instead of the title
wordmark_ink: true               # a one-colour SVG mark drawn in the page's own ink
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 <post@vestmark.example>
  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.

Any text a person reads (name, description, label, placeholder, a mail's subject and body, consequence, encouragements) may be a list instead of one string: one entry per storyline, all lists the same length.

A question

One YAML file is one page.

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: The town hall
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.

Pictures across the page

slideshow lays a strip of pictures under the question, the full width of the screen: every picture as tall as the strip and as wide as its own shape, swiped or arrowed through, its caption over its foot.

slideshow:
  max_height: 60               # svh; 60 unless set, between 20 and 100
  slides:
    - image: images/built/01.webp     # a path in the content repo, or https://
      caption: The canteen at the theatre, renewed
      width: 3207                     # the picture's own size, optional:
      height: 1400                    # holds its place before it has loaded

Pictures in the content repo are served from this origin under /site/; webp, avif and jpeg may be kept by the browser for an hour. Keep them web-sized: around 1400 pixels tall is plenty.

Files at the site root

A repo's .well-known/ directory is served at the root of the site, at the same paths it has in the repo:

.well-known/security.txt      -> https://<site>/.well-known/security.txt
.well-known/assetlinks.json   -> https://<site>/.well-known/assetlinks.json
.well-known/matrix/server     -> https://<site>/.well-known/matrix/server

This is the one part of the URL space a repo may write into directly. RFC 8615 reserves /.well-known/, and a question's id comes from a file name under questions/, which cannot begin with a dot - so nothing shipped here can shadow a page. A path that is not in the repo answers 404, not the app shell, so a program looking for one of these can tell a missing file from a present one.

A file with no extension is served as JSON, which is what the extensionless well-known files are. .txt, .xml, .webmanifest and .mobileconfig are typed as themselves; the last matters because iOS only offers to install a configuration profile that arrives as application/x-apple-aspen-config.

Paths a reverse proxy claims in front of portal never reach the repo - mail autoconfiguration (/.well-known/autoconfig/) and the CalDAV and CardDAV redirects are usually handed to a mail server that generates them.

An alternative

One choice. Never two actions behind one heading: if two things can be done, they are two alternatives.

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:

    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
    # or with words of its own, using any field of the form:
    # invite:
    #   group: guests
    #   mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }

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.

    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)

        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:

          - 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.

          - 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:

        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 /notices/{id}, the dynamic page /notices/[key] whose form carries the notice's id back in with value: "{key}":

        resource:
          source: { kind: kv, bucket: notices }
          public: true
          link: { to: "/notices/{id}", label: I can lend or borrow this }

A resource

Live data, declared by content and read by the server. What a resource reads is never a client-supplied parameter.

        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}"
          limit: 1                 # at most this many, after jq
          empty: Nothing decided yet.
          link: { to: "/notices/{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.

limit: 1 shows only the first item — for a feed where only the newest matters. A releases card wants the cut someone should run, not every cut there has ever been, and a page that grows by one card per release gets longer forever. It applies after jq, so a filter can reorder and the limit takes the top of that ordering. A source that yields a single record has no length and ignores it; limit: 0 on a list shows nothing, and the empty line speaks instead.

A spec with neither public: true nor requires_group is unreachable: reads fail closed, and mutations always need a group whatever public says.

where: { field: email, equals: "{user.email}" } is "my cases": the records carrying the signed-in person's own address, read from the session. A file on such a record is theirs alone to open.

A record that carries a picture (a type: file field holding a JPEG, PNG, WebP, AVIF or GIF) is shown rather than read when the list has no transitions: a card with the picture on top, the first field as its heading, and the other fields under it, with no field names, state or time. The cards appear in the order the records were put up. That is how a site shows the people who work there, or a gallery of what it has built. On a desk, the same picture is a thumbnail that opens. accept: image/jpeg,image/png,image/webp on the field makes a phone send a JPEG whatever it keeps its photos as. What the person wrote is shown as markdown on stricter rules than the site's own: paragraphs, line breaks as typed, lists, emphasis and links to https, mailto or tel (marked nofollow ugc); no images, no raw HTML.

A desk (a list with transitions) shows every record still moving, and of those in a final state - one the state machine has no move out of - only the three most recently decided, newest first. "Show older" under the list brings three more each time it is pressed. The desk stays about the work in hand however long the site has run; nothing is hidden from a read-only list, and leaves_after still takes a record out of every list altogether.

A releases manifest - a kind: url resource whose JSON is { name, latest, versions: [{ version, date, notes, assets: [{ platform, url, sha256, size }] }], channels: { arch: { repo, package, note }, ios: { testflight, note } } }, newest version first, as a release pipeline writes it - renders as one card: the newest version, the file for the visitor's own platform first (read from the browser: ios, macos, linux-x86_64, linux-aarch64, windows, android), the other files and the channels folded under "All downloads". Linux gets the package repo lines beside its file; iPhones and Macs get TestFlight. Platforms a manifest lacks fall through to the folded list.

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.