Files

44 KiB
Raw Permalink Blame History

Nieuwe website bouwen & live zetten — runbook (De Websmid)

Hoe je dit gebruikt: geef dit bestand aan Claude aan het begin van een nieuw website-project ("bouw een nieuwe site zoals Behangerbrabant, volg newsite.md"). Het beschrijft de architectuur, tools, proces, mail, DNS en livegang — niet de lay-out/het design (dat doe je per klant met /impeccable + de klant-content).

Referentie-template = dit project (behangerbrabant) of de zustersite ../exterios. Kopieer daar de patronen en de concrete bestanden uit (zie hieronder welke). Dit is een onepage-marketingsite-recept; voor een webshop/blog wijkt het af.


0. TL;DR — de stack in één zin

Server-rendered Astro 6 onepager + emdash headless CMS, gehost op Cloudflare Workers met D1 (content/leads), R2 (media) en KV (sessies). Mail loopt via de eigen DirectAdmin-mailbox op de hostingserver (authenticated SMTP vanuit de Worker), niet via Cloudflare Email Routing. Domein-DNS staat op Cloudflare.


1. Software-architectuur

Browser ──► Cloudflare Worker (Astro SSR)
                 ├─► /                 ─► pages/index.astro ─► getEmDashCollection() ─► D1
                 ├─► /api/contact      ─► honeypot + time-trap + validatie
                 │                          ├─► opslaan in D1 (vangnet, contact_submissions)
                 │                          └─► mail via authenticated SMTP ─► srv8 (DirectAdmin)
                 ├─► /_emdash/admin     ─► emdash admin (Portable Text editor, media library)
                 │                          └─► writes ─► D1 + R2
                 └─► src/middleware.ts  ─► forceert http ─► https (301)
  • Output: output: "server" (SSR per request). Geen statische pre-render van de homepage (content komt live uit D1, zodat de klant alles zelf kan beheren).
  • Content: elke sectie leest uit een emdash-collectie via getEmDashCollection(...) met inline fallback in de component (lage blast-radius; site blijft staan als D1 even faalt of nog niet geseed is).
  • Per-collectie tabellen in D1: emdash slaat elke collectie op als tabel ec_<slug> waarin elk veld een echte kolom is (titel, locatie, prijs, ...). Plus systeem- kolommen (id, slug, status, created_at, ...). Belangrijk voor directe SQL-edits.
  • CMS-login = passwordless (magic-link via een eigen e-mail-provider-plugin op dezelfde srv8-mailbox; geen wachtwoorden). Gebruikers worden als rij in users aangemaakt. Zie §6b.

Stack & versies (uit package.json)

Laag Pakket Reden
Framework astro ^6 SSR onepager, snelle TTFB
Adapter @astrojs/cloudflare ^13 Workers SSR
CMS emdash + @emdash-cms/cloudflare ^0.1 klant beheert teksten/foto's/prijzen zelf
React react/react-dom ^19 emdash admin-UI dependency
DB Cloudflare D1 content + leads (contact_submissions)
Storage Cloudflare R2 media uploads
Sessies Cloudflare KV auto-provisioned (SESSION)
Images Cloudflare Images auto-provisioned (IMAGES)
Styling scoped CSS + CSS custom properties geen Tailwind, lichte payload

2. Tools & accounts (eenmalig per machine / per project)

  • Cloudflare-account De Websmid: CLOUDFLARE_ACCOUNT_ID=41972f4905f2c83f51111b80283e030b (Mike@dewebsmid.nl). wrangler whoami om in te loggen/checken.
  • Node + wrangler (in repo). Op deze Windows/corporate-machine roep je tools aan via node node_modules/<tool>/bin/... (zie §9, de .bin-shims ontbreken door --ignore-scripts).
  • Python + Pillow (systeem-python) voor beeld-conversie naar WebP (sharp is kapot door --ignore-scripts). Installeer libs met pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org <pkg>.
  • Hosting (DirectAdmin) bij de collega voor de mailbox + DKIM. Server bv. srv8.dewebsmid.nl.
  • Domeinregistrar — nameservers mogen naar Cloudflare.

3. Repo scaffolden

Kopieer de structuur van dit project / ../exterios. Kernbestanden:

package.json          name -> <project>, scripts (dev/build/deploy/typecheck)
tsconfig.json         extends astro/tsconfigs/strict
astro.config.mjs      output:server, adapter cloudflare(), emdash({database:d1, storage:r2}), react()
wrangler.jsonc        name, main ./src/worker.ts, compat nodejs_compat, D1+R2 bindings, routes
src/worker.ts         re-export @astrojs/cloudflare server handler + emdash PluginBridge
src/live.config.ts    emdash live collections
src/cloudflare-env.d.ts  minimale types voor `cloudflare:workers` (env) + `cloudflare:sockets`
src/middleware.ts     http->https redirect (kopieer 1-op-1, zie §7)
src/lib/smtp.ts       SMTP-client (kopieer 1-op-1, zie §6)
src/pages/api/contact.ts  contactformulier-handler (kopieer + pas adressen aan, zie §6)
seed/seed.json        emdash schema + demo content
public/robots.txt, public/favicon.svg, public/_headers, public/fonts/<font>.woff2

Installeren (Windows/corporate): NODE_OPTIONS=--use-system-ca npm install --ignore-scripts (zonder die env hangt npm op het corporate-cert; --ignore-scripts want better-sqlite3 compileert hier niet).


4. Cloudflare resources aanmaken

$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"; $env:NODE_OPTIONS="--use-system-ca"
node node_modules/wrangler/bin/wrangler.js d1 create <project>-db        # regio WEUR
node node_modules/wrangler/bin/wrangler.js r2 bucket create <project>-media
  • Zet de D1 database_id + R2-bucketnaam in wrangler.jsonc (binding D1 = DB, R2 = MEDIA). KV (SESSION) en Images (IMAGES) worden auto-geprovisioned bij deploy.

