# 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: ```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 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 ```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. ## 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 ```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 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:** ```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. ### 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 `

`, 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:** - 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: ```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:** - `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: ```jsonc { "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-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 ``-tags med lyd-URLer. Samme mønster som eksisterende podcastfabrikken-konsept. ## SEO og metadata Ved rendering genererer maskinrommet: - `` 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