# Publisering — Fra privat tanke til offentlig artikkel

## Kjerneflyt

En artikkel reiser fra privat til publisert via edges. Kildematerialet —
chatmeldinger, notater, transkripsjoner — er egne noder koblet med
`source_material`-edges.

### To veier til en artikkel

**1. Artikkel fra start.** Bruker åpner artikkelverktøyet på
arbeidsflaten og skriver. Artikkelen er en innholdsnode fra fødsel.

```
1. Privat artikkel   Node med metadata.document. Bare din.
       ↓              (legg til intended_for → samling)
2. Under arbeid       Tema-forhåndsvisning i editoren.
       ↓              (submitted_to erstatter intended_for)
3. Innsendt           Redaktøren vurderer.
       ↓              (belongs_to erstatter submitted_to)
4. Publisert          Maskinrommet rendrer HTML, genererer URL.
```

**2. Artikkel fra kildemateriale.** Bruker drar chatbobler, notater eller
transkripsjoner til artikkelverktøyet. Ny artikkelnode opprettes med
`source_material`-edges tilbake til kildene. Artikkelen følger deretter
publiseringsreisen over. Se [arbeidsflaten](../retninger/arbeidsflaten.md).

```
Chatboble  ←source_material── Artikkel-node
Notat      ←source_material──    ↑
Transkripsjon ←source_material── │
                                  ↓
                    intended_for → submitted_to → belongs_to → publisert
```

Tilbaketrekking: fjern `belongs_to`-edgen → artikkelen forsvinner fra
publikasjonen. Noden lever videre privat.

## To publiseringsmodeller

Flyten ovenfor dekker den **personlige** modellen — du eier samlingen og
publiserer direkte. Men en publikasjon med flere bidragsytere trenger
**redaksjonell kontroll**: noen skriver, andre bestemmer hva som publiseres.

Forskjellen er én innstilling i `publishing`-traiten:

```jsonc
"publishing": {
  "slug": "sidelinja-magasin",
  "require_approval": true,
  "submission_roles": ["member"]
}
```

| Innstilling | Personlig blogg | Redaksjonell publikasjon |
|---|---|---|
| `require_approval` | `false` | `true` |
| `submission_roles` | — (ikke relevant) | `["member"]` eller `["member", "reader"]` |
| Hvem publiserer | Eieren selv | Owner/admin godkjenner |
| Edge ved publisering | `belongs_to` direkte | `submitted_to` → godkjenning → `belongs_to` |

`submission_roles` styrer hvem som kan sende inn. `member` betyr at du
må være medlem av samlingen. `reader` åpner for at lesere også kan
foreslå innhold — åpen innsending.

### Personlig publisering (`require_approval: false`)

```
Ole eier "Oles blogg" (samling med publishing-trait)
Ole skriver artikkel → legger til belongs_to-edge → publisert
```

Ingen mellomsteg. Maskinrommet rendrer HTML til CAS umiddelbart.

### Redaksjonell publisering (`require_approval: true`)

```
Redaktøren eier "Nettmagasinet" (samling med publishing-trait)
Ole er medlem (member-edge til samlingen)

1. Ole skriver artikkel (privat node)
2. Ole sender inn        → submitted_to-edge til Nettmagasinet
3. Redaktøren ser den    → visning: noder med submitted_to til min samling
4. Diskusjon             → kommunikasjonsnode med edges til artikkel + deltakere
5. Ole reviderer          (samme node, nytt innhold)
6. Redaktøren godkjenner → submitted_to erstattes med belongs_to
7. Redaktøren planlegger → publish_at i edge-metadata
8. Maskinrommet rendrer  → HTML til CAS ved publish_at
```

Artikkelen er alltid én node. Den reiser gjennom systemet ved at edges
endres. Materialet den bygger på er andre noder, sporbare gjennom grafen
via `source_material`-edges.

## Innsending: `submitted_to`-edge

Ny edge-type for redaksjonell publisering:

```
artikkel ──submitted_to──→ samling (med publishing-trait)
```

### Edge-metadata

```jsonc
{
  "status": "pending",
  "submitted_at": "2026-03-17T10:00:00Z"
}
```

### Status-verdier

| Status | Betydning | Hvem endrer |
|---|---|---|
| `pending` | Venter på vurdering | Settes automatisk ved innsending |
| `in_review` | Under vurdering | Redaktør |
| `revision_requested` | Forfatter må revidere | Redaktør (legger til feedback) |
| `rejected` | Avvist | Redaktør |
| `approved` | Godkjent, klar for publisering | Redaktør |

