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>
841 lines
32 KiB
Markdown
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 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
|