CMS-krønike·marts 2026·3 minutter læsning

CMS Kronik #06: Blokeditor og Docker Blokeditor & Docker

Én billedjustering-fejl, der udviklede sig til et fuldt blokeditor-system, en ødelagt upload-arkitektur, der blev repareret tre gange, og to fungerende deploymenter — lokal Docker og Fly.io.

Det starter altid med "bare en lille ting"

Et billede i TipTap flød venstre i editoren, men ikke på siden. "Vi ordner det hurtigt."

Seks timer senere: Et fuldt bloksystem, et billedkaruseller, en genopbygget upload-arkitektur og vores første Dockerfile.

Bloksystemet

Kerneideen: Genanvendelige indholdselementer — sammenligninger, notitser, karuseller — defineret i cms.config.ts og indsat i artikler med [block:slug]. Hvert bloktype har sit eget felt-skema; editoren viser kun de relevante felter baseret på en blockType-diskriminator. Samme mønster som Drupal-paragraffer, implementeret i 30 linjer TypeScript.

Blokke renderes som 🧩-brikker i TipTap via en tilpasset atomisk Node-udvidelse. Dobbeltklik for at erstatte, autoudfyldningssøgning, Enter for at vælge. En billedkarusell-blok med træk-og-slip-gallerieditor var den første, vi byggede for at teste hele systemet ende-til-ende.

To fejl, begge usynlige i TypeScript

Efter at have bygget hele systemet viste [block:slug] intet på siden.

Fejl 1: Regulært udtryk matchede ikke, fordi TipTap havde escapet [block:slug] til \[block:slug\] i lagring. Fix: Normaliser før regulært udtryk.

Fejl 2: getBlocksFromContent() returnerer fulde Document-objekter, men BlockRenderer læste data.blockType, hvor den faktiske sti er doc.data.blockType. Fix: Pak .data ud, før det sendes til rendereren.

Begge var skjult bag as unknown as-casts. TypeScript kan ikke redde dig fra formmismatch, du selv har castet væk.

Upload-arkitekturen brød tre gange

To separate Next.js-apps kan ikke dele en public/-mappe. Vi gennemgik de åbenlyst forkerte løsninger — ændre UPLOAD_DIR, tilføje et symbolsk link — før vi landede på den rigtige: Admin-serveren leverer uploads dynamisk via GET /api/uploads/[...path], der læser fra UPLOAD_DIR, med en Next.js-omskrivning, der mapper /uploads/* til den rute. Ingen symbolske links, ingen kopiering, fungerer overalt.

TipTap-rundturejser

Billedjustering gik også tabt ved genindlæsning. tiptap-markdown serialiserer float:left til title-attributten, men parser det ikke tilbage. Fix: parse.updateDOM() i udvidelseslageret — gennemgå <img>-elementer, før ProseMirror parser DOM’en, og genskab data-align og style.width fra titlen.

At skrive en tilpasset serializer er ikke nok. Du skal også skrive parseren.

Docker: to containere, én kommando

CMS-admin-Dockerfilen er flertrins: Alpine med native byggeværktøjer til better-sqlite3, en byggefase, en slank kørende fase. export const dynamic = 'force-dynamic' forhindrer Next.js i at forhåndsgenerere ved byggetid med en lokal sti, der ikke eksisterer inde i containeren.

Webhouse-site får sin egen Dockerfil, med byggekonteksten sat til overordnet mappe, så både cms-engine/packages/cms og webhouse-site/ er tilgængelige i samme bygge. CONTENT_DIR som miljøvariabel lader CMS’en løse den rigtige indholdsti ved kørsel i stedet for relativt til den kompilerede fil.

docker compose up fra cms-engine/ starter begge: Siden på port 4009, admin’en på 4010. En enkelt monteret volumen peger begge containere på den samme indhold- og uploadsmappe på værten. Rediger en JSON-fil lokalt, genindlæs browseren — øjeblikkeligt.

Fly.io: samme opsætning, Stockholm

Fly.io-deploymenten tager den samme idé ét skridt videre. En kombineret Dockerfil bygger begge apps i ét image. Et shell-startscript sår indhold fra billedet til et vedvarende Fly-volumen ved første opstart, derefter starter begge Next.js-processer bundet til 0.0.0.0.

Én fly deploy-kommando. Én maskine i arn (Stockholm). Ét vedvarende volumen på /data delt mellem begge processer. Siden er på port 443, admin’en på 3010 — begge kører på webhouse-cms.fly.dev med nul konfiguration ud over fly.toml.