5. Content-model + seed (emdash)

  • Modelleer 1 singleton per onepage-blok + flat collections waar herhaling logisch is (zie architecture.md voor het volledige datamodel: site_settings, hero, usps, pakketten, arte_showcase, segmenten, projecten, werkwijze, reviews, faq).
  • Beeldvelden: zie §5a (Beeld & media koppelen) hieronder — leeg laten werkt alléén als de component het veld óók uitleest (anders verandert een upload niks).
  • Seeden gaat via de setup-wizard, niet via CLI. emdash seed (CLI) werkt hier NIET (better-sqlite3 niet gecompileerd). Draai de wizard op https://<host>/_emdash/admin/setup → "Include sample content".
  • Content achteraf direct aanpassen (bv. demo-tekst overschrijven) kan met SQL op de remote D1 — elk veld is een kolom in ec_<collectie>:
    node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote `
      --command "SELECT id,slug,title FROM ec_projecten ORDER BY created_at;" --json
    node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote `
      --command "UPDATE ec_projecten SET title='...' WHERE id='<ulid>';" --json
    
    De publieke site leest deze kolommen direct (geen rebuild nodig). Let op revisie- kolommen live_revision_id/draft_revision_id: bij geseede content zijn die null, dan is een directe kolom-update schoon (admin-editor leest dezelfde kolom).

⚠️ Schema leeft in D1, niet in seed.json (op runtime). Collecties/velden staan in _emdash_collections + _emdash_fields; seed.json is alleen de éénmalige seed. Een veld toevoegen aan een live site = D1-migratie (ALTER TABLE ec_<slug> ADD COLUMN ... + INSERT INTO _emdash_fields (id=ULID, collection_id, slug, label, type, column_type, sort_order)), én seed.json bijwerken voor verse installs. Alléén seed.json aanpassen laat het veld NIET in de live-admin verschijnen. Zie memory emdash-schema-lives-in-d1.

5a. Beeld & media koppelen