Metadata ved tilbakemelding:

```jsonc
{
  "status": "revision_requested",
  "submitted_at": "2026-03-17T10:00:00Z",
  "feedback": "Trenger sterkere intro. Kan du utdype kilden i avsnitt 3?",
  "feedback_by": "uuid-redaktør",
  "feedback_at": "2026-03-17T14:30:00Z"
}
```

### Fra godkjent til publisert

Når redaktøren godkjenner:

1. `submitted_to`-edgen slettes
2. `belongs_to`-edge opprettes fra artikkel → samling
3. Valgfritt: `publish_at` i edge-metadata for planlagt publisering

```jsonc
// belongs_to-edge ved planlagt publisering
{
  "publish_at": "2026-04-01T08:00:00Z",
  "approved_by": "uuid-redaktør",
  "approved_at": "2026-03-20T16:00:00Z"
}
```

Maskinrommet sjekker periodisk for `belongs_to`-edges med `publish_at`
i fortiden som ikke er rendret ennå. Ved treff: render HTML → CAS →
oppdater RSS.

Umiddelbar publisering: `publish_at` settes ikke, maskinrommet rendrer
med en gang.

### Avvisning

Redaktøren setter status til `rejected`. Artikkelen forblir Oles
private node — den forsvinner fra redaktørens visning, men Ole mister
ingenting. Ole kan revidere og sende inn på nytt (ny `submitted_to`-edge).

## Redaktørens arbeidsflate

Redaktørens "innboks" er **ikke** en egen node — det er en **visning**:

> "Noder med `submitted_to`-edge til min samling, gruppert på status"

Dette er en spørring mot grafen, konsistent med prinsippet om at
visninger er spørringer, ikke containere. Samlingens `kanban`-trait
(hvis aktiv) kan drive denne visningen som et brett:

```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Innkomne │ │ Under    │ │ Godkjent │ │ Planlagt │
│          │ │ vurdering│ │          │ │          │
│ Artikkel │ │ Artikkel │ │ Artikkel │ │ Artikkel │
│ fra Ole  │ │ fra Lise │ │ fra Kari │ │ fra Arne │
│          │ │          │ │          │ │ 1. april │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
```

Drag-and-drop mellom kolonner endrer `status` i edge-metadata.
Siste kolonne ("Planlagt") setter `publish_at`.

## Diskusjon om innsendt artikkel

Når redaktøren vil gi feedback utover et kort notat i edge-metadata,
opprettes en kommunikasjonsnode — samme primitiv som brukes for chat
og møter:

```
Kommunikasjonsnode (node_kind: "communication")
  ←── member ── Ole
  ←── member ── Redaktøren
  ←── belongs_to ── artikkelen (kontekst)
```

Samtalen lever som en vanlig tråd. Meldinger er noder med
`belongs_to`-edge til kommunikasjonsnoden. Når artikkelen er publisert
ligger samtalehistorikken igjen som arkiv — nyttig for revisjonshistorikk.

**Implementert:** `create_communication` aksepterer `context_id` som
oppretter `belongs_to`-edge automatisk. Redaksjonell arbeidsflate viser
"Start samtale"-knapp på hver innsending og lenker til eksisterende
samtaler. Redaktøren er owner, forfatteren member.

## Håndhevelse i maskinrommet (implementert)

Maskinrommet validerer alle edge-operasjoner i `create_edge` og
`update_edge`. For publiseringssamlinger med `require_approval: true`:

- **`belongs_to`-edge til samlingen:** Kun owner/admin kan opprette.
  Forsøk fra member/reader avvises.
- **`submitted_to`-edge til samlingen:** Tillatt for roller i
  `submission_roles`. Maskinrommet sjekker at brukeren har riktig
  rolle-edge til samlingen.
- **Status-endring på `submitted_to`:** Kun owner/admin kan endre
  status (godkjenne, avvise, be om revisjon). Forfatter kan kun
  trekke tilbake (slette sin `submitted_to`-edge).

For samlinger med `require_approval: false`:
- `belongs_to`-edge tillatt for owner/admin direkte.
- Ingen `submitted_to`-logikk.

## Hvorfor edge-metadata, ikke workflow-noder

En alternativ modell er å gjøre hvert beslutningspunkt til sin egen
node — en "innsending"-node, en "godkjenning"-node, etc. Det ville
følge "alt er noder"-filosofien konsekvent.

Vi velger edge-metadata i stedet fordi:

