44 KiB
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
usersaangemaakt. 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 whoamiom 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 (
sharpis kapot door--ignore-scripts). Installeer libs metpip 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 inwrangler.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.mdvoor 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 ophttps://<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>:De publieke site leest deze kolommen direct (geen rebuild nodig). Let op revisie- kolommennode 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>';" --jsonlive_revision_id/draft_revision_id: bij geseede content zijn dienull, 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.jsonis 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)), énseed.jsonbijwerken voor verse installs. Alléén seed.json aanpassen laat het veld NIET in de live-admin verschijnen. Zie memoryemdash-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 } }.srcwordt op displaytijd afgeleid uitstorageKey; geserveerd via/_emdash/api/media/file/<storageKey>(R2, immutable cached).astro.configgebruiktstorage: 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: leesentry.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 ruwjson-veld (slechte UX). - Placeholders alvast in de media-library zetten (anders ziet de klant lege
beeldvelden): R2-
object put+ rij inmedia+ veld-JSON. Zie memoryemdash-media-population(+scripts/migrate-images.sqlin 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
srcper 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. EensrcsetFor()-helper hoortundefinedte 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 (zoalsscripts/gen-cms-sql.mjs, die je importeerbaar maakt door de CLI achter eenimport.meta.url===…-guard te zetten). Dedupliceer: éénmedia-rij per unieke foto, meerdere velden mogen dezelfdestorageKeydelen → 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>uitsqlite_masteren de kolommen van_emdash_collections/_emdash_fields. Genereer dan:INSERT _emdash_collections+CREATE TABLE ec_<slug>(zelfde systeemkolommen) + per veldINSERT _emdash_fields+ content-INSERTs. Laat search/FTS weg (supports zondersearch, geensearchable-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-defaultspages/postsworden 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/getEmDashCollectionin try/catch). Zo breekt de site nooit, ook tijdens de migratie. Verifieer per stap met een sentinel: zet een herkenbare waarde viad1 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 GEENimagezijn → 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 geendescription/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 inmedia(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}}(geensrc; 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
- Honeypot (
website_url) + time-trap (tshidden field; <2s of >6h = stille 200). - Validatie (naam/e-mail/bericht), 422 met veld-errors.
- Opslaan in D1 (
contact_submissions, migratiemigrations/0001_*.sql) = vangnet. - 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.tsondersteunt hiervoor een aparteenvelopeFrom.
- Melding naar de klant:
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 putvanuit Windows PowerShell plakt een UTF-8 BOM vóór de waarde → SMTP-auth faalt stil met535 Incorrect authentication data. Gebruik bashprintf '%s'of het Cloudflare-dashboard.contact.tsstript 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 geenpassword_hash). Methodes: passkey (WebAuthn), magic-link (e-mail) en OAuth (Google/GitHub via secretsEMDASH_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.
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 uitcloudflare:workersenv en levert af viasendMail. Gooi bij fout (zodat de pipeline het meldt, niet stil "verstuurd").- Registreren in
astro.config.mjs:⚠️ Entrypoint = absoluut pad met forward slashes — emdash genereert er eenimport { 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"] }], })importmee, dus een resolvebare specifier is nodig (backslashes breken het). - Config-plugins zijn standaard enabled; als enige
email:deliver-provider wordt 'ie bij runtime-init auto-geselecteerd + gepersisteerd (optieemdash: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).
⚠️ Inlogmail-link geeft Cloudflare-placeholder "There is nothing here yet" (geleerd bij Helvoirt)
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:
DELETEuitauth_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 viawrangler 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 deruaper ongeluk nog opinfo@behangerbrabant.nl(copy-paste). De MX mag direct naarsrv8.dewebsmid.nl(een losmail.<domein>-A-record is optioneel; alleen nodig als het mailprogramma die hostname gebruikt).
HTTPS / redirect / security
- http→https:
src/middleware.tsdoet een 301 op basis van decf-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.jsoncroutes(apex +www, beidecustom_domain: true). ⚠️ Rebuild ná elkewrangler.jsonc-wijziging, anders deployt wrangler een stale config (de adapter genereertdist/server/wrangler.json). ⚠️ Bij het toevoegen vanroutesschakelt de deployworkers.devuit (custom-domain-only — goed tegen duplicate content, maar de preview-URL stopt en open passkeys op die URL breken, zie §6b). De middleware regeltwww→apex+http→httpsal op worker-niveau, dus de redirects werken ook zonder de dashboard-instellingen. ⚠️ Update meteen ookemdash:site_urlnaar 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.svgMOET 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. NooitDisallow: /.sitemap.xmlendpoint + canonical naar apex (non-www) viasite:inastro.config.mjs.- Meta
title+descriptioninBase.astro; JSON-LDLocalBusiness+FAQPage. - Beelden: WebP, responsive maten via Pillow. Nieuwe bestandsnamen bij vervanging
(bv.
-v2-) om cache-staleness te vermijden;width/heightop<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-facemetfont-display: swapintokens.css, en de externefonts.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 decss2-API: width-varianten (bv. Archivo Expanded) zijn door Google samengevoegd in de variabele basisfamilie →family=Archivo+Expandedlevert 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.
- 2 externe origins. Download het woff2 met een Chrome-UA via de
- 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 → gebruikseguisym.ttf(Segoe UI Symbol) voor de sterren. public/_headersvoor 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-heroloading="lazy".
- Host fonts zelf i.p.v. Google Fonts: één variabel woff2 (latin-subset) in
- 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.seoinnode_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, enBase.astroleest die ook niet. Site-brede klant-instellingen (SEO-titel/ description, GTM, NAW) horen in desite_settings-collectie (label "Instellingen") — dáár leestBase.astrouit. De klant zoekt GTM vaak op de verkeerde (ingebouwde) pagina.
- 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)metid=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.
- 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 alleenGTM-, niet GA4'sG-XXXX):const gtmId = (((s.gtm as string|undefined) ?? "").match(/GTM-[A-Z0-9]+/i)?.[0] ?? "").toUpperCase();- Head (zo hoog mogelijk in
<head>), alleen alsgtmIdgezet is: het officiële GTM-<script>viais: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).
- Head (zo hoog mogelijk in
- 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;npxwil 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 PowerShellStart-Job+Receive-Jobals 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
.xlsxmetopenpyxl; het oude WP-bron-.xlsis OLE2 (lezen metxlrd).
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 → insite_settings(§8a). Evt. ook Search Console-/Bing-verificatie (ingebouwde Settings → SEO). - Akkoord op definitieve teksten + moment van livegang.
12. Go-live volgorde (samengevat)
- Scaffold +
npm install(corporate env). - Cloudflare D1 + R2 aanmaken, IDs in
wrangler.jsonc. - Componenten + content-model bouwen;
seed/seed.json. Lees image-velden meteen uit (helper + statische fallback), galerij = losseimage-velden (§5a). npm run build+deploy→ eerste Worker op*.workers.dev.- Setup-wizard draaien → content seeden; demo-tekst evt. via SQL bijwerken; placeholders in de media-library zetten + beeldvelden koppelen (§5a).
- Mail:
src/lib/smtp.ts+contact.tskoppelen;CONTACT_SMTP_HOSTop de serverhostname (srv8), NIETmail.<domein>(§6); secretCONTACT_SMTP_PASSzetten (bash!); testen viawrangler tail→ 2×mail-sent+ alle paden (422/honeypot/time-trap). - CMS-login (§6b): e-mail-provider-plugin registreren (hergebruikt
smtp.ts) + klant-gebruiker(s) inusersaanmaken; magic-link testen (send → token → tail). Vermijd een passkey op de preview-URL (breekt na de domeinkoppeling, §6b). - Zone op Cloudflare + nameservers om → verifieer dat de zone
activeis (nietpending; NS naar de CF-NS, niet de hoster — §7); custom domain viaroutes(rebuild! → workers.dev gaat uit). Zet meteenemdash:site_urlop het echte domein (anders zijn de inlogmails dood — §6b). - Mail-DNS (MX/SPF/DKIM/DMARC) zetten (dashboard of DNS-token) + via DoH verifiëren.
- HTTPS: middleware + "Always Use HTTPS" + SSL Full(strict) + HSTS.
- SEO + perf-basics: favicon, robots, sitemap, canonical, meta, JSON-LD, self-hosted font,
_headers-caching. - Checklist invullen (§10) en met de klant aflopen.
- 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-caoveral 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), nietnslookup/poort 53. - Secrets via bash
printf, nooit via PowerShell-pipe (BOM → 535). - Rebuild ná elke
wrangler.jsonc-wijziging (adapter genereert eigendist/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/.vitewissen; 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-.jsen losse bestanden). Herstelnode_modulesmetnpm 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"), waarBase.astrouit leest. De klant zoekt GTM vaak op de verkeerde pagina. Zie §8a. - Passkey is origin-gebonden: een passkey op
*.workers.devwerkt 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_urlblijft 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
activeis (nietpending); 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:
$HOMEis 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 opsuccess);curl.exemet een JSON-body breekt op de quoting → gebruikInvoke-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.