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>
32 KiB
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.
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:
"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
{
"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:
{
"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:
submitted_to-edgen slettesbelongs_to-edge opprettes fra artikkel → samling- Valgfritt:
publish_ati edge-metadata for planlagt publisering
// 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 isubmission_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 sinsubmitted_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:
- 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.
- Graf-hygiene. Tre-fire ekstra noder per artikkel for å representere tilstand skaper bloat uten informasjonsgevinst.
- 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 iab_events-tabell, evaluerer signifikans med z-test for proporsjoner, og markerer vinner/taper i edge-metadata. Redaktør kan overstyre viaPOST /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
- 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.
nodes.titleer det interne displaynavnet. "Vegard", "Møte 15. mars", arbeidstittelen i redaksjonen. Det er ikke det som vises på forsiden til leserne.- Varianter er naturlige. Flere noder med samme edge-type til samme artikkel = flere varianter. Ingen spesiell mekanisme.
Edge-metadata for varianter
// 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_optimizedviaab_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) mededge_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:
{
"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 redirectPOST /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:
"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:
// 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_orderbestemmer rekkefølge. Overstiger antalletfeatured_max→ eldste ikke-pinned featured faller til strøm (FIFO). - Pin →
pinned: truei 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 viatagged-edges til tag-noder (node_kind: 'tag'). Paginert (20/side), cachet iDynamicPageCachemed samlingensindex_cache_ttl. - Arkiv:
/pub/{slug}/arkiv— kronologisk med månedsgruppering. Paginert, cachet. - Søk:
/pub/{slug}/sok?q=...— PostgreSQL fulltekstsøk viatsvector-kolonne med GIN-indeks. Norsk stemming ('norwegian'), vektet tittel (A) og innhold (B), rangert viats_rank. Kort cache-TTL (maks 60s). - Om-side:
/pub/{slug}/om— node medmetadata.page_role: "about"ogbelongs_to-edge til samlingen. Serveres fra CAS med immutable cache hvis pre-rendret, ellers on-the-fly viaabout.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:
-- 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 viarender_index-jobb ved publisering. Samlingensmetadata.rendered_index.index_hashpeker til CAS-filen. Serveres medCache-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å
sloti 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-sluger unik per publiseringssamlingnode-short-ider 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:
{
"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_versionlar 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
- Bruker legger til domene i samlingens
publishing-trait:"publishing": { "custom_domain": "mittmagasin.no", ... } - Maskinrommet validerer at DNS peker til serveren
- Caddy registrerer domenet via on-demand TLS — sertifikat hentes automatisk ved første besøk
- Validerings-callback fra Caddy mot maskinrommet bekrefter at domenet er registrert
Caddy on-demand TLS-konfigurasjon
# 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