1. **Status er en egenskap ved relasjonen**, ikke en ting som
   eksisterer i seg selv. "Oles artikkel er innsendt til magasinet"
   beskriver forholdet mellom to noder, ikke en tredje entitet.
2. **Graf-hygiene.** Tre-fire ekstra noder per artikkel for å
   representere tilstand skaper bloat uten informasjonsgevinst.
3. **Enklere spørringer.** "Vis innsendte artikler" er én spørring
   mot edges. Med workflow-noder trenger du joins.

Unntaket er når et beslutningspunkt trenger *egen kontekst* —
samtaler, deltakere, historikk. Da opprettes en kommunikasjonsnode
(se "Diskusjon om innsendt artikkel" ovenfor). Men det er en
samtale, ikke en workflow-tilstand.

## Presentasjonselementer er noder

> **Status:** Implementert i oppgave 14.16 (presentasjonselementer) og
> 14.17 (A/B-testing). Backend: query-endpoint
> (`/query/presentation_elements`), rendering bruker presentasjonselementer
> (title, subtitle, summary, og_image) med fallback til artikkelnoden.
> Frontend: PresentationEditor-komponent integrert i PublishDialog.
> A/B-testing: maskinrommet roterer varianter ved forside-rendering,
> logger impressions/klikk i `ab_events`-tabell, evaluerer signifikans
> med z-test for proporsjoner, og markerer vinner/taper i edge-metadata.
> Redaktør kan overstyre via `POST /intentions/ab_override`.

En ingress er en tekst. En overskrift er en tekst. Et forsidebilde er
et bilde. Alt som vises *om* en artikkel på forsiden er en *ting med
egen forfatter, eget tidspunkt, og potensielt flere varianter*. Det
er noder, ikke felt.

```
Artikkel (innholdsnoden)
  ←── title ──── "Trikken kan bli gratis" (node, content)
  ←── title ──── "Gratis trikk fra 2027?" (node, content)
  ←── summary ── "Oslo bystyre vurderer..." (node, content)
  ←── summary ── "Fra 2027 kan trikken..." (node, content, AI-generert)
  ←── og_image ── Trikk i solnedgang (node, media)
  ←── og_image ── Bystyresalen (node, media)
```

### Prinsipper

1. **Hvis det er en tekst om en annen tekst, er det en node.**
   Ingress, undertittel, OG-beskrivelse, tweet-tekst, podcast-intro —
   alt som presenterer en annen node er selv en node.
2. **`nodes.title` er det interne displaynavnet.** "Vegard", "Møte
   15. mars", arbeidstittelen i redaksjonen. Det er ikke det som vises
   på forsiden til leserne.
3. **Varianter er naturlige.** Flere noder med samme edge-type til
   samme artikkel = flere varianter. Ingen spesiell mekanisme.

### Edge-metadata for varianter

```jsonc
// title-edge fra tittelnode → artikkel
{
  "variant": "editorial",    // "editorial", "ai", "social", "rss"
  "language": "no"
}
```

| Edge-type | Hva | Eksempel |
|---|---|---|
| `title` | Publisert overskrift | "Trikken kan bli gratis" |
| `subtitle` | Undertittel | "Bystyret splittet om finansiering" |
| `summary` | Ingress / forhåndsvisning | 1-2 setninger |
| `og_image` | Forsidebilde / OpenGraph-bilde | CAS-media-node |
| `og_description` | OG/meta-beskrivelse | Kort tekst for deling |

### Automatisk A/B-testing

> **Status:** Implementert i oppgave 14.17.

Når det finnes flere noder med samme edge-type til samme artikkel,
er default å A/B-teste automatisk.

**Mekanikk (implementert):**
- Maskinrommet roterer varianter tilfeldig ved forside-rendering
  (i `fetch_index_articles_optimized` via `ab_select()`)
- Logger impressions ved forside-serve (cache hit og miss) og klikk
  ved artikkelbesøk — alt asynkront (fire-and-forget) i `ab_events`-tabell
- `ab_events.hour_of_week` (0-167) lagres for tidspunkt-normalisering
- Periodisk evaluator (hvert 5. minutt) beregner CTR per variant,
  kjører z-test for proporsjoner (p < 0.05), oppdaterer edge-metadata
- Etter statistisk signifikans → vinneren markeres, taperne retires
- Redaktøren kan alltid overstyre via `POST /intentions/ab_override`

**Database:**
- `ab_events`-tabell (migrasjon 012) med `edge_id`, `article_id`,
  `collection_id`, `event_type` (impression/click), `hour_of_week`