Emdash provisioned image-velden, maar de componenten moeten ze ook uitlezen — een leeg/ongekoppeld veld betekent dat een admin-upload niets verandert op de site.

  • Opslagvorm (local provider op R2): een image-veld is JSON { id, provider:"local", filename, mimeType, width, height, alt, meta:{ storageKey } }. src wordt op displaytijd afgeleid uit storageKey; geserveerd via /_emdash/api/media/file/<storageKey> (R2, immutable cached). astro.config gebruikt storage: r2({ binding:"MEDIA" }) → local provider (géén Cloudflare-Images-resizing).
  • Bouw het uitlezen vanaf het begin in. Helper src/lib/images.ts#resolveCmsImage(value) → { src, width?, height?, alt? } (handelt local/extern/JSON-string af). Component: lees entry.data.<veld>, gebruik .src, en val terug op de statische placeholder-webp (mét responsive srcset) als het veld leeg is — nul risico, nul zichtbare verandering tot iemand upload.
  • Galerij met vast aantal beelden (bv. 4 stalen) = losse image-velden (image_1..image_4, nette picker per slot), niet een ruw json-veld (slechte UX).
  • Placeholders alvast in de media-library zetten (anders ziet de klant lege beeldvelden): R2-object put + rij in media + veld-JSON. Zie memory emdash-media-population (+ scripts/migrate-images.sql in dit project).
  • Decoratieve velden (logo, favicon) worden vaak níét gerenderd (bespoke SVG-mark in de header); laat die met rust of verwijder het veld om de admin niet te misleiden.
  • Responsive: local provider = één src per beeld. Voor automatische maatvarianten (ook voor klantuploads) zou je de Cloudflare-Images-provider moeten aanzetten — niet standaard nodig, wel een nette upgrade. ⚠️ Upload de juiste grootte (CMS = één formaat): een -1280-variant voor hero/cards, kleiner voor beelden boven de vouw — niet het 2752px-origineel. Een srcsetFor()-helper hoort undefined te geven voor CMS-paden (/_emdash/api/media/file/...), zodat een leeg srcset geen kapotte -768-URL's bouwt; de code-fallback (statische webp) houdt z'n srcset.
  • Media op schaal koppelen (veel beeldvelden, bv. 25 dienst-foto's): genereer R2-uploads + media-rijen + veld-JSON met een script (zoals scripts/gen-cms-sql.mjs, die je importeerbaar maakt door de CLI achter een import.meta.url===…-guard te zetten). Dedupliceer: één media-rij per unieke foto, meerdere velden mogen dezelfde storageKey delen → schone mediabibliotheek.

5b. Collecties toevoegen aan een AL-geseede site + admin-UX (geleerd bij Helvoirt)

Na de eerste seed leeft het schema in D1. Een collectie/veld toevoegen kan zónder de wizard opnieuw te draaien (die reset auth + herintroduceert defaults), via SQL die emdash's eigen tabelstructuur spiegelt:

  • Lees de echte DDL van een bestaande ec_<collectie> uit sqlite_master en de kolommen van _emdash_collections/_emdash_fields. Genereer dan: INSERT _emdash_collections + CREATE TABLE ec_<slug> (zelfde systeemkolommen) + per veld INSERT _emdash_fields + content-INSERTs. Laat search/FTS weg (supports zonder search, geen searchable-velden) → geen FTS-tabellen/triggers nodig, dus de SQL blijft compleet en veilig. Veld→column_type: string/text/select/datetime/slug/image/file/reference→TEXT, integer/boolean→INTEGER, number→REAL, portableText/json/multiSelect/repeater→JSON. ids = ULID (ulidx). Patroon in dit project: scripts/gen-cms-sql.mjs <model> <out.sql> + scripts/cms-models/*.
  • emdash-defaults pages/posts worden alléén bij setup aangemaakt (niet per boot) → veilig te verwijderen (DROP TABLE ec_* + _emdash_fts_* + rijen uit _emdash_collections/ _emdash_fields); ze blijven weg zolang de wizard niet opnieuw draait.
  • "Bestaande hardcoded pagina's → CMS" migreren: lees elke pagina uit het CMS met de huidige tekst als fallback (getEmDashEntry/getEmDashCollection in try/catch). Zo breekt de site nooit, ook tijdens de migratie. Verifieer per stap met een sentinel: zet een herkenbare waarde via d1 execute --remote, fetch de live pagina, zet terug.

Emdash admin-UX (v0.1.x) — wat wel/niet native is:

  • Dupliceren van een entry = native (kopieer-icoon in de lijst → conceptkopie van alle velden + SEO). Geen eigen knop nodig.
  • Repeater-velden = inline rijen toevoegen/verwijderen/verslepen (WP-Bakery-gevoel). Definitie via _emdash_fields.validation = {subFields:[{slug,type,label,required?}], minItems, maxItems}. ⚠️ Sub-velden kunnen GEEN image zijn → beeld-rijen modelleer je als een aparte collectie met een image-veld (collectie heeft Add New + delete per rij).
  • "(optional)" bij niet-verplichte velden is niet globaal uit te zetten (geen branding/ custom-CSS-hook in deze beta). Enige nette weg: het veld verplicht maken (UPDATE _emdash_fields SET required=1 ...). Velden hebben geen description/help-tekst — zet uitleg in het label (bv. "SEO · paginatitel").
  • Media handmatig in de library: wrangler r2 object put <bucket>/<storageKey> --file ... --remote (storage_key == exacte R2-key), + rij in media (id=ULID, filename, mime_type, width, height, alt, storage_key, status='ready'), + image-veld-JSON {id, provider:"local", filename, mimeType, width, height, alt, meta:{storageKey}} (geen src; wordt op displaytijd afgeleid). NB: CMS- beeld = één formaat (geen auto-srcset); kies een passende variant of de Cloudflare-Images-provider.

6. Contactformulier + MAIL (belangrijkste sectie)

Architectuurbesluit (geleerd bij Behangerbrabant)

De klant krijgt een echte mailbox op de hostingserver (DirectAdmin, bv. info@<domein> op srv8.dewebsmid.nl). Daardoor moet MX naar srv8 wijzen, en dat sluit Cloudflare Email Routing uit (die eist MX naar Cloudflare — wederzijds uitsluitend). Dus:

  • De Worker stuurt de formuliermelding via authenticated SMTP rechtstreeks naar de hosting-SMTP (poort 465, implicit TLS). Workers kunnen geen poort 25 en geen nodemailer; daarom een hand-geschreven SMTP-client op cloudflare:sockets → src/lib/smtp.ts (kopieer 1-op-1).
  • Aflevering aan info@<domein> is lokaal op srv8, dus de melding werkt al vóór de DNS rond is (auth-SMTP, niet afhankelijk van MX/SPF/DKIM).

Wat src/pages/api/contact.ts doet

  1. Honeypot (website_url) + time-trap (ts hidden field; <2s of >6h = stille 200).
  2. Validatie (naam/e-mail/bericht), 422 met veld-errors.
  3. Opslaan in D1 (contact_submissions, migratie migrations/0001_*.sql) = vangnet.
  4. Twee mails via Promise.allSettled (één fout blokkeert de ander niet):
    • Melding naar de klant: To = info@<domein>, Reply-To = bezoeker, house-style tekst ("Beste, ... Naam/Telefoonnummer/E-mailadres/Bericht").
    • Bevestiging (autoreply) naar de aanvrager: To = bezoeker, Reply-To = info@, ondertekend met de contactpersoon van de klant (bv. "Joey") + T/E.
    • From-header = Bedrijfsnaam <website@<domein>> (house-style); envelope (MAIL FROM) blijft de geauthenticeerde mailbox (info@) zodat srv8 niet weigert. smtp.ts ondersteunt hiervoor een aparte envelopeFrom.

Config & secret

Niet-geheime config zit als constant/env met fallback in contact.ts (CONTACT_SMTP_HOST srv8, CONTACT_SMTP_PORT 465, CONTACT_SMTP_USER info@, CONTACT_EMAIL_TO info@, CONTACT_EMAIL_FROM website@). Alleen het wachtwoord is een secret, nooit in de repo:

# ZET SECRETS VIA BASH, NIET VIA POWERSHELL-PIPE.
printf '%s' '<mailbox-wachtwoord>' | NODE_OPTIONS=--use-system-ca \
  node node_modules/wrangler/bin/wrangler.js secret put CONTACT_SMTP_PASS

⚠️ BOM-valkuil: "x" | wrangler secret put vanuit Windows PowerShell plakt een UTF-8 BOM vóór de waarde → SMTP-auth faalt stil met 535 Incorrect authentication data. Gebruik bash printf '%s' of het Cloudflare-dashboard. contact.ts stript defensief een leidende BOM (charCodeAt(0)===0xFEFF), maar zet hem gewoon goed.

⚠️ SMTP-host: gebruik de échte serverhostname, niet mail.<domein> (geleerd bij Helvoirt)

Na de NS-verhuizing naar Cloudflare resolvet mail.<domein> vaak niet (de mail-records gingen niet mee bij de overstap → NXDOMAIN). De Worker geeft dan Error: Stream was cancelled (een cloudflare:sockets-verbindingsfout: host onvindbaar). Verbinden via een kaal IP lukt óók niet (het TLS-cert matcht de hostname, niet het IP).

Oplossing: zet CONTACT_SMTP_HOST op de echte hostname van de DirectAdmin-server (bv. srv8.dewebsmid.nl) — die resolvet wél los van het klantdomein én heeft een geldig cert. CONTACT_SMTP_USER/EMAIL_TO/EMAIL_FROM blijven info@<domein> (alleen de TCP-verbinding gaat naar srv8). Zet 'm in wrangler.jsonc vars (niet-geheim, dus geen secret):

"vars": { "CONTACT_SMTP_HOST": "srv8.dewebsmid.nl" }

Doe dit meteen — laat de code-default niet op mail.<domein> staan, dat kost een debug-ronde.

Serverhostname onbekend? Vis 'm uit de /webmail-redirect van het klantdomein: curl.exe -sk -D - https://<domein>/webmail → Location: https://srv8.dewebsmid.nl/roundcube.

Outbound werkt vóór de mail-DNS: de melding naar info@<domein> wordt lokaal op srv8 afgeleverd (de mailbox staat daar), dus die komt aan zonder MX. De autoreply naar de bezoeker heeft SPF/DKIM/DMARC wél nodig voor de afleverbaarheid (anders spam) — zie §7.

Testen (na deploy)

Start wrangler tail in de achtergrond, doe een test-POST, en kijk naar de logs:

[contact] new-submission
[contact] mail-sent melding-klant
[contact] mail-sent bevestiging-aanvrager

POST-voorbeeld (Origin-header is nodig wegens Astro checkOrigin, ts tussen 2s en 6h):

$ts=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()-5000
curl.exe -sk --resolve <domein>:443:<cf-ip> -H "Origin: https://<domein>" `
  -H "Content-Type: application/x-www-form-urlencoded" `
  -d "name=Test&email=jij%40mail.nl&message=test&website_url=&ts=$ts" https://<domein>/api/contact

Ruim testrijen op uit contact_submissions na het testen. Bevestig bij de collega dat de mail echt in de inbox landt (250 = aangenomen door srv8, niet per se afgeleverd).


6b. CMS-login & gebruikers (auth)

Hoe logt de klant in op /_emdash/admin? Dit is niet triviaal in deze emdash-beta:

  • Emdash is passwordless — er is geen e-mail+wachtwoord (de users-tabel heeft geen password_hash). Methodes: passkey (WebAuthn), magic-link (e-mail) en OAuth (Google/GitHub via secrets EMDASH_OAUTH_GOOGLE_CLIENT_ID/_SECRET; redirect-URI /_emdash/api/auth/oauth/<p>/callback; koppelt op geverifieerd e-mailadres).
  • ⚠️ De admin-knop "Invite User" is stuk: de invite-link wijst naar het API-endpoint /_emdash/api/auth/invite/accept (geeft JSON) i.p.v. een pagina die een passkey registreert. Een invite kan dus niet afgerond worden → niet gebruiken.
  • ⚠️ De ingebouwde "Sign in with email link" (magic-link) geeft 503 zolang er geen e-mail-provider-plugin is (de email:deliver-hook). De GitHub/Google-knoppen werken niet zonder OAuth-secrets.

Oplossing: e-mail-provider-plugin (hergebruikt de srv8-mailbox)

Maakt de ingebouwde magic-link werkend, met dezelfde src/lib/smtp.ts als §6.

  1. src/plugins/smtp-email.ts — standard-formaat plugin: export default { hooks: { "email:deliver": { exclusive: true, handler } } } (géén emdash-import nodig). De handler leest de secret uit cloudflare:workers env en levert af via sendMail. Gooi bij fout (zodat de pipeline het meldt, niet stil "verstuurd").
  2. Registreren in astro.config.mjs:
    import { fileURLToPath } from "node:url";
    const smtpEntry = fileURLToPath(new URL("./src/plugins/smtp-email.ts", import.meta.url)).replace(/\\/g, "/");
    // ...
    emdash({
      database: d1({ binding: "DB", session: "auto" }),
      storage: r2({ binding: "MEDIA" }),
      plugins: [{ id: "<project>-smtp-email", version: "1.0.0", entrypoint: smtpEntry, format: "standard", capabilities: ["email:provide"] }],
    })
    
    ⚠️ Entrypoint = absoluut pad met forward slashes — emdash genereert er een import mee, dus een resolvebare specifier is nodig (backslashes breken het).
  3. Config-plugins zijn standaard enabled; als enige email:deliver-provider wordt 'ie bij runtime-init auto-geselecteerd + gepersisteerd (optie emdash:exclusive_hook:email:deliver). ⚠️ In dev registreert emdash óók een console-provider → twee providers → geen auto-select; alleen lokaal relevant, productie heeft alleen de plugin.

Daarna werkt /_emdash/admin/login → "Sign in with email link" → e-mail → klik → sessie (KV) → /_emdash/admin. Test (na deploy): POST /_emdash/api/auth/magic-link/send {"email":"..."} → 200 + een rij in auth_tokens (type='magic_link') + wrangler tail zonder SMTP-fout. Zie memory emdash-passwordless-magic-link.

⚠️ Login kapot na domeinkoppeling — en handmatig inloggen (geleerd bij Helvoirt)

Passkeys zijn origin-gebonden. Registreert iemand een passkey/Windows-Hello-pincode op de preview (*.workers.dev), dan werkt die niet meer op het echte domein na de koppeling. Windows toont dan alleen "beveiligingssleutel / telefoon", niet de eigen pincode → de klant kan niet meer in. Oplossing: een nieuwe passkey op het echte domein registreren (kan pas ná inloggen) — dus vermijd passkeys op de preview, of regel eerst een andere login.

Vastgelopen (passkey dood én mailbox nog niet klaar)? Mint zelf een magic-link-token (werkt zonder mailbox). Token = base64url(32 random bytes); in auth_tokens staat alleen de SHA-256-hash ervan:

node -e "const c=require('crypto');const r=c.randomBytes(32);console.log('TOKEN='+r.toString('base64url'));console.log('HASH='+c.createHash('sha256').update(r).digest('base64url'))"
# zet de HASH in auth_tokens (hash = PK, type='magic_link', 48u geldig) + e-mail geverifieerd:
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote --command `
 "INSERT INTO auth_tokens (hash,user_id,email,type,expires_at,created_at) VALUES `
 ('<HASH>','<user-id>','<email>','magic_link',strftime('%Y-%m-%dT%H:%M:%fZ','now','+2 days'),strftime('%Y-%m-%dT%H:%M:%fZ','now')); `
  UPDATE users SET email_verified=1 WHERE id='<user-id>';"

Geef de gebruiker dan: https://<domein>/_emdash/api/auth/magic-link/verify?token=<TOKEN> (single-use, verloopt op expires_at; verifyMagicLink hasht de token en zoekt op de hash).

Symptoom: de klant vraagt "Sign in with email link" aan, krijgt netjes een mail, maar de link in die mail leidt naar de Cloudflare-placeholderpagina "There is nothing here yet — Powered by Cloudflare" in plaats van de admin. Inloggen lukt nooit.

Oorzaak: emdash bouwt elke uitgaande systeemlink (magic-link én e-mailverificatie) op uit de opgeslagen optie emdash:site_url, niet uit de request-Host (bewust — anti-spoofing, zie node_modules/emdash/src/api/site-url.ts#getSiteBaseUrl). Die optie wordt één keer gezet door de setup-wizard, en dat gebeurt op de preview-origin (*.workers.dev). Na de domeinkoppeling schakelt de deploy workers.dev uit (§7) → de opgeslagen URL wijst naar een dode subdomein → Cloudflare serveert de placeholder. De rest van de site werkt prima (die draait op de request-host), alleen de inlogmail is stuk.

Fix (data op de live D1, geen redeploy nodig). De waarde is een JSON-string, dus inclusief de dubbele quotes; tabel options, kolommen name/value:

node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote \
  --command "UPDATE options SET value = '\"https://<domein>\"' WHERE name = 'emdash:site_url';"
# verifieer:
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote \
  --command "SELECT name, value FROM options WHERE name = 'emdash:site_url';" --json

getSiteBaseUrl plakt er zelf /_emdash achter, dus zet alleen het kale domein (geen pad, geen trailing slash). Daarna een nieuwe inlog-link aanvragen — mails van vóór de fix bevatten nog de dode link.

Preventie: zet emdash:site_url meteen op het echte domein zodra je de custom domain koppelt (§7 / go-live stap 8), nog vóór je de klant laat inloggen. Controleer 'm met de SELECT hierboven; staat er nog *.workers.dev, dan is élke inlogmail dood.

Gebruikers aanmaken (provisioning)

Geen werkende UI (invite stuk) → rij in users via SQL. Magic-link mailt alleen naar bestaande users (geen open signup).

node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote --command `
 "INSERT INTO users (id, email, role, email_verified, disabled, created_at, updated_at) VALUES `
 ('<ULID>','<email-lowercase>',50,1,0,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
  • Rollen: 50=ADMIN, 40=EDITOR, 30=AUTHOR. Voor een klant is EDITOR vaak genoeg (alle content, geen gebruikers/instellingen). ULID via node --input-type=module -e "import {ulid} from 'ulidx'; console.log(ulid())".
  • Verwijderen: DELETE uit auth_tokens / oauth_accounts / credentials (WHERE user_id …) + users.

Klant-handover

Stuur de klant een mail met inlog-uitleg (URL, "Sign in with email link", waar content + SEO-velden staan). ⚠️ De inlogmail die de klant ontvangt is Engelstalig ("Sign in to …") — emdash's standaardtemplate; benoem de knop in je handover-mail. Voorbeeld-handover: zie de mail die we voor Behangerbrabant schreven (in todo.md/sessielog).


7. DNS (op Cloudflare) + HTTPS

Zone activeren + nameservers (geleerd bij Helvoirt)

"De klant heeft de DNS omgezet" ≠ klaar. Verifieer dat de zone op Cloudflare active is (niet pending): de registrar moet de nameservers naar de Cloudflare-toegewezen NS wijzen (bv. cesar/priscilla.ns.cloudflare.com), niet naar de hoster (bv. dns1.hosted.nl). Wijzen ze nog naar de hoster, dan SERVFAILt het domein overal ("lame delegation" in de DoH-respons) en blijft de zone pending. Check de status + de toegewezen NS via de API (token uit de wrangler-config):

$cfg = "$env:APPDATA\xdg.config\.wrangler\config\default.toml"
$tok = ((Get-Content $cfg | Where-Object {$_ -match 'oauth_token'}) -replace '.*=\s*"?([^"]+)"?.*','$1').Trim()
(Invoke-RestMethod "https://api.cloudflare.com/client/v4/zones?name=<domein>" -Headers @{Authorization="Bearer $tok"}).result |
  Select-Object name,status,@{n='ns';e={$_.name_servers -join ', '}}

Pas custom-domain koppelen (routes + rebuild) als de zone active is.

Mail-DNS (door de collega aan te leveren / via DNS-token toe te voegen)

Type Naam Waarde Prio
MX @ srv8.dewebsmid.nl 10
TXT (SPF) @ v=spf1 mx include:_spf.dewebsmid.nl ~all —
TXT (DMARC) _dmarc v=DMARC1; p=none; rua=mailto:info@<domein> —
TXT (DKIM) x._domainkey public key uit DirectAdmin (selector x = default) —
  • MX = DNS-only (mail gaat nooit door de Cloudflare-proxy). De apex-A blijft proxied voor de site — MX staat daar los van.
  • DKIM-key komt uit DirectAdmin (E-mail → DKIM). Mail werkt zonder, maar belandt vaker in spam.
  • Verifieer via DoH (corporate-DNS blokkeert poort 53): curl -sk "https://dns.google/resolve?name=<domein>&type=MX" (en TXT, _dmarc, x._domainkey).

⚠️ Wie zet de DNS? De wrangler-OAuth-login kan zones lezen (/zones?name= werkt), maar geeft 403 op DNS-records (list/edit) én op zone-instellingen (SSL-modus, Always Use HTTPS, HSTS). Dus die gaan via het dashboard of via een aparte API-token met Zone.DNS:Edit (My Profile → API Tokens → "Edit zone DNS" → specifieke zone). Wel werkt het aanmaken van het custom domain via wrangler deploy (Workers-scope). Vraag de DNS-token aan de klant/Mike als je de mail-DNS/HTTPS zelf via de API wilt zetten.

⚠️ Mail-DNS van een zustersite kopiëren? Pas álle domeinverwijzingen aan — vooral de DMARC rua (en evt. de DKIM-key). Bij Helvoirt stond de rua per ongeluk nog op info@behangerbrabant.nl (copy-paste). De MX mag direct naar srv8.dewebsmid.nl (een los mail.<domein>-A-record is optioneel; alleen nodig als het mailprogramma die hostname gebruikt).

HTTPS / redirect / security

  • http→https: src/middleware.ts doet een 301 op basis van de cf-visitor-header (kopieer 1-op-1; doet niets zonder die header, dus dev breekt niet). Plus optioneel in het dashboard: SSL/TLS → Edge Certificates → "Always Use HTTPS" aan (edge-niveau, maakt de middleware een no-op) en daarna HSTS aan.
  • SSL-modus op Full of Full (strict) (niet Flexible → anders redirect-loop).
  • Spam-bescherming = honeypot + time-trap (server-side), geen reCAPTCHA.
  • Security headers zijn al gezet (Permissions-Policy, X-Content-Type-Options nosniff, X-Frame-Options, Referrer-Policy). CSP optioneel.
  • Custom domain koppelen via wrangler.jsonc routes (apex + www, beide custom_domain: true). ⚠️ Rebuild ná elke wrangler.jsonc-wijziging, anders deployt wrangler een stale config (de adapter genereert dist/server/wrangler.json). ⚠️ Bij het toevoegen van routes schakelt de deploy workers.dev uit (custom-domain-only — goed tegen duplicate content, maar de preview-URL stopt en open passkeys op die URL breken, zie §6b). De middleware regelt www→apex + http→https al op worker-niveau, dus de redirects werken ook zonder de dashboard-instellingen. ⚠️ Update meteen ook emdash:site_url naar het echte domein (de setup-wizard zette 'm op de *.workers.dev-preview die nu uit gaat) — anders wijst élke inlogmail-link naar een dode subdomein en krijgt de klant de Cloudflare-placeholder. Zie §6b.

8. SEO / performance / afronding basics

  • public/favicon.svg MOET bestaan (Base.astro verwijst ernaar; ontbreekt = 404). Brand-mark als SVG met eigen donkere achtergrond.
  • public/robots.txt: User-agent: * / Allow: / / Disallow: /_emdash/ + Disallow: /api/ / Sitemap: https://<domein>/sitemap.xml. Nooit Disallow: /.
  • sitemap.xml endpoint + canonical naar apex (non-www) via site: in astro.config.mjs.
  • Meta title + description in Base.astro; JSON-LD LocalBusiness + FAQPage.
  • Beelden: WebP, responsive maten via Pillow. Nieuwe bestandsnamen bij vervanging (bv. -v2-) om cache-staleness te vermijden; width/height op <img> matchen om CLS te voorkomen.
  • Performance (mobiele Lighthouse-winst, geverifieerd bij Behangerbrabant):
    • Host fonts zelf i.p.v. Google Fonts: één variabel woff2 (latin-subset) in public/fonts/, @font-face met font-display: swap in tokens.css, en de externe fonts.googleapis.com/gstatic.com-links eruit. Schrapt een render-blocking stylesheet
      • 2 externe origins. Download het woff2 met een Chrome-UA via de css2-URL (zelfde font = identiek uiterlijk). Evt. <link rel="preload" as="font" ... crossorigin>. ⚠️ Niet elk font is self-te-hosten via de css2-API: width-varianten (bv. Archivo Expanded) zijn door Google samengevoegd in de variabele basisfamilie → family=Archivo+Expanded levert geen @font-face. Dan: of de juiste axis/instance opvragen, of het font gewoon op Google Fonts laten (preconnect + display=swap) i.p.v. de display-typografie te riskeren. Geverifieerd bij Helvoirt.
    • OG-image (1200×630) genereren kan deterministisch met Pillow (scripts/make-og.py): echte foto cover-croppen + petrol-gradient + wordmark + 4,9-Google. ⚠️ Het ster-glyph (★, U+2605) zit niet in Arial → gebruik seguisym.ttf (Segoe UI Symbol) voor de sterren.
    • public/_headers voor far-future immutable caching op /fonts/* en /images/* (versie-vaste namen) — Workers Static Assets honoreert dit; lost de "efficient cache policy"-melding op. HTML niet cachen (SSR).
    • LCP-beeld preloaden (fetchpriority="high"), non-hero loading="lazy".
  • Lighthouse meten: er is geen Lighthouse-MCP; de keyless PageSpeed-API is vaak 429 (dagquota). Meet met een eigen Google PSI API-key (...runPagespeed?url=...&strategy=mobile&key=<KEY>) of via de PageSpeed-website.

8a. Analytics / Google Tag Manager (GTM)

De klant moet z'n eigen GTM-container kunnen koppelen zonder code aan te raken.

⚠️ Twee "settings" in emdash — verwar ze niet. De ingebouwde pagina Settings → SEO heeft een vaste, hardcoded veldenlijst (SiteSettings.seo in node_modules/emdash/src/settings/types.ts: title-separator, default OG-image, robots.txt, Google Search Console-verificatie, Bing-verificatie). Daar kun je geen veld aan toevoegen, en Base.astro leest die ook niet. Site-brede klant-instellingen (SEO-titel/ description, GTM, NAW) horen in de site_settings-collectie (label "Instellingen") — dáár leest Base.astro uit. De klant zoekt GTM vaak op de verkeerde (ingebouwde) pagina.

  1. Veld toevoegen — live = D1-migratie (zie §5 ⚠️ + memory emdash-schema-lives-in-d1):
    • ALTER TABLE ec_site_settings ADD COLUMN gtm TEXT;
    • INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, sort_order) met id=ULID, collection_id=<site_settings-id>, slug='gtm', label='Google Tag Manager ID (bv. GTM-XXXXXX)', type='string', column_type='TEXT'.
    • seed.json óók bijwerken (veld-def + lege startwaarde) voor verse installs.
    • De admin leest de veldenlijst live uit D1 (SchemaRegistry.listFields, géén cache), dus het veld verschijnt direct in de Instellingen-editor — geen rebuild nodig.
  2. Renderen in Base.astro — vraag alleen de container-ID (GTM-XXXXXX), niet het hele snippet, en bouw zelf de officiële code. Robuust uitlezen (vist de ID ook uit een per ongeluk geplakt volledig snippet; matcht bewust alleen GTM-, niet GA4's G-XXXX):
    const gtmId = (((s.gtm as string|undefined) ?? "").match(/GTM-[A-Z0-9]+/i)?.[0] ?? "").toUpperCase();
    
    • Head (zo hoog mogelijk in <head>), alleen als gtmId gezet is: het officiële GTM-<script> via is:inline define:vars={{ gtmId }} (de IIFE eindigend op 'dataLayer', gtmId).
    • Body (direct na <body>): het <noscript>-<iframe src=...ns.html?id=${gtmId}>.
    • Leeg veld → niets geïnjecteerd ({gtmId && (...)}-guard).
  3. Verifiëren (na deploy): zet tijdelijk een nep-ID (GTM-TEST000 — niet-bestaande container = laadt niets) op de remote D1, fetch de live HTML (gtm.js?id= in de head + ns.html?id= na de body), en zet 'm daarna terug op leeg.

⚠️ GTM laadt zelf geen cookies tot er in de container een tag vuurt. Komt er tracking met cookies, dan is er apart een cookie-consent nodig (staat niet standaard op de site).

Concreet voorbeeld in dit project: scripts/migrate-gtm-field.sql + het GTM-blok in src/layouts/Base.astro (frontmatter gtmId + de twee {gtmId && ...}-blokken).


9. Bouwen & deployen (Windows/corporate)

$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"
$env:NODE_OPTIONS="--use-system-ca"   # corporate-cert workaround (anders hangt het)
node node_modules/astro/bin/astro.mjs check      # typecheck: 0/0/0
node node_modules/astro/bin/astro.mjs build
node node_modules/wrangler/bin/wrangler.js deploy
  • Roep tools aan via node node_modules/<tool>/bin/... (de .bin-shims ontbreken door --ignore-scripts; npx wil dan een fresh install starten).
  • Secrets blijven bestaan over deploys heen (los van de gegenereerde config).
  • Verifiëren vanaf deze machine: corporate-DNS kan het domein niet resolven → forceer de Cloudflare-IP: curl -sk --resolve <domein>:443:<cf-ip> https://<domein>/. IP ophalen via DoH: curl -sk "https://dns.google/resolve?name=<domein>&type=A".
  • Edge-cache na deploy: de HTML kan een paar seconden een stale versie geven; poll opnieuw na ~5s vóór je concludeert dat een wijziging niet live is.
  • Live-logs: node node_modules/wrangler/bin/wrangler.js tail <project> --format json (vang via een PowerShell Start-Job + Receive-Job als je niet interactief bent).

10. Checklist invullen

De oude standaard-checklist is WordPress-gericht en is vervangen door een schone, stack-eigen versie. Begin vanaf het lege template Checklist - Websites (template, leeg).xlsx (72 punten, WP-vrij, dropdown op "Uitgevoerd" = Ja/Nee/N.v.t./Deels/Actie) en lever per project een ingevulde .xlsx op. Vul de kolommen Uitgevoerd / Wie / Wanneer / Opmerking. Voorbeeld van een ingevulde versie: Checklist - Websites - behangerbrabant (ingevuld).xlsx.

De checklist heeft 9 secties: 1) Voorbereiding 2) Algemeen/inhoud 3) Techniek & beveiliging 4) Contactformulier & mail 5) DNS/hosting/mail-infra 6) Performance & toegankelijkheid 7) Browsers & responsive 8) SEO & vindbaarheid 9) Na livegang. Genereren/lezen van .xlsx met openpyxl; het oude WP-bron-.xls is OLE2 (lezen met xlrd).

Kern van de WP→stack-vertaling: plugins/iThemes/wp-login/xmlrpc/Duplicator/themes-FTP/ PHP-versie/cache-plugin/reCAPTCHA = N.v.t. (equivalent: Cloudflare + Astro: checkOrigin, honeypot+time-trap, edge-caching, Worker compatibility_date); formulieren "opgeslagen in beheer" = D1 contact_submissions; redirect via DirectAdmin = middleware + Always Use HTTPS.


11. Per-klant te verzamelen (vóór livegang)

  • Telefoon, e-mail, NAW, KvK/BTW, openingstijden → in site_settings (admin).
  • Mailbox: adres + wachtwoord + in/uit-server + IP (van de collega). Contactpersoon voor de autoreply-handtekening.
  • DKIM-record uit DirectAdmin (collega).
  • CMS-login: e-mailadres(sen) van de klant + gewenste rol (ADMIN/EDITOR) → gebruiker(s) aanmaken (§6b).
  • Echte foto's (hero/projecten/Arte), logo (SVG), favicon/og-image, echte reviews.
  • GTM-container-ID (GTM-XXXXXX) van de klant, indien ze Tag Manager gebruiken → in site_settings (§8a). Evt. ook Search Console-/Bing-verificatie (ingebouwde Settings → SEO).
  • Akkoord op definitieve teksten + moment van livegang.

12. Go-live volgorde (samengevat)

  1. Scaffold + npm install (corporate env).
  2. Cloudflare D1 + R2 aanmaken, IDs in wrangler.jsonc.
  3. Componenten + content-model bouwen; seed/seed.json. Lees image-velden meteen uit (helper + statische fallback), galerij = losse image-velden (§5a).
  4. npm run build + deploy → eerste Worker op *.workers.dev.
  5. Setup-wizard draaien → content seeden; demo-tekst evt. via SQL bijwerken; placeholders in de media-library zetten + beeldvelden koppelen (§5a).
  6. Mail: src/lib/smtp.ts + contact.ts koppelen; CONTACT_SMTP_HOST op de serverhostname (srv8), NIET mail.<domein> (§6); secret CONTACT_SMTP_PASS zetten (bash!); testen via wrangler tail → 2× mail-sent + alle paden (422/honeypot/time-trap).
  7. CMS-login (§6b): e-mail-provider-plugin registreren (hergebruikt smtp.ts) + klant-gebruiker(s) in users aanmaken; magic-link testen (send → token → tail). Vermijd een passkey op de preview-URL (breekt na de domeinkoppeling, §6b).
  8. Zone op Cloudflare + nameservers om → verifieer dat de zone active is (niet pending; NS naar de CF-NS, niet de hoster — §7); custom domain via routes (rebuild! → workers.dev gaat uit). Zet meteen emdash:site_url op het echte domein (anders zijn de inlogmails dood — §6b).
  9. Mail-DNS (MX/SPF/DKIM/DMARC) zetten (dashboard of DNS-token) + via DoH verifiëren.
  10. HTTPS: middleware + "Always Use HTTPS" + SSL Full(strict) + HSTS.
  11. SEO + perf-basics: favicon, robots, sitemap, canonical, meta, JSON-LD, self-hosted font, _headers-caching.
  12. Checklist invullen (§10) en met de klant aflopen.
  13. Klant-handover: inlog-uitleg mailen (§6b) → klant-akkoord → live; daarna Search Console + Analytics + PageSpeed-meting.

13. Bekende valkuilen (Windows/corporate) — niet opnieuw intrappen

  • NODE_OPTIONS=--use-system-ca overal nodig (npm, astro, wrangler), anders hangt het stil op het corporate-MITM-cert.
  • Tools via node node_modules/.../bin (geen .bin-shims door --ignore-scripts).
  • Corporate-DNS resolvet je nieuwe domein niet → forceer CF-IP met curl --resolve, en check publieke DNS via DoH (dns.google/resolve), niet nslookup/poort 53.
  • Secrets via bash printf, nooit via PowerShell-pipe (BOM → 535).
  • Rebuild ná elke wrangler.jsonc-wijziging (adapter genereert eigen dist/server/wrangler.json).
  • emdash seed-CLI werkt niet (better-sqlite3) → seed via wizard.
  • Dev-server (Vite) is instabiel (dep-optimizer 500's): vers herstarten + evt. node_modules/.vite wissen; poll op content, niet alleen HTTP 200. Productie-build heeft dit niet — testen kan het betrouwbaarst gewoon op een deploy.
  • Bestanden kunnen op deze machine verdwijnen (antivirus/sync wist o.a. node_modules- .js en losse bestanden). Herstel node_modules met npm ci --ignore-scripts (met env); controleer na het aanmaken van losse bestanden of ze nog bestaan.
  • Emdash-login werkt niet out-of-the-box: géén e-mail+wachtwoord, "Invite User" is een dood JSON-endpoint, en de magic-link-knop geeft 503 zonder e-mail-provider-plugin. Los op met de plugin + gebruikers via SQL (§6b). Reken er niet op dat de admin-UI dit regelt.
  • Een leeg image-veld doet niks tenzij de component het uitleest (helper + fallback, §5a). Upload-in-admin ≠ zichtbaar-op-site zolang de koppeling ontbreekt.
  • Twee "settings" in emdash: de ingebouwde Settings → SEO-pagina is hardcoded en niet uitbreidbaar; site-brede klant-velden (SEO-titel/description, GTM, NAW) horen in de site_settings-collectie ("Instellingen"), waar Base.astro uit leest. De klant zoekt GTM vaak op de verkeerde pagina. Zie §8a.
  • Passkey is origin-gebonden: een passkey op *.workers.dev werkt NIET op het echte domein → login breekt na de domeinkoppeling. Nieuwe passkey op het echte domein, of mint handmatig een magic-link-token (§6b). Vermijd passkeys op de preview.
  • emdash:site_url blijft op de preview staan: de inlogmail-link wijst dan naar de uitgezette *.workers.dev → de klant krijgt Cloudflares "There is nothing here yet"-placeholder. Update de optie (UPDATE options SET value='"https://<domein>"' ...) bij de domeinkoppeling. Zie §6b.
  • mail.<domein> resolvet vaak niet na de NS-verhuizing → CONTACT_SMTP_HOST = de serverhostname (srv8), te vinden via de /webmail-redirect. Error: Stream was cancelled = Worker-SMTP-verbindingsfout (host onvindbaar / cert-mismatch bij kaal IP). Zie §6.
  • "DNS is omgezet" ≠ klaar: check dat de Cloudflare-zone active is (niet pending); NS moeten naar de CF-NS, niet de hoster (anders SERVFAIL / lame delegation). Zie §7.
  • wrangler-OAuth-token: 403 op DNS-records én zone-instellingen (SSL/HSTS); custom-domain- creatie werkt wél. Mail-DNS/HTTPS via dashboard of Zone.DNS:Edit-token. Zie §7.
  • Mail-DNS van een zuster-zone kopiëren? Pas de DMARC rua (en DKIM) aan naar het nieuwe domein — copy-paste-valkuil (Helvoirt stond op behangerbrabant).
  • PowerShell-valkuilen: $HOME is read-only (gebruik een andere variabelenaam); native-exe- stderr laat het tool vaak "exit 255/1" melden terwijl de actie slaagde (check de echte output op success); curl.exe met een JSON-body breekt op de quoting → gebruik Invoke-WebRequest -Body '{"...":"..."}' voor JSON-POSTs.

14. Verwijzingen

  • architecture.md — datamodel, sectie-volgorde, repo-layout (deels achterhaald op detail).
  • todo.md — actuele projectstatus + alle besluiten/gotchas per sessie.
  • Checklist - Websites (template, leeg).xlsx — leeg herbruikbaar template (start hier).
  • Checklist - Websites - behangerbrabant (ingevuld).xlsx — voorbeeld van een ingevulde checklist.
  • Memory wrangler-secret-bom-footgun — de BOM-valkuil (geldt voor alle zustersites).
  • Memory emdash-schema-lives-in-d1 — schema in D1; veld toevoegen / collectie verwijderen op een live site.
  • Memory emdash-media-population — image-veld opslag/serveer-model + zelf media in de library zetten.
  • Memory emdash-passwordless-magic-link — passwordless auth, invite-bug, en de e-mail-provider-plugin voor magic-link.