synops/docs/concepts/publisering.md
vegard 7eae02eeb5 Fullfør oppgave 7.5: Segmenttabell-migrasjon og SRT-pipeline
Oppretter transcription_segments-tabellen i PostgreSQL som master-kopi
for alle transkripsjoner. transcribe.rs er oppdatert fra verbose_json
til SRT-format med full parse → segment-innsetting pipeline.

Endringer:
- Migration 005: transcription_segments med GIN fulltekstsøk (norsk)
- transcribe.rs: SRT-parser, segment-innsetting, node-oppdatering
- Miljøvariabler: WHISPER_MODEL (default "medium"), WHISPER_INITIAL_PROMPT
- Docker-compose: nye env vars for maskinrommet-containeren
- Docs: oppdatert podcastfabrikken, arkitektur, primitiver, CLAUDE.md

Tabellen kjørt på server, maskinrommet restartet med nye env vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:19:00 +01:00

28 KiB

Publisering — Fra privat tanke til offentlig artikkel

Kjerneflyt

En node reiser fra privat til publisert uten kopiering eller konvertering. Den får bare nye edges til samlinger med stadig rikere traits.

1. Privat tanke     Node uten edges. Bare din.
       ↓            (legg til belongs_to → kommunikasjonsnode)
2. Delt med venner  Diskutert, kommentert, forbedret.
       ↓            (legg til belongs_to → bredere gruppe)
3. Bredere gruppe   Flere perspektiver, mer feedback.
       ↓            (legg til belongs_to → samling med publishing-trait)
4. Publisert        Maskinrommet rendrer HTML, genererer URL.
       ↓
5. Verden leser     synops.no/pub/slug/id — Caddy → maskinrommet → CAS.

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 kopieres aldri. Den reiser gjennom systemet ved at edges endres.

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:

  1. submitted_to-edgen slettes
  2. belongs_to-edge opprettes fra artikkel → samling
  3. Valgfritt: publish_at i 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.

Håndhevelse i maskinrommet

Maskinrommet validerer alle edge-operasjoner. 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

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

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

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

Mekanikk:

  • Maskinrommet roterer varianter ved forside-rendering
  • Logger hvilken variant som ble vist (impression) og om leseren klikket videre til artikkelen (konvertering)
  • Normaliserer CTR mot tidspunkt — klikk kl. 08 mandag morgen har annen baseline enn kl. 22 lørdag kveld
  • Etter statistisk signifikans → vinneren markeres, taperen deaktiveres
  • Redaktøren kan alltid overstyre — pin en spesifikk variant

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.

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:

  • Sett hero → maskinrommet setter slot: "hero". Forrige hero flyttes automatisk tilbake til strøm.
  • Sett featured → slot: "featured", slot_order bestemmer rekkefølge. Overstiger antallet featured_max → eldste featured faller til strøm (FIFO).
  • Pin → artikkel blir stående i slot uavhengig av alder.

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.

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:

  • index_mode: "static" — full HTML rendres til CAS ved publisering. Passer for magasin/blogg med lav frekvens.
  • index_mode: "dynamic" — maskinrommet serverer on-demand med in-memory cache, invalidert ved publisering. index_cache_ttl styrer cachens levetid. Passer for nyhetsavis med høy frekvens.

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:

{
  "metadata": {
    "document": { /* TipTap/ProseMirror JSON */ },
    "rendered": {
      "html_hash": "cas://sha256-abc123",
      "rendered_at": "2026-03-17T14:30:00Z",
      "renderer_version": 2
    }
  }
}

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

# 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