- Indekser på `(edge_id, event_type)`, `(collection_id, created_at)`,
  `(article_id, edge_id)`

**Edge-metadata under testing:**

```jsonc
{
  "variant": "editorial",
  "ab_status": "testing",     // "testing", "winner", "retired"
  "impressions": 4821,
  "clicks": 312,
  "ctr": 0.0647,
  "started_at": "2026-04-01T08:00:00Z"
}
```

**Oppførsel:**
- Én variant → ingen A/B, bare visning. Null overhead.
- To+ varianter → automatisk testing. Null konfigurasjon.
- Redaktøren trenger ikke vite at dette skjer. Skriver du én tittel
  får du én tittel. Skriver du to får du automatisk testing.

**Endepunkter:**
- `GET /pub/{slug}/t/{article_id}?v={edge_id}` — klikk-sporing redirect
- `POST /intentions/ab_override` — redaktør overstyrer (krever owner/admin)

**Evaluering:**
- Minimum 100 impressions per variant før evaluering vurderes
- Z-test for proporsjoner med p < 0.05 for signifikans
- Multi-variant (>2): beste variant testes mot nest-beste
- Evaluator kjører som bakgrunns-loop (`start_ab_evaluator`)

### Hva med podcast-episoder?

Samme mønster. AI-analyse i podcastfabrikken genererer forslag til
tittel, sammendrag, show notes og kapittler — disse er noder med
edges til episoden:

```
Episode #42 (innholdsnode)
  ←── title ──── "Klimapolitikkens blinde flekk" (AI-generert)
  ←── title ──── "Ep. 42: Klimapolitikk" (redaksjonell)
  ←── summary ── AI-sammendrag (node, content)
  ←── summary ── Manuelt sammendrag (node, content)
  ←── show_notes ── AI-genererte show notes (node, content)
  ←── show_notes ── Redigerte show notes (node, content)
  ←── chapter ── "00:00 Intro" (node, metadata: { at: "00:00:00" })
  ←── chapter ── "05:23 Intervju" (node, metadata: { at: "00:05:23" })
```

Redaksjonen velger mellom AI-forslag og manuelt skrevne varianter.
A/B-testing gir mening for titler og sammendrag i RSS-feeden.
Kapittler er ikke A/B-testbare, men de er fortsatt noder fordi de
har egne tidsstempler, kan redigeres uavhengig, og kan ha flere
varianter (f.eks. AI-genererte vs. manuelt justerte).

## Skriveopplevelse vs. forside-kontroll

### Problemet

TipTap lar forfatteren formatere fritt — overskrifter, blockquotes,
inline-stiler, bilder. Det er bra for artikkelen. Men forsiden trenger
forutsigbar, enhetlig struktur. Hvis forfatteren åpner med en `<h2>`,
bruker kursiv overalt, eller legger inn en tabell i første avsnitt,
ser forside-kortet rotete ut.

### Løsningen: forsiden leser aldri fra artikkelens dokument

Forsiden bruker *kun* presentasjonsnodene — aldri `metadata.document`:

| Forside-element | Kilde | Format |
|---|---|---|
| Overskrift | `title`-node | Ren tekst, ingen formatering |
| Ingress | `summary`-node | Ren tekst, ~200 tegn |
| Bilde | `og_image`-node | CAS-media-node |
| Forfatter | `created_by` → person-node | `title` på personnoden |
| Dato | `publish_at` i edge-metadata | Tidsstempel |

Presentasjonsnodene har kun `content`-feltet (ren tekst). Ingen
`metadata.document`, ingen TipTap-formatering. Forfatteren skriver
tittel og ingress i egne, enkle tekstfelt — ikke i rik-editoren.
Det er en bevisst begrensning: forsiden er designerens domene.

### Artikkelsiden: fri formatering, styrt av tema

Inne i artikkelen rendres `metadata.document` fritt — TipTap-struktur
er ønsket. Men forfatteren bestemmer *struktur* (overskrifter,
blockquotes, bilder, lister), temaet bestemmer *stil* (fonter, farger,
spacing, bredde). Tera-templaten wrapper dokumentet i tema-CSS som
normaliserer utseendet.

```
Forside:       title-node.content + summary-node.content + og_image
               → ren tekst, tema-styrt layout, alltid forutsigbart

Artikkelside:  metadata.document → Tera-template + tema-CSS
               → rik formatering, men temaet styrer utseende
```

### Publiseringssteget i frontend

