synops/docs/concepts/publisering.md
vegard 1425a82cdd Fullfører oppgave 14.17: A/B-testing for presentasjonselementer
Implementerer automatisk A/B-testing for forside-varianter:

- PG-migrasjon 012: ab_events-tabell for impression/klikk-logging
  med hour_of_week (0-167) for tidspunkt-normalisering
- Variant-rotasjon: ab_select() velger tilfeldig blant testing-varianter
  ved forside-rendering, winner prioriteres, retired filtreres bort
- Impression-logging: asynkron fire-and-forget ved forside-serve
  (både cache-hit og -miss), lagres i ab_events
- Klikk-attribusjon: artikkelbesøk sjekker forside-cache for aktive
  AB-varianter og logger klikk. Eksplisitt tracking via
  GET /pub/{slug}/t/{article_id}?v={edge_id}
- Periodisk evaluator (300s intervall): z-test for proporsjoner
  (p < 0.05), minimum 100 impressions per variant, oppdaterer
  edge-metadata (ab_status, impressions, clicks, ctr)
- Redaktør-overstyring: POST /intentions/ab_override markerer
  valgt variant som winner, andre som retired (krever owner/admin)
- Auto-initialisering: maybe_start_ab_test() setter ab_status=testing
  automatisk når >1 variant av samme type opprettes

Alle 42 tester passerer inkludert 3 nye z-test-tester.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 03:13:39 +00:00

841 lines
32 KiB
Markdown

# 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 `(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