Når forfatteren publiserer, vises et publiseringspanel ved siden av
(eller over) editoren:

```
┌─────────────────────────────────────────────┐
│ Publiser til: Nettmagasinet                  │
├─────────────────────────────────────────────┤
│ Tittel:    [ Trikken kan bli gratis       ] │
│ Ingress:   [ Oslo bystyre vurderer gratis ] │
│            [ kollektivtransport fra 2027. ] │
│ Bilde:     [ 🖼 Dra bilde hit / velg     ] │
│ Slug:      [ /om-trikken                  ] │
│ Publiser:  [ Nå ▾ ]  [ Publiser ]          │
├─────────────────────────────────────────────┤
│ + Legg til variant (for A/B-testing)         │
└─────────────────────────────────────────────┘
```

Hvert felt oppretter en presentasjonsnode med riktig edge-type.
Feltene er *enkle tekstfelt* — ingen rik-editor, ingen formatering.
"Legg til variant" oppretter en ekstra node av samme type.

Forfatteren som bare vil publisere fyller ut tittel og ingress og
trykker publiser. Forfatteren som vil A/B-teste legger til en variant.
Redaktøren kan overstyre begge deler.

### Forhåndsvisning med publikasjonens tema

Forfatteren kan forhåndsvise artikkelen med målpublikasjonens
utseende direkte i editoren:

```
┌─ Editor ──────────────────────────────────────┐
│ Visning: [ Redigering ▾ ]  [ Trikkemagasinet ] │
│                                                │
│  (innholdet vises med temaets fonter, farger,  │
│   bredde, spacing — WYSIWYG for publisert      │
│   resultat)                                    │
└────────────────────────────────────────────────┘
```

Teknisk: frontend henter `theme_config` fra samlingens
`publishing`-trait og appliserer CSS-variablene på editor-containeren.
Ingen rendering, ingen maskinrommet-kall — ren CSS-bytte i frontend.

Forutsetning: forfatteren har edge til samlingen — enten `member_of`
eller `intended_for` (se nedenfor).

### `intended_for`-edge: artikkelens reise

Forfatteren markerer tidlig hvilken publikasjon artikkelen er ment
for. Det er en edge — `intended_for` — som uttrykker intensjon uten
å sende inn:

```
intended_for → submitted_to → belongs_to
(arbeidsfase)   (vurdering)    (publisert)
```

Én relasjon mellom artikkel og samling. Edge-typen *er* tilstanden:

| Edge-type | Fase | Hva skjer |
|---|---|---|
| `intended_for` | Skriving | Tema-forhåndsvisning i editor, samlingen vet ingenting |
| `submitted_to` | Vurdering | Redaktøren ser artikkelen, status-metadata styrer flyten |
| `belongs_to` | Publisert | Maskinrommet rendrer, artikkelen er live |

`intended_for` gir systemet kontekst fra starten:
- Hvilken tema-CSS som tilbys i editoren
- Hvilke presentasjonselementer samlingen krever (f.eks. Trikkemagasinet
  krever ingress + bilde, bloggen bare tittel)
- Hvem som potensielt vil motta artikkelen ved innsending

Forfatteren kan endre `intended_for` fritt — det er bare en
arbeidshypotese. Først ved eksplisitt innsending konverteres den
til `submitted_to`.

## Temaer og forside-layout

### Innebygde temaer

Hvert tema er et sett med Tera-templates (Jinja2-lignende, Rust-native)
og CSS-variabler. Tema velges i `publishing`-traiten:

```jsonc
"publishing": {
  "slug": "nettmagasinet",
  "theme": "avis",
  "theme_config": {
    "colors": { "primary": "#1a1a2e", "accent": "#e94560" },
    "typography": {
      "heading_font": "Georgia, serif",
      "body_font": "system-ui, sans-serif"
    },
    "layout": { "max_width": "1200px" },
    "logo_hash": "cas://sha256-def456"
  },
  "index_mode": "dynamic",
  "index_cache_ttl": 300,
  "stream_page_size": 20,
  "featured_max": 4
}
```

Temaet setter alle defaults. `theme_config` overstyrer spesifikke
verdier via CSS-variabler (`--color-primary`, `--font-heading`, etc.).
Fungerer meningsfullt med bare `"theme": "magasin"` — null konfigurasjon.

| Tema | Karakter | Forside-layout |
|---|---|---|
| **Avis** | Tett, multi-kolonne, informasjonstung | Hero + sidebar + rutenett |
| **Magasin** | Store bilder, luft, editorial | Hero fullbredde + cards + kronologisk |
| **Blogg** | Enkel, én kolonne, personlig | Kronologisk liste, evt. pinned øverst |
| **Tidsskrift** | Akademisk, tekstdrevet, minimalt | Nummerliste med innholdsfortegnelse |

Temaer er kode (Tera + CSS) som lever i repoet. Nye temaer er en
utvikleroppgave, ikke en brukeroppgave. Ingen page-builder, ingen
plugin-arkitektur — innebygde temaer som ser bra ut.

### Redaksjonell prioritering via slots

Redaktøren styrer forsiden gjennom `slot`-metadata på `belongs_to`-edgen:

```jsonc
// belongs_to-edge fra artikkel → publikasjon
{
  "publish_at": "2026-04-01T08:00:00Z",
  "slot": "hero",
  "slot_order": 1,
  "pinned": false
}
```

Tre plasser med økende automatisering:

| Plass | Antall | Styring | Visning |
|---|---|---|---|
| `hero` | Maks 1 | Manuell | Stor, dominant øverst |
| `featured` | Konfigurerbart (default 4) | Manuell | Fremhevet, mindre enn hero |
| `null` (strøm) | Ubegrenset | Automatisk | Kronologisk etter `publish_at` |

**Gode defaults uten kurerering:** Alt som publiseres havner i strømmen,
sortert på dato. Ingen hero, ingen featured — forsiden er en ren
kronologisk flyt som fungerer fra dag én uten redaksjonelt arbeid.

**Når redaktøren griper inn** (via `POST /intentions/set_slot`):
- Sett hero → maskinrommet setter `slot: "hero"`. Forrige hero flyttes
  automatisk tilbake til strøm (med mindre den er pinned).
- Sett featured → `slot: "featured"`, `slot_order` bestemmer rekkefølge.
  Overstiger antallet `featured_max` → eldste ikke-pinned featured faller
  til strøm (FIFO).
- Pin → `pinned: true` i edge-metadata. Artikkel blir stående i slot
  uavhengig av alder og automatisk fjerning.
- Krever owner/admin-tilgang til samlingen.

### Forside-administrasjon i frontend

```
┌─────────────────────────────────────────┐
│              HERO                         │
│  [ Drag artikkel hit ]                   │
├──────────┬──────────┬──────────┬────────┤
│ FEATURED │ FEATURED │ FEATURED │  [ + ] │
├──────────┴──────────┴──────────┴────────┤
│ STRØM (automatisk, nyeste først)         │
│  • Artikkel 5 — 1. april                │
│  • Artikkel 4 — 28. mars     [📌 Pin]   │
│  • Artikkel 3 — 25. mars     [⬆ Fremhev]│
└─────────────────────────────────────────┘
```

Drag-and-drop mellom plasser. Maskinrommet oppdaterer edge-metadata
og regenererer forside (CAS eller cache-invalidering).

### Sidetyper

| Side | Kilde | Rendering |
|---|---|---|
| **Forside** | Hero + featured + strøm | Statisk CAS eller dynamisk cache |
| **Artikkelside** | Én node | Statisk CAS (alltid) |
| **Kategori/tag** | Artikler med bestemt tag-edge | Dynamisk, maskinrommet, paginert |
| **Arkiv** | Alle artikler kronologisk | Dynamisk, maskinrommet, paginert |
| **Søk** | Fulltekstsøk i PG | Dynamisk, maskinrommet |
| **Om-side** | Node med `page_role: "about"` | Statisk CAS |

Enkeltartikler rendres alltid til statisk CAS. Forsiden kan være
statisk CAS (magasin, lav frekvens) eller dynamisk med cache
(nyhetsavis, høy frekvens) — styrt av `index_mode` i trait-konfig.
Kategori-, arkiv- og søkesider er alltid dynamiske med paginering.

**Implementert (mars 2026):** Alle sidetyper er implementert:

- **Kategori:** `/pub/{slug}/kategori/{tag}` — filtrerer artikler via
  `tagged`-edges til tag-noder (`node_kind: 'tag'`). Paginert (20/side),
  cachet i `DynamicPageCache` med samlingens `index_cache_ttl`.
- **Arkiv:** `/pub/{slug}/arkiv` — kronologisk med månedsgruppering.
  Paginert, cachet.
- **Søk:** `/pub/{slug}/sok?q=...` — PostgreSQL fulltekstsøk via
  `tsvector`-kolonne med GIN-indeks. Norsk stemming (`'norwegian'`),
  vektet tittel (A) og innhold (B), rangert via `ts_rank`. Kort
  cache-TTL (maks 60s).
- **Om-side:** `/pub/{slug}/om` — node med `metadata.page_role: "about"`
  og `belongs_to`-edge til samlingen. Serveres fra CAS med immutable
  cache hvis pre-rendret, ellers on-the-fly via `about.html`-template.
  `render_about_to_cas()` lagrer rendret HTML i CAS.
- **Navigasjon:** Base-template inkluderer lenker til Arkiv og Søk i
  headeren for alle publiserte sider.
- **Custom domains:** Alle dynamiske sider har custom domain-varianter
  (`/custom-domain/kategori/{tag}`, `/custom-domain/arkiv`, etc.).

### Skalering for store publikasjoner

Designet skal håndtere en nettavis med ~30.000 artikler over 30 år
(~3 per dag). Implikasjoner:

**Enkeltartikler i CAS:** 30.000 HTML-filer á ~80KB = ~2.4 GB. Trivielt.
CAS-pruning beholder kun gjeldende `html_hash` per artikkel — eldre
versjoner prunes automatisk.

**Forside-spørringer:** Forsiden trenger aldri alle 30.000 artikler.
Tre indekserte spørringer:

```sql
-- Hero (maks 1)
SELECT n.* FROM nodes n JOIN edges e ON e.source_id = n.id
WHERE e.target_id = $collection AND e.edge_type = 'belongs_to'
  AND e.metadata->>'slot' = 'hero';

-- Featured (maks N)
SELECT n.* FROM nodes n JOIN edges e ON e.source_id = n.id
WHERE e.target_id = $collection AND e.edge_type = 'belongs_to'
  AND e.metadata->>'slot' = 'featured'
ORDER BY (e.metadata->>'slot_order')::int;

-- Strøm (paginert)
SELECT n.* FROM nodes n JOIN edges e ON e.source_id = n.id
WHERE e.target_id = $collection AND e.edge_type = 'belongs_to'
  AND e.metadata->>'slot' IS NULL
ORDER BY (e.metadata->>'publish_at')::timestamptz DESC
LIMIT 20 OFFSET $page;
```

Med indeks på `(target_id, edge_type)` og GIN-indeks på `metadata`
er dette raskt uansett samlingsstørrelse.

**Forside-rendering (implementert):**
- `index_mode: "static"` — full HTML rendres til CAS via `render_index`-jobb
  ved publisering. Samlingens `metadata.rendered_index.index_hash` peker til
  CAS-filen. Serveres med `Cache-Control: immutable`.
  Passer for magasin/blogg med lav frekvens.
- `index_mode: "dynamic"` (default) — maskinrommet serverer on-demand med
  in-memory cache (`IndexCache`), invalidert ved publisering (belongs_to-endring).
  `index_cache_ttl` (default 300s) styrer cachens levetid.
  Passer for nyhetsavis med høy frekvens.
- Tre separate indekserte PG-spørringer for hero/featured/strøm —
  filtrerer på `slot` i edge-metadata, bruker GIN-indeks.

**Bulk re-rendering ved temaendring:** Temaendring trigger batch-jobb
via jobbkøen. Maskinrommet paginerer 100 artikler om gangen, rendrer
til CAS, oppdaterer `metadata.rendered.html_hash`. Med ~100ms per
artikkel: ~50 min for 30.000. Ikke blokkerende — artikler serveres
med gammelt tema til de er re-rendret. `renderer_version` i metadata
identifiserer hvilke som gjenstår.

**RSS-feed:** Inneholder de `rss_max_items` nyeste (default 50).
Regenereres ved publisering. Trivielt uansett samlingsstørrelse.

## URL-struktur

### Uten eget domene

```
synops.no/pub/{samlings-slug}/{node-short-id}
```

Eksempel: `synops.no/pub/mittmagasin/a7f3e2`

- `samlings-slug` er unik per publiseringssamling
- `node-short-id` er et kort derivat av node-id (stabilt, permanent)
- Valgfri lesbar slug: `synops.no/pub/mittmagasin/om-trikken-og-tanker`
  (lagret som metadata på noden, redirect ved endring)

### Med eget domene

```
mittmagasin.no/a7f3e2
mittmagasin.no/om-trikken-og-tanker
```

Domenet kobles i samlingens trait-konfigurasjon. Caddy håndterer TLS og
ruting automatisk.

## HTML-rendering og CAS

Rendret HTML lagres i CAS (content-addressable storage), akkurat som
andre avledede representasjoner (transkripsjoner, thumbnails).

```
Dokument (metadata.document)  →  Renderer  →  HTML (CAS)
Lydfil (CAS)                  →  Whisper    →  Transkripsjon (content)
```

Noden peker på rendret resultat via metadata:

```jsonc
{
  "metadata": {
    "document": { /* TipTap/ProseMirror JSON */ },
    "rendered": {
      "html_hash": "a1b2c3d4e5f6...",  // SHA-256 hex-digest, peker til CAS
      "rendered_at": "2026-03-17T14:30:00Z",
      "renderer_version": 1
    }
  }
}
```

### Serving-modell

Caddy reverse-proxyer publiserings-URLer til maskinrommet. Maskinrommet
slår opp CAS-hash fra node-metadata og streamer filen:

```
Leser → Caddy → maskinrommet (slug → hash oppslag) → CAS-fil fra disk
                ↑ Cache-Control: public, max-age=31536000, immutable
```

Maskinrommet eier mappingen mellom slug og CAS-hash — å duplisere den
til filsystemet (symlinks) eller Caddy-konfig ville vært en ekstra
synkroniseringsbyrde uten reell gevinst. CAS-hashen endres aldri, så
Caddy og nettlesere cacher aggressivt.

For kategori-, arkiv- og søkesider serverer maskinrommet dynamisk HTML
direkte (ingen CAS), med kortere cache-TTL.

### Gevinster

- **Deduplisering** — rediger og angre → hashen peker tilbake til forrige
  versjon uten ekstra lagring
- **Immutabel** — en gitt hash er alltid samme HTML. Caches aggressivt,
  CDN-vennlig
- **Pruning fungerer** — gammel rendret HTML uten referanser ryddes bort
  som alt annet i CAS
- **Revisjonshistorikk gratis** — hver publisering genererer ny hash
- **Bulk-regenerering** — `renderer_version` lar maskinrommet finne alle
  noder med eldre versjon og re-rendere ved malendring
- **Én sannhetskilde** — maskinrommet eier slug→hash-mappingen, ingen
  symlinks eller filsystem-synkronisering å vedlikeholde

## Custom domain-mekanisme

1. Bruker legger til domene i samlingens `publishing`-trait:
   ```jsonc
   "publishing": { "custom_domain": "mittmagasin.no", ... }
   ```
2. Maskinrommet validerer at DNS peker til serveren
3. Caddy registrerer domenet via on-demand TLS — sertifikat hentes
   automatisk ved første besøk
4. Validerings-callback fra Caddy mot maskinrommet bekrefter at domenet
   er registrert

### Caddy on-demand TLS-konfigurasjon

```caddyfile
# Dynamiske custom domains for publiseringssamlinger
:443 {
    tls {
        on_demand {
            ask http://maskinrommet:3100/internal/verify-domain
        }
    }
    reverse_proxy maskinrommet:3100
}
```

Maskinrommet svarer 200 hvis domenet tilhører en samling med
`publishing`-trait, 404 ellers. Caddy henter kun sertifikat for
verifiserte domener.

## RSS/Atom

En samling med `rss`-trait genererer feed automatisk:

```
synops.no/pub/{slug}/feed.xml
mittmagasin.no/feed.xml
```

Feeden genereres på nytt ved publisering/avpublisering. For podcast-samlinger
inkluderes `<enclosure>`-tags med lyd-URLer. Samme mønster som eksisterende
podcastfabrikken-konsept.

## SEO og metadata

Ved rendering genererer maskinrommet:

- `<title>` fra node-tittel
- `<meta name="description">` fra første avsnitt eller manuell oppsummering
- OpenGraph-tags (tittel, beskrivelse, bilde)
- `<link rel="canonical">` for å unngå duplikat-innhold
- Strukturert data (JSON-LD) for artikler
- `<link rel="alternate" type="application/atom+xml">` for feed

OG-defaults kan settes på samlingsnivå i `publishing`-traiten, med
mulighet for overstyring per node.

## Tilgangskontroll

Publisert innhold serveres *uten autentisering*. Tilgangsmodellen:

- Samlingen har `visibility: open` (eller spesifikk publishing-logikk)
- Noder med `belongs_to`-edge til publiseringssamlingen rendres som HTML
- Noder uten slik edge er usynlige for offentligheten
- Kommentarer (hvis `comments`-trait er aktiv) kan kreve innlogging
  eller tillate anonyme bidrag avhengig av konfigurasjon
