# webhouse.app Docs > Documentation for @webhouse/cms — the AI-native, file-based, framework-agnostic CMS. > Full content export for AI consumption. See also: [llms.txt](https://docs.webhouse.app/llms.txt) ## docs/producing-articles-via-api-da Title: Producér artikler via API Updated: 2026-05-03 Locale: da Hvordan AI-sessioner og eksterne tjenester opretter og publicerer CMS-indhold programmatisk — endpoint, JSON-shape, access-tokens, og hvad der sker efter en succesfuld POST. ## Hvornår dette gælder Denne guide er til **AI-sessioner, scheduled jobs og eksterne tjenester** der har brug for at pushe indhold ind i et CMS-site uden at gå via admin-UI'en. Typiske cases: - En research-agent-session skriver en markdown-artikel og publicerer den på din blog - En daglig cron lægger en digest-post sammensat af eksterne kilder - En webhook fra et tredjeparts-værktøj (Zapier, n8n) opretter dokumenter on-demand Hvis du er en menneskelig editor der bruger `webhouse.app/admin`, har du ikke brug for denne — brug bare editoren. ## Kontrakten i ét afsnit `POST` dit dokument-JSON til `/api/cms/{collection}?site={siteId}` med en `Authorization: Bearer wh_...` header der bærer en token scoped `content:write` for det site. Body'en behøver kun fire felter (`slug`, `status`, `data`, valgfrit `locale`); serveren udfylder `id`, `_fieldMeta`, `updatedAt` og audit-metadata. Brug `status: "published"` — drafts trigger ikke live-site revalidation-webhooken. ## Trin 1 — Opret en access-token I `webhouse.app/admin`: 1. Klik på din avatar → **Account Preferences** → **Access tokens** → **New token** 2. Sæt scope til `content:write` og resource til det specifikke site (`site:trail`, ikke `site:*`) 3. Begræns valgfrit til en enkelt collection (`posts` only) for least-privilege 4. Kopiér den genererede `wh_...`-streng — den vises kun én gang For cross-session brug (fx at give tokenen til en anden cc-session), skal brugeren der ejer tokenen indsætte den i modtagerens miljø. Der er ingen sikker cross-session sharing-primitiv endnu. ## Trin 2 — POST dokumentet ```bash curl -X POST 'https://webhouse.app/api/cms/posts?site=trail' \ -H 'Authorization: Bearer wh_xxx' \ -H 'Content-Type: application/json' \ -d '{ "slug": "three-architectures-of-agent-memory", "status": "published", "data": { "title": "Three architectures of agent memory — and why Trail picked Compile", "excerpt": "RAG, fine-tuning, and compile-time integration each solve a different memory problem.", "content": "## Sektions-overskrift\n\nMarkdown-body…\n\n{{svg:scan-wall-curve}}\n\nMere prosa…", "date": "2026-05-03", "author": "Trail Team", "category": "research", "tags": ["rag", "llm-wiki", "agent-memory"], "readTime": "9 min read" } }' ``` Svar på success (HTTP 201): ```json { "id": "three-architectures-of-agent-memory", "slug": "three-architectures-of-agent-memory", "status": "published", "locale": "en", "data": { /* echoed back */ }, "updatedAt": "2026-05-03T15:42:11.000Z", "_fieldMeta": {} } ``` ## Hvad body-felterne betyder | Felt | Påkrævet | Noter | |-------|----------|-------| | `slug` | ja | URL-safe (`a-z0-9-`), unik inden for collection. Bliver URL-fragmentet efter `urlPrefix`. | | `status` | ja | `"published"` eller `"draft"`. **Brug `"published"`** medmindre du specifikt vil stage drafts — draft-dokumenter fyrer ikke revalidation-webhook, så live-sitet forbliver stale. | | `data` | ja | Objekt der matcher collection's field-schema. Field-navne SKAL matche `cms.config.ts` præcist. Ukendte felter strippes stille. | | `locale` | nej | Default `config.defaultLocale`. Sæt eksplicit hvis sitet er flersproget og du opretter en ikke-default-locale variant. | | `translationGroup` | nej | UUID der linker oversættelser af samme dokument. Påkrævet for flersprogede sites — brug SAMME UUID for alle sprog-varianter. Generer med `crypto.randomUUID()`. | ## Hvad sker der efter POST 1. Dokument-JSON skrives til `_data/sites/{siteId}/content/{collection}/{slug}.json` på cms-admin-serveren (eller til GitHub repo for github-adapter sites) 2. cms-admin gemmer en revision under `_revisions/` 3. Hvis sitet har `revalidateUrl` konfigureret, fyrer revalidation-webhooken — Next.js sites kalder `revalidatePath()` for den berørte route inden for ~2 sekunder 4. Hvis sitet bruger GitHub Pages og `deployOnSave: true`, kører raket-pipelinen: build.ts producerer det statiske output, de diff'ede filer uploades til `gh-pages`-branchen, GitHub Pages publicerer, og `page_build`-webhooken fyrer tilbage til cms-admin 5. Admin-UI viser en toast og (hvis Web Push er aktiveret) en native OS-notifikation For et clean GH-Pages site som trail-landing er end-to-end POST → live URL typisk 30–90 sekunder. ## Flersprogede posts For et site med `da` og `en` locales, opret ét dokument pr. sprog med en DELT `translationGroup`: ```bash UUID=$(node -e 'console.log(crypto.randomUUID())') # English variant curl -X POST '.../api/cms/posts?site=trail' -d "{ \"slug\": \"my-post\", \"locale\": \"en\", \"translationGroup\": \"$UUID\", \"data\": { /* English content */ } }" # Dansk variant — SAMME UUID curl -X POST '.../api/cms/posts?site=trail' -d "{ \"slug\": \"mit-indlaeg\", \"locale\": \"da\", \"translationGroup\": \"$UUID\", \"data\": { /* Dansk indhold */ } }" ``` Uden delt `translationGroup` knækker sprogskifteren, hreflang-tags og side-by-side oversætteren alle. ## Opdatér et eksisterende dokument Brug `PATCH` for partial update eller `PUT` for full replace: ```bash curl -X PATCH 'https://webhouse.app/api/cms/posts/{slug}?site=trail' \ -H 'Authorization: Bearer wh_xxx' \ -H 'Content-Type: application/json' \ -d '{"data": {"title": "Opdateret titel"}}' ``` PATCH merger de leverede `data`-keys med eksisterende dokument. Andre top-level felter (`status`, `locale`) opdateres uafhængigt. ## SVG og andre build-time-assets Content API'en håndterer IKKE build-time-assets som `{{svg:...}}`-placeholders som en custom build.ts resolver ved compile-time. De lever i **sitets source-repository**, ikke i CMS: - For et GitHub-backed site: åbn en PR der tilføjer `apps/landing/public/uploads/svg/.svg` plus de build.ts-ændringer der wirer nye slugs op - For et CMS-uploaded billede (brugt i en richtext-editor eller `image`-felt): brug `POST /api/media` med multipart form-data Distinktionen: hvis asset'et er **refereret i din build.ts ved compile-time**, hører det hjemme i site source. Hvis det er **rendered ind i editor-content**, hører det hjemme i CMS media. ## Almindelige fejl - **POST uden `?site=...`** — token-baserede callers SKAL altid inkludere `?site=` i URL'en. Serveren falder tilbage til registry default site når den udelades, hvilket stille skriver til det FORKERTE site. Token'ens site-scope claim tjekkes mod `?site=`, ikke mod fallbacks. - **Status `"draft"`** — vises stille ikke på live-sitet fordi revalidation ikke fyrer. Brug `"published"`. - **Field-navne med typos** — ukendte felter dropes stille. Dokumentet oprettes men mangler data. Kør `GET /api/cms/{collection}/{slug}` for at verificere round-trippen. - **Flersproget uden `translationGroup`** — orphaner dokumentet fra dets translation group. Side-by-side editor og sprogskifter knækker. - **Genbrug af eksisterende slug** — POST returnerer 409. Brug PATCH i stedet, eller vælg en unik slug. ## Verifikation af success ```bash # Landede den? curl 'https://webhouse.app/api/cms/posts/{slug}?site=trail' \ -H 'Authorization: Bearer wh_xxx' # Er den live på det deployede site? curl -I 'https://www.dit-site.com/posts/{slug}/' ``` Den anden skal returnere `HTTP/2 200`. Hvis den returnerer 404, fyrede build/deploy-pipelinen ikke — tjek `_data/sites/{siteId}/deploy-log.json` for det seneste entry. ## Se også - [Headless Site API & Chat Embedding](/docs/headless-api-da) — den fulde REST-reference og chat-embed flow. - [Content API & ContentService](/docs/api-reference-da) — den in-process programmatiske API som build.ts bruger. - [Storage adapters](/docs/storage-adapters-da) — hvor JSON'en fysisk lever (filesystem, GitHub, SQLite). --- ## docs/producing-articles-via-api Title: Producing articles via API Updated: 2026-05-03 Locale: en How AI sessions and external services create and publish CMS content programmatically — endpoint, JSON shape, access tokens, and what happens after a successful POST. ## When to use this This guide is for **AI sessions, scheduled jobs, and external services** that need to push content into a CMS site without going through the admin UI. Typical cases: - A research-agent session writes a markdown article and publishes it to your blog - A daily cron drops a digest post compiled from external sources - A webhook from a third-party tool (Zapier, n8n) creates documents on demand If you are a human editing through `webhouse.app/admin`, you don't need this — just use the editor. ## The contract in one paragraph `POST` your document JSON to `/api/cms/{collection}?site={siteId}` with an `Authorization: Bearer wh_...` header carrying a token scoped `content:write` for that site. The body needs only four fields (`slug`, `status`, `data`, optionally `locale`); the server fills in `id`, `_fieldMeta`, `updatedAt`, and audit metadata. Use `status: "published"` — drafts do not trigger the live-site revalidation webhook. ## Step 1 — Create an access token In `webhouse.app/admin`: 1. Click your avatar → **Account Preferences** → **Access tokens** → **New token** 2. Set scope to `content:write` and resource to the specific site (`site:trail`, not `site:*`) 3. Optionally restrict to a single collection (`posts` only) for least-privilege 4. Copy the generated `wh_...` string — it is shown once only For cross-session use (e.g. handing the token to a different cc session), the user owning the token must paste it into the recipient's environment. There is no secure cross-session sharing primitive yet. ## Step 2 — POST the document ```bash curl -X POST 'https://webhouse.app/api/cms/posts?site=trail' \ -H 'Authorization: Bearer wh_xxx' \ -H 'Content-Type: application/json' \ -d '{ "slug": "three-architectures-of-agent-memory", "status": "published", "data": { "title": "Three architectures of agent memory — and why Trail picked Compile", "excerpt": "RAG, fine-tuning, and compile-time integration each solve a different memory problem. Here is what each gives up — and why Trail spent a year building the third.", "content": "## Section heading\n\nMarkdown body…\n\n{{svg:scan-wall-curve}}\n\nMore prose…", "date": "2026-05-03", "author": "Trail Team", "category": "research", "tags": ["rag", "llm-wiki", "agent-memory"], "readTime": "9 min read" } }' ``` Response on success (HTTP 201): ```json { "id": "three-architectures-of-agent-memory", "slug": "three-architectures-of-agent-memory", "status": "published", "locale": "en", "data": { /* echoed back */ }, "updatedAt": "2026-05-03T15:42:11.000Z", "_fieldMeta": {} } ``` ## What the body fields mean | Field | Required | Notes | |-------|----------|-------| | `slug` | yes | URL-safe (`a-z0-9-`), unique within the collection. Becomes the URL fragment after `urlPrefix`. | | `status` | yes | `"published"` or `"draft"`. **Use `"published"`** unless you specifically want to stage drafts — draft documents do not fire the revalidation webhook, so the live site stays stale. | | `data` | yes | Object matching the collection's field schema. Field names must match `cms.config.ts` exactly. Unknown fields are stripped silently. | | `locale` | no | Defaults to `config.defaultLocale`. Set explicitly if the site is multilingual and you are creating a non-default-locale variant. | | `translationGroup` | no | UUID linking translations of the same document. Required for multilingual sites — use the SAME UUID for all language variants. Generate with `crypto.randomUUID()`. | ## What happens after the POST 1. The document JSON is written to `_data/sites/{siteId}/content/{collection}/{slug}.json` on the cms-admin server (or to the GitHub repo for github-adapter sites) 2. cms-admin saves a revision under `_revisions/` 3. If the site has `revalidateUrl` configured, the revalidation webhook fires — Next.js sites call `revalidatePath()` for the affected route within ~2 seconds 4. If the site uses GitHub Pages and `deployOnSave: true`, the rocket pipeline runs: build.ts produces the static output, the diffed files upload to the `gh-pages` branch, GitHub Pages publishes, and the `page_build` webhook fires back to cms-admin 5. The admin UI shows a toast and (if Web Push is enabled) a native OS notification For a clean GH-Pages site like trail-landing, end-to-end POST → live URL is typically 30–90 seconds. ## Multilingual posts For a site with `da` and `en` locales, create one document per language with a SHARED `translationGroup`: ```bash UUID=$(node -e 'console.log(crypto.randomUUID())') # English variant curl -X POST '.../api/cms/posts?site=trail' -d "{ \"slug\": \"my-post\", \"locale\": \"en\", \"translationGroup\": \"$UUID\", \"data\": { /* English content */ } }" # Danish variant — SAME UUID curl -X POST '.../api/cms/posts?site=trail' -d "{ \"slug\": \"mit-indlaeg\", \"locale\": \"da\", \"translationGroup\": \"$UUID\", \"data\": { /* Danish content */ } }" ``` Without shared `translationGroup`, the language switcher, hreflang tags, and side-by-side translation editor all break. ## Updating an existing document Use `PATCH` for partial update or `PUT` for full replace: ```bash curl -X PATCH 'https://webhouse.app/api/cms/posts/{slug}?site=trail' \ -H 'Authorization: Bearer wh_xxx' \ -H 'Content-Type: application/json' \ -d '{"data": {"title": "Updated title"}}' ``` PATCH merges the supplied `data` keys with the existing document. Other top-level fields (`status`, `locale`) update independently. ## SVG and other build-time assets The Content API does NOT handle build-time assets like `{{svg:...}}` placeholders that a custom build.ts resolves at compile time. Those live in the **site's source repository**, not the CMS: - For a GitHub-backed site: open a PR adding `apps/landing/public/uploads/svg/.svg` plus any build.ts changes that wire up new slugs - For a CMS-uploaded image (used in a richtext editor or `image` field): use `POST /api/media` with multipart form-data The distinction: if the asset is **referenced in your build.ts at compile time**, it belongs in the site source. If it is **rendered into editor content**, it belongs in CMS media. ## Common mistakes - **POST without `?site=...`** — token-based callers MUST always include `?site=` on the URL. The server falls back to the registry default site when omitted, which silently writes to the WRONG site. The token's site-scope claim is checked against `?site=`, not against fallbacks. - **Status `"draft"`** — silently does not appear on the live site because revalidation does not fire. Use `"published"`. - **Field names with typos** — unknown fields are silently dropped. The document is created but missing data. Run `GET /api/cms/{collection}/{slug}` to verify the round-trip. - **Multilingual without `translationGroup`** — orphans the document from its translation group. Side-by-side editor and language switcher break. - **Reusing an existing slug** — POST returns 409. Use PATCH instead, or pick a unique slug. ## Verifying success ```bash # Did it land? curl 'https://webhouse.app/api/cms/posts/{slug}?site=trail' \ -H 'Authorization: Bearer wh_xxx' # Is it live on the deployed site? curl -I 'https://www.your-site.com/posts/{slug}/' ``` The second should return `HTTP/2 200`. If it returns 404, the build/deploy pipeline did not fire — check `_data/sites/{siteId}/deploy-log.json` for the most recent entry. ## See also - [Headless Site API & Chat Embedding](/docs/headless-api) — the full REST reference and chat-embed flow. - [Content API & ContentService](/docs/api-reference) — the in-process programmatic API used by build.ts. - [Storage adapters](/docs/storage-adapters) — where the JSON physically lives (filesystem, GitHub, SQLite). --- ## docs/headless-api-da Title: Headless Site API & Chat-integrering Updated: 2026-04-28 Locale: da Brug CMS Admin som et headless backend i dit eget Next.js-site. Læs indhold, trigger deploys og integrer AI-chat — alt autentificeret med et permanent wh_ Access Token. ## Overblik WebHouse CMS Admin er et fuldt headless backend. Ethvert Next.js-site (eller andet framework) kan kalde dets REST API med et permanent `wh_` Access Token — ingen OAuth-redirect, ingen cookie-session. Det giver dig mulighed for at bygge brugerdefinerede admin-paneler, booking-styring, form-indbakker og endda en AI-chat inde i dit eget brandede UI. ## 1. Opret et Access Token Gå til **Konto Indstillinger → Access Tokens → Opret tilpasset token**. Vælg kun de rettigheder dit site har brug for: | Brugsscenarie | Rettigheder | |---|---| | Læs indhold | `content.read` | | Fuldt indhold CRUD | `content.read content.create content.edit content.publish content.delete` | | Trigger deploys | `deploy:trigger deploy:read` | | Form-indbakke | `forms.read` | Angiv **Site-scope** for at begrænse tokenet til et specifikt site. Gem det i `.env`: ``` CMS_API_TOKEN=wh_xxxxxxxxxxxxxxxxxxxx CMS_API_URL=https://webhouse.app ``` **Eksponér aldrig tokenet client-side.** Brug det kun i server components, API routes eller `getServerSideProps`. ## 2. Læs indhold ```typescript // app/posts/page.tsx export default async function PostsPage() { const res = await fetch( `${process.env.CMS_API_URL}/api/cms/posts?status=published`, { headers: { Authorization: `Bearer ${process.env.CMS_API_TOKEN}` }, next: { revalidate: 60 }, } ); const { documents } = await res.json(); return {documents.map((p: any) => {p.data.title})}; } ``` ## 3. Content API Reference ``` GET /api/cms/{collection} List dokumenter GET /api/cms/{collection}/{slug} Hent efter slug POST /api/cms/{collection} Opret PATCH /api/cms/{collection}/{slug} Opdater DELETE /api/cms/{collection}/{id} Papirkurv Query-parametre: status, locale, limit, offset, tags ``` ## 4. Site Admin byggeklodser ### Trigger et deploy ```typescript await fetch(`${process.env.CMS_API_URL}/api/admin/deploy`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CMS_API_TOKEN}` }, }); ``` ### Læs form-indsendelser ```typescript const res = await fetch( `${process.env.CMS_API_URL}/api/admin/forms/contact/submissions`, { headers: { Authorization: `Bearer ${process.env.CMS_API_TOKEN}` } } ); const { submissions } = await res.json(); ``` ## 5. Integrer AI-chat CMS-chatten kører den samme Claude-model og de samme værktøjer som CMS Admin. Proxy den gennem en server-route i dit site: ```typescript // app/api/chat/route.ts export async function POST(request: Request) { const body = await request.text(); const upstream = await fetch(`${process.env.CMS_API_URL}/api/cms/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, }, body, }); return new Response(upstream.body, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, }); } ``` Konsumer herefter streamen i en client component med en standard `ReadableStream`-parser. Chatten streamer SSE-events med `event: text`, `event: tool_call`, `event: tool_result` og `event: done`. **Begrænsværktøjer** ved kun at give de rettigheder du ønsker på Access Token — chatten eksekverer kun værktøjer der matcher tokenets rettighedssæt. ## 6. ICD Revalidering Konfigurer revaliderings-webhook i Siteindstillinger → Deploy → Revalidate URL for at forbinde Next.js ISR med CMS-indholdsudgivelse: ```typescript // app/api/revalidate/route.ts import { revalidatePath } from 'next/cache'; export async function POST(req: Request) { const secret = req.headers.get('x-webhouse-secret'); if (secret !== process.env.REVALIDATE_SECRET) return new Response('Uautoriseret', { status: 401 }); const { slug, collection } = await req.json(); revalidatePath(`/${collection}/${slug}`); return Response.json({ revalidated: true }); } ``` ## Videre læsning - [Access Tokens](/docs/access-tokens) — oprettelse og scope-styring - [Content API](/docs/api-reference) — fuld endpoint-reference - [ICD Deploy](/docs/deployment) — øjeblikkelig indholdsdeploy --- ## docs/headless-api Title: Headless Site API & Chat Embedding Updated: 2026-04-28 Locale: en Use CMS Admin as a headless backend inside your own Next.js site. Read content, trigger deploys, and embed the AI chat — all authenticated with a permanent wh_ Access Token. ## Overview WebHouse CMS Admin is a full headless backend. Any Next.js (or other) site can call its REST API with a permanent `wh_` Access Token — no OAuth redirect, no cookie session. This lets you build custom admin panels, booking management, form inboxes, and even an AI chat inside your own branded UI. ## 1. Create an Access Token Go to **Account Preferences → Access Tokens → Create custom token**. Choose only the permissions your site needs: | Use case | Permissions | |---|---| | Read content | `content.read` | | Full content CRUD | `content.read content.create content.edit content.publish content.delete` | | Trigger deploys | `deploy:trigger deploy:read` | | Form inbox | `forms.read` | Set **Site scope** to restrict the token to a specific site. Store it in `.env`: ``` CMS_API_TOKEN=wh_xxxxxxxxxxxxxxxxxxxx CMS_API_URL=https://webhouse.app ``` **Never expose the token client-side.** Use it in server components, API routes, or `getServerSideProps` only. ## 2. Read Content ```typescript // app/posts/page.tsx export default async function PostsPage() { const res = await fetch( `${process.env.CMS_API_URL}/api/cms/posts?status=published`, { headers: { Authorization: `Bearer ${process.env.CMS_API_TOKEN}` }, next: { revalidate: 60 }, } ); const { documents } = await res.json(); return {documents.map((p: any) => {p.data.title})}; } ``` ## 3. Content API Reference ``` GET /api/cms/{collection} List documents GET /api/cms/{collection}/{slug} Get by slug POST /api/cms/{collection} Create PATCH /api/cms/{collection}/{slug} Update DELETE /api/cms/{collection}/{id} Trash Query params: status, locale, limit, offset, tags ``` ## 4. Site Admin Building Blocks ### Trigger a deploy ```typescript await fetch(`${process.env.CMS_API_URL}/api/admin/deploy`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CMS_API_TOKEN}` }, }); ``` ### Read form submissions ```typescript const res = await fetch( `${process.env.CMS_API_URL}/api/admin/forms/contact/submissions`, { headers: { Authorization: `Bearer ${process.env.CMS_API_TOKEN}` } } ); const { submissions } = await res.json(); ``` ## 5. Embed the AI Chat The CMS chat runs the same Claude model and tools as CMS Admin. Proxy it through a server route in your site: ```typescript // app/api/chat/route.ts export async function POST(request: Request) { const body = await request.text(); const upstream = await fetch(`${process.env.CMS_API_URL}/api/cms/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.CMS_API_TOKEN}`, }, body, }); return new Response(upstream.body, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, }); } ``` Then consume the stream in a client component with a standard `ReadableStream` parser. The chat streams SSE events with `event: text`, `event: tool_call`, `event: tool_result`, and `event: done`. **Restrict tools** by granting only the permissions you want on the Access Token — the chat only executes tools matching the token's permission set. ## 6. ICD Revalidation Configure the revalidate webhook in Site Settings → Deploy → Revalidate URL to connect Next.js ISR with CMS content publishing: ```typescript // app/api/revalidate/route.ts import { revalidatePath } from 'next/cache'; export async function POST(req: Request) { const secret = req.headers.get('x-webhouse-secret'); if (secret !== process.env.REVALIDATE_SECRET) return new Response('Unauthorized', { status: 401 }); const { slug, collection } = await req.json(); revalidatePath(`/${collection}/${slug}`); return Response.json({ revalidated: true }); } ``` ## Further reading - [Access Tokens](/docs/access-tokens) — creating and scoping tokens - [Content API](/docs/api-reference) — full endpoint reference - [ICD Deployment](/docs/deployment) — instant content deployment --- ## docs/admin-data-location-da Title: Placering af admin-data Updated: 2026-04-20 Locale: da Hvor cms-admin gemmer registry, brugere, access-tokens og anden server-level tilstand — og hvordan du konfigurerer det for dev, Docker, Fly og Kubernetes så det overlever genstarter. ## De to slags data cms-admin er en **server**. Sites som den administrerer er **klienter**. Den linje afgør hvor hvert stykke data lever: - **Admin-server data** — deles på tværs af alle sites: registry over orgs og sites, brugerkonti, access-tokens, push device-tokens, beam transfer-sessions, goto deep-link-shortcuts, agent-templates, org-level settings. Intet af dette hører hjemme inde i et enkelt sites mappe. - **Per-site data** — tilhører ét site og rejser med det: content JSON, site-config, team-medlemskab for dét site, analytics, lighthouse-rapporter, formular-submissions, brand voice, deploy-log, planlagte events. Lever inde i sitets egen mappe, så når sitet flyttes til en anden server følger dets state med. Denne doc handler om **admin-server**-halvdelen — hvor den gemmes, og hvordan du sikrer at den overlever genstarter på alle platforme. ## Resolution-orden `getAdminDataDir()` tjekker disse i rækkefølge og bruger den første der findes: | # | Prioritet | Placering | Bruges af | |---|-----------|-----------|-----------| | 1 | Override | `$WEBHOUSE_DATA_DIR` | Production — Docker, Fly, Kubernetes (sat eksplicit i Dockerfile / fly.toml / pod spec) | | 2 | Auto-detect | `/data/cms-admin/` hvis writable | Containers hvor deploy-laget har mounted et persistent volume der (ingen env-variabel nødvendig) | | 3 | Linux-standard | `$XDG_DATA_HOME/webhouse-cms/` | Linux-dev med XDG eksplicit | | 4 | XDG default | `$HOME/.local/share/webhouse-cms/` | Linux-dev default | | 5 | Simpel fallback | `$HOME/.webhouse/cms-admin/` | macOS + Linux udviklermaskiner | | 6 | Legacy | `{CMS_CONFIG_PATH-parent}/_admin/` | Kun pre-0.2.18 deployments — auto-migreret ved første boot med 0.2.18+ | Override'en vinder altid. I produktion bør du **altid** enten sætte `WEBHOUSE_DATA_DIR` eksplicit eller mount'e et persistent volume ved `/data/cms-admin` — ellers tørrer container-genstarter registry, brugere og access-tokens. ## Opsætning platform for platform ### Lokal udvikling (macOS, Linux) Intet at konfigurere — cms-admin opretter `$HOME/.webhouse/cms-admin/` ved første boot og skriver der. For at flytte det eksplicit: ```bash export WEBHOUSE_DATA_DIR=/opt/webhouse-cms mkdir -p $WEBHOUSE_DATA_DIR pnpm dev ``` ### Docker (plain) De leverede Dockerfiles sætter `WEBHOUSE_DATA_DIR=/data/cms-admin` og forventer et volume mountet der: ```bash docker run \ -p 3010:3010 \ -v $(pwd)/cms-admin-data:/data/cms-admin \ -v $(pwd)/my-site:/site \ ghcr.io/webhousecode/cms-admin ``` Med `docker compose` deklarerer `docker-compose.yml` allerede et `cms_admin_data` named volume: ```yaml volumes: - cms_admin_data:/data/cms-admin volumes: cms_admin_data: ``` `docker compose down` efterfulgt af `docker compose up` genbruger volumen; `docker compose down -v` sletter det. ### Fly.io `deploy/fly.toml` kobler allerede `cms_data`-volumen til `/data` og sætter `WEBHOUSE_DATA_DIR=/data/cms-admin`. Redeploy er sikkert — volumen overlever en hvilken som helst enkelt maskine: ```toml [env] WEBHOUSE_DATA_DIR = "/data/cms-admin" [mounts] source = "cms_data" destination = "/data" ``` Første-gangs volume-opsætning (én gang pr. region): ```bash fly volumes create cms_data --region arn --size 1 fly deploy ``` ### Kubernetes Brug et `PersistentVolumeClaim` og mount det ved `/data/cms-admin`. Container-image'et auto-detecter `/data/cms-admin` når det er writable, så du behøver strengt taget ikke `WEBHOUSE_DATA_DIR` hvis du mount'er der — men det er god skik at sætte det eksplicit: ```yaml env: - name: WEBHOUSE_DATA_DIR value: /data/cms-admin volumeMounts: - name: admin-data mountPath: /data/cms-admin volumes: - name: admin-data persistentVolumeClaim: claimName: cms-admin-data ``` ### Bare-metal Linux (tarball-install) Den ugentlige tarball indeholder kun kode — ingen runtime-data. Installations-flow: ```bash tar xf webhouse-cms-0.2.18.tar.gz -C /opt/ useradd --system --home /var/lib/webhouse-cms webhouse mkdir -p /var/lib/webhouse-cms chown webhouse:webhouse /var/lib/webhouse-cms # /etc/systemd/system/webhouse-cms.service [Service] User=webhouse Environment="WEBHOUSE_DATA_DIR=/var/lib/webhouse-cms" Environment="CMS_CONFIG_PATH=/srv/site/cms.config.ts" WorkingDirectory=/opt/webhouse-cms/packages/cms-admin ExecStart=/usr/bin/node server.js ``` ## Verifikation af opsætningen Efter første boot skal `$WEBHOUSE_DATA_DIR` indeholde: ``` registry.json # alle orgs + sites _data/ users.json # brugerkonti access-tokens.json # API-tokens device_tokens.json # push-modtagere invitations.json # ventende invitationer org-settings/ .json agent-templates/ / beam-sessions/ # cross-site beam-overførsler beam-sites/ # beam-import staging goto-links.json # /admin/goto/ short links ``` For at bekræfte at cms-admin læser fra det rette sted: ```bash curl -sk https://localhost:3010/api/cms/registry \ -H "Cookie: " | jq '.registry.orgs | length' ``` Antallet skal matche hvad der står i `$WEBHOUSE_DATA_DIR/registry.json`. ## Hvad sker der når mappen er flygtig Hvis du kører cms-admin i Docker/Fly/Kubernetes **uden** at mount'e et volume ved `/data/cms-admin`: 1. Første boot: cms-admin opretter registry.json osv. i containerens lokale filsystem. 2. Du logger ind, opretter orgs/sites, minter tokens. 3. Containeren genstarter (redeploy, OOM kill, host reboot). 4. Registry + brugere + tokens er væk. Du er tilbage til en frisk installation. 0.2.18+'s resolution-orden prøver hårdt på at fange det — auto-detecter `/data/cms-admin` når det findes, og falder tilbage til `$HOME` ellers — men en container uden hverken mount eller env-variabel vil stadig starte, og vil stadig miste data. Verificér altid at dit volume er mounted før du inviterer rigtige brugere. ## Migration fra pre-0.2.18 Før 0.2.18 levede admin-server-data i `{CMS_CONFIG_PATH-parent}/_admin/` og nogle filer i `{CMS_CONFIG_PATH-parent}/_data/`. Ved første boot med 0.2.18+, hvis den nye placering er tom og den gamle har en `registry.json`, læser cms-admin transparent fra den gamle sti — intet går i stykker. For at migrere permanent: ```bash mkdir -p $WEBHOUSE_DATA_DIR/_data cp -R /sti/til/bootstrap-site/_admin/. $WEBHOUSE_DATA_DIR/ cp /sti/til/bootstrap-site/_data/users.json $WEBHOUSE_DATA_DIR/_data/ cp /sti/til/bootstrap-site/_data/device_tokens.json $WEBHOUSE_DATA_DIR/_data/ cp /sti/til/bootstrap-site/_data/access-tokens.json $WEBHOUSE_DATA_DIR/_data/ cp -R /sti/til/bootstrap-site/_data/beam-sessions $WEBHOUSE_DATA_DIR/_data/ cp -R /sti/til/bootstrap-site/.beam-sites $WEBHOUSE_DATA_DIR/beam-sites ``` Efter migration lad gerne de legacy-stier blive stående én release som rollback-sikkerhed, slet dem så når du har bekræftet at alt virker. ## Se også - [Docker deployment](/docs/docker-deployment-da) — fuld Docker + Fly opsætnings-gennemgang. - [Deployment oversigt](/docs/deployment-da) — alle deploy-targets. --- ## docs/admin-data-location Title: Admin data location Updated: 2026-04-20 Locale: en Where cms-admin stores registry, users, access-tokens, and other server-level state — and how to configure it for dev, Docker, Fly, and Kubernetes so it survives restarts. ## The two kinds of data cms-admin is a **server**. Sites it manages are **clients**. That line determines where each piece of data lives: - **Admin-server data** — shared across every site: registry of orgs and sites, user accounts, access-tokens, push device tokens, beam transfer sessions, goto deep-link shortcuts, agent templates, org-level settings. None of this belongs inside any one site's folder. - **Per-site data** — belongs to one site and travels with it: content JSON, site-config, team membership for that site, analytics, lighthouse reports, form submissions, brand voice, deploy log, scheduled events. Lives inside the site's own folder so moving the site to another server takes its state along. This doc is about the **admin-server** half — where it goes and how to make sure it persists across restarts on every platform. ## Resolution order `getAdminDataDir()` checks these in order and uses the first one that resolves: | # | Priority | Location | Used by | |---|----------|----------|---------| | 1 | Override | `$WEBHOUSE_DATA_DIR` | Production — Docker, Fly, Kubernetes (set explicitly in Dockerfile / fly.toml / pod spec) | | 2 | Auto-detect | `/data/cms-admin/` if writable | Containers where the deploy layer has mounted a persistent volume there (no env var needed) | | 3 | Linux standard | `$XDG_DATA_HOME/webhouse-cms/` | Linux dev with XDG explicit | | 4 | XDG default | `$HOME/.local/share/webhouse-cms/` | Linux dev default | | 5 | Simple fallback | `$HOME/.webhouse/cms-admin/` | macOS + Linux developer machines | | 6 | Legacy | `{CMS_CONFIG_PATH-parent}/_admin/` | Pre-0.2.18 deployments only — auto-migrated on first boot with 0.2.18+ | The override always wins. In production you should **always** either set `WEBHOUSE_DATA_DIR` explicitly or mount a persistent volume at `/data/cms-admin` — otherwise container restarts wipe the registry, users, and access-tokens. ## Platform-by-platform setup ### Local development (macOS, Linux) Nothing to configure — cms-admin creates `$HOME/.webhouse/cms-admin/` on first boot and writes there. To move it explicitly: ```bash export WEBHOUSE_DATA_DIR=/opt/webhouse-cms mkdir -p $WEBHOUSE_DATA_DIR pnpm dev ``` ### Docker (plain) The shipped Dockerfiles set `WEBHOUSE_DATA_DIR=/data/cms-admin` and expect a volume mount there: ```bash docker run \ -p 3010:3010 \ -v $(pwd)/cms-admin-data:/data/cms-admin \ -v $(pwd)/my-site:/site \ ghcr.io/webhousecode/cms-admin ``` With `docker compose`, `docker-compose.yml` already declares a `cms_admin_data` named volume: ```yaml volumes: - cms_admin_data:/data/cms-admin volumes: cms_admin_data: ``` `docker compose down` followed by `docker compose up` reuses the volume; `docker compose down -v` wipes it. ### Fly.io `deploy/fly.toml` already attaches the `cms_data` volume at `/data` and sets `WEBHOUSE_DATA_DIR=/data/cms-admin`. Redeploy is safe — the volume outlives any one machine: ```toml [env] WEBHOUSE_DATA_DIR = "/data/cms-admin" [mounts] source = "cms_data" destination = "/data" ``` First-time volume setup (once per region): ```bash fly volumes create cms_data --region arn --size 1 fly deploy ``` ### Kubernetes Use a `PersistentVolumeClaim` and mount it at `/data/cms-admin`. The container image auto-detects the `/data/cms-admin` path when writable, so you don't strictly need `WEBHOUSE_DATA_DIR` if you mount there — but it's a good habit to set it explicitly: ```yaml env: - name: WEBHOUSE_DATA_DIR value: /data/cms-admin volumeMounts: - name: admin-data mountPath: /data/cms-admin volumes: - name: admin-data persistentVolumeClaim: claimName: cms-admin-data ``` ### Bare-metal Linux (tarball install) The weekly tarball contains code only — no runtime data. Install flow: ```bash tar xf webhouse-cms-0.2.18.tar.gz -C /opt/ useradd --system --home /var/lib/webhouse-cms webhouse mkdir -p /var/lib/webhouse-cms chown webhouse:webhouse /var/lib/webhouse-cms # /etc/systemd/system/webhouse-cms.service [Service] User=webhouse Environment="WEBHOUSE_DATA_DIR=/var/lib/webhouse-cms" Environment="CMS_CONFIG_PATH=/srv/site/cms.config.ts" WorkingDirectory=/opt/webhouse-cms/packages/cms-admin ExecStart=/usr/bin/node server.js ``` ## Verifying the configuration After first boot, `$WEBHOUSE_DATA_DIR` should contain: ``` registry.json # all orgs + sites _data/ users.json # user accounts access-tokens.json # API tokens device_tokens.json # push targets invitations.json # pending invites org-settings/ .json agent-templates/ / beam-sessions/ # cross-site beam transfers beam-sites/ # beam-import staging goto-links.json # /admin/goto/ short links ``` To confirm cms-admin is reading from the right place: ```bash curl -sk https://localhost:3010/api/cms/registry \ -H "Cookie: " | jq '.registry.orgs | length' ``` The count should match what's in `$WEBHOUSE_DATA_DIR/registry.json`. ## What happens when the directory is ephemeral If you run cms-admin in Docker/Fly/Kubernetes **without** mounting a volume at `/data/cms-admin`: 1. First boot: cms-admin creates registry.json etc. in the container's local filesystem. 2. You log in, create orgs/sites, mint tokens. 3. Container restarts (redeploy, OOM kill, host reboot). 4. Registry + users + tokens are gone. You're back to a fresh install. The 0.2.18+ resolution order tries hard to catch this — auto-detecting `/data/cms-admin` when present, and falling back to `$HOME` otherwise — but a container with neither mount nor env var will still start, and will still lose data. Always verify your volume is mounted before inviting real users. ## Migrating from pre-0.2.18 Before 0.2.18, admin-server data lived in `{CMS_CONFIG_PATH-parent}/_admin/` and some files in `{CMS_CONFIG_PATH-parent}/_data/`. On first boot with 0.2.18+, if the new location is empty and the legacy one has a `registry.json`, cms-admin reads from the legacy path transparently — nothing breaks. To migrate permanently: ```bash mkdir -p $WEBHOUSE_DATA_DIR/_data cp -R /path/to/bootstrap-site/_admin/. $WEBHOUSE_DATA_DIR/ cp /path/to/bootstrap-site/_data/users.json $WEBHOUSE_DATA_DIR/_data/ cp /path/to/bootstrap-site/_data/device_tokens.json $WEBHOUSE_DATA_DIR/_data/ cp /path/to/bootstrap-site/_data/access-tokens.json $WEBHOUSE_DATA_DIR/_data/ cp -R /path/to/bootstrap-site/_data/beam-sessions $WEBHOUSE_DATA_DIR/_data/ cp -R /path/to/bootstrap-site/.beam-sites $WEBHOUSE_DATA_DIR/beam-sites ``` After migration, leave the legacy paths in place for a release as a rollback safety net, then delete them once you've confirmed everything works. ## See also - [Docker deployment](/docs/docker-deployment) — full Docker + Fly setup walkthrough. - [Deployment overview](/docs/deployment) — all deploy targets. --- ## docs/releases-da Title: Releases & downloads Updated: 2026-04-16 Locale: da Download webhouse.app CMS admin-serveren som tarball eller Docker-image. Ugentlige auto-builds + taggede stable-releases. ## Download-kanaler webhouse.app shipper admin-serveren via to kanaler — vælg den der passer dig. ### Stable (tagged) Udgives manuelt når en milestone lander. Tracket under `:latest`. - **Docker**: `docker pull ghcr.io/webhousecode/cms-admin:latest` - **Tarball**: [github.com/webhousecode/cms/releases/latest](https://github.com/webhousecode/cms/releases/latest) ### Weekly (automatiseret) Hver mandag kl. 16:00 UTC. Springes over hvis der ingen commits er siden sidste weekly. Peger altid på HEAD af `main` ved build-tidspunkt. - **Docker**: `docker pull ghcr.io/webhousecode/cms-admin:weekly` - **Tarball**: [github.com/webhousecode/cms/releases/tag/weekly](https://github.com/webhousecode/cms/releases/tag/weekly) - **Plain-text indeks**: [releases.txt](https://raw.githubusercontent.com/webhousecode/cms/main/releases.txt) (nyeste øverst, til scripts) ## Hvad er der i tarballen En prebygget **Next.js standalone bundle** — ikke source-kode du skal `pnpm install`, ikke noget du kører med `next start`. Det er en self-contained server: ``` cms-admin-2026.04.20/ run.sh # wrapper-script packages/cms-admin/ server.js # entry point — en Next.js standalone server .next/static/ # kompilerede assets public/ # public-filer node_modules/ # minimale deps (allerede løst) ``` Aktiveret via `output: "standalone"` i `next.config.ts`. Du kører den med `node server.js` — `next`-CLI'en er ikke installeret og ikke nødvendig. Total størrelse ~50-100 MB mod ~500 MB for en fuld `pnpm install` + `next start` opsætning. **Kvalitetssikring**: hver release er typecheck'et og testet i GitHub Actions før tarballen bygges. Fejler typecheck eller tests, udgives releasen ikke. ## Quick start — tarball Kræver Node.js 22 eller nyere. Virker på Linux, macOS og Windows (WSL eller Git Bash). ```bash curl -L https://github.com/webhousecode/cms/releases/latest/download/cms-admin.tar.gz | tar xz cd cms-admin-* ./run.sh ``` Under motorhjelmen kører `run.sh`: ```bash cd packages/cms-admin export PORT="${PORT:-3010}" export HOSTNAME="${HOSTNAME:-0.0.0.0}" exec node server.js ``` Besøg [http://localhost:3010](http://localhost:3010) og fuldfør setup-wizarden — du opretter første admin-konto interaktivt (email + password). Der er ingen default-bruger eller password. ### Environment variabler Overskriv defaults med env vars før `./run.sh`: | Var | Default | Formål | |---|---|---| | `PORT` | `3010` | Listen-port | | `HOSTNAME` | `0.0.0.0` | Bind-adresse | | `ADMIN_EMAIL` | — | Auto-opret admin ved første boot | | `ADMIN_PASSWORD` | auto-generated | Hvis `ADMIN_EMAIL` er sat uden password, genereres et og logges til stdout | | `CMS_CONFIG_PATH` | — | Sti eller `github://owner/repo`-URI til et sites `cms.config.ts` | ## Quick start — Docker ```bash docker run -p 3010:3010 -v $(pwd):/site \ -e ADMIN_EMAIL=you@example.com \ ghcr.io/webhousecode/cms-admin:latest ``` Multi-platform (`linux/amd64`, `linux/arm64`) så den kører på Intel-Macs, Apple Silicon og typiske server-CPU'er. ## Vælg stable vs. weekly | | Stable | Weekly | |---|---|---| | Kadence | Hver 2.-4. uge | Hver mandag (hvis der er ændringer) | | Breaking changes | Dokumenteret i release notes | Kommunikeres kun hvis tilsigtet | | Anbefalet til | Produktion, self-hosted | Hobby, early adopters, local dev | | Rollback | Pin til en tidligere `vX.Y.Z`-tag | Pin til en tidligere `YYYY.MM.DD`-tag | Begge kanaler er funktionelt samme build — weekly shipper bare oftere. ## Lav en stable release (kun maintainer) ```bash git tag -a v0.3.0 -m "Feature-summary" git push origin v0.3.0 ``` Det fyrer `release-stable` GitHub Actions-workflow'en der bygger tarballen, pusher Docker-imaget til GHCR og opretter en permanent GitHub Release markeret som `latest`. ## Troubleshooting **`run.sh: command not found`** — du er på Windows CMD/PowerShell. Brug `cd cms-admin-* && cd packages/cms-admin && node server.js`, eller kør fra WSL / Git Bash. **Port already in use** — tilsidesæt med `PORT=4010 ./run.sh`. **Kan ikke nå serveren fra en anden maskine på LAN'et** — sæt `HOSTNAME=0.0.0.0` (default allerede). **Vil du køre fra source med hot reload?** Det er en anden vej — klon repoet og kør `pnpm dev` i stedet. Tarballen er en production-style build uden file watching. ## Relateret - [Deploy-settings](/docs/settings-deploy-da) — provider-config til at publicere SITES (forskelligt fra selve admin-serveren) - [Fly.io Live](/docs/deploy-fly-live-da) — volume-baseret site-deploy - [ICD](/docs/instant-content-deployment-da) — content-sync til kørende Next.js-sites --- ## docs/releases Title: Releases & downloads Updated: 2026-04-16 Locale: en Download the webhouse.app CMS admin server as a tarball or Docker image. Weekly auto-builds + tagged stable releases. ## Download channels webhouse.app ships the admin server through two channels — pick whichever fits your setup. ### Stable (tagged) Cut manually when a milestone lands. Tracked under `:latest`. - **Docker**: `docker pull ghcr.io/webhousecode/cms-admin:latest` - **Tarball**: [github.com/webhousecode/cms/releases/latest](https://github.com/webhousecode/cms/releases/latest) ### Weekly (automated) Every Monday at 16:00 UTC. Skipped if there are no commits since the previous weekly. Always points to the HEAD of `main` as of build time. - **Docker**: `docker pull ghcr.io/webhousecode/cms-admin:weekly` - **Tarball**: [github.com/webhousecode/cms/releases/tag/weekly](https://github.com/webhousecode/cms/releases/tag/weekly) - **Plain-text index**: [releases.txt](https://raw.githubusercontent.com/webhousecode/cms/main/releases.txt) (latest entries first, for scripts) ## What's in the tarball A pre-built **Next.js standalone bundle** — not source code you need to `pnpm install`, not something you run with `next start`. It's a self-contained server: ``` cms-admin-2026.04.20/ run.sh # convenience wrapper packages/cms-admin/ server.js # entry point — a Next.js standalone server .next/static/ # compiled assets public/ # public files node_modules/ # minimal deps (already resolved) ``` Enabled via `output: "standalone"` in `next.config.ts`. You run it with `node server.js` — the `next` CLI is not installed and not needed. Total size ~50–100 MB vs. ~500 MB for a full `pnpm install` + `next start` setup. **Quality gate**: every release is typechecked and tested in GitHub Actions before the tarball is built. If typecheck or tests fail, the release does not ship. ## Quick start — tarball Requires Node.js 22 or newer. Works on Linux, macOS, and Windows (WSL or Git Bash). ```bash curl -L https://github.com/webhousecode/cms/releases/latest/download/cms-admin.tar.gz | tar xz cd cms-admin-* ./run.sh ``` Under the hood `run.sh` does: ```bash cd packages/cms-admin export PORT="${PORT:-3010}" export HOSTNAME="${HOSTNAME:-0.0.0.0}" exec node server.js ``` Visit [http://localhost:3010](http://localhost:3010) and complete the setup wizard — you create the first admin account interactively (email + password). There is no default user or password. ### Environment variables Override the defaults with env vars before `./run.sh`: | Var | Default | Purpose | |---|---|---| | `PORT` | `3010` | Listen port | | `HOSTNAME` | `0.0.0.0` | Bind address | | `ADMIN_EMAIL` | — | Auto-create admin on first boot | | `ADMIN_PASSWORD` | auto-generated | If `ADMIN_EMAIL` is set without a password, one is generated and logged to stdout | | `CMS_CONFIG_PATH` | — | Path or `github://owner/repo` URI of a site's `cms.config.ts` | ## Quick start — Docker ```bash docker run -p 3010:3010 -v $(pwd):/site \ -e ADMIN_EMAIL=you@example.com \ ghcr.io/webhousecode/cms-admin:latest ``` Multi-platform (`linux/amd64`, `linux/arm64`) so it runs on Intel Macs, Apple Silicon, and typical server CPUs. The image bakes in the same standalone bundle as the tarball. ## Choosing stable vs. weekly | | Stable | Weekly | |---|---|---| | Cadence | Every 2–4 weeks | Every Monday (if there are changes) | | Breaking changes | Documented in release notes | Only communicated if intentional | | Recommended for | Production self-hosted | Hobby projects, early adopters, local dev | | Rollback | Pin to any past `vX.Y.Z` tag | Pin to any past `YYYY.MM.DD` tag | Both channels are functionally the same build — weekly just ships more often. ## Cutting a stable release (maintainer only) ```bash git tag -a v0.3.0 -m "Feature summary" git push origin v0.3.0 ``` That fires the `release-stable` GitHub Actions workflow, which builds the tarball, pushes the Docker image to GHCR, and creates a permanent GitHub Release marked as `latest`. ## Troubleshooting **`run.sh: command not found`** — you're on Windows CMD/PowerShell. Use `cd cms-admin-* && cd packages/cms-admin && node server.js`, or run from WSL / Git Bash. **Port already in use** — override with `PORT=4010 ./run.sh`. **Can't reach the server from another machine on the LAN** — set `HOSTNAME=0.0.0.0` (default already). **Want to run from source with hot reload?** That's a different path — clone the repo and `pnpm dev` instead. The tarball is a production-style build, no file watching. ## Related - [Deploy settings](/docs/settings-deploy) — provider config for publishing SITES (different from the admin server itself) - [Fly.io Live](/docs/deploy-fly-live) — volume-backed site deploys - [ICD](/docs/instant-content-deployment) — content sync to running Next.js sites --- ## docs/deploy-cloudflare-pages-da Title: Cloudflare Pages — globalt edge, gratis Updated: 2026-04-16 Locale: da Direkte Cloudflare Pages API-upload. 300+ edge-PoPs, gratis tier dækker de fleste sites. Hurtigste vej til rent statisk indhold globalt. ## Hvad det er **Cloudflare Pages (direct)** sender dit byggede site til Cloudflare's edge-netværk via Direct Upload API. Ingen git push, ingen webhook-relay, ingen wrangler CLI — bare en HTTPS-upload. Cloudflare serverer filerne fra 300+ PoPs globalt med automatisk HTTPS og ubegrænset bandwidth på gratis tier. > Denne erstatter den tidligere `Cloudflare (webhook)`-provider, som kun POSTede til en build hook-URL. Den gamle eksisterer stadig for bagudkompatibilitet, men nye sites bør vælge `Cloudflare Pages (direct)`. ## Hvornår du skal bruge det - Dit site er statisk (ingen SSR, ingen custom server-logik) - Du vil have hurtigste first-byte-time globalt (10–30 ms på edge) - Du vil have lavest mulige omkostning ($0 på gratis tier for typiske små sites) - Du er OK med Cloudflare som dependency (DNS valgfri, projekt kan stadig bruge enhver registrar) ## Hvornår du IKKE skal bruge det - Dit site er en Next.js SSR-app — brug Vercel, Fly.io (rebuild), eller Cloudflare Workers. - Du skal kunne skrive-ved-runtime (uploads, formularindsendelser) — Pages er immutable statisk. - Du er allerede committed til Fly for andre services og vil have én hosting-provider — brug [Fly.io Live](/docs/deploy-fly-live-da). ## Hvordan det virker 1. CMS admin bygger dit site lokalt (`deploy/`) 2. Poster alle filer som `multipart/form-data` til `POST /accounts/:id/pages/projects/:name/deployments` 3. Cloudflare processerer uploaden, opretter en ny deployment og propagerer til edge 4. Den nye version er live globalt på ~3–10 sekunder Ved første deploy opretter admin'en automatisk Pages-projektet (type: Direct Upload). Efterfølgende deploys tilføjer bare nye deployments til det eksisterende projekt. ## Setup ### 1. Hent dine Cloudflare-credentials **Account ID** — Cloudflare dashboard → vælg et domæne → højre sidebar, kopier Account ID. **API token** — Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token med: - **Permissions**: Account → Cloudflare Pages → Edit - **Account resources**: Include → din account Ingen andre scopes nødvendige. Gem tokenet sikkert — det vises ikke igen. ### 2. Konfigurér provideren Settings → Deploy → **Cloudflare Pages (direct)**. Udfyld: - **API token** — fra step 1 - **Account ID** — fra step 1 - **Project name** — lowercase, cifre, bindestreger (fx `my-site`). Auto-genereres fra dit site-navn hvis tomt. Max 58 tegn. ### 3. Klik Deploy Første klik opretter Cloudflare Pages-projektet og uploader dine filer. Du ser live-URL'en: `https://.pages.dev` (eller dit custom domæne hvis konfigureret). ### 4. Custom domæne (valgfrit) Cloudflare dashboard → Pages → dit projekt → Custom domains → Set up a custom domain. Cloudflare håndterer DNS + certs automatisk. ## Payload-format Uploaden bruger multipart form-data. Feltnavne er stier med ledende slash (fx `/index.html`, `/assets/app.js`). Binære filer sendes as-is — Cloudflare håndterer content-type detection. Ingen HMAC-signing på denne provider — Cloudflare bruger din Bearer-token direkte. Hold tokenet hemmeligt. ## Rollback I Cloudflare dashboard'et listes hver deployment under Pages → dit projekt → Deployments. Klik på en historisk deployment → Rollback. Pages-URL'en opdateres øjeblikkeligt. CMS admin eksponerer endnu ikke Cloudflare-rollback i UI'et — brug dashboard'et indtil videre. ## Grænser (gratis tier) - **500 builds per måned** — en build = én deploy. De fleste sites laver 10–30/md. - **Ubegrænset requests** — ingen bandwidth-caps. - **25 MB per fil** — store videoer bør gå til R2 eller anden CDN. - **20 000 filer per deployment** — nok til ethvert normalt site. Paid Pro ($20/md) hæver build-grænsen til 5000/md. De fleste små sites behøver aldrig at betale. ## Sammenligning | | Cloudflare Pages | Fly.io Live | GitHub Pages | Vercel | |---|---|---|---|---| | Tid pr. deploy | 3–10 s | 200 ms–1 s | 15–30 s | 20–60 s | | Global edge | Ja (300+ PoPs) | Nej | Ja (CDN) | Ja (Edge) | | Gratis tier | Generøs | Nej | Ja | Ja (Hobby) | | SSR-support | Workers (separat) | Nej | Nej | Ja | | Custom domæne | Gratis, nemt | Flyctl, kræver DNS | Subpath-venligt | Gratis | ## Troubleshooting **"project name invalid"** — projektnavne skal være lowercase bogstaver, cifre og bindestreger (max 58 tegn). Admin'en slugifier dit site-navn — hvis det fejler, indtast et gyldigt navn manuelt. **"deployment rejected"** — Cloudflare returnerer en grund i fejlen. Almindelige årsager: for stor fil (>25 MB), for mange filer (>20k), udløbet token. **"account ID missing"** — kopier Account ID præcist fra Cloudflare-dashboard'et. Det er 32 hex-tegn. ## Relateret - [Fly.io Live](/docs/deploy-fly-live-da) — når du vil have alt på Fly - [Deploy settings](/docs/settings-deploy-da) — fuld provider-reference - [Instant Content Deployment](/docs/instant-content-deployment-da) — push-til-kørende-Next.js-patteren, ikke relevant for statiske sites på Pages --- ## docs/deploy-cloudflare-pages Title: Cloudflare Pages — global edge, free Updated: 2026-04-16 Locale: en Direct Cloudflare Pages API upload. 300+ edge PoPs, free tier covers most sites. The fastest path for pure-static content worldwide. ## What it is **Cloudflare Pages (direct)** pushes your built site to Cloudflare's edge network via the Direct Upload API. No git push, no webhook relay, no wrangler CLI — just an HTTPS upload. Cloudflare serves the files from 300+ PoPs worldwide with automatic HTTPS and unlimited bandwidth on the free tier. > This replaces the legacy `Cloudflare (webhook)` provider, which only POSTed to a build hook URL. That one still exists for backwards compatibility, but new sites should pick `Cloudflare Pages (direct)`. ## When to use it - Your site is static (no SSR, no custom server logic) - You want the fastest possible first-byte time globally (10–30 ms at edge) - You want the lowest cost ($0 on free tier for typical small sites) - You're OK with Cloudflare as a dependency (DNS optional, project can still use any registrar) ## When NOT to use it - Your site is a Next.js SSR app — use Vercel, Fly.io (rebuild), or Cloudflare Workers. - You need write-at-runtime content storage (uploads, form submissions) — Pages is immutable static. - You're already committed to Fly for other services and want one hosting provider — use [Fly.io Live](/docs/deploy-fly-live). ## How it works 1. CMS admin builds your site locally (`deploy/`) 2. Posts all files as `multipart/form-data` to `POST /accounts/:id/pages/projects/:name/deployments` 3. Cloudflare processes the upload, creates a new deployment, and propagates to the edge 4. The new version is live globally in ~3–10 seconds On first deploy, the admin automatically creates the Pages project (type: Direct Upload). Subsequent deploys just add new deployments to the existing project. ## Setup ### 1. Get your Cloudflare credentials **Account ID** — Cloudflare dashboard → select any domain → right sidebar, copy the Account ID. **API token** — Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token with: - **Permissions**: Account → Cloudflare Pages → Edit - **Account resources**: Include → your account No other scopes are needed. Save the token securely — it won't be shown again. ### 2. Configure the provider Settings → Deploy → **Cloudflare Pages (direct)**. Provide: - **API token** — from step 1 - **Account ID** — from step 1 - **Project name** — lowercase, digits, hyphens (e.g. `my-site`). Auto-generated from your site name if left empty. Max 58 characters. ### 3. Click Deploy First click creates the Cloudflare Pages project and uploads your files. You'll see the live URL: `https://.pages.dev` (or your custom domain if configured). ### 4. Custom domain (optional) Cloudflare dashboard → Pages → your project → Custom domains → Set up a custom domain. Cloudflare handles DNS + certs automatically. ## Payload format The upload uses multipart form-data. Field names are paths with leading slash (e.g. `/index.html`, `/assets/app.js`). Binary files are sent as-is — Cloudflare handles content-type detection. There's no HMAC signing on this provider — Cloudflare uses your Bearer token directly. Keep the token secret. ## Rollback In the Cloudflare dashboard, each deployment is listed under Pages → your project → Deployments. Click any historical deployment → Rollback. The Pages URL updates immediately. The CMS admin doesn't expose Cloudflare rollback in UI yet — use the dashboard for now. ## Limits (free tier) - **500 builds per month** — a build = one deploy. Most sites do 10–30/month. - **Unlimited requests** — no bandwidth caps. - **25 MB per file** — large videos should go to R2 or another CDN. - **20 000 files per deployment** — enough for any normal site. Paid Pro ($20/mo) raises the build limit to 5000/month. Most small sites never need to pay. ## Comparison | | Cloudflare Pages | Fly.io Live | GitHub Pages | Vercel | |---|---|---|---|---| | Time per deploy | 3–10 s | 200 ms–1 s | 15–30 s | 20–60 s | | Global edge | Yes (300+ PoPs) | No | Yes (CDN) | Yes (Edge) | | Free tier | Generous | No | Yes | Yes (Hobby) | | SSR support | Workers (separate) | No | No | Yes | | Custom domain | Free, easy | Flyctl, requires DNS | Subpath friendly | Free | ## Troubleshooting **"project name invalid"** — project names must be lowercase letters, digits, and hyphens (max 58 chars). The admin slugifies your site name — if that fails, enter a valid name manually. **"deployment rejected"** — Cloudflare returns a reason in the error. Common causes: oversized file (>25 MB), too many files (>20k), expired token. **"account ID missing"** — copy the Account ID exactly from the Cloudflare dashboard. It's 32 hex characters. ## Related - [Fly.io Live](/docs/deploy-fly-live) — when you want everything on Fly - [Deploy settings](/docs/settings-deploy) — full provider reference - [Instant Content Deployment](/docs/instant-content-deployment) — the push-to-running-Next.js pattern, not relevant for static sites on Pages --- ## docs/deploy-fly-live-da Title: Fly.io Live — øjeblikkelige content-deploys Updated: 2026-04-16 Locale: da Volume-baseret Fly.io-deploy. Første push opretter infrastrukturen; hver rettelse derefter synkroniserer kun ændrede filer på ~200 ms–1 s. ## Hvad det er **Fly.io Live** er en deploy-provider der adskiller infrastruktur fra indhold. Docker-imaget indeholder kun en let web-server + signeret sync-endpoint. Dit sites filer ligger på et persistent Fly Volume. Når du publicerer en content-ændring, uploades kun de ændrede filer — ikke hele imaget. Resultat: typisk tekstrettelse er live på **200 ms–1 s**, mod ~30–120 s for en fuld Docker-rebuild. ## Hvornår skal du bruge Fly.io Live - Du vil have alt på Fly (EU data-residency, samme konto som dine andre services) - Dit site er statisk (build.ts producerer HTML/CSS/JS/billeder) - Du vil have "gem i CMS → live"-feedback uden at vente på Docker - Du kører også Fly-apps til dynamiske services (formularer, auth, SSR-sider) og vil have det statiske indhold med samme sted ## Hvornår skal du IKKE bruge det Hvis dit site er rent statisk uden Fly-krav er **[Cloudflare Pages](/docs/deploy-cloudflare-pages-da)** hurtigere (300+ PoPs) og gratis. Fly Live er single-region by default — brug kun hvis du har en Fly-specifik grund. Hvis dit site er en Next.js SSR-app eller en custom server, brug den klassiske **Fly.io (rebuild)**-provider — SSR-apps kan ikke udtrykkes som statiske filer. ## Hvordan det virker ### Første deploy (infrastruktur-setup) 1. CMS admin kører `flyctl apps create`, `flyctl volumes create` 2. Genererer en HMAC-hemmelighed og gemmer i Fly Secrets som `SYNC_SECRET` 3. Bygger og deployer sync-endpoint Docker-imaget (Caddy/Bun der serverer fra volume'et) 4. Venter på at `/_icd/health` svarer 5. Udfører første content-sync Dette tager ca. 30–60 sekunder, én gang. ### Efterfølgende deploys (content-sync, hver gang du gemmer) 1. CMS admin bygger dit site lokalt (`deploy/`) 2. Henter remote filmanifest: `GET /_icd/manifest` → `{ files: { path: sha256 } }` 3. Diff'er mod lokal — beregner `{ added, changed, removed, unchanged }` 4. `POST /_icd/deploys` → starter ny staging-deploy 5. `PUT` hver ændret/tilføjet fil, `DELETE` hver fjernet 6. `POST /_icd/deploys/:id/commit` → atomisk symlink-swap på serveren Transport er HMAC-SHA256 signeret (samme pattern som [Instant Content Deployment](/docs/instant-content-deployment-da)). Alle requests udløber efter 5 minutter. ### Atomiske deploys Hver deploy bor i sin egen mappe under `/srv/deploys//`. Ved commit bytter serveren atomisk `/srv/current`-symlinket til den nye mappe. Inflight requests ser aldrig et halvopdateret træ. Serveren beholder de sidste 5 deploys for rollback; ældre fjernes. ## Setup ### 1. Konfigurér provideren Settings → Deploy → **Fly.io Live (instant sync)**. Udfyld: - **API token** — Fly personal access token ([hent en](https://fly.io/dashboard/personal/tokens)) - **App name** — auto-genereres fra dit site-navn hvis tomt - **Region** — default `arn` (Stockholm); vælg den Fly-region tættest på dine brugere - **Volume name** — default `site_data`; du behøver sjældent ændre dette Org-slug auto-detekteres fra din token. ### 2. Klik Deploy Første klik provisioner infrastrukturen og laver så den første content-sync. Du ser live-URL'en (`https://.fly.dev`) når det er færdigt. ### 3. Efterfølgende saves Når det er sat op, er hver deploy hurtig. Slå **Deploy on save** til i samme panel hvis du vil auto-publicere ved content-ændringer. ## Rebuild af infrastruktur Docker-imaget skal sjældent rebuildes. Når cms-admin shipper en ny version af sync-endpoint'en, viser admin-UI'et en "Rebuild infrastructure"-prompt — klik for at opdatere imaget. Volume-data bevares, så indhold overlever rebuilds. Hvad kræver infra-rebuild: - cms-admin-opdatering med ny sync-endpoint-version - Custom server-config (headers, redirects) — kræver redigering af image-template - Fly machine resize (memory, CPU) — via `fly.toml` ## Custom domæne Tilføj dit domæne i Settings → Deploy → Custom domain. Fly Live kører automatisk `flyctl certs add` ved næste deploy. Konfigurér DNS-records hos din registrar efter Fly's instruktioner. ## Begrænsninger - **Single-region**: Fly volumes er region-pinnede. For global lav latens, par med en CDN foran, eller brug Cloudflare Pages i stedet. - **Én writer**: Volume'et har én primary machine. Skalér ikke horisontalt uden at forstå Fly volume-semantikken. - **1 GB volume default**: Fint for de fleste sites (indhold er småt). Udvid via `flyctl volumes extend` hvis nødvendigt. ## Troubleshooting **"Sync endpoint did not come online"** — første deploy timeout'de mens den ventede på containeren. Check Fly logs: `flyctl logs -a `. Mest almindelige årsag: volume'et mountede ikke. **"Invalid signature"** på sync-requests — `SYNC_SECRET` i Fly Secrets kom ud af sync med CMS-config. Kør deploy'en igen; admin'en regenererer og skubber en ny hemmelighed. **"Version mismatch, rebuilding infra"** — forventet når du opgraderer cms-admin til en version med ny sync-endpoint. Lad den køre; rebuilden tager ~60 s. ## Sammenligning | | Fly.io Live | Fly.io (rebuild) | Cloudflare Pages | GitHub Pages | |---|---|---|---|---| | Tid pr. content-rettelse | ~200 ms–1 s | 30–120 s | 3–10 s | 15–30 s | | Global edge | Nej (1 region) | Nej | Ja (300+ PoPs) | Ja (CDN) | | SSR-support | Nej (kun statisk) | Ja | Kun Workers | Nej | | Månedlig pris (lille site) | ~$3–5 | ~$3–5 | $0 | $0 | | Hvornår bruges det | Fly-økosystemet | Next.js / SSR-apps | Rent statisk, globalt | Open source-projekter | ## Relateret - [Cloudflare Pages](/docs/deploy-cloudflare-pages-da) — alternativ til rent statisk - [Instant Content Deployment (ICD)](/docs/instant-content-deployment-da) — samme HMAC-pattern brugt til Next.js revalidation - [Deploy settings](/docs/settings-deploy-da) — fuld provider-reference - [Fly.io (rebuild)](/docs/docker-deployment-da) — Docker-rebuild-hver-gang-stien (SSR-apps) --- ## docs/deploy-fly-live Title: Fly.io Live — instant content deploys Updated: 2026-04-16 Locale: en Volume-backed Fly.io deploys. First push creates the infrastructure; every edit after that syncs only changed files in ~200 ms–1 s. ## What it is **Fly.io Live** is a deploy provider that decouples infrastructure from content. The Docker image contains only a lightweight web server and a signed sync endpoint. Your site's files live on a persistent Fly Volume. Publishing a content change uploads only the changed files — not the whole image. Result: typical text edit is live in **200 ms–1 s**, vs. ~30–120 s for a full Docker rebuild. ## When to use Fly.io Live - You want everything in Fly (EU data residency, same account as your other services) - Your site is static (build.ts outputs HTML/CSS/JS/images) - You want "save in CMS → live" feedback without waiting on Docker - You also run Fly apps for dynamic services (forms, auth, SSR pages) and want the static content there too ## When NOT to use it If your site is pure static with no Fly requirement, **[Cloudflare Pages](/docs/deploy-cloudflare-pages)** is faster (300+ PoPs) and free. Fly Live is single-region by default — use only if you have a Fly-specific reason. If your site is a Next.js SSR app or a custom server, use the classic **Fly.io (rebuild)** provider — SSR apps can't be expressed as static files. ## How it works ### First deploy (infrastructure setup) 1. CMS admin runs `flyctl apps create`, `flyctl volumes create` 2. Generates an HMAC secret and stores it in Fly Secrets as `SYNC_SECRET` 3. Builds and deploys the sync-endpoint Docker image (Caddy/Bun serving from the volume) 4. Waits for `/_icd/health` to respond 5. Does the first content sync This takes about 30–60 seconds, one time. ### Subsequent deploys (content sync, every save) 1. CMS admin builds your site locally (`deploy/`) 2. Fetches the remote file manifest: `GET /_icd/manifest` → `{ files: { path: sha256 } }` 3. Diffs against local — computes `{ added, changed, removed, unchanged }` 4. `POST /_icd/deploys` → begins a new staging deploy 5. `PUT` each changed/added file, `DELETE` each removed file 6. `POST /_icd/deploys/:id/commit` → atomic symlink swap on the server Transport is HMAC-SHA256 signed (same pattern as [Instant Content Deployment](/docs/instant-content-deployment)). All requests expire after 5 minutes. ### Atomic deploys Each deploy lives in its own directory under `/srv/deploys//`. On commit, the server atomically swaps the `/srv/current` symlink to point at the new directory. Requests in flight never see a half-updated tree. The server keeps the last 5 deploys for rollback; older ones are pruned. ## Setup ### 1. Configure the provider Settings → Deploy → **Fly.io Live (instant sync)**. Provide: - **API token** — Fly personal access token ([get one](https://fly.io/dashboard/personal/tokens)) - **App name** — auto-generated from your site name if left empty - **Region** — default `arn` (Stockholm); pick the closest Fly region to your users - **Volume name** — default `site_data`; you rarely need to change this The organisation slug is auto-detected from your token. ### 2. Click Deploy First click provisions the infrastructure and then does the first content sync. You'll see the live URL (`https://.fly.dev`) when it completes. ### 3. Subsequent saves Once set up, every deploy is fast. Enable **Deploy on save** in the same panel if you want auto-publish on content changes. ## Rebuilding infrastructure The Docker image rarely needs rebuilding. When cms-admin ships a new version of the sync-endpoint server, the admin UI surfaces a "Rebuild infrastructure" prompt — click it to refresh the image. Volume data is preserved, so content survives rebuilds. Triggers that require an infra rebuild: - cms-admin update with a new sync-endpoint version - Custom server configuration (headers, redirects) — requires editing the image template - Fly machine resize (memory, CPU) — via `fly.toml` ## Custom domain Add your domain in Settings → Deploy → Custom domain. Fly Live automatically runs `flyctl certs add` on the next deploy. Configure the DNS records at your registrar per Fly's instructions. ## Limitations - **Single-region**: Fly volumes are region-pinned. For global low latency, pair with a CDN in front, or use Cloudflare Pages instead. - **One writer**: The volume has one primary machine. Don't scale horizontally without understanding Fly volume semantics. - **1 GB volume default**: Fine for most sites (content is small). Resize via `flyctl volumes extend` if needed. ## Troubleshooting **"Sync endpoint did not come online"** — first deploy timed out waiting for the container. Check Fly logs: `flyctl logs -a `. Most common cause: the volume failed to mount. **"Invalid signature"** on sync requests — the `SYNC_SECRET` in Fly Secrets got out of sync with the CMS config. Re-run the deploy; the admin will regenerate and push a new secret. **"Version mismatch, rebuilding infra"** — expected when you upgrade cms-admin to a version with a newer sync-endpoint. Let it run; the rebuild takes ~60 s. ## Comparison | | Fly.io Live | Fly.io (rebuild) | Cloudflare Pages | GitHub Pages | |---|---|---|---|---| | Time per content edit | ~200 ms–1 s | 30–120 s | 3–10 s | 15–30 s | | Global edge | No (1 region) | No | Yes (300+ PoPs) | Yes (CDN) | | SSR support | No (static only) | Yes | Workers only | No | | Monthly cost (small site) | ~$3–5 | ~$3–5 | $0 | $0 | | When to pick it | Fly ecosystem | Next.js / SSR apps | Pure static, global | Open-source projects | ## Related - [Cloudflare Pages](/docs/deploy-cloudflare-pages) — alternative for pure static - [Instant Content Deployment (ICD)](/docs/instant-content-deployment) — the same HMAC pattern used for Next.js revalidation - [Deploy settings](/docs/settings-deploy) — full provider reference - [Fly.io (rebuild)](/docs/docker-deployment) — the Docker-rebuild-every-deploy path (SSR apps) --- ## docs/ai-builder-guide-da Title: AI Builder Guide Updated: 2026-04-16 Locale: da Et dedikeret site på ai.webhouse.app plus 21 modulære docs — enhver AI-kodningsassistent får én URL til at bygge med @webhouse/cms. ## Én URL til enhver AI Når en udvikler beder en AI-kodningsassistent (Claude Code, Cursor, Copilot, Gemini, Windsurf) om at bygge et website med **@webhouse/cms**, kan de nu bare sige: > "Brug https://ai.webhouse.app til at bygge det." AI'en henter den URL og får en **selvstændig Step 0–9 walkthrough**: tjek miljø → scaffold → plan med brugeren → rediger `cms.config.ts` → opret indhold → wire rendering → SEO → deploy → aflever tilbage. Siden leveres som `text/markdown` uden visuel pynt — skræddersyet til LLM-forbrug. Alle 21 dybdemoduler ligger på `ai.webhouse.app/ai/{slug}` hvis AI'en har brug for mere om et specifikt emne. ## Hvad findes på ai.webhouse.app | URL | Formål | |-----|--------| | `/ai` | Selvguidet Step 0–9 walkthrough. **Start her.** | | `/ai/01-getting-started` | Scaffolding + første kørsel | | `/ai/02-config-reference` | `defineConfig`, `defineCollection`, options | | `/ai/03-field-types` | Alle 21 felttyper | | `/ai/04-blocks` | Bloksystem (hero, features, CTA-sektioner) | | `/ai/05-richtext` | TipTap-editor, indlejret medie | | `/ai/06-storage-adapters` | Filsystem, GitHub, SQLite | | `/ai/07-content-structure` | Dokument-JSON-format, content-layout | | `/ai/08-nextjs-patterns` | Sider, layouts, loader-funktioner | | `/ai/09-cli-reference` | Alle CLI-kommandoer | | `/ai/10-config-example` | Komplet real-world `cms.config.ts` | | `/ai/11-api-reference` | Programmatisk ContentService-brug | | `/ai/12-admin-ui` | CMS admin setup, Docker, npx | | `/ai/13-site-building` | Almindelige fejl, mønstre, rendering | | `/ai/14-relationships` | Indholdsrelationer, opløsning, reverse lookups | | `/ai/15-seo` | Metadata, JSON-LD, AI SEO | | `/ai/16-images` | Billedhåndtering, responsive, next/image | | `/ai/17-i18n` | Flersproget, locale-routing, oversættelse | | `/ai/18-deployment` | Vercel, Netlify, GitHub Pages, Fly.io, Cloudflare | | `/ai/19-troubleshooting` | Almindelige fejl, debugging, FAQ | | `/ai/20-interactives` | Datadrevet interaktivt indhold | | `/ai/21-framework-consumers` | Non-TS backends (Java, .NET, PHP, Python, Ruby, Go) | | `/ai/llms.txt` | llms.txt-standard (til LLM-crawlere) | | `/ai/manifest.json` | JSON-manifest med alle moduler + beskrivelser | | `/ai/index.json` | Sorteret modulliste | Alle svar sætter `X-Robots-Tag: noindex` — AI-sitet indekseres ikke af traditionelle søgemaskiner. Det er lavet til maskiner. ## Hvorfor et dedikeret AI-site? De 21 moduler findes allerede på [GitHub raw](https://raw.githubusercontent.com/webhousecode/cms/main/docs/ai-guide/index.md), men det er ikke nok: 1. **Discoverability** — én pæn URL er lettere at huske og skrive end en dyb GitHub-sti. 2. **Rate-limits** — GitHub raw throttler ved 60 requests/time uden auth. En ny AI-session der henter flere moduler rammer det hurtigt. 3. **Platform-forskelle** — ikke alle AI-værktøjer har ergonomisk URL-fetch til GitHub-stier, men stort set alle kan hente en almindelig URL som `ai.webhouse.app`. 4. **Selvguidet startpunkt** — det eksisterende indeks er en modulliste. Det antager AI'en allerede ved hvad `@webhouse/cms` er. Den nye `/ai`-walkthrough er skrevet til en blank AI-session — **"du er en AI, følg disse skridt, hvis du mangler X så hent Y"**. 5. **Maskinlæsbare endpoints** — AI-platforme forventer i stigende grad `llms.txt`, struktureret manifest-JSON og versioning. GitHub raw markdown giver ikke noget af det. ## Walkthrough'en (`/ai`) Walkthrough'en er struktureret som en **procedure**, ikke en reference: ``` Step 0 — Tjek miljø Step 1 — Scaffold projektet (npm create @webhouse/cms@latest) Step 2 — Forstå modellen (dokument-JSON, cms.config.ts, felttyper) Step 3 — Plan sitet med brugeren (5 spørgsmål) Step 4 — Rediger cms.config.ts (kind + description kræves per collection) Step 5 — Opret startindhold Step 6 — Wire rendering (Next.js / statisk / non-TS consumer) Step 7 — SEO (hvis relevant) Step 8 — Deploy Step 9 — Aflever tilbage til brugeren Troubleshooting — førstegangsløsninger Dybdemodul-indeks Ikke-forhandlelige regler (8 regler fra CLAUDE.md kritiske regler) ``` AI'en kan gennemføre en basis-build **fra ende til anden uden at hente andre moduler**. Dybere moduler er kun til specifikke emner (i18n, kompleks SEO, non-TS backend). ## Sådan bruger du det I ethvert AI-kodningsværktøj indsæt: ``` Byg mig et website med @webhouse/cms. Start med at hente https://ai.webhouse.app ``` AI'en vil: 1. Hente `/ai` og følge Step 0 (tjekker `node --version`, spørger hvor den skal scaffolde) 2. Køre `npm create @webhouse/cms@latest` (Step 1) 3. Forklare modellen og planlægge med dig (Step 2–3) 4. Redigere `cms.config.ts`, oprette indhold, wire rendering (Step 4–6) 5. Hente specifikke dybdemoduler efter behov (SEO, i18n, deployment) 6. Aflevere et kørende lokalt site med deploy-hooks konfigureret Ingen manuel CLAUDE.md-opsætning, ingen copy-paste-konfiguration fra Stack Overflow, ingen "hvor skal jeg starte?"-forvirring. ## Virker med enhver AI-platform Hele sitet er almindelig `text/markdown` og `application/json` — alle AI-kodningsassistenter forstår det: - **Claude Code** — `WebFetch`-værktøjet henter URL'en; det lange kontekstvindue klarer hele walkthrough'en - **Cursor / Windsurf** — indsæt URL'en; inline-chatten henter den - **GitHub Copilot** — chatten understøtter URL-kontekst; hent `/ai` ind i prompten - **Gemini (IDE-udvidelser)** — URL-bevidst chat henter markdown'en - **Enhver fremtidig platform** — så længe den understøtter URL-fetch, virker det ## Maskinlæsbare endpoints Til programmatisk brug (MCP-servere, agent-frameworks, CI-pipelines): ```bash # Fuldt JSON-manifest med alle moduler + beskrivelser + endpoints curl https://ai.webhouse.app/ai/manifest.json # Almindelig sorteret modulliste curl https://ai.webhouse.app/ai/index.json # llms.txt-standard (til LLM-crawlere) curl https://ai.webhouse.app/ai/llms.txt ``` Eksempel på `manifest.json`: ```json { "name": "@webhouse/cms AI Builder Site", "version": "0.1.0", "entry": "https://ai.webhouse.app/ai", "modules": [ { "slug": "01-getting-started", "url": "...", "description": "New project, first setup" }, ... ], "endpoints": { "walkthrough": "https://ai.webhouse.app/ai", "llms_txt": "https://ai.webhouse.app/ai/llms.txt", "manifest": "https://ai.webhouse.app/ai/manifest.json" } } ``` ## Kanonisk kilde + fallbacks Hvis `ai.webhouse.app` ikke er tilgængelig, ligger den kanoniske markdown i CMS-monorepoet og serveres fra GitHub raw: ``` https://raw.githubusercontent.com/webhousecode/cms/main/docs/ai-guide/index.md https://raw.githubusercontent.com/webhousecode/cms/main/docs/ai-guide/01-getting-started.md ... ``` Ethvert scaffoldet projekt leveres også med `packages/cms/CLAUDE.md` (slank indeks, ~180 linjer) der refererer de samme moduler. AI'en har derfor tre redundante stier til samme indhold: 1. `https://ai.webhouse.app` (dette site — primær) 2. GitHub raw (fallback, rate-limited) 3. Lokal `packages/cms/CLAUDE.md` i `node_modules/@webhouse/cms` (offline) ## Dogfooding AI Builder Site er selv bygget med `@webhouse/cms` — det ligger som en route-gruppe (`src/app/ai/*`) på [docs.webhouse.app](https://docs.webhouse.app), som er fuldstændigt CMS-drevet. Ét site, to målgrupper: `/docs` for mennesker, `/ai` for AI-agenter. Samme indholdsmodel, samme deploy-pipeline. Denne side (den du læser lige nu) er et dokument i `docs`-collection'en, gemt som JSON i `content/docs/ai-builder-guide.json`. Den forklarer det companion-AI-site der ligger ved siden af. Selvrefererende, fuldt editerbar via admin-UI'et og tilgængelig på både engelsk og dansk. ## Ikke-forhandlelige regler Walkthrough'en indprenter otte regler AI-sessioner skal følge når de bygger med `@webhouse/cms`: 1. Hver collection SKAL have `kind` og `description` 2. `cms.config.ts` SKAL erklære `storage` eksplicit (standard er SQLite) 3. NAVNGIV ALDRIG en collection `site-settings`, `settings`, `config`, `admin`, `media`, `interactives` 4. `image-gallery`-værdier SKAL være `{ url, alt }[]` — aldrig plain strings 5. Dokumenter SKAL have `_fieldMeta: {}` 6. Slug SKAL matche filnavnet 7. Filtrér altid på `status === "published"` ved rendering 8. BRUG ALDRIG CDN-scripts (Tailwind, Bootstrap) i statiske builds — kun inline CSS De regler forebygger de mest almindelige AI-genererede fejl vi har set i tidlige builds. --- ## docs/ai-builder-guide Title: AI Builder Guide Updated: 2026-04-16 Locale: en A dedicated site at ai.webhouse.app plus 21 modular docs — every AI coding assistant gets one URL to build with @webhouse/cms. ## One URL for any AI When a developer asks an AI coding assistant (Claude Code, Cursor, Copilot, Gemini, Windsurf) to build a website with **@webhouse/cms**, they can now just say: > "Use https://ai.webhouse.app to build it." The AI fetches that URL and gets a **self-contained Step 0–9 walkthrough**: verify environment → scaffold → plan with the user → edit `cms.config.ts` → create content → wire rendering → SEO → deploy → hand back. The page is served as `text/markdown`, with zero visual chrome — purpose-built for LLM consumption. All 21 deep-dive modules live at `ai.webhouse.app/ai/{slug}` if the AI needs depth on a specific topic. ## What ships at ai.webhouse.app | URL | Purpose | |-----|---------| | `/ai` | Self-guided Step 0–9 walkthrough. **Start here.** | | `/ai/01-getting-started` | Scaffolding + first run | | `/ai/02-config-reference` | `defineConfig`, `defineCollection`, collection options | | `/ai/03-field-types` | All 21 field types | | `/ai/04-blocks` | Block system (hero, features, CTA sections) | | `/ai/05-richtext` | TipTap editor, embedded media | | `/ai/06-storage-adapters` | Filesystem, GitHub, SQLite | | `/ai/07-content-structure` | Document JSON format, content directory layout | | `/ai/08-nextjs-patterns` | Pages, layouts, loader functions | | `/ai/09-cli-reference` | All CLI commands | | `/ai/10-config-example` | Complete real-world `cms.config.ts` | | `/ai/11-api-reference` | Programmatic ContentService usage | | `/ai/12-admin-ui` | CMS admin setup, Docker, npx | | `/ai/13-site-building` | Common mistakes, patterns, rendering | | `/ai/14-relationships` | Content relations, resolving, reverse lookups | | `/ai/15-seo` | Metadata, JSON-LD, AI SEO | | `/ai/16-images` | Image handling, responsive, next/image | | `/ai/17-i18n` | Multi-language, locale routing, translation | | `/ai/18-deployment` | Vercel, Netlify, GitHub Pages, Fly.io, Cloudflare | | `/ai/19-troubleshooting` | Common errors, debugging, FAQ | | `/ai/20-interactives` | Data-driven interactive content | | `/ai/21-framework-consumers` | Non-TS backends (Java, .NET, PHP, Python, Ruby, Go) | | `/ai/llms.txt` | llms.txt standard (for LLM site crawlers) | | `/ai/manifest.json` | JSON manifest with all modules + descriptions + endpoints | | `/ai/index.json` | Ordered module list | All responses set `X-Robots-Tag: noindex` — the AI site is not indexed by traditional search engines. It's there for machines. ## Why a dedicated AI site? The 21 modules already exist on [GitHub raw](https://raw.githubusercontent.com/webhousecode/cms/main/docs/ai-guide/index.md), but that's not enough: 1. **Discoverability** — one pretty URL is easier to remember and type than a deep GitHub path. 2. **Rate limits** — GitHub raw throttles at 60 requests/hour unauthenticated. A fresh AI session that fetches several modules hits that quickly. 3. **Platform variance** — not every AI coding tool has ergonomic URL-fetch tooling for GitHub paths, but essentially all of them can fetch a plain URL like `ai.webhouse.app`. 4. **Self-guided entry point** — the existing index is a module list. It assumes the AI already knows what `@webhouse/cms` is. The new `/ai` walkthrough is written for a blank AI session — **"you are an AI, follow these steps, if you need X fetch Y"**. 5. **Machine-readable endpoints** — AI platforms increasingly expect `llms.txt`, structured manifest JSON, and versioning. GitHub raw markdown doesn't provide any of that. ## The walkthrough (`/ai`) The walkthrough is structured as a **procedure**, not a reference: ``` Step 0 — Verify environment Step 1 — Scaffold the project (npm create @webhouse/cms@latest) Step 2 — Understand the model (document JSON, cms.config.ts, field types) Step 3 — Plan the site with the user (5 questions) Step 4 — Edit cms.config.ts (kind + description required per collection) Step 5 — Create starter content Step 6 — Wire up rendering (Next.js / static / non-TS consumer) Step 7 — SEO (if relevant) Step 8 — Deploy Step 9 — Hand back to the user Troubleshooting — first-pass fixes Deep-dive module index Non-negotiable rules (8 rules from CLAUDE.md critical rules) ``` The AI can complete a basic build **end-to-end without fetching any other module**. Deeper modules are only for specific topics (i18n, complex SEO, non-TS backend). ## How to use it In any AI coding tool, paste: ``` Build me a website with @webhouse/cms. Start by fetching https://ai.webhouse.app ``` The AI will: 1. Fetch `/ai` and follow Step 0 (check `node --version`, ask where to scaffold) 2. Run `npm create @webhouse/cms@latest` (Step 1) 3. Explain the model and plan with you (Steps 2–3) 4. Edit `cms.config.ts`, create content, wire rendering (Steps 4–6) 5. Fetch specific deep-dive modules as needed (SEO, i18n, deployment) 6. Hand back a running local site with deploy hooks configured No manual CLAUDE.md setup, no copy-pasting config from Stack Overflow, no "where should I start?" confusion. ## Works with any AI platform The entire site is plain `text/markdown` and `application/json` — every AI coding assistant understands it: - **Claude Code** — `WebFetch` tool pulls the URL; the long context window handles the full walkthrough - **Cursor / Windsurf** — paste the URL; the inline chat fetches it - **GitHub Copilot** — chat supports URL context; fetch `/ai` into the prompt - **Gemini (IDE extensions)** — URL-aware chat fetches the markdown - **Any future platform** — as long as it supports URL fetching, it works ## Machine-readable endpoints For programmatic use (MCP servers, agent frameworks, CI pipelines): ```bash # Full JSON manifest with all modules + descriptions + endpoints curl https://ai.webhouse.app/ai/manifest.json # Plain ordered module list curl https://ai.webhouse.app/ai/index.json # llms.txt standard (for LLM crawlers) curl https://ai.webhouse.app/ai/llms.txt ``` Example `manifest.json`: ```json { "name": "@webhouse/cms AI Builder Site", "version": "0.1.0", "entry": "https://ai.webhouse.app/ai", "modules": [ { "slug": "01-getting-started", "url": "...", "description": "New project, first setup" }, ... ], "endpoints": { "walkthrough": "https://ai.webhouse.app/ai", "llms_txt": "https://ai.webhouse.app/ai/llms.txt", "manifest": "https://ai.webhouse.app/ai/manifest.json" } } ``` ## Canonical source + fallbacks If `ai.webhouse.app` is unreachable, the canonical markdown lives in the CMS monorepo and is served from GitHub raw: ``` https://raw.githubusercontent.com/webhousecode/cms/main/docs/ai-guide/index.md https://raw.githubusercontent.com/webhousecode/cms/main/docs/ai-guide/01-getting-started.md ... ``` Every scaffolded project also ships with `packages/cms/CLAUDE.md` (slim index, ~180 lines) that references the same modules. So the AI has three redundant paths to the same content: 1. `https://ai.webhouse.app` (this site — primary) 2. GitHub raw (fallback, rate-limited) 3. Local `packages/cms/CLAUDE.md` in `node_modules/@webhouse/cms` (offline) ## Dogfooding The AI Builder Site itself is built with `@webhouse/cms` — it lives as a route group (`src/app/ai/*`) on [docs.webhouse.app](https://docs.webhouse.app), which is entirely CMS-managed. One site, two audiences: `/docs` for humans, `/ai` for AI agents. Same content model, same deploy pipeline. This page (the one you're reading) is a document in the `docs` collection, stored as JSON at `content/docs/ai-builder-guide.json`. It explains the companion AI site that sits next to it. Self-referential, fully editable via the admin UI, and available in both English and Danish. ## Non-negotiable rules The walkthrough reinforces eight rules that AI sessions must follow when building with `@webhouse/cms`: 1. Every collection MUST have `kind` and `description` 2. `cms.config.ts` MUST declare `storage` explicitly (default is SQLite) 3. NEVER name a collection `site-settings`, `settings`, `config`, `admin`, `media`, `interactives` 4. `image-gallery` values MUST be `{ url, alt }[]` — never plain strings 5. Documents MUST have `_fieldMeta: {}` 6. Slug MUST match filename 7. Always filter by `status === "published"` when rendering 8. NEVER use CDN scripts (Tailwind, Bootstrap) in static builds — inline CSS only These rules prevent the most common AI-generated mistakes we've seen across early builds. --- ## docs/settings-schema-da Title: Schema-settings Updated: 2026-04-15 Locale: da Redigér collections og feltdefinitioner live — samme skema du ville skrive i cms.config.ts, men fra admin-UI'et. Kun synlig når schema-edit er aktiveret. ## Hvor det er **Settings → Schema** (`/admin/settings?tab=schema`) — kun synlig når `schemaEditEnabled` er true i site-config. For produktionssites er dette normalt fra; for dev og onboarding er det on. ## Hvad fanen lader dig gøre Tilføj, fjern, omarrangér og ændr typer på felter i collections uden at redigere `cms.config.ts` i hånden. Ændringer træder i kraft øjeblikkeligt — ingen restart, intet build-trin. Fanen lister hver collection med: - **Navn** (internt id) og **label** (visningsnavn) - **Antal felter** - **Edit schema**-knap — dykker ind i felt-editoren - **+ New collection** — scaffolder en ny collection med dine valgte felter og `kind` ## Redigér en collection Klik **Edit schema** på en hvilken som helst collection. Editoren viser: - **Collection-meta** — navn, label, `kind` (page / snippet / data / form / global), beskrivelse, `urlPrefix`, `urlPattern`, `previewable` - **Felter** — drag for at omarrangere, klik for at redigere, skraldespand for at fjerne - **Add field** — vælg en felttype fra paletten - **Blokke** — hvis collectionen bruger `blocks`-felttypen, redigér blok-registryet her Gem for at skrive ændringer. CMS'et opdaterer det in-memory skema, regenererer `webhouse-schema.json` og fyrer save-hook'et til eventuelle downstream consumers. ## Felttyper du kan tilføje `text`, `textarea`, `richtext`, `number`, `boolean`, `date`, `image`, `image-gallery`, `video`, `audio`, `htmldoc`, `file`, `interactive`, `column-slots`, `map`, `select`, `tags`, `relation`, `array`, `object`, `blocks`. Hvert felt har et standard sæt af options (required, default, description) plus type-specifikke options (`select` har `options[]`, `relation` har `collection`, `array` har `fields[]` osv.). ## Operationer der kræver omhu Nogle skema-ændringer kan stille bryde eksisterende indhold. Fanen advarer før du committer, men vid hvad du laver: - **Omdøbning af et felt** — eksisterende dokumenter har stadig den gamle nøgle i deres `data`-objekt. Enten omdøb via et migrations-script eller efterlad det gamle felt (CMS'et ignorerer nøgler der ikke er i skemaet — data er stadig der, bare ikke vist i editoren). - **Fjernelse af et felt** — data forbliver på disk, editoren skjuler den bare. Tilføj feltet tilbage når som helst for at se data vende tilbage. - **Ændring af en felttype** — HØJ RISIKO. Et richtext-felts markdown passer ikke ind i et text-felt; en relation omdannet til tags mister referencerne. Fanen blokerer de mest destruktive type-ændringer; andre viser en advarsel. - **Ændring af `kind`** — f.eks. `page` → `data` fjerner URL-generering. Eksisterende URL'er 404 efter næste deploy. Sæt redirects op først. - **Fjernelse af en collection** — gør det ikke. Trash dokumenterne først, fjern derefter. Ellers bliver datafilerne liggende orphaned på disken. ## Re-eksportér skemaet til ikke-TS-consumers Hvis dit projekt har ikke-TypeScript consumers (Java, .NET, PHP, Python, Ruby, Go), er `webhouse-schema.json`-filen kontrakten. **Enhver skema-ændring via denne fane re-genererer den fil automatisk og skriver den til projekt-rod.** Commit både `cms.config.ts` og `webhouse-schema.json` i samme commit. Se [Framework consumers](/docs/framework-consumers-da) for hvorfor det betyder noget. ## Deaktivér schema-edit For produktionssites, sæt `SCHEMA_EDIT_ENABLED=false` (eller fjern env var'en). Fanen forsvinder fra sidebaren. Redaktører kan ikke ændre skemaet; kun en udvikler med repo-adgang kan. ## Relateret - [Collections](/docs/collections-da) — konceptuel oversigt over collections - [Field types](/docs/field-types-da) — fuld felttype-reference - [Collection-metadata](/docs/collection-metadata-da) — `kind`- og `description`-reglerne - [Framework-consumers](/docs/framework-consumers-da) — hvorfor `webhouse-schema.json` skal være i sync --- ## docs/settings-schema Title: Schema settings Updated: 2026-04-15 Locale: en Edit collections and field definitions live — the same schema you'd write in cms.config.ts, but from the admin UI. Only visible when schema-edit is enabled. ## Where it is **Settings → Schema** (`/admin/settings?tab=schema`) — only visible when `schemaEditEnabled` is true in site config. For production sites this is usually off; for dev and onboarding it's on. ## What the tab lets you do Add, remove, reorder, and retype fields on collections without editing `cms.config.ts` by hand. Changes take effect immediately — no restart, no build step. The tab lists every collection with: - **Name** (internal id) and **label** (display name) - **Field count** - **Edit schema** button — drills into the field editor - **+ New collection** — scaffolds a new collection with your chosen fields and `kind` ## Editing a collection Click **Edit schema** on any collection. The editor shows: - **Collection meta** — name, label, `kind` (page / snippet / data / form / global), description, `urlPrefix`, `urlPattern`, `previewable` - **Fields** — drag to reorder, click to edit, trash icon to remove - **Add field** — pick a field type from the palette - **Blocks** — if the collection uses the `blocks` field type, edit the block registry here Save to write changes. The CMS updates the in-memory schema, regenerates `webhouse-schema.json`, and fires the save hook to any downstream consumers. ## Field types you can add `text`, `textarea`, `richtext`, `number`, `boolean`, `date`, `image`, `image-gallery`, `video`, `audio`, `htmldoc`, `file`, `interactive`, `column-slots`, `map`, `select`, `tags`, `relation`, `array`, `object`, `blocks`. Each field has a standard set of options (required, default, description) plus type-specific options (`select` has `options[]`, `relation` has `collection`, `array` has `fields[]`, etc.). ## The care-required operations Some schema changes can silently break existing content. The tab warns before you commit, but know what you're doing: - **Renaming a field** — existing documents still have the old key in their `data` object. Either rename via a migration script or leave the old field (the CMS ignores keys not in the schema — the data is still there, just not shown in the editor). - **Removing a field** — the data stays on disk, the editor just hides it. Add the field back any time to see the data return. - **Changing a field type** — HIGH RISK. A richtext field's markdown won't fit into a text field; a relation turned into tags loses the references. The tab blocks the most destructive type changes; others show a warning. - **Changing `kind`** — e.g. `page` → `data` removes URL generation. Existing URLs 404 after the next deploy. Set up redirects first. - **Removing a collection** — don't. Trash the documents first, then remove. Otherwise the data files stay on disk orphaned. ## Re-export the schema for non-TS consumers If your project has non-TypeScript consumers (Java, .NET, PHP, Python, Ruby, Go), the `webhouse-schema.json` file is the contract. **Any schema change via this tab re-generates that file automatically and writes it to the project root.** Commit both `cms.config.ts` and `webhouse-schema.json` in the same commit. See [Framework consumers](/docs/framework-consumers) for why this matters. ## Disabling schema edit For production sites, set `SCHEMA_EDIT_ENABLED=false` (or remove the env var). The tab disappears from the sidebar. Editors can't change the schema; only a developer with repo access can. ## Related - [Collections](/docs/collections) — conceptual overview of collections - [Field types](/docs/field-types) — full field type reference - [Collection metadata](/docs/collection-metadata) — the `kind` and `description` rules - [Framework consumers](/docs/framework-consumers) — why `webhouse-schema.json` must stay in sync --- ## docs/settings-globals-da Title: Globals-settings Updated: 2026-04-15 Locale: da Single-record collections til site-wide data — footers, juridisk tekst, kontaktinfo, alt der dukker op på mange sider. ## Hvor det er **Settings → Globals** (`/admin/settings?tab=globals`) — kun synlig når dit site har mindst én collection med `kind: 'global'`. ## Hvad en global er En **global** er en collection der holder præcis ét dokument. Brug det til site-wide data der ikke hører hjemme på en selvstændig side: - **Footer** — links, tagline, copyright, social handles - **Juridisk** — privatlivspolitik-reference, GDPR-kontakt, cookie-notice - **Kontakt** — email, telefon, adresse, åbningstider - **Pris-tiers** — hvis priser dukker op på flere sider, hold dem her - **Settings-lignende indhold** — announcement-bannere, holiday-tilstand, A/B-flags I modsætning til regulære collections har globals ikke `/admin//`-URL'er. De redigeres via denne fane og konsumeres af frontend'en via content-API'et. ## Definér en global i cms.config.ts ```typescript import { defineConfig, defineCollection } from '@webhouse/cms'; export default defineConfig({ collections: [ defineCollection({ name: 'footer', label: 'Footer', kind: 'global', description: 'Site-wide footer: links, tagline, copyright. Single record, renderet på hver side.', fields: [ { name: 'tagline', type: 'text' }, { name: 'copyright', type: 'text' }, { name: 'links', type: 'array', fields: [ { name: 'label', type: 'text' }, { name: 'href', type: 'text' }, ]}, ], }), ], }); ``` `kind: 'global'` er switch'en. CMS admin opretter præcis ét dokument, normalt på slug `global`. Du kan ikke tilføje flere. ## Per-fane layout Globals-fanen lister hver global collection med en **Edit**-knap. Klik på en og du føres til den regulære dokumenteditor — samme richtext, image og feltstøtte som enhver collection. ## Konsumér globals på din frontend **I et statisk build (build.ts):** ```typescript import { readFileSync } from 'node:fs'; import { join } from 'node:path'; const footer = JSON.parse( readFileSync(join('content/footer/footer.json'), 'utf-8'), ).data; // Brug footer.tagline, footer.links osv. i hver page-render ``` **I Next.js:** ```typescript // app/lib/globals.ts export async function getFooter() { const raw = await fs.readFile('content/footer/footer.json', 'utf-8'); return JSON.parse(raw).data; } ``` Globals caches af content-servicen ved request-tid — at læse dem er effektivt gratis. ## Hvornår der skal bruges en global vs en regulær collection | Brug en global | Brug en regulær collection | |---|---| | Footer-tekst | Blogindlæg | | Site-metadata | Team-medlemmer | | Juridiske disclaimers | Case studies | | Single record, ingen URL | Mange records, hver med sin egen URL | I tvivl, spørg: dukker det op på mange sider? Er der altid præcis én? Hvis begge ja → global. ## Relateret - [Collections](/docs/collections-da) — grundlaget for at definere collections - [Field types](/docs/field-types-da) — hvilke feltyper der er tilladt i globals - [Config reference](/docs/config-reference-da) — fuld `kind`-værdireference --- ## docs/settings-globals Title: Globals settings Updated: 2026-04-15 Locale: en Single-record collections for site-wide data — footers, legal text, contact info, anything that appears on many pages. ## Where it is **Settings → Globals** (`/admin/settings?tab=globals`) — only visible when your site has at least one collection with `kind: 'global'`. ## What a global is A **global** is a collection that holds exactly one document. Use it for site-wide data that doesn't belong in a standalone page: - **Footer** — links, tagline, copyright, social handles - **Legal** — privacy policy reference, GDPR contact, cookie notice - **Contact** — email, phone, address, opening hours - **Pricing tiers** — if prices appear on multiple pages, keep them here - **Settings-like content** — announcement banners, holiday mode, A/B flags Unlike regular collections, globals don't have `/admin//` URLs. They're edited via this tab and consumed by the frontend via the content API. ## Defining a global in cms.config.ts ```typescript import { defineConfig, defineCollection } from '@webhouse/cms'; export default defineConfig({ collections: [ defineCollection({ name: 'footer', label: 'Footer', kind: 'global', description: 'Site-wide footer: links, tagline, copyright. Single record, rendered on every page.', fields: [ { name: 'tagline', type: 'text' }, { name: 'copyright', type: 'text' }, { name: 'links', type: 'array', fields: [ { name: 'label', type: 'text' }, { name: 'href', type: 'text' }, ]}, ], }), ], }); ``` `kind: 'global'` is the switch. The CMS admin creates exactly one document, usually at slug `global`. You can't add more. ## Per-tab layout The Globals tab lists each global collection with an **Edit** button. Click one and you're taken to the regular document editor — same richtext, image, and field support as any collection. ## Consuming globals on your frontend **In a static build (build.ts):** ```typescript import { readFileSync } from 'node:fs'; import { join } from 'node:path'; const footer = JSON.parse( readFileSync(join('content/footer/footer.json'), 'utf-8'), ).data; // Use footer.tagline, footer.links, etc. in every page render ``` **In Next.js:** ```typescript // app/lib/globals.ts export async function getFooter() { const raw = await fs.readFile('content/footer/footer.json', 'utf-8'); return JSON.parse(raw).data; } ``` Globals are cached by the content service at request time — reading them is effectively free. ## When to use a global vs a regular collection | Use a global | Use a regular collection | |---|---| | Footer text | Blog posts | | Site metadata | Team members | | Legal disclaimers | Case studies | | Single record, no URL | Many records, each with its own URL | If in doubt, ask: does this appear on many pages? Is there always exactly one? If both yes → global. ## Related - [Collections](/docs/collections) — the fundamentals of defining collections - [Field types](/docs/field-types) — what field types are allowed in globals - [Config reference](/docs/config-reference) — full `kind` value reference --- ## docs/settings-mcp-da Title: MCP-settings Updated: 2026-04-15 Locale: da Eksponér dit sites indhold til eksterne AI-værktøjer (Claude Desktop, Cursor, custom agenter) via Model Context Protocol. Read-only offentligt endpoint eller autentificeret read/write. ## Hvor det er **Settings → MCP** (`/admin/settings?tab=mcp`). ## Hvad MCP er Model Context Protocol er en åben standard der lader AI-værktøjer opdage og interagere med dit indhold via et typeret, struktureret API. Hvor REST er til mennesker + scripts, er MCP til AI. Når du plugger et MCP-endpoint ind i Claude Desktop (eller Cursor, eller en custom agent), ser AI'en en liste af tools den kan kalde — `list_documents`, `get_document`, `create_document`, `search_content` osv. Den bruger de tools under samtale uden at du kopiér-indsætter indhold frem og tilbage. ## To endpoints CMS'et eksponerer to MCP-endpoints per site: | Endpoint | Auth | Adgang | Brug til | |---|---|---|---| | **Public MCP** | Ingen | Read-only | AI-drevet søgning på dit site. Alle kan plugge det ind i deres agent | | **Authenticated MCP** | API-nøgle | Fuld read + write | Indholdsproduktion fra AI-værktøjer. Brugt af personale | Det offentlige endpoint kan opdages via `/ai-plugin.json` i site-roden — AI-platforme der understøtter MCP discovery kan auto-forbinde. ## Konfiguration ### Public MCP Intet at konfigurere. Den er tændt når GEO-settings tillader crawlers. Sluk den fra denne fane hvis du vil have et lukket site. ### Authenticated MCP 1. Klik **Generate new API key** — gemt som site-secret, aldrig echoet tilbage 2. Kopiér den genererede nøgle (vist én gang) 3. Vælg scopes: read / write / admin 4. Valgfrit pin til en specifik Claude Desktop- eller Cursor-config Fanen viser også den fulde MCP-konfiguration som JSON: ```json { "mcpServers": { "my-site": { "url": "https://my-site.com/api/mcp/authed", "headers": { "Authorization": "Bearer " } } } } ``` Indsæt det i Claude Desktops `config.json` eller Cursors MCP-settings for at forbinde. ## Tilgængelige tools via MCP Det autentificerede endpoint eksponerer samme 49-tool overflade som in-app chatten, med nogle tilføjelser til filsystems-operationer. Det offentlige endpoint eksponerer en reduceret delmængde (read-only, ingen konfiguration eller destruktive handlinger). Se [AI Chat tools](/docs/help-and-shortcuts-da) for den fulde liste. ## Nøgle-rotation Klik **Revoke** på en hvilken som helst API-nøgle for at invalidere den. Generér en ny — værktøjer konfigureret med den gamle nøgle holder op med at virke, omkonfigurér med den nye. Ingen automatisk rotation; sæt en kalender-påmindelse hvis du har brug for 90-dages rotation for compliance. ## Audit Hvert MCP tool-kald logges til event-loggen med: tool-navn, argumenter, timestamp, resultat-opsummering, varighed. Filtrér event-loggen til `source: mcp` for kun at se MCP-aktivitet. ## Relateret - [MCP-server-koncept](/docs/mcp-server-da) — protokol forklaret - [MCP-client](/docs/mcp-client-da) — det read-only offentlige MCP - [Help-panel AI Chat-liste](/docs/help-and-shortcuts-da) — tool-katalog - [GEO-settings](/docs/settings-geo-da) — hvor offentlig MCP-discovery slåes til --- ## docs/settings-mcp Title: MCP settings Updated: 2026-04-15 Locale: en Expose your site's content to external AI tools (Claude Desktop, Cursor, custom agents) via Model Context Protocol. Read-only public endpoint or authenticated read/write. ## Where it is **Settings → MCP** (`/admin/settings?tab=mcp`). ## What MCP is Model Context Protocol is an open standard that lets AI tools discover and interact with your content via a typed, structured API. Where REST is for humans + scripts, MCP is for AI. When you plug an MCP endpoint into Claude Desktop (or Cursor, or a custom agent), the AI sees a list of tools it can call — `list_documents`, `get_document`, `create_document`, `search_content`, etc. It uses those tools during conversation without you copy-pasting content back and forth. ## Two endpoints The CMS exposes two MCP endpoints per site: | Endpoint | Auth | Access | Use for | |---|---|---|---| | **Public MCP** | None | Read-only | AI-powered search on your site. Anyone can plug it into their agent | | **Authenticated MCP** | API key | Full read + write | Content production from AI tools. Used by staff | The public endpoint is discoverable via `/ai-plugin.json` at the site root — AI platforms that support MCP discovery can auto-connect. ## Configuration ### Public MCP Nothing to configure. It's on when GEO settings allow crawlers. Toggle it off from this tab if you want a closed site. ### Authenticated MCP 1. Click **Generate new API key** — stored as a site secret, never echoed back 2. Copy the generated key (shown once) 3. Pick scopes: read / write / admin 4. Optionally pin to a specific Claude Desktop or Cursor config The tab also shows the full MCP configuration JSON: ```json { "mcpServers": { "my-site": { "url": "https://my-site.com/api/mcp/authed", "headers": { "Authorization": "Bearer " } } } } ``` Paste that into Claude Desktop's `config.json` or Cursor's MCP settings to connect. ## Available tools via MCP The authenticated endpoint exposes the same 49-tool surface as the in-app chat, with some additions for file-system operations. The public endpoint exposes a reduced subset (read-only, no configuration or destructive actions). See [AI Chat tools](/docs/help-and-shortcuts) for the full list. ## Key rotation Click **Revoke** on any API key to invalidate it. Generate a new one — tools configured with the old key stop working, reconfigure with the new. No automatic rotation; set a calendar reminder if you need 90-day rotation for compliance. ## Audit Every MCP tool call is logged to the event log with: tool name, arguments, timestamp, result summary, duration. Filter the event log to `source: mcp` to see only MCP activity. ## Related - [MCP server concept](/docs/mcp-server) — protocol explained - [MCP client](/docs/mcp-client) — the read-only public MCP - [Help panel AI Chat list](/docs/help-and-shortcuts) — tool catalogue - [GEO settings](/docs/settings-geo) — where public MCP discovery is toggled --- ## docs/settings-automation-da Title: Automation-settings Updated: 2026-04-15 Locale: da Scheduled baggrundsopgaver — link checker, Lighthouse-audits, media processing og webhooks der fyrer ved content-events. ## Hvor det er **Settings → Automation** (`/admin/settings?tab=tools`). Internt hedder fanen `tools`, men UI-labelen er **Automation**. Begge peger på samme sted. ## Hvad den konfigurerer Fire kategorier af scheduled eller event-drevet baggrundsarbejde: ### Link checker | Felt | Formål | |---|---| | **Schedule** | Off, dagligt, ugentligt eller en specifik dag | | **Tidspunkt** | Hvornår checkeren kører (bruger site-tidszone) | | **Kun interne** | Spring eksterne URL'er over (hurtigere på store sites, misser tredjeparts-rådne links) | | **Alert ved fejl** | Email notifikationslisten når ≥ N brudte links findes | Checkeren går gennem hvert publiceret dokument, udtrækker links og HTTP-GET'er hver enkelt. Resultater lander i `/admin/link-checker` med bulk-fix-handlinger. Se [Link checker](/docs/link-checker-da) for resultat-fortolkning og almindelige fixes. ### Lighthouse | Felt | Formål | |---|---| | **Schedule** | Off, dagligt, ugentligt | | **Targets** | Hvilke URL'er der auditeres (typisk forsiden + top 5 sider) | | **Mobil + desktop** | Kører begge som default | | **Alert-tærskel** | Score hvorunder en alert fyrer | Lighthouse kører via Google PageSpeed Insights API — skal have en offentligt tilgængelig Preview-URL (Settings → General). Se [Lighthouse](/docs/lighthouse-da). ### Media processing Defaults for hvordan uploadede billeder transformeres: - **WebP variant-bredder** — kommasepareret liste (default `400,800,1200,1600`) - **JPEG-kvalitet** — 0–100 (default 82) - **EXIF-håndtering** — Behold / strip-lokation / strip-alt (default behold, da EXIF driver kort-visninger) - **AI-analyse ved upload** — auto-generér captions, alt-tekst, tags. Bruger content-modellen fra Settings → AI Ændringer gælder fremtidige uploads. Batch-reprocess eksisterende medier via **Reprocess all**-knappen. Se [Media processing](/docs/media-da). ### Webhooks Udgående HTTP-kald når content-events sker. Almindelig brug: fyr en Discord / Slack-notifikation ved publish, eller udløs en tredjeparts build-pipeline ved deploy. | Felt | Formål | |---|---| | **URL** | Hvor CMS'et POST'er | | **Events** | Hvilke events der udløser webhook'et (published, deployed, agent-completed osv.) | | **Secret** | Valgfrit — inkluderet som `X-CMS-Signature` til verifikation | | **Retry** | Antal retries ved non-2xx (default 3) | Payload er en JSON-envelope med `event`, `timestamp`, `siteId` og event-specifik data. De seneste 50 leveringer logges per webhook til debugging. ## Almindelige kombinationer - **Daglig link check + ugentlig Lighthouse** — den fornuftige default for et produktionssite - **Webhook til Slack ved publish** — redaktører ser hvad der gik live uden at tjekke admin - **Webhook til en CI-pipeline ved deploy** — udløs en downstream-pipeline (tests, cache warmup) - **Aggressiv media processing** — bred WebP-range, AI-analyse on for SEO-tunge sites ## Relateret - [Link checker](/docs/link-checker-da) — resultat-fortolkning - [Lighthouse](/docs/lighthouse-da) — score-breakdown - [Media processing](/docs/media-da) — EXIF, WebP-varianter, AI-analyse - [Email-settings](/docs/settings-email-da) — hvor automation-alerts går --- ## docs/settings-automation Title: Automation settings Updated: 2026-04-15 Locale: en Scheduled background tasks — link checker, Lighthouse audits, media processing, and webhooks that fire on content events. ## Where it is **Settings → Automation** (`/admin/settings?tab=tools`). Internally the tab is called `tools`, but the UI label is **Automation**. Both point at the same place. ## What it configures Four categories of scheduled or event-driven background work: ### Link checker | Field | Purpose | |---|---| | **Schedule** | Off, daily, weekly, or a specific day | | **Time** | When the checker runs (uses site timezone) | | **Internal only** | Skip external URLs (speeds up on large sites, misses third-party rot) | | **Alert on failures** | Email the notification list when ≥ N broken links are found | The checker walks every published document, extracts links, and HTTP-GETs each one. Results land in `/admin/link-checker` with bulk-fix actions. See [Link checker](/docs/link-checker) for result interpretation and common fixes. ### Lighthouse | Field | Purpose | |---|---| | **Schedule** | Off, daily, weekly | | **Targets** | Which URLs to audit (usually the homepage + top 5 pages) | | **Mobile + desktop** | Runs both by default | | **Alert threshold** | Score below which an alert fires | Lighthouse runs via Google PageSpeed Insights API — needs a publicly reachable Preview URL (Settings → General). See [Lighthouse](/docs/lighthouse). ### Media processing Defaults for how uploaded images are transformed: - **WebP variant widths** — comma-separated list (default `400,800,1200,1600`) - **JPEG quality** — 0–100 (default 82) - **EXIF handling** — Keep / strip-location / strip-all (default keep, since EXIF powers map views) - **AI analysis on upload** — auto-generate captions, alt text, tags. Uses the content model from Settings → AI Changes apply to future uploads. Batch-reprocess existing media via the **Reprocess all** button. See [Media processing](/docs/media). ### Webhooks Outbound HTTP calls when content events happen. Common use: fire a Discord / Slack notification on publish, or trigger a third-party build pipeline on deploy. | Field | Purpose | |---|---| | **URL** | Where the CMS POSTs | | **Events** | Which events trigger the webhook (published, deployed, agent-completed, etc.) | | **Secret** | Optional — included as `X-CMS-Signature` for verification | | **Retry** | Number of retries on non-2xx (default 3) | Payload is a JSON envelope with `event`, `timestamp`, `siteId`, and event-specific data. The last 50 deliveries are logged per webhook for debugging. ## Common combinations - **Daily link check + weekly Lighthouse** — the sensible default for a production site - **Webhook to Slack on publish** — editors see what went live without checking the admin - **Webhook to a CI pipeline on deploy** — trigger a downstream pipeline (tests, cache warmup) - **Aggressive media processing** — wide WebP range, AI analysis on for SEO-heavy sites ## Related - [Link checker](/docs/link-checker) — result interpretation - [Lighthouse](/docs/lighthouse) — score breakdown - [Media processing](/docs/media) — EXIF, WebP variants, AI analysis - [Email settings](/docs/settings-email) — where automation alerts go --- ## docs/settings-backup-da Title: Backup-settings Updated: 2026-04-15 Locale: da Scheduled snapshots af indhold, settings og site-config — valgfrit synkroniseret til cloud storage. Gendan med ét klik. ## Hvor det er **Settings → Backup** (`/admin/settings?tab=backup`). ## Hvad der backuppes - Alle dokumenter (`content/**/*.json`) - Site-config (`cms.config.ts`) - Site-settings (`_data/site-config.json`) - Agent-configs (`_data/agents/`) - Brand voice + lokalitets-caches - Team + invitationer (`_data/team.json`) - Event-log (seneste 500 entries) - Genereret skema (`webhouse-schema.json`) **Ikke** backuppet (de ligger andetsteds): - Uploadede medie-binære filer — behandl dem som et separat backup-problem (cloud sync dit uploads/-bibliotek) - Secrets — redactet til `REDACTED_BY_BACKUP` og genindtastet ved restore - Build-output (`dist/`, `.next/`) ## Schedule | Felt | Anbefalet | |---|---| | **Frekvens** | Dagligt | | **Tidspunkt** | Uden for arbejdstid for dit team (03:00 lokalt virker godt) | | **Retention** | 30 dage — generøst uden at oppuste storage | Scheduleren kører in-process (instrumentation-node.ts); ingen ekstern cron påkrævet. Bruger sitets konfigurerede tidszone. ## Storage To muligheder: **Lokal** (default) — backups ligger i `_data/backups/YYYY-MM-DD/` som zip-arkiver. Simpelt, hurtigt, ingen eksterne afhængigheder. Mist disken og du mister backups. **Cloud** — vælg én: - **Cloudflare R2** — S3-kompatibel, billig egress. Sæt endpoint, access-nøgle, secret, bucket - **S3-kompatibel** — ethvert S3-kompatibelt object store (AWS, MinIO, Backblaze B2) - **pCloud** — via WebDAV. Nyttigt til ikke-tekniske operatører der allerede har pCloud Cloud-backups beholder den lokale kopi OG synkroniserer en kopi til remote. Hvis den lokale disk dør, gendan fra cloud via **Import backup**. ## Manuel backup **Backup now**-knappen snapshotter øjeblikkeligt. Tager 1–3 sekunder for de fleste sites. Nyttigt før et risikabelt deploy eller en skema-ændring. ## Restore Klik på en backup i historik-listen → **Restore**. CMS'et: 1. Opretter en sikkerheds-backup af den aktuelle tilstand (tagged `pre-restore`) 2. Udpakker det valgte arkiv 3. Skriver content + settings + agents til disk 4. Prompter dig til at genindtaste redactede secrets 5. Genindlæser admin Eller brug **Import backup** til at gendanne en backup fra et andet site eller miljø. ## Alerts Når en scheduled backup fejler, fyrer CMS'et en email-alert til notifikationsmodtagerne (Settings → Email) og pusher `build_failed`-emnet til mobil. Stille fejl ville være slemt, så dette er aktiveret som default. ## Relateret - [Backup feature-oversigt](/docs/backup-da) — historik og koncepter - [Beam](/docs/settings-beam-da) — portabel single-fil arkiv (andet use case) - [Email-settings](/docs/settings-email-da) — hvor backup-failure alerts går --- ## docs/settings-backup Title: Backup settings Updated: 2026-04-15 Locale: en Scheduled snapshots of content, settings, and site config — optionally synced to cloud storage. Restore with one click. ## Where it is **Settings → Backup** (`/admin/settings?tab=backup`). ## What gets backed up - All documents (`content/**/*.json`) - Site config (`cms.config.ts`) - Site settings (`_data/site-config.json`) - Agent configs (`_data/agents/`) - Brand voice + locale caches - Team + invites (`_data/team.json`) - Event log (last 500 entries) - Generated schema (`webhouse-schema.json`) **Not** backed up (they live elsewhere): - Uploaded media binaries — treat these as a separate backup problem (cloud sync your uploads/ dir) - Secrets — redacted to `REDACTED_BY_BACKUP` and re-entered on restore - Build output (`dist/`, `.next/`) ## Schedule | Field | Recommended | |---|---| | **Frequency** | Daily | | **Time** | Off-hours for your team (03:00 local works well) | | **Retention** | 30 days — generous without bloating storage | The scheduler runs in-process (instrumentation-node.ts); no external cron required. Uses the site's configured timezone. ## Storage Two options: **Local** (default) — backups sit in `_data/backups/YYYY-MM-DD/` as zip archives. Simple, fast, no external dependencies. Lose the disk and you lose the backups. **Cloud** — choose one: - **Cloudflare R2** — S3-compatible, cheap egress. Set endpoint, access key, secret, bucket - **S3-compatible** — any S3-compatible object store (AWS, MinIO, Backblaze B2) - **pCloud** — via WebDAV. Useful for non-technical operators who already have pCloud Cloud backups keep the local copy AND sync a copy to the remote. If the local disk dies, restore from the cloud via **Import backup**. ## Manual backup The **Backup now** button snapshots immediately. Takes 1–3 seconds for most sites. Useful before a risky deploy or a schema change. ## Restore Click any backup in the history list → **Restore**. The CMS: 1. Creates a safety backup of the current state (tagged `pre-restore`) 2. Extracts the chosen archive 3. Writes content + settings + agents to disk 4. Prompts you to re-enter redacted secrets 5. Reloads the admin Or use **Import backup** to restore a backup from a different site or environment. ## Alerts When a scheduled backup fails, the CMS fires an email alert to the notification recipients (Settings → Email) and pushes the `build_failed` topic to mobile. Silent failures would be bad, so this is on by default. ## Related - [Backup feature overview](/docs/backup) — history and concepts - [Beam](/docs/settings-beam) — portable single-file archive (different use case) - [Email settings](/docs/settings-email) — where backup-failure alerts go --- ## docs/settings-deploy-da Title: Deploy-settings Updated: 2026-04-15 Locale: da Vælg din hosting-udbyder, sæt repo eller deploy-token og konfigurér Instant Content Deployment til 2-sekunders indholds-push. ## Hvor det er **Settings → Deploy** (`/admin/settings?tab=deploy`). ## Udbyder-valg Fem understøttede targets for Deploy-knappen: | Udbyder | Hvordan det virker | Hvornår det bruges | |---|---|---| | **GitHub Pages** | Builder sitet, pusher `dist/` til `gh-pages`-branch | Gratis, SSL inkluderet, kun statisk | | **Vercel** | Udløser et Vercel-deploy via token | Hurtigst SSR / edge runtime | | **Netlify** | Pusher build til Netlify via auth-token | Gratis tier, preview-deploys per branch | | **Fly.io** | Docker build + deploy | EU-region (arn), fuld server-støtte, persistente diske | | **Cloudflare Pages** | Direkte upload | Hurtigt CDN, billig egress | Hver udbyder har sit eget token-felt. Tokens er site-level secrets — skjulte efter gem, redaktører kan ikke læse dem tilbage. ## Instant Content Deployment (ICD) Killer-featuren. I stedet for at udløse et fuldt build hver gang indhold ændres, signerer ICD et webhook og fyrer det til din frontends revalidation-endpoint. Next.js (eller enhver ISR-kapabel frontend) regenererer kun de påvirkede sider. **~2 sekunder i stedet for 5–10 minutter.** For at aktivere: 1. Sæt `Revalidation URL` — endpointet på din frontend der accepterer `POST /api/revalidate` 2. Generér en **signerings-secret** (klik Generate) — bruges til at signere webhook-payloadet 3. Kopiér secret'en ind i din frontends miljø 4. Klik **Test revalidation** — fanen sender et dummy-payload for at verificere round-trip 5. Slå **Deploy on save** til for at fyre ICD ved hvert content-save Se [ICD + Docker deploy](/docs/icd-and-docker-da) for det fulde flow. ### Hvornår ICD falder tilbage til fuldt deploy ICD håndterer indholdsændringer. Den håndterer IKKE: - Config-ændringer (`cms.config.ts`) - Nye collections - Skema-redigeringer - Build.ts-ændringer De kræver en fuld rebuild, som fanen fyrer automatisk. ## Deploy on save En toggle. Når aktiveret, udløser gemning af et dokument ICD (eller fuldt deploy hvis ICD ikke er konfigureret). Fantastisk for hurtige teams med en lille redigeringscadence. **Fra** når du foretrækker et eksplicit "Deploy"-trin — f.eks. en review-workflow hvor flere redigeringer går live sammen. ## Deploy-historik Højre-panelet viser de seneste 50 deploys: timestamp, trigger (manuel / gem / schedule), varighed, status og commit-SHA / image-tag. Klik på en entry for fuldt log-output. ## Scheduled deploys Valgfrit cron-felt. Nyttigt for sites der rebuilder natligt (f.eks. for at genopfriske eksterne feeds, genkøre agenter, opdatere statistikker). Bruger sitets tidszone. ## Relateret - [Deploy feature-oversigt](/docs/deploy-da) — bredere deploy-primer på tværs af udbydere - [ICD + Docker one-click](/docs/icd-and-docker-da) — instant-content og one-click Docker-wizarden - [Backup-settings](/docs/settings-backup-da) — snapshot før et risikabelt deploy --- ## docs/settings-deploy Title: Deploy settings Updated: 2026-04-15 Locale: en Pick your hosting provider, set the repo or deploy token, and configure Instant Content Deployment for 2-second content pushes. ## Where it is **Settings → Deploy** (`/admin/settings?tab=deploy`). ## Provider selection Five supported targets for the Deploy button: | Provider | How it works | When to use | |---|---|---| | **GitHub Pages** | Builds site, pushes `dist/` to `gh-pages` branch | Free, SSL included, static-only | | **Vercel** | Triggers a Vercel deploy via token | Fastest SSR / edge runtime | | **Netlify** | Pushes build to Netlify via auth token | Free tier, preview deploys per branch | | **Fly.io** | Docker build + deploy | EU region (arn), full server support, persistent disks | | **Cloudflare Pages** | Direct upload | Fast CDN, cheap egress | Each provider has its own token field. Tokens are site-level secrets — hidden after save, editors can't read them back. ## Instant Content Deployment (ICD) The killer feature. Instead of triggering a full build every time content changes, ICD signs a webhook and fires it to your frontend's revalidation endpoint. Next.js (or any ISR-capable frontend) regenerates just the affected pages. **~2 seconds instead of 5–10 minutes.** To enable: 1. Set `Revalidation URL` — the endpoint on your frontend that accepts `POST /api/revalidate` 2. Generate a **signing secret** (click Generate) — used to sign the webhook payload 3. Copy the secret into your frontend's environment 4. Click **Test revalidation** — the tab sends a dummy payload to verify round-trip 5. Toggle **Deploy on save** to fire ICD on every content save See [ICD + Docker deploy](/docs/icd-and-docker) for the full flow. ### When ICD falls back to full deploy ICD handles content changes. It does NOT handle: - Config changes (`cms.config.ts`) - New collections - Schema edits - Build.ts changes Those require a full rebuild, which the tab fires automatically. ## Deploy on save A toggle. When on, saving any document triggers ICD (or full deploy if ICD isn't configured). Great for fast teams with a small edit cadence. **Off** when you prefer an explicit "Deploy" step — e.g. a review workflow where multiple edits go live together. ## Deploy history The right-hand panel shows the last 50 deploys: timestamp, trigger (manual / save / schedule), duration, status, and the commit SHA / image tag. Click any entry for full log output. ## Scheduled deploys Optional cron field. Useful for sites that rebuild nightly (e.g. to refresh external feeds, re-run agents, update statistics). Uses the site timezone. ## Related - [Deploy feature overview](/docs/deploy) — wider deploy primer across providers - [ICD + Docker one-click](/docs/icd-and-docker) — the instant-content and one-click Docker wizard - [Backup settings](/docs/settings-backup) — snapshot before a risky deploy --- ## docs/settings-brand-voice-da Title: Brand Voice-fanen Updated: 2026-04-15 Locale: da Settings-fanen til at konfigurere dit sites brand voice — det guidede AI-interview, voice-previewet og re-interview-arbejdsgangen. ## Hvor det er **Settings → Brand Voice** (`/admin/settings?tab=brand-voice`). Denne fane viser den aktuelle brand voice på én gang: persona-opsummering, primær tone, målgruppe, content pillars, SEO-keywords og brand personality-adjektiver. Hvis ingen voice er defineret, beder fanen dig køre det guidede AI-interview. ## Første kørsel: interviewet Klik **Start interview**. Claude spiller rollen som brand-strateg og stiller 6–8 spørgsmål om: - Hvad virksomheden laver og hvem den betjener - Adjektiver der beskriver brandets personlighed - Emner brandet er autoritativ på - Emner der skal undgås - Eksempel-fraser der lyder som brandet - Målgruppens kontekst (industri, ekspertise-niveau, intention) Interviewet tager 2–3 minutter. Til sidst genererer Claude et komplet `BrandVoice` JSON-payload og viser det til gennemsyn. Klik **Save** for at gemme det som første version. ## Re-interview vs redigér To måder at ændre voicen på: - **Re-interview** — klik denne fra fanens edit-side. Starter et friskt interview. Nyttigt når brandets retning har skiftet (ny målgruppe, nyt produkt-fokus, rebrand). - **Redigér felter direkte** — alle felter er redigerbare som strukturerede form-inputs. Nyttigt til små tweaks som at tilføje et keyword eller udskifte et tone-ord. Hvert gem opretter en ny version. Historik-fanen viser tidligere versioner med mulighed for at aktivere en hvilken som helst af dem. ## Hvad fanen IKKE konfigurerer Lokalitets-specifikke voice-varianter genereres on demand af AI-consumers der har brug for dem — de redigeres ikke i denne fane. Når chatten spørger om voicen i en ikke-primær lokalitet, auto-oversætter CMS'et via `/api/cms/brand-voice/translate` og cacher resultatet i `_data/brand-voice-.json`. Hvis du vil hånd-redigere den danske voice, åbn den JSON-fil direkte eller brug API'et. ## Effekt Brand voice indsprøjtes i hver AI-prompt CMS'et kører — chat, felt-generering, agenter, SEO-optimering, oversættelse. Når først konfigureret, aligner AI-output med din voice uden at du skal gentage den i hver prompt. Det er den højeste-gearing-indstilling i CMS'et for indholdskvalitet. ## Relateret - [Brand Voice-koncept](/docs/brand-voice-da) — dybere forklaring på hvordan voice indsprøjtes i prompts - [AI Prompts](/docs/settings-prompts-da) — til prompt-niveau overrides når brand voice ikke er nok - [AI-settings](/docs/settings-ai-da) — modellen valgt til interviewet (Opus anbefalet) --- ## docs/settings-brand-voice Title: Brand Voice tab Updated: 2026-04-15 Locale: en The Settings tab for configuring your site's brand voice — the guided AI interview, the voice preview, and the re-interview workflow. ## Where it is **Settings → Brand Voice** (`/admin/settings?tab=brand-voice`). This tab shows the current brand voice at a glance: the persona summary, primary tone, target audience, content pillars, SEO keywords, and brand personality adjectives. If no voice is defined, the tab prompts you to run the guided AI interview. ## First-run: the interview Click **Start interview**. Claude plays the role of a brand strategist and asks 6–8 questions about: - What the business does and who it serves - Adjectives that describe the brand's personality - Topics the brand is authoritative on - Topics to avoid - Sample phrases that sound like the brand - The target audience's context (industry, expertise level, intent) The interview takes 2–3 minutes. At the end, Claude generates a complete `BrandVoice` JSON payload and shows it for review. Click **Save** to store it as the first version. ## Re-interview vs edit Two ways to change the voice: - **Re-interview** — click this from the tab's edit page. Starts a fresh interview. Useful when the brand's direction has shifted (new audience, new product focus, rebrand). - **Edit fields directly** — all fields are editable as structured form inputs. Useful for small tweaks like adding a keyword or swapping a tone word. Every save creates a new version. The history tab shows previous versions with the option to activate any of them. ## What the tab does NOT configure Locale-specific voice variants are generated on demand by the AI consumers that need them — they're not edited in this tab. When the chat asks for the voice in a non-primary locale, the CMS auto-translates via `/api/cms/brand-voice/translate` and caches the result in `_data/brand-voice-.json`. If you want to hand-edit the Danish voice, open that JSON file directly or use the API. ## Impact Brand voice is injected into every AI prompt the CMS runs — chat, field generation, agents, SEO optimisation, translation. Once configured, AI output aligns with your voice without you having to restate it in every prompt. This is the highest-leverage setting in the CMS for content quality. ## Related - [Brand Voice concept](/docs/brand-voice) — deeper explanation of how voice gets injected into prompts - [AI Prompts](/docs/settings-prompts) — for prompt-level overrides when brand voice isn't enough - [AI settings](/docs/settings-ai) — the model picked for the interview (Opus recommended) --- ## docs/settings-ai-da Title: AI-settings Updated: 2026-04-15 Locale: da API-nøgler og model-defaults til hvert AI-feature i CMS'et — chat, agenter, felt-generering, SEO-optimering, oversættelse, interaktiver. ## Hvor det er **Settings → AI** (`/admin/settings?tab=ai`). Denne fane har to sektioner: **AI Providers** (API-nøglerne) og **AI Model Defaults** (hvilken model går til hvilket feature). ## AI Providers Parse feature via en API-nøgle fra en udbyder. P.t. understøttet: | Udbyder | Hvad det bruges til | |---|---| | **Anthropic** | Claude — bruges som default til alt (chat, agenter, felt-generering, SEO, GEO, translate) | | **OpenAI** | GPT — alternativ vej, bruges hvis du foretrækker ikke-Anthropic routing | Nøgler gemmes **per site**, ikke per konto. To sites i samme org kan bruge forskellige nøgler hvis du vil have isoleret billing. Nøgler forlader aldrig serveren — redaktører med editor- eller viewer-roller kan ikke læse dem tilbage. ### Miljø-fallback Hvis en nøgle ikke er sat per-site, falder CMS'et tilbage til `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` fra miljøet. Nyttigt i dev når du ikke vil indsætte nøgler i hvert test-site, men **stol ikke på dette i produktion** — en manglende per-site-nøgle skjuler en glemt konfiguration. ## AI Model Defaults Vælg modellen for hver feature-kategori. Forskellige opgaver ønsker forskellige modeller: | Slot | Anbefalet | Hvornår der skal afviges | |---|---|---| | **Content-model** | Haiku eller Sonnet | Brug Opus til premium-sites hvor felt-generering-kvalitet betyder mere end pris | | **Chat-model** | Sonnet | Opus til kompleks tool-calling; aldrig Haiku (tool-calling degraderer) | | **Premium-model** | Opus | Bruges til Brand Voice-interviews og lang-form generering hvor one-shot kvalitet er kritisk | | **Code-model** | Sonnet | Bruges til `generate_interactive`. Haiku producerer upålidelig JavaScript | Også konfigurerbart per slot: - **Max tokens** — maksimalt output per request (typisk: 4096 for felter, 16384 for chat, 8192 for code) - **Temperatur** — kreativitets-knap. 0.0 for faktuel (SEO-optimizer), 0.7 for kreativ (indholdsgenerering), 1.0 for brainstorming (aldrig til produktionsindhold) - **Max-iterationer** — hvor mange tool-kald chatten kan kæde per tur. Default 25. Hæv til komplekse multi-trin research-flows. ## Per-feature overrides Nogle features kan overrule modellen på feature-niveau: - **Agenter** — hver agent-config kan pinne en model-override - **Prompts** — AI Prompts-fanen kan overrule model per prompt-template - **Workflows** — hver workflow-node kan pinne sin egen model Overrides lader dig bruge Opus til én high-stakes agent, mens ethvert andet feature holdes på Sonnet for omkostningskontrol. ## Rate limits & budgetter Denne fane enforce'r ikke budgetter direkte — det håndteres af **agent budget**-indstillingen (per agent) og **cockpit** (globale lofter). Se [Cockpit](/docs/cockpit-da) for site-wide AI-omkostningskontrol. ## Relateret - [AI-agenter](/docs/ai-agents-da) — konfigurér individuelle agenter - [Cockpit](/docs/cockpit-da) — global temperatur, token-budget, omkostningslofter - [AI Prompts](/docs/settings-prompts-da) — prompt-template-overrides - [Brand Voice](/docs/brand-voice-da) — den voice der indsprøjtes i hver AI-prompt --- ## docs/settings-ai Title: AI settings Updated: 2026-04-15 Locale: en API keys and model defaults for every AI feature in the CMS — chat, agents, field generation, SEO optimisation, translation, interactives. ## Where it is **Settings → AI** (`/admin/settings?tab=ai`). This tab has two sections: **AI Providers** (the API keys) and **AI Model Defaults** (which model goes to which feature). ## AI Providers Parse the feature via an API key from a provider. Currently supported: | Provider | What for | |---|---| | **Anthropic** | Claude — used by default for everything (chat, agents, field generation, SEO, GEO, translate) | | **OpenAI** | GPT — alternative path, used if you prefer non-Anthropic routing | Keys are stored **per site**, not per account. Two sites in the same org can use different keys if you want isolated billing. Keys never leave the server — editors with editor or viewer roles cannot read them back. ### Environment fallback If a key isn't set per-site, the CMS falls back to `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` from the environment. Useful in dev when you don't want to paste keys into every test site, but **don't rely on this in production** — a missing per-site key hides a forgotten configuration. ## AI Model Defaults Choose the model for each feature category. Different tasks want different models: | Slot | Recommended | When to deviate | |---|---|---| | **Content model** | Haiku or Sonnet | Use Opus for premium sites where field generation quality matters more than cost | | **Chat model** | Sonnet | Opus for complex tool-calling; never Haiku (tool-calling degrades) | | **Premium model** | Opus | Used for Brand Voice interviews and long-form generation where one-shot quality is critical | | **Code model** | Sonnet | Used for `generate_interactive`. Haiku produces unreliable JavaScript | Also configurable per slot: - **Max tokens** — maximum output per request (typical: 4096 for fields, 16384 for chat, 8192 for code) - **Temperature** — creativity knob. 0.0 for factual (SEO optimiser), 0.7 for creative (content generation), 1.0 for brainstorming (never for production content) - **Max iterations** — how many tool calls the chat can chain per turn. Default 25. Raise for complex multi-step research flows. ## Per-feature overrides Some features can override the model at the feature level: - **Agents** — each agent config can pin a model override - **Prompts** — the AI Prompts tab can override model per prompt template - **Workflows** — each workflow node can pin its own model Overrides let you use Opus for one high-stakes agent while keeping every other feature on Sonnet for cost control. ## Rate limits & budgets This tab doesn't enforce budgets directly — that's handled by the **agent budget** setting (per agent) and **cockpit** (global caps). See [Cockpit](/docs/cockpit) for site-wide AI cost control. ## Related - [AI agents](/docs/ai-agents) — configure individual agents - [Cockpit](/docs/cockpit) — global temperature, token budget, cost ceilings - [AI Prompts](/docs/settings-prompts) — prompt template overrides - [Brand Voice](/docs/brand-voice) — the voice injected into every AI prompt --- ## docs/settings-general-da Title: General-settings Updated: 2026-04-15 Locale: da Site-identitet — navn, preview-URL, beskrivelse, tidszone og de små men vigtige defaults der styrer hvordan admin præsenterer dit site. ## Hvor det er **Settings → General** (`/admin/settings?tab=general`) — default landing-fanen. ## Felter | Felt | Hvad det styrer | |---|---| | **Site-navn** | Vist i admin-header, browser-fane og som ``-fallback på sider uden custom meta-titel | | **Site-beskrivelse** | Default `meta description` når en side ikke angiver en. Bruges også i OG-tags og i llms.txt | | **Preview URL** | Base-URL'en admin bruger til at bygge preview-links. Peg på produktions-URL når deployet, eller på en localhost dev-server under udvikling. Preview-URL'er bygges som `previewSiteUrl + urlPrefix + / + slug` | | **Tidszone** | Bruges til scheduled publishes, backup-tider, link-check schedules og Lighthouse cadence-visninger | | **Default lokalitet** | Fallback-lokalitet for dokumenter uden eksplicit `locale`-felt | | **Lokaliteter** | Kommasepareret liste af aktiverede lokaliteter (f.eks. `en, da, de`). Tilføjer en locale-vælger til hvert dokument og wirer i18n ind i sitemap hreflang | | **Tøm papirkurv** | One-click handling der permanent sletter alle trashed dokumenter uanset retention-periode. Bruges når du skal genvinde disk eller hard-slette følsomt indhold | ## Preview URL — den du ændrer mest Den ene værdi værd at tjekke ofte. Almindelige værdier: - **Lokal dev**: `http://localhost:3001` (eller den port dit sites dev-server bruger) - **Produktions Next.js-site**: `https://example.com` - **Statisk site på GitHub Pages**: `https://brugernavn.github.io/repo` Når preview-URL'er giver 404, er årsagen næsten altid: 1. Forkert `urlPrefix` på collectionen — hvis collectionen er `posts` med `urlPrefix: '/blog'`, previewer et dok med slug `hello` på `previewSiteUrl + /blog/hello`, hvilket kun virker hvis din frontend serverer den path 2. En collection der bruger category-URL-pattern (`urlPattern: '/:category/:slug'`) hvor `category`-feltet ikke er sat på dokumentet 3. i18n locale-prefixes — flersprogede sites med `/da/`, `/en/` har brug for at build'et udsender redirect-HTML på den CMS-forventede path (se [i18n-docs](/docs/i18n-da)) ## Tidszone Gemmes som en IANA zone-identifier (`Europe/Copenhagen`, `America/New_York` osv.). Påvirker: - Scheduled publish/unpublish — sæt en tid, CMS fortolker i denne zone - Backup cron-tider (Settings → Backup) - Link checker cron-tider (Settings → Automation) - Visning af `updatedAt` / `createdAt`-timestamps i lister Hvis redaktører i forskellige zoner samarbejder, vælg sitets primære målgruppezone, ikke den første redaktørs. ## Lokaliteter Lokaliteter-feltet låser i18n op. Sæt det til en kommasepareret liste (`en, da, de`). Hver collection-side viser så en locale-skifter og dokumenteditoren har en "Translate to…"-handling. Se [i18n](/docs/i18n-da) for fulde i18n-mekanikker — dette felt tænder bare for det. ## Relateret - [Site settings oversigt](/docs/site-settings-da) — bredere primer - [i18n](/docs/i18n-da) — flersprogs-opsætning - [Papirkurv](/docs/trash-da) — 30-dages retention og hvordan purge virker --- ## docs/settings-general Title: General settings Updated: 2026-04-15 Locale: en Site identity — name, preview URL, description, timezone, and the small but important defaults that control how the admin presents your site. ## Where it is **Settings → General** (`/admin/settings?tab=general`) — the default landing tab. ## Fields | Field | What it controls | |---|---| | **Site name** | Shown in the admin header, browser tab, and as the `` fallback on pages without a custom meta title | | **Site description** | Default `meta description` when a page doesn't specify one. Also used in OG tags and in llms.txt | | **Preview URL** | The base URL the admin uses to build preview links. Point it at your production URL once deployed, or at a localhost dev server during development. Preview URLs are constructed as `previewSiteUrl + urlPrefix + / + slug` | | **Timezone** | Used for scheduled publishes, backup times, link-check schedules, and Lighthouse cadence displays | | **Default locale** | The fallback locale for documents without an explicit `locale` field | | **Locales** | Comma-separated list of enabled locales (e.g. `en, da, de`). Adds a locale selector to every document and wires i18n into sitemap hreflang | | **Purge trash** | One-click action that permanently deletes all trashed documents regardless of retention period. Used when you need to reclaim disk or hard-delete sensitive content | ## Preview URL — the one you'll change most The single field worth checking often. Common values: - **Local dev**: `http://localhost:3001` (or whichever port your site's dev server uses) - **Production Next.js site**: `https://example.com` - **Static site on GitHub Pages**: `https://youruser.github.io/repo` When Preview URLs 404, the cause is almost always: 1. Wrong `urlPrefix` on the collection — if the collection is `posts` with `urlPrefix: '/blog'`, a doc with slug `hello` previews at `previewSiteUrl + /blog/hello`, which only works if your frontend serves that path 2. A collection using a category URL pattern (`urlPattern: '/:category/:slug'`) where the `category` field hasn't been set on the document 3. i18n locale prefixes — multilingual sites with `/da/`, `/en/` need the build to output redirect HTML at the CMS-expected path (see [i18n docs](/docs/i18n)) ## Timezone Stored as an IANA zone identifier (`Europe/Copenhagen`, `America/New_York`, etc.). Affects: - Scheduled publish/unpublish — set a time, CMS interprets it in this zone - Backup cron times (Settings → Backup) - Link checker cron times (Settings → Automation) - Display of `updatedAt` / `createdAt` timestamps in lists If editors in different zones collaborate, pick the site's primary audience zone, not the first editor's. ## Locales The locales field unlocks i18n. Set it to a comma-separated list (`en, da, de`). Every collection page then shows a locale switcher and the document editor has a "Translate to…" action. See [i18n](/docs/i18n) for the full i18n mechanics — this field just turns it on. ## Related - [Site settings overview](/docs/site-settings) — wider primer - [i18n](/docs/i18n) — multi-language setup - [Trash](/docs/trash) — 30-day retention and how purge works --- ## docs/settings-prompts-da Title: AI Prompts-settings Updated: 2026-04-15 Locale: da Tilpas de prompt-templates CMS'et bruger til felt-generering, SEO-optimering, oversættelse og andre AI-features — per site. ## Hvor det er **Settings → AI Prompts** (`/admin/settings?tab=prompts`). CMS'et bruger kuraterede prompt-templates til hvert AI-baseret feature — generere et felt, omskrive, oversætte, score SEO, optimere til GEO, opsummere, udtrække metadata. Denne fane lader dig overrule defaults per site, så outputtet matcher dit sites behov uden at skulle bygge en custom agent. ## Prompt-kategorier Fanen lister hvert prompt-slot CMS'et eksponerer, grupperet efter formål: ### Indholdsgenerering - **Field generate** — udfylder et enkelt felt ud fra resten af dokumentet - **Field rewrite** — omskriver et felt (kortere, længere, formelt, afslappet osv.) - **Document generate** — genererer et komplet dokument fra en titel + brief - **Interactive generate** — AI-bygger en standalone HTML-widget ### Optimering - **SEO optimise** — genererer meta-titel, beskrivelse, keywords, JSON-LD til et dokument - **GEO optimise** — omstrukturerer indhold til at være answer-first, tilføjer spørgsmåls-headere, citations, statistik - **Alt-text generate** — udfylder `alt` for et billede ud fra dens caption og filnavn ### Oversættelse - **Document translate** — dokument-niveau oversættelse til en mål-lokalitet - **Field translate** — enkelt-felts oversættelse (bruges til UI-strings i globals) ### Research - **Content research** — indsamler live web-kilder via `web_search` til et udkast - **Citation format** — formaterer en ekstern URL til en ordentlig citation-blok ### Moderation - **Proofread** — grammatik, stil, tone-rettelser (inline proofreader) - **Moderation check** — flagger potentielt skadeligt indhold ## Hvordan en prompt ser ud Hver prompt har tre dele: - **System** — faste instruktioner om rolle, tone, output-format. Behøver sjældent ændring. - **User-template** — den variable del, med placeholder-tokens som `{{field}}`, `{{title}}`, `{{brand_voice}}`, `{{locale}}`, `{{document_json}}` - **Example output** — et udarbejdet eksempel modellen kan forankre sig på (valgfrit men hjælper) Brand Voice indsprøjtes automatisk som `{{brand_voice}}`; du behøver ikke duplikere det i hver prompt. ## Overrule en prompt 1. Find prompten i listen. Hver række viser slot-navn og aktuel kilde (`default` eller `custom`). 2. Klik **Edit**. 3. Skriv din override. Editoren viser default-teksten som reference og fremhæver placeholder-tokens. 4. **Test** kører prompten mod et sample-dokument med din override. Verificér at outputtet ser rigtigt ud. 5. **Save** — træder i kraft øjeblikkeligt for alle fremtidige AI-kald på dette site. Overrides gemmes i `_data/prompts.json`. De inkluderes i site-beams (så eksporterede sites bærer deres prompt-customisering med). ## Fortryd Hver række har en **Revert to default**-knap der dropper override'en. Default-teksten leveres med CMS'et og opdateres ved hver release — at fortryde betyder at du følger enhver forbedring @webhouse/cms-holdet laver. ## Hvornår der skal overrules Grib fat i denne fane når: - En specifik prompt konsistent producerer output der rammer ved siden af dit brand - Du vil indsprøjte domæne-specifikke constraints (f.eks. "inkludér altid produktnavnet", "undgå disse juridiske fraser") - Du A/B-tester tone-strategier mellem to sites - Du vil oversætte prompterne selv, så de matcher et ikke-engelsk redaktør-team **Overrul IKKE** når du kan løse problemet via Brand Voice — det er enklere, billigere og propagerer til hvert feature automatisk. ## Relateret - [Brand Voice](/docs/brand-voice-da) — den globale tone-config indsprøjtet i hver prompt - [AI-agenter](/docs/ai-agents-da) — til fulde custom agents med deres egne prompts - [Cockpit](/docs/cockpit-da) — global temperatur, token-budget, model-valg --- ## docs/settings-prompts Title: AI Prompts settings Updated: 2026-04-15 Locale: en Customise the prompt templates the CMS uses for field generation, SEO optimisation, translation, and other AI features — per site. ## Where it is **Settings → AI Prompts** (`/admin/settings?tab=prompts`). The CMS uses curated prompt templates for every AI-backed feature — generating a field, rewriting, translating, scoring SEO, optimising for GEO, summarising, extracting metadata. This tab lets you override the defaults on a per-site basis so the output matches your site's needs without having to build a custom agent. ## Prompt categories The tab lists every prompt slot the CMS exposes, grouped by purpose: ### Content generation - **Field generate** — fills a single field given the rest of the document - **Field rewrite** — rewrites a field (shorter, longer, formal, casual, etc.) - **Document generate** — generates a complete document from a title + brief - **Interactive generate** — AI-builds a standalone HTML widget ### Optimisation - **SEO optimise** — generates meta title, description, keywords, JSON-LD for a document - **GEO optimise** — restructures content to be answer-first, adds question headers, citations, statistics - **Alt-text generate** — fills `alt` for an image given its caption and filename ### Translation - **Document translate** — document-level translation to a target locale - **Field translate** — single-field translation (used for UI strings in globals) ### Research - **Content research** — gathers live web sources via `web_search` for a draft - **Citation format** — formats an external URL into a proper citation block ### Moderation - **Proofread** — grammar, style, tone corrections (the inline proofreader) - **Moderation check** — flags potentially harmful content ## What a prompt looks like Each prompt has three parts: - **System** — fixed instructions about role, tone, output format. Seldom needs changing. - **User template** — the variable part, with placeholder tokens like `{{field}}`, `{{title}}`, `{{brand_voice}}`, `{{locale}}`, `{{document_json}}` - **Example output** — a worked example the model can anchor on (optional but helps) Brand Voice is automatically injected as `{{brand_voice}}`; you don't need to duplicate it into every prompt. ## Overriding a prompt 1. Find the prompt in the list. Each row shows the slot name and the current source (`default` or `custom`). 2. Click **Edit**. 3. Write your override. The editor shows the default text as a reference and highlights placeholder tokens. 4. **Test** runs the prompt against a sample document with your override. Verify the output looks right. 5. **Save** — takes effect immediately for all future AI calls on this site. Overrides are stored in `_data/prompts.json`. They're included in site beams (so exported sites carry their prompt customisation). ## Reverting Each row has a **Revert to default** button that drops the override. The default text is shipped with the CMS and updated with each release — reverting means you follow any improvements the @webhouse/cms team makes. ## When to override Reach for this tab when: - A specific prompt is consistently producing output that misses your brand - You want to inject domain-specific constraints (e.g. "always include the product name", "avoid these legal phrases") - You're A/B testing tone strategies between two sites - You want to translate prompts themselves to match a non-English editor team **Don't** override when you can solve the problem via Brand Voice — that's simpler, cheaper, and propagates to every feature automatically. ## Related - [Brand Voice](/docs/brand-voice) — the global tone config injected into every prompt - [AI agents](/docs/ai-agents) — for full custom agents with their own prompts - [Cockpit](/docs/cockpit) — global temperature, token budget, model selection --- ## docs/settings-beam-da Title: Beam — site-export & import Updated: 2026-04-15 Locale: da Eksportér et komplet site som et portabelt .beam-arkiv og importér det andetsteds. Indhold, settings, skema, agenter, medie-manifester — alt undtagen uploadede binære filer. ## Hvor det er **Settings → Beam** (`/admin/settings?tab=beam`). En **beam** er et enkelt-fils arkiv af et komplet @webhouse/cms-site. Tænk på det som en git bundle for CMS-tilstand: portabel, reproducerbar og nyttig til at flytte et site mellem miljøer eller dele en site-template med en medarbejder. ## Hvad en beam indeholder - **Indhold** — hver dokument JSON-fil (`content/**/*.json`) - **Site-config** — `cms.config.ts` (som tekst) - **Settings** — `_data/site-config.json`, backup-config, deploy-config (secrets redacted) - **Skema** — genereret `webhouse-schema.json` - **Agenter** — `_data/agents/` (prompts, budgets, locale-config) - **Brand voice** — `_data/brand-voice.json` + locale-caches - **Team** — `_data/team.json` (medlemsliste; ikke password-hashes — de ligger på kontoen, ikke sitet) - **Event-log** — seneste 500 entries - **Medie-manifest** — filnavne + EXIF + AI-analyse for hvert medie-asset ## Hvad en beam IKKE indeholder - **Uploadede binære filer** — billeder, videoer, PDF'er bliver hvor de er. En beam refererer til dem; den indlejrer dem ikke. Eksportér medier separat hvis du skal flytte et site til en ny host - **Node-modules** — target-miljøet har sine egne - **Secrets** — API-nøgler, kodeord, webhook-signerings-secrets redactes til `REDACTED_BY_BEAM`. Du genindtaster dem ved import - **Genereret build-output** — `dist/`, `.next/`, `public/uploads/*-400w.webp` ## Eksport Klik **Export site as .beam**. CMS'et genererer et signeret arkiv og streamer det til dig som `{site-id}-{YYYY-MM-DD}-{short-hash}.beam`. Størrelsen er typisk 50 KB – 5 MB afhængigt af indholdet. Beam'en er signeret så senere imports kan verificere at den ikke er tampered med. Signeringsnøglen er site-specifik; udskift sitet og signaturen brydes, hvilket er det du vil. ## Import To entry points: 1. **Denne fane** — importér ind i *dette* site. Overskriver aktuelt indhold, config, settings. Backupper den eksisterende tilstand til en timestamped folder først (sikkerhedsnet). 2. **`/admin/sites/new → Beam`-fane** — opret et nyt site fra en beam. Lader dig omdøbe sitet, ændre organisation, beholde eller genskabe skemaet. Ved import gør CMS'et: 1. Verificerer arkiv-signaturen 2. Backupper aktuel tilstand (hvis der importeres ind i et eksisterende site) 3. Skriver indhold + config + settings til disk 4. Genregistrerer sitet i registryet 5. Prompter dig til at indsætte redactede secrets (API-nøgler) før første deploy ## Use cases - **Migrér fra dev til prod** — eksportér fra dev-maskinen, importér til produktions-host - **Template et site** — byg et showcase-site, beam det, del med en kunde til at seede deres eget site - **Time-travel** — hold et rullende sæt af beams som en grovkornet backup over fulde snapshots - **Medarbejder-overdragelse** — "her er en beam, start et nyt site fra den" ## Forhold til backups Backups (Settings → Backup) gemmer inkrementelle snapshots med cloud-sync. Beams er one-shot, fuld-site portable arkiver beregnet til at blive flyttet. | Use case | Brug | |---|---| | Dagligt sikkerhedsnet | Backup | | Gendan dette site til en tidligere dato | Backup | | Flyt til en ny host / konto | Beam | | Overdrag til en medarbejder | Beam | | Klon et template-site | Beam | ## Relateret - [Backup-settings](/docs/backup-da) — scheduled snapshots - [WordPress-migration](/docs/wordpress-migration-da) — importér fra eksterne WP-sites - [Framework-consumers](/docs/framework-consumers-da) — hvad `webhouse-schema.json` bruges til --- ## docs/settings-beam Title: Beam — site export & import Updated: 2026-04-15 Locale: en Export a complete site as a portable .beam archive and import it elsewhere. Content, settings, schema, agents, media manifests — everything except uploaded binaries. ## Where it is **Settings → Beam** (`/admin/settings?tab=beam`). A **beam** is a single-file archive of a complete @webhouse/cms site. Think of it like a git bundle for CMS state: portable, reproducible, and useful for moving a site between environments or sharing a site template with a collaborator. ## What goes in a beam - **Content** — every document JSON file (`content/**/*.json`) - **Site config** — `cms.config.ts` (as text) - **Settings** — `_data/site-config.json`, backups config, deploy config (secrets redacted) - **Schema** — generated `webhouse-schema.json` - **Agents** — `_data/agents/` (prompts, budgets, locale config) - **Brand voice** — `_data/brand-voice.json` + locale caches - **Team** — `_data/team.json` (member list; not password hashes — those live on the account, not the site) - **Event log** — last 500 entries - **Media manifest** — filenames + EXIF + AI-analysis for every media asset ## What's NOT in a beam - **Uploaded binaries** — images, videos, PDFs stay where they are. A beam references them; it doesn't embed them. Export media separately if you need to move a site to a new host - **Node modules** — the target environment has its own - **Secrets** — API keys, passwords, webhook signing secrets are redacted to `REDACTED_BY_BEAM`. You re-enter them on import - **Generated build output** — `dist/`, `.next/`, `public/uploads/*-400w.webp` ## Exporting Click **Export site as .beam**. The CMS generates a signed archive and streams it to you as `{site-id}-{YYYY-MM-DD}-{short-hash}.beam`. Size is usually 50 KB – 5 MB depending on content. The beam is signed so later imports can verify it wasn't tampered with. The signing key is site-specific; swap the site and the signature breaks, which is what you want. ## Importing Two entry points: 1. **This tab** — import into *this* site. Overwrites current content, config, settings. Backs up the existing state to a timestamped folder first (safety net). 2. **`/admin/sites/new → Beam` tab** — create a new site from a beam. Lets you rename the site, change the organization, keep or regenerate the schema. On import the CMS: 1. Verifies the archive signature 2. Backs up the current state (if importing into an existing site) 3. Writes content + config + settings to disk 4. Re-registers the site in the registry 5. Prompts you to paste redacted secrets (API keys) before first deploy ## Use cases - **Migrate from dev to prod** — export from the dev machine, import to the production host - **Template a site** — build a showcase site, beam it, share with a client to seed their own site - **Time-travel** — keep a rolling set of beams as a coarse-grained backup over full snapshots - **Collaborator handoff** — "here's a beam, spin up a fresh site from it" ## Relationship to backups Backups (Settings → Backup) store incremental snapshots with cloud sync. Beams are one-shot, full-site portable archives meant to be moved. | Use case | Use | |---|---| | Daily safety net | Backup | | Restore this site to a previous date | Backup | | Move to a new host / account | Beam | | Hand off to a collaborator | Beam | | Clone a template site | Beam | ## Related - [Backup settings](/docs/backup) — scheduled snapshots - [WordPress migration](/docs/wordpress-migration) — import from external WP sites - [Framework consumers](/docs/framework-consumers) — what `webhouse-schema.json` is used for --- ## docs/settings-geo-da Title: GEO-settings Updated: 2026-04-15 Locale: da AI-synlighedskontrol — hvilke AI-crawlers der kan indeksere dit site, hvad robots.txt-strategien er, og hvordan dit indhold dukker op i ChatGPT / Claude / Perplexity-svar. ## Hvor det er **Settings → GEO** (`/admin/settings?tab=geo`). **GEO** (Generative Engine Optimization) handler om at gøre dit indhold findbart og citerbart af AI-platforme som ChatGPT, Claude og Perplexity — svarende til hvad SEO er for Google. Denne fane kontrollerer hvilke crawlers der kan tilgå dit indhold, og hvordan discovery-filer genereres. ## Robots.txt-strategi Kernekontrollen. Vælg en af fire strategier: | Strategi | Hvad den gør | |---|---| | **Maximum** | Alle bots tilladt, inkl. training-bots. Bedst til maksimal synlighed. *Default* | | **Balanced** | Search-bots tilladt (ChatGPT-User, Claude-SearchBot, PerplexityBot). Training-bots blokeret (GPTBot, ClaudeBot, Google-Extended). Dit indhold driver AI-svar, men bruges ikke til model-træning | | **Restrictive** | Alle AI-bots blokeret. Kun traditionelle søgemaskiner tilladt. Brug hvis juridisk påkrævet | | **Custom** | Definér dine egne regler linje for linje. Fanen viser den genererede `robots.txt` til gennemsyn | Den genererede `robots.txt` disallower også `/admin/`, `/api/` og preview-paths i alle strategier. ## Genererede discovery-filer Hvert build skriver disse filer til site-roden (det er dem crawlers og AI-platforme leder efter): - **`robots.txt`** — crawler-politik baseret på din strategi - **`sitemap.xml`** — alle indekserbare sider, med `` til multi-locale sites - **`llms.txt`** — AI-venligt indeks af din site-struktur + MCP endpoint-pointer - **`llms-full.txt`** — fuldt markdown-dump af hvert publiceret dokument, til retrieval-augmented AI-brug - **`feed.xml`** — RSS-feed af dine indlæg - **`ai-plugin.json`** — MCP plugin-manifest så AI-platforme kan finde dit live content-API - **Per-side `.md`-filer** — hver HTML-side har en markdown-sibling på samme URL + `.md` (f.eks. `/blog/post.html` → `/blog/post.md`) Alt dette er automatisk når først deploy kører. GEO-fanen lader dig konfigurere *hvad* der går i hver, ikke build-mekanikken. ## Land / region-restriktioner Valgfrit. De fleste sites efterlader dette blankt. Sæt her hvis dit indhold er geografisk begrænset (juridisk, regional licensering). Værdien feeder ind i: - `` på hver side - `llms.txt`-præamblen - Structured data (`areaServed`) i JSON-LD for virksomheder ## AI-citations-tuning GEO-scoring (vist i dashboardet) måler hvor godt dit indhold er struktureret til AI-citation. Denne fane fremhæver reglerne og lader dig overrule nogle: - **Answer-first** — lead-paragrafer besvarer H1-spørgsmålet direkte - **Spørgsmåls-headere** — H2'er matcher hvordan folk faktisk spørger - **Statistik** — inkludér tal, procenter, datapunkter - **Citations** — link til autoritative eksterne kilder - **Freshness** — indhold opdateret inden for 90 dage - **JSON-LD** — structured data til artikler, FAQ'er, HowTo - **Navngivet forfatter** — E-E-A-T trust-signaler - **Dybde** — 800+ ord til omfattende dækning Fanen viser hvilke regler dit site pt. består + Optimize All-knappen til at køre alle dem mod hvert publiceret dokument. ## mcp-plugin-endpointet Når GEO er aktiveret, eksponerer CMS'et et offentligt MCP-endpoint på `/api/mcp/public`. AI-platforme der understøtter MCP (Claude Desktop, Cursor, custom agents) kan plugge det ind og få live, struktureret adgang til dit indhold — ikke bare den scrapede HTML. Det er den kanoniske måde at gøre dit site AI-nativt på. Se [MCP-settings](/docs/settings-mcp-da) for den autentificerede version med skrivadgang. ## Relateret - [SEO](/docs/seo-da) — søgemaskineoptimering (søster-feature) - [Visibility-dashboard](/docs/visibility-da) — kombineret SEO + GEO-score - [MCP-server](/docs/mcp-server-da) — fulde MCP protokol-docs - [llms.txt-specifikation](https://llmstxt.org/) — standarden dette implementerer --- ## docs/settings-geo Title: GEO settings Updated: 2026-04-15 Locale: en AI visibility controls — which AI crawlers can index your site, what the robots.txt strategy is, and how your content surfaces in ChatGPT / Claude / Perplexity answers. ## Where it is **Settings → GEO** (`/admin/settings?tab=geo`). **GEO** (Generative Engine Optimization) is about making your content discoverable and citable by AI platforms like ChatGPT, Claude, and Perplexity — similar to how SEO is for Google. This tab controls which crawlers can access your content and how the discovery files are generated. ## Robots.txt strategy The core control. Pick one of four strategies: | Strategy | What it does | |---|---| | **Maximum** | All bots allowed, including training bots. Best for maximum visibility. *Default* | | **Balanced** | Search bots allowed (ChatGPT-User, Claude-SearchBot, PerplexityBot). Training bots blocked (GPTBot, ClaudeBot, Google-Extended). Your content powers AI answers but isn't used for model training | | **Restrictive** | All AI bots blocked. Only traditional search engines allowed. Use if legally required | | **Custom** | Define your own rules line by line. The tab exposes the generated `robots.txt` for review | The generated `robots.txt` also disallows `/admin/`, `/api/`, and preview paths on every strategy. ## Generated discovery files Every build writes these files at the site root (they're what crawlers and AI platforms look for): - **`robots.txt`** — crawler policy based on your strategy - **`sitemap.xml`** — all indexable pages, with `` for multi-locale sites - **`llms.txt`** — AI-friendly index of your site structure + MCP endpoint pointer - **`llms-full.txt`** — full markdown dump of every published document, for retrieval-augmented AI use - **`feed.xml`** — RSS feed of your posts - **`ai-plugin.json`** — MCP plugin manifest so AI platforms can discover your live content API - **Per-page `.md` files** — every HTML page has a markdown sibling at the same URL + `.md` (e.g. `/blog/post.html` → `/blog/post.md`) All of this is automatic once the deploy runs. The GEO tab lets you configure *what* goes in each, not the build mechanics. ## Country / region restrictions Optional. Most sites leave this blank. Set here if your content is geographically restricted (legal, regional licensing). The value feeds into: - `` in every page - The `llms.txt` preamble - Structured data (`areaServed`) in JSON-LD for businesses ## AI citation tuning GEO scoring (shown in the dashboard) measures how well your content is structured for AI citation. This tab surfaces the rules and lets you override some: - **Answer-first** — lead paragraphs answer the H1 question directly - **Question headers** — H2s match how people actually ask - **Statistics** — include numbers, percentages, data points - **Citations** — link to authoritative external sources - **Freshness** — content updated within 90 days - **JSON-LD** — structured data for articles, FAQs, HowTo - **Named author** — E-E-A-T trust signals - **Depth** — 800+ words for comprehensive coverage The tab shows which rules your site currently passes + the Optimize All button to run all of them against every published doc. ## The mcp-plugin endpoint When GEO is enabled, the CMS exposes a public MCP endpoint at `/api/mcp/public`. AI platforms that support MCP (Claude Desktop, Cursor, custom agents) can plug it in and get live, structured access to your content — not just the scraped HTML. This is the canonical way to make your site AI-native. See [MCP settings](/docs/settings-mcp) for the auth'd version with write access. ## Related - [SEO](/docs/seo) — search-engine optimization (sister feature) - [Visibility dashboard](/docs/visibility) — combined SEO + GEO score - [MCP server](/docs/mcp-server) — full MCP protocol docs - [llms.txt spec](https://llmstxt.org/) — the standard this implements --- ## docs/settings-email-da Title: Email-settings Updated: 2026-04-15 Locale: da Transaktionsmail — From-adressen, udbyderen og notifikationsmodtagere til invitationer, formular-indsendelser og alerts. ## Hvor det er **Settings → Email** (`/admin/settings?tab=email`). ## Hvad fanen konfigurerer Hvordan udgående mail sendes fra dit site. CMS'et sender mail til: - **Team-invitationer** — onboarding af nye medlemmer - **Formular-indsendelser** — form-engine mailer en kopi af hver indsendelse til site-ejeren - **Agent-notifikationer** — alerts når en langvarig agent afslutter - **Backup-alerts** — når en scheduled backup fejler - **Link-checker rapporter** — når crawleren finder brudte links - **Lighthouse-alerts** — når scores falder under tærskler Uden denne fane udfyldt falder CMS'et tilbage til environment-level defaults fra `.env`. Per-site konfiguration her overrider disse. ## Felterne | Felt | Formål | |---|---| | **Provider** | Hvilken email-service. P.t.: Resend | | **API-nøgle** | Din udbyders API-nøgle. Gemmes som site-level secret — aldrig eksponeret til redaktører | | **From-adresse** | `From:`-headeren på udgående mail. Skal være et domæne du har verificeret hos udbyderen | | **From-navn** | Visningsnavn på `From:`-headeren (f.eks. "Acme CMS") | | **Reply-to** | Hvor svar går hen. Ofte en inbox du faktisk overvåger | | **Notifikationsmodtagere** | Kommasepareret liste af emails der får system-alerts (build-fejl, scheduled backups, link-check rapporter) | ## Resend-opsætning 1. Opret en Resend-konto og verificér dit sending-domæne. 2. Opret en API-nøgle scoped til det domæne. 3. Indsæt nøglen i denne fane. 4. Send en test-email via **Test**-knappen. Testen sender en kort besked til den første notifikationsmodtager. Hvis den lander, er du færdig. ## Leveringsevne-tjekliste Hvis mails lander i spam, tjek i rækkefølge: - **SPF** — domænet DNS har `v=spf1 include:amazonses.com ~all` (eller Resends tilsvarende) - **DKIM** — Resends DKIM-records er i DNS og verificeret - **DMARC** — mindst `v=DMARC1; p=none;` til passiv overvågning - **From-domæne matcher sending-domæne** — send ikke `@webhouse.dk`-mail fra et ikke-webhouse-verificeret Resend-domæne Resends dashboard viser per-mail leveringsstatus og SPF/DKIM-verifikationsresultater. ## Per-form overrides Form-engine kan overrule modtagerlisten per formular via `notifications.email` i `cms.config.ts`: ```typescript forms: [ { name: 'contact', notifications: { email: ['sales@example.com', 'cc@example.com'], }, ... }, ], ``` Når sat, går indsendelser fra den formular til de listede modtagere i stedet for sitets default-notifikationsliste. ## Hvad der sker uden en udbyder Email-triggede features degraderer pænt: - Team-invitationer viser signup-URL'en på skærmen (kopier manuelt) - Formular-indsendelser gemmer stadig i inboxen, bare ingen mail - Agent- og backup-alerts logger til event-loggen men pinger ingen ## Relateret - [Forms](/docs/form-engine-da) — hvordan indgående formular-indsendelser virker - [Notifikationer & alerts](/docs/notifications-da) — den fulde liste af system-events der kan maile - [Agenter](/docs/agents-da) — agent-afslutningsnotifikationer --- ## docs/settings-email Title: Email settings Updated: 2026-04-15 Locale: en Transactional email — the From address, provider, and notification recipients for invites, form submissions, and alerts. ## Where it is **Settings → Email** (`/admin/settings?tab=email`). ## What the tab configures How outgoing email is sent from your site. The CMS sends email for: - **Team invites** — onboarding new members - **Form submissions** — the form engine emails a copy of every submission to the site owner - **Agent notifications** — alerts when a long-running agent finishes - **Backup alerts** — when a scheduled backup fails - **Link checker reports** — when the crawler finds broken links - **Lighthouse alerts** — when scores drop below thresholds Without this tab filled in, the CMS falls back to environment-level defaults from `.env`. Configuring per-site here overrides those. ## The fields | Field | Purpose | |---|---| | **Provider** | Which email service. Currently: Resend | | **API key** | Your provider's API key. Stored as a site-level secret — never exposed to editors | | **From address** | The `From:` header on outgoing mail. Must be a domain you've verified with the provider | | **From name** | Display name on the `From:` header (e.g. "Acme CMS") | | **Reply-to** | Where replies go. Often an inbox you actually monitor | | **Notification recipients** | Comma-separated list of emails that get system alerts (build failures, scheduled backups, link-check reports) | ## Resend setup 1. Create a Resend account and verify your sending domain. 2. Create an API key scoped to that domain. 3. Paste the key in this tab. 4. Send a test email using the **Test** button. The test sends a short message to the first notification recipient. If it lands, you're done. ## Deliverability checklist If mails land in spam, check in order: - **SPF** — domain DNS has `v=spf1 include:amazonses.com ~all` (or Resend's equivalent) - **DKIM** — Resend's DKIM records are in DNS and verified - **DMARC** — at least `v=DMARC1; p=none;` for passive monitoring - **From domain matches sending domain** — don't send `@webhouse.dk` mail from a non-webhouse-verified Resend domain Resend's dashboard shows per-mail delivery status and SPF/DKIM verification results. ## Per-form overrides The form engine can override the recipient list on a per-form basis via `notifications.email` in `cms.config.ts`: ```typescript forms: [ { name: 'contact', notifications: { email: ['sales@example.com', 'cc@example.com'], }, ... }, ], ``` When set, submissions from that form go to the listed recipients instead of the site's default notification list. ## What happens without a provider Email-triggered features degrade cleanly: - Team invites show the signup URL on screen (copy manually) - Form submissions still save to the inbox, just no email - Agent and backup alerts log to the event log but don't ping anyone ## Related - [Forms](/docs/form-engine) — how inbound form submissions work - [Notifications & alerts](/docs/notifications) — the full list of system events that can email - [Agents](/docs/agents) — agent completion notifications --- ## docs/settings-team-da Title: Team-settings Updated: 2026-04-15 Locale: da Invitér folk til dit site og tildel roller. Admin, editor og viewer — per-site, ikke per-konto. ## Hvor det er **Settings → Team** (`/admin/settings?tab=team`). ## Hvad fanen konfigurerer Hvem der kan tilgå dette site og hvad de kan gøre. Team-medlemskab er **per-site** — en person der er admin på ét site kan være viewer på et andet, eller slet ingen adgang have. Det er enheden af adgangskontrol. ## Roller Tre indbyggede roller: | Rolle | Kan gøre | |---|---| | **Admin** | Alt — invitere og fjerne medlemmer, ændre enhver indstilling, deploye, slette indhold, installere agenter, redigere skemaet | | **Editor** | Oprette, redigere, publicere og trashe dokumenter. Uploade medier. Køre agenter. Kan ikke ændre indstillinger eller invitere andre | | **Viewer** | Read-only. Kan preview sider og gennemse indhold, men kan ikke gemme redigeringer | Roller er bevidst grove. Finere granuleret permissions (per-collection, per-felt) er planlagt men ikke shippet. ## Invitér nogen 1. Klik **Invite member**. 2. Indtast deres email. 3. Vælg en rolle. 4. Send. Den inviterede får en email med et tilmeldingslink. Hvis de allerede har en webhouse.app-konto, giver invitationen adgang til dette site på deres eksisterende login. Hvis de ikke har, fører linket dem gennem kontooprettelse og derefter direkte ind i dit site. Invitationer udløber efter 7 dage. Gensend fra pending-listen. ## Ændre eller fjerne en rolle Klik på rolle-pillen ved siden af et medlems navn og vælg en ny. For at fjerne nogen helt, brug `×`-knappen — bekræfter inline (Remove? Yes / No) før de kickes ud. **Du kan ikke degradere eller fjerne den sidste admin.** UI'et blokerer det, så et site aldrig kan ende uden admin. ## Konto vs team-medlemskab Én webhouse.app-konto kan tilhøre teams på et hvilket som helst antal sites. Kontoen bærer: email, kodeord (eller passkey), 2FA-config. Team-medlemskabet på hvert site bærer: rolle, dato tilføjet, per-site præferencer. Det betyder at forlade et team ikke sletter din konto, og at slette din konto fjerner dig fra ethvert team automatisk. ## Hvad mobilappen ser Mobilappen bruger samme team-records. Hvis nogen har editor-adgang på et site via web-admin, har de editor-adgang fra telefonen også. Push-notifikations emne-præferencer er bundet til medlemskabet, så brugeren kan opt-out af `deploy_succeeded`-notifikationer på ét site uden at påvirke andre. ## Audit trail Hver invitation, rolleændring og fjernelse logges i audit-loggen (Settings → Event log når du aktiverer den). Nyttigt når du skal bevise hvem der havde adgang hvornår. ## Relateret - [Passwordless login](/docs/passwordless-login-da) — hvordan konto-auth virker (passkeys, TOTP) - [Permissions-system](/docs/permissions-da) — den lavere-niveau permission-model roller mapper til --- ## docs/settings-team Title: Team settings Updated: 2026-04-15 Locale: en Invite people to your site and assign roles. Admin, editor, and viewer — per-site, not per-account. ## Where it is **Settings → Team** (`/admin/settings?tab=team`). ## What the tab configures Who can access this site and what they can do. Team membership is **per-site** — a person who's an admin on one site can be a viewer on another, or have no access at all. That's the unit of access control. ## Roles Three built-in roles: | Role | Can do | |---|---| | **Admin** | Everything — invite and remove members, change any setting, deploy, delete content, install agents, edit the schema | | **Editor** | Create, edit, publish, and trash documents. Upload media. Run agents. Cannot change settings or invite others | | **Viewer** | Read-only. Can preview pages and browse content but cannot save edits | Roles are intentionally coarse. Finer-grained permissions (per-collection, per-field) are planned but not shipped. ## Inviting someone 1. Click **Invite member**. 2. Enter their email. 3. Pick a role. 4. Send. The invitee gets an email with a signup link. If they already have a webhouse.app account, the invite grants access to this site on their existing login. If they don't, the link takes them through account creation, then straight into your site. Invites expire after 7 days. Resend from the pending list. ## Changing or removing a role Click the role pill next to a member's name and pick a new one. To remove someone entirely, use the `×` button — confirms inline (Remove? Yes / No) before kicking them out. **You can't demote or remove the last admin.** The UI blocks it so a site can never end up admin-less. ## Account vs team membership One webhouse.app account can belong to teams on any number of sites. The account carries: email, password (or passkey), 2FA config. The team membership on each site carries: role, date added, per-site preferences. This means leaving a team doesn't delete your account, and deleting your account removes you from every team automatically. ## What the mobile app sees The mobile app uses the same team records. If someone has editor access on a site via the web admin, they have editor access from the phone too. Push-notification topic preferences are tied to the membership, so the user can opt out of `deploy_succeeded` notifications on one site without affecting others. ## Audit trail Every invite, role change, and removal is logged in the audit log (Settings → Event log once you enable it). Useful when you need to prove who had access when. ## Related - [Passwordless login](/docs/passwordless-login) — how account auth works (passkeys, TOTP) - [Permissions system](/docs/permissions) — the lower-level permission model that roles map to --- ## docs/help-and-shortcuts-da Title: Hjælp-panel, genveje & AI Chat-værktøjer Updated: 2026-04-15 Locale: da In-app Help & Support-drawer — dokumentationslinks, tastaturgenveje og en quick reference for alle værktøjer AI Chat kan kalde. ## Hvor du finder det Tryk `h` hvor som helst i admin, eller klik på **Help**-ikonet i site-headeren. En drawer glider ind fra højre med tre faner: **Help**, **Shortcuts** og **AI Chat**. ## Help-fane Fire destinationer til når noget er galt: | Link | Går til | |---|---| | Documentation | docs.webhouse.app (dette site) | | Troubleshooting | /docs/troubleshooting | | System status | status.webhouse.app | | Contact support | cms@webhouse.dk | ## Shortcuts-fane Hver tastaturgenvej er scoped til den overflade hvor den giver mening — enkelt-tast-genveje fyrer kun når intet input er fokuseret. ### Chat med dit site | Taster | Handling | |---|---| | `Ctrl + Shift + C` | Skift Chat / Admin-tilstand | | `/` | Fokusér chat-input | | `Cmd + Shift + N` | Ny samtale | ### Generelt | Taster | Handling | |---|---| | `Cmd + K` | Command palette | | `h` | Åbn Help & Support | | `p` | Preview site | | `d` | Deploy site | | `t` | Ny fane | | `c` | Luk fane | | `Cmd + Shift + ←` | Forrige fane | | `Cmd + Shift + →` | Næste fane | ### Dokumenteditor | Taster | Handling | |---|---| | `Cmd + S` | Gem dokument | | `Cmd + Shift + P` | Publicér | ### Collection-liste | Taster | Handling | |---|---| | `n` | Nyt element | | `g` | Generér med AI | ### Rich-text-editor | Taster | Handling | |---|---| | `Cmd + B` | Fed | | `Cmd + I` | Kursiv | | `Cmd + U` | Understreg | | `Cmd + Shift + X` | Gennemstreg | | `Cmd + Shift + 7` | Ordnet liste | | `Cmd + Shift + 8` | Punktliste | | `Cmd + Shift + B` | Blockquote | | `Cmd + Shift + E` | Code block | På Windows/Linux, udskift `Cmd` med `Ctrl`. ## AI Chat-fane — quick reference Det her er et kort over hvad AI Chat kan gøre når du er i Chat-tilstand. Du kalder dem ikke direkte — du spørger på naturligt sprog, og modellen vælger det rigtige værktøj. Listen er her så du ved hvad der er muligt. ### Indhold - **Site overview** — collections, antal dokumenter, settings på ét sted - **Search content** — fuldtekstsøgning på tværs af alle dokumenter - **List / read docs** — gennemse collections, læs fulde dokumenter - **Create / edit** — opret, opdatér, publicér, unpublish docs - **Clone document** — duplikér ethvert dokument som kladde - **Generate content** — AI-skriv eller omskriv ethvert felt - **Inline edit form** — redigér specifikke felter direkte i chatten - **Bulk publish** — publicér alle kladder på én gang - **Bulk update** — opdatér et felt på tværs af mange docs - **List drafts** — alle upublicerede dokumenter på tværs af collections - **Get schema** — inspicér collection-felter og blokdefinitioner - **Restore from trash** — gendan et trashed dokument ### Medier - **Search media** — find billeder ud fra AI-captions, tags, filnavn - **List media** — browse alle filer med AI-analysedata - **Upload** — upload billeder via `+`-knap eller drag & drop ### AI-agenter - **List agents** — se alle konfigurerede AI-agenter - **Create agent** — opsæt en ny copywriter, SEO eller custom agent - **Run agent** — udfør en agent med en prompt - **Curation queue** — gennemgå, godkend eller afvis AI-indhold - **Approve queue item** — publicér en AI-genereret kladde - **Reject queue item** — kassér eller returnér en kladde - **Agent templates** — start fra en template, gem nye - **Agent feedback** — accept / reject-statistikker per agent - **Agent budget** — sæt token- eller cost-lofter per agent - **Agent locale** — lås en agent til et specifikt sprog ### Workflows - **List / create / run / delete workflows** — kæd flere agenter sammen til en pipeline der kører på ét trin ### Formularer - **List submissions** — alle indsendelser per form - **Read submission** — læs en specifik indsendelse - **Form stats** — indsendelsesantal og konverteringsdata ### SEO & kvalitet - **Run Lighthouse** — mobile + desktop performance-audit - **Lighthouse scores** — nyeste scores for hver side - **Lighthouse history** — scores over tid ### Oversættelse - **Translate document** — AI-oversæt ét dokument til en lokalitet - **Translate site** — AI-oversæt alle dokumenter til en lokalitet ### Interaktiver - **Generate interactive** — AI-byg en custom HTML-widget - **Enable image generation** — slå AI-billedgenerering til/fra per site ### Hukommelse & web - **Search / add / forget memories** — chattens langtidshukommelse - **Web search** — live web-søgning via Brave eller Tavily - **Web fetch** — hent og læs en hvilken som helst URL i samtalen ### Operationer - **Deploy** — deploy site til konfigureret udbyder - **Build site** — genopbyg statiske sider - **Backup** — opret en backup nu - **Link checker** — tjek for brudte links - **Site settings** — vis og opdatér konfiguration - **Deploy history** — vis nylige deployments ### Scheduling & historik - **Schedule publish** — sæt fremtidig publicér/unpublish-dato - **Calendar** — se scheduled publishes/unpublishes - **Revisions** — vis dokumentets ændringshistorik - **Trash** — list trashed docs, gendan dem - **Content stats** — ordtællinger, AI-ratio, aktivitet ## Tips - Quick-listen er kurateret — chatten har et par flere tools der ikke vises her (intern plumbing). Spørg "what can you do?" i chat-tilstand for den live liste mod dit site. - Genveje er registreret globalt men fyrer kun på den rette overflade — du kan ikke ved et uheld trigge `n` (nyt element) mens du skriver i rich-text-editoren. - `h` åbner Help-panelet fra hvor som helst. Det er den hurtigste vej ind. --- ## docs/help-and-shortcuts Title: Help panel, shortcuts & AI Chat tools Updated: 2026-04-15 Locale: en In-app Help & Support drawer — documentation links, keyboard shortcuts, and a quick reference for every tool the AI Chat can call. ## Where to find it Press `h` anywhere in the admin, or click the **Help** icon in the site header. A drawer slides in from the right with three tabs: **Help**, **Shortcuts**, and **AI Chat**. ## Help tab Four destinations for when something's off: | Link | Goes to | |---|---| | Documentation | docs.webhouse.app (this site) | | Troubleshooting | /docs/troubleshooting | | System status | status.webhouse.app | | Contact support | cms@webhouse.dk | ## Shortcuts tab Every keyboard shortcut is scoped to the surface where it makes sense — single-key shortcuts only fire when no input is focused. ### Chat with Your Site | Keys | Action | |---|---| | `Ctrl + Shift + C` | Toggle Chat / Admin mode | | `/` | Focus chat input | | `Cmd + Shift + N` | New conversation | ### General | Keys | Action | |---|---| | `Cmd + K` | Command palette | | `h` | Open Help & Support | | `p` | Preview site | | `d` | Deploy site | | `t` | New tab | | `c` | Close tab | | `Cmd + Shift + ←` | Previous tab | | `Cmd + Shift + →` | Next tab | ### Document editor | Keys | Action | |---|---| | `Cmd + S` | Save document | | `Cmd + Shift + P` | Publish | ### Collection list | Keys | Action | |---|---| | `n` | New item | | `g` | Generate with AI | ### Rich-text editor | Keys | Action | |---|---| | `Cmd + B` | Bold | | `Cmd + I` | Italic | | `Cmd + U` | Underline | | `Cmd + Shift + X` | Strikethrough | | `Cmd + Shift + 7` | Ordered list | | `Cmd + Shift + 8` | Bullet list | | `Cmd + Shift + B` | Blockquote | | `Cmd + Shift + E` | Code block | On Windows/Linux, swap `Cmd` for `Ctrl`. ## AI Chat tab — tool quick reference This is a map of what the AI Chat can do when you're in Chat mode. You don't call these directly — you ask in natural language and the model picks the right tool. The list is here so you know what's possible. ### Content - **Site overview** — collections, doc counts, settings at a glance - **Search content** — full-text search across all documents - **List / read docs** — browse collections, read full documents - **Create / edit** — create, update, publish, unpublish docs - **Clone document** — duplicate any document as a draft - **Generate content** — AI-write or rewrite any field - **Inline edit form** — edit specific fields directly in chat - **Bulk publish** — publish all drafts at once - **Bulk update** — update a field across many docs - **List drafts** — all unpublished documents across collections - **Get schema** — inspect collection fields and block definitions - **Restore from trash** — recover a trashed document ### Media - **Search media** — find images by AI captions, tags, filename - **List media** — browse all files with AI analysis data - **Upload** — upload images via `+` button or drag & drop ### AI agents - **List agents** — view all configured AI agents - **Create agent** — set up a new copywriter, SEO, or custom agent - **Run agent** — execute an agent with a prompt - **Curation queue** — review, approve, or reject AI content - **Approve queue item** — publish an AI-generated draft - **Reject queue item** — discard or send back a draft - **Agent templates** — start from a template, save new ones - **Agent feedback** — accept / reject stats per agent - **Agent budget** — set token or cost ceilings per agent - **Agent locale** — lock an agent to a specific language ### Workflows - **List / create / run / delete workflows** — chain multiple agents into a pipeline that runs in one step ### Forms - **List submissions** — all form submissions per form - **Read submission** — read a specific submission - **Form stats** — submission counts and conversion data ### SEO & quality - **Run Lighthouse** — mobile + desktop performance audit - **Lighthouse scores** — latest scores for every page - **Lighthouse history** — scores over time ### Translation - **Translate document** — AI-translate one document to a locale - **Translate site** — AI-translate every document to a locale ### Interactives - **Generate interactive** — AI-build a custom HTML widget - **Enable image generation** — turn AI image gen on/off per site ### Memory & web - **Search / add / forget memories** — the chat's long-term memory - **Web search** — live web search via Brave or Tavily - **Web fetch** — fetch and read any URL in the conversation ### Operations - **Deploy** — deploy site to configured provider - **Build site** — rebuild static pages - **Backup** — create a backup right now - **Link checker** — check for broken links - **Site settings** — view and update configuration - **Deploy history** — view recent deployments ### Scheduling & history - **Schedule publish** — set future publish/unpublish date - **Calendar** — view scheduled publishes/unpublishes - **Revisions** — view document change history - **Trash** — list trashed docs, restore them - **Content stats** — word counts, AI ratio, activity ## Tips - The quick-list is curated — the chat has a few more tools not surfaced here (internal plumbing). Ask "what can you do?" in chat mode for the live list against your site. - Shortcuts are registered globally but only fire on the appropriate surface — you can't accidentally trigger `n` (new item) while typing in the richtext editor. - `h` opens the Help panel from anywhere. It's the fastest way in. --- ## docs/wordpress-migration-da Title: WordPress-migration (F03) Updated: 2026-04-15 Locale: da Probe et live WordPress-site, gennemgå tema/builder/indhold, og importér posts, pages, medier og taxonomier til et nyt @webhouse/cms site. ## Hvad det gør Peg på et offentligt WordPress-site, klik dig igennem en 4-trins wizard, og end med et helt nyt @webhouse/cms site der indeholder WP-indholdet som JSON-dokumenter plus downloadede medier. Fase 1 håndterer indholds- + medie-siden rent; tema/design-ekstraktion er Fase 2. Migrationen bor på **/admin/sites/new → WordPress-fanen**. ## Hvad der importeres - Posts, pages og custom post types - Mediefiler (billeder, PDF'er, downloads) — downloadet og gemt under `/uploads/` - Featured images, kædet til det importerede indlæg - Kategorier og tags - Excerpts, publiceringsstatus, publiceringsdato - Forfattere (som tekst — ikke som relations-collection i Fase 1) **Ikke importeret (Fase 2+):** - Kommentarer - Menuer / navigation - ACF / custom fields - Gutenberg-only blokke der ikke transformeres til ren HTML (beholdes som HTML men ikke struktureret) - Page-builder shortcodes (Divi, WPBakery) — de leaker som rå `[et_pb_...]`-tekst indtil Fase 2's HTML-scraping fallback lander ## Wizarden (4 trin) ### 1. Probe Indtast WordPress-sitets URL. Wizarden kalder WP REST API ('/wp-json/wp/v2/...') for at detektere: - Tema-navn og version - Page builder i brug (Elementor, Divi, WPBakery, Gutenberg, Classic) - Indholdsoversigt: post-antal per post type, medie-antal, taxonomi-antal Tager ~3–5 sekunder. Virker på ethvert self-hosted WordPress med REST API aktiveret (default siden WP 4.7 — ca. 90% af sites). wordpress.com-hostede sites understøttes ikke direkte; du ville have brug for REST API tilgængelig. ### 2. Gennemgang Gennemgå den detekterede metadata. Ingen indholds-preview endnu — det er en Fase 2-tilføjelse. Beslut om der skal fortsættes baseret på inventory-tallene. ### 3. Navngiv Giv det nye site et ID og visningsnavn, vælg hvilken organisation det skal tilføjes under. Wizarden auto-genererer `cms.config.ts` baseret på de opdagede post types — en WP custom post type kaldet `exhibitions` bliver en CMS-collection med samme navn. ### 4. Migrér Spinner-skærm. Wizarden: - Pagerer WP REST API (100 elementer per side, ingen forsinkelse mellem sider) - Downloader hver mediefil, slugifier filnavnet (f.eks. `photo-a1b2.jpg`), skriver til `public/uploads/` - Rewriter ``-URLs i indlægs-indhold fra `wp-content/uploads/...` til de nye `/uploads/...`-paths - Opretter ét JSON-dokument per post/page i `content//.json` - Skriver den genererede `cms.config.ts` med `urlPrefix` der matcher de originale WP-paths (så redirects du har opsat kan blive ved med at virke 1:1) - Registrerer sitet i CMS-registryet under den valgte org Varighed: ~30 sekunder for en lille blog, op til 5 minutter for et site med hundredvis af mediefiler. Ingen progress bar i Fase 1 — bare den endelige "Åbn site i CMS"-knap. ## Authentication Offentlige WP-sites kræver ingenting. For private sites (f.eks. `wp-admin`-beskyttede), understøtter wizarden WordPress application passwords: `brugernavn:app-password` sendt via HTTP Basic Auth. ## Hvad der skal tjekkes efter migration - **Brudte shortcodes** — hvis kilden brugte Divi/WPBakery/Elementor, vil du se rå shortcode-tekst i importeret indhold. Enten ryd manuelt op eller vent på Fase 2's HTML-scraping. - **Forfatter-links** — forfattere kommer ind som tekst. Hvis du vil have relationelle forfattere, tilføj en `team`-collection og omskriv `author`-feltet som en relation. - **Custom fields** — ACF-felter droppes. Tjek WP-admin-kilden for felter du har brug for, og tilføj dem igen som @webhouse/cms-felter i `cms.config.ts` (re-eksportér `webhouse-schema.json` hvis sitet har ikke-TS consumers). - **URL-prefix** — verificér at `urlPrefix` matcher den originale struktur så dine gamle URLs stadig resolver. - **Billeder med tekst i** — de downloadede billeder er byte-identiske kopier, ingen alt-tekst udledt. Kør media AI-analyse for at generere alt-tekst i bulk. ## Fase 2+ roadmap - Design-token ekstraktion via Dembrandt (farver, fonts, spacing-skala) - Tailwind-config auto-generering fra udtrukne tokens - HTML-scraping fallback til page-builder sites (Divi, WPBakery) - WXR XML-import (WP export-fil) som offline-alternativ - Custom field mapping UI - Indholds-preview før commit Fase 1 er den sikre baseline — den gør intet uventet, og alt den importerer er tabsløst mod WP REST API-responsen. --- ## docs/wordpress-migration Title: WordPress migration (F03) Updated: 2026-04-15 Locale: en Probe a live WordPress site, review its theme/builder/content, and import posts, pages, media and taxonomies into a fresh @webhouse/cms site. ## What it does Point at a public WordPress site, click through a 4-step wizard, and end up with a brand-new @webhouse/cms site containing the WP content as JSON documents plus downloaded media. Phase 1 handles the content + media side cleanly; theme/design extraction is Phase 2. The migration lives at **/admin/sites/new → WordPress tab**. ## What gets imported - Posts, pages, and custom post types - Media files (images, PDFs, downloads) — downloaded and stored under `/uploads/` - Featured images, wired to the imported post - Categories and tags - Excerpts, publish status, publish date - Authors (as text — not as a relation collection in Phase 1) **Not imported (Phase 2+):** - Comments - Menus / navigation - ACF / custom fields - Gutenberg-only blocks that don't transform to clean HTML (kept as HTML but not structured) - Page-builder shortcodes (Divi, WPBakery) — they leak as raw `[et_pb_...]` text until Phase 2's HTML-scraping fallback lands ## The wizard (4 steps) ### 1. Probe Enter the WordPress site URL. The wizard calls the WP REST API (`/wp-json/wp/v2/...`) to detect: - Theme name and version - Page builder in use (Elementor, Divi, WPBakery, Gutenberg, Classic) - Content inventory: post counts per post type, media counts, taxonomy counts Takes ~3–5 seconds. Works on any self-hosted WordPress with the REST API enabled (default since WP 4.7 — about 90% of sites). wordpress.com hosted sites are not supported directly; you'd need the REST API accessible. ### 2. Review Review the detected metadata. No content preview yet — that's a Phase 2 addition. Decide whether to continue based on the inventory numbers. ### 3. Name Give the new site an ID and display name, pick which organization to add it under. The wizard will auto-generate `cms.config.ts` based on the discovered post types — a WP custom post type called `exhibitions` becomes a CMS collection with the same name. ### 4. Migrate Spinner screen. The wizard: - Paginates the WP REST API (100 items per page, no delay between pages) - Downloads each media file, slugifies the filename (e.g. `photo-a1b2.jpg`), writes to `public/uploads/` - Rewrites `` URLs in post content from `wp-content/uploads/...` to the new `/uploads/...` paths - Creates one JSON document per post/page in `content//.json` - Writes the generated `cms.config.ts` with `urlPrefix` matching the original WP paths (so any redirects you set up can keep working 1:1) - Registers the site in the CMS registry under the chosen org Duration: ~30 seconds for a small blog, up to 5 minutes for a site with hundreds of media files. No progress bar in Phase 1 — just the final "Open site in CMS" button. ## Authentication Public WP sites need nothing. For private sites (e.g. `wp-admin`-protected), the wizard supports WordPress application passwords: `username:app-password` passed via HTTP Basic Auth. ## What to check after migration - **Broken shortcodes** — if the source used Divi/WPBakery/Elementor, you'll see raw shortcode text in imported content. Either manually clean up or wait for Phase 2 HTML scraping. - **Author links** — authors come in as text. If you want relational authors, add a `team` collection and rewrite the `author` field as a relation. - **Custom fields** — ACF fields are dropped. Check the WP admin source for fields you need and re-add them as @webhouse/cms fields in `cms.config.ts` (re-exporting `webhouse-schema.json` if the site has non-TS consumers). - **URL prefix** — verify `urlPrefix` matches the original structure so your old URLs still resolve. - **Images with text in them** — the downloaded images are byte-identical copies, no alt text inferred. Run the media AI analysis to generate alt text in bulk. ## Phase 2+ roadmap - Design token extraction via Dembrandt (colors, fonts, spacing scale) - Tailwind config auto-generation from extracted tokens - HTML scraping fallback for page-builder sites (Divi, WPBakery) - WXR XML import (WP export file) as an offline alternative - Custom field mapping UI - Content preview before commit Phase 1 is the safe baseline — it won't do anything unexpected, and everything it does import is lossless against the WP REST API response. --- ## docs/icd-and-docker-da Title: Instant Content Deployment & Docker Deploy (F126) Updated: 2026-04-15 Locale: da To deploy-veje: ICD til 2-sekunders indholds-push via ISR-revalidation, og one-click Docker-deploys til Fly.io fra template. F126 leverer to separate deploy-mekanismer. De løser forskellige problemer: **ICD** gør indhold live på ~2 sekunder uden en fuld rebuild; **One-Click Docker** opretter en hel CMS-instans på Fly.io fra en template med få klik. ## Instant Content Deployment (ICD) ### Hvad det gør Når en redaktør gemmer indhold, pinger ICD dit sites revalidation-endpoint med et signeret webhook i stedet for at trigge den fulde build+deploy-pipeline. Din Next.js (eller anden ISR-kapabel) frontend regenererer de påvirkede sider on demand. Indhold er live om **~2 sekunder** i stedet for 5–10 minutter. ### Hvordan det virker 1. Redaktør gemmer et dokument. 2. CMS beregner påvirkede paths ud fra collectionens `urlPrefix` (f.eks. gemning af et indlæg i `posts`-collectionen med `urlPrefix: /blog` påvirker `/blog/my-post` og `/blog`). 3. Et signeret HMAC-SHA256 POST går til den konfigurerede `revalidateUrl` med: - Headers: `X-CMS-Event: content.revalidate`, `X-CMS-Signature: sha256=` - Body: `{ collection, slug, action, paths, document }` 4. Hvis frontend returnerer 2xx, springes auto-deploy over. Hvis den fejler, falder systemet tilbage til den fulde deploy-pipeline. 5. De sidste 50 leveringer logges (timestamp, paths, status, varighed) til fejlfinding. ### Opsætning To settings i CMS admin: - **Settings → Revalidation** — konfigurér `revalidateUrl` og signerings-secret. Test-knappen verificerer at endpointet svarer korrekt. - **Settings → Deploy** — toggle **Deploy on Save** for at aktivere ICD. På frontend-siden, implementér `POST /api/revalidate` der verificerer signaturen og kalder Next.js `revalidatePath(path)` for hver path i payloadet. En reference-implementation leveres med `next-js-boilerplate`. ### Hvornår ICD kører vs fuld deploy | Handling | Trigger | |---|---| | Gem richtext / felt-redigering | Kun ICD | | Publicér / unpublish | Kun ICD | | Upload media | ICD (med media-URL i payload) | | Config-ændring (cms.config.ts) | Fuld deploy | | Ny collection | Fuld deploy | | Theme/build.ts-ændring | Fuld deploy | ### Understøttede stacks Enhver frontend med on-demand revalidation: Next.js (App Router eller Pages Router), Remix med route-revalidation, custom caches der eksponerer et invalidate-by-path webhook. Virker på Vercel, Netlify, Fly.io, self-hosted — CMS'et bekymrer sig ikke om hvor din frontend kører. ## One-Click Docker Deploy ### Hvad det gør En 4-trins wizard på `/admin/deploy/docker` der opretter en helt ny CMS-instans på Fly.io fra en template. Ingen lokal Docker påkrævet, ingen manuel `fly launch`, ingen secret-jonglering. ### Wizarden 1. **Template-vælger** — 12 indbyggede templates: `blog`, `landing`, `agency`, `portfolio`, `portfolio-squared`, `freelancer`, `boutique`, `studio`, `bridgeberg`, `cmsdemo`, `static-boilerplate`, `nextjs-boilerplate`. Hver viser et screenshot og kort beskrivelse. 2. **Konfigurér** — app-navn, region (`arn` Stockholm som default, også `sea`, `sin`, osv.), VM-størrelse (`shared-cpu-1x` / `shared-cpu-2x` / `dedicated-1x`), admin-email. 3. **Forbind Fly.io** — indsæt din Fly auth-token; wizarden verificerer den via Fly GraphQL API. 4. **Deploy** — real-time SSE-stream der viser: app-oprettelse → secret-opsætning (`CMS_CONFIG_PATH`, `NEXTAUTH_SECRET`, AI-nøgler) → machine-allokering → IP-tildeling → health check. Wizarden bruger det præ-byggede `ghcr.io/webhousecode/cms-admin` image — ingen lokal Docker-build påkrævet. Template-filer hentes fra GitHub ved deploy-tid, så du får den seneste boilerplate-version uden at re-downloade CMS-repoet. ### Custom build-kommandoer (`build.command`) One-Click Docker opretter CMS-instanser. Til custom frameworks (Hugo, Laravel, Django, Rails) på dine egne hosts, brug `build.command` i `cms.config.ts`: ```typescript build: { command: 'hugo --minify', outDir: 'public', docker: 'hugo', // preset: { image: 'klakegg/hugo:ext-alpine', workdir: '/workspace' } } ``` Docker-presets: `php`, `laravel`, `python`, `django`, `ruby`, `rails`, `go`, `hugo`, `node`, `dotnet`. Hver ekspanderer til en `{ image, workdir }`-config; du kan også sende det fulde objekt direkte. ### Build-profiler Flere targets per site — dev vs produktion, JAR vs statisk osv.: ```typescript build: { profiles: [ { name: 'dev', command: 'npm run dev', outDir: 'dist', description: 'Hurtigt lokalt' }, { name: 'prod', command: 'mvn package', outDir: 'target', description: 'Production JAR' }, ], defaultProfile: 'prod', } ``` Når profiler er konfigureret, viser Build-knappen i CMS admin en dropdown til at vælge hvilken profil der skal køres. Hver profil får sin egen build-historik og output-mappe. ## Hvornår der skal bruges hvad | Opgave | Værktøj | |---|---| | Indholds-redigering på et eksisterende site | ICD | | Config/skema-ændring | Fuld deploy | | Oprette en ny CMS-instans | One-Click Docker | | Bygge et site med et ikke-TS framework | `build.command` + Docker-preset | | Multi-environment builds | Build-profiler | ICD er aktiveret som default når først et revalidation-endpoint er konfigureret. One-Click Docker er en engangs provisioning-handling. Build-kommandoer og profiler er per-site config — skib dem i `cms.config.ts` og de er aktive med det samme. --- ## docs/icd-and-docker Title: Instant Content Deployment & Docker Deploy (F126) Updated: 2026-04-15 Locale: en Two deploy paths: ICD for 2-second content pushes via ISR revalidation, and one-click Docker deploys to Fly.io from template. F126 ships two separate deploy mechanisms. They solve different problems: **ICD** makes content go live in ~2 seconds without a full rebuild; **One-Click Docker** spins up an entire CMS instance on Fly.io from a template in a few clicks. ## Instant Content Deployment (ICD) ### What it does When an editor saves content, ICD pings your site's revalidation endpoint with a signed webhook instead of triggering the full build+deploy pipeline. Your Next.js (or other ISR-capable) frontend regenerates the affected pages on demand. Content is live in **~2 seconds** instead of 5–10 minutes. ### How it works 1. Editor saves a document. 2. CMS computes affected paths from the collection's `urlPrefix` (e.g. saving a post in `posts` collection with `urlPrefix: /blog` affects `/blog/my-post` and `/blog`). 3. A signed HMAC-SHA256 POST goes to the configured `revalidateUrl` with: - Headers: `X-CMS-Event: content.revalidate`, `X-CMS-Signature: sha256=` - Body: `{ collection, slug, action, paths, document }` 4. If the frontend returns 2xx, auto-deploy is skipped. If it fails, the system falls back to the full deploy pipeline. 5. The last 50 deliveries are logged (timestamp, paths, status, duration) for troubleshooting. ### Setup Two settings in CMS admin: - **Settings → Revalidation** — configure `revalidateUrl` and the signing secret. Test button verifies the endpoint responds correctly. - **Settings → Deploy** — toggle **Deploy on Save** to enable ICD. On the frontend side, implement `POST /api/revalidate` that verifies the signature and calls Next.js `revalidatePath(path)` for each path in the payload. A reference implementation ships with `next-js-boilerplate`. ### When ICD runs vs full deploy | Action | Triggers | |---|---| | Save richtext / field edit | ICD only | | Publish / unpublish | ICD only | | Upload media | ICD (with media URL in payload) | | Config change (cms.config.ts) | Full deploy | | New collection | Full deploy | | Theme/build.ts change | Full deploy | ### Supported stacks Any frontend with on-demand revalidation: Next.js (App Router or Pages Router), Remix with route revalidation, custom caches that expose an invalidate-by-path webhook. Works on Vercel, Netlify, Fly.io, self-hosted — the CMS doesn't care where your frontend runs. ## One-Click Docker Deploy ### What it does A 4-step wizard at `/admin/deploy/docker` that provisions a brand-new CMS instance on Fly.io from a template. No local Docker required, no manual `fly launch`, no secret juggling. ### The wizard 1. **Template picker** — 12 built-in templates: `blog`, `landing`, `agency`, `portfolio`, `portfolio-squared`, `freelancer`, `boutique`, `studio`, `bridgeberg`, `cmsdemo`, `static-boilerplate`, `nextjs-boilerplate`. Each shows a screenshot and short description. 2. **Configure** — app name, region (`arn` Stockholm by default, also `sea`, `sin`, etc.), VM size (`shared-cpu-1x` / `shared-cpu-2x` / `dedicated-1x`), admin email. 3. **Connect Fly.io** — paste your Fly auth token; the wizard verifies it via the Fly GraphQL API. 4. **Deploy** — real-time SSE stream showing: app creation → secret setup (`CMS_CONFIG_PATH`, `NEXTAUTH_SECRET`, AI keys) → machine allocation → IP assignment → health check. The wizard uses the pre-built `ghcr.io/webhousecode/cms-admin` image — no local Docker build required. Template files are fetched from GitHub at deploy time, so you get the latest boilerplate version without re-downloading the CMS repo. ### Custom build commands (`build.command`) One-Click Docker provisions CMS instances. For custom frameworks (Hugo, Laravel, Django, Rails) on your own hosts, use `build.command` in `cms.config.ts`: ```typescript build: { command: 'hugo --minify', outDir: 'public', docker: 'hugo', // preset: { image: 'klakegg/hugo:ext-alpine', workdir: '/workspace' } } ``` Docker presets: `php`, `laravel`, `python`, `django`, `ruby`, `rails`, `go`, `hugo`, `node`, `dotnet`. Each expands to a `{ image, workdir }` config; you can also pass the full object directly. ### Build profiles Multiple targets per site — dev vs production, JAR vs static, etc.: ```typescript build: { profiles: [ { name: 'dev', command: 'npm run dev', outDir: 'dist', description: 'Fast local' }, { name: 'prod', command: 'mvn package', outDir: 'target', description: 'Production JAR' }, ], defaultProfile: 'prod', } ``` When profiles are configured, the Build button in CMS admin shows a dropdown to pick which profile to run. Each profile gets its own build history and output directory. ## When to use which | Task | Tool | |---|---| | Content edit on an existing site | ICD | | Config/schema change | Full deploy | | Spin up a new CMS instance | One-Click Docker | | Build a site with a non-TS framework | `build.command` + Docker preset | | Multi-environment builds | Build profiles | ICD is on by default once a revalidation endpoint is configured. One-Click Docker is a one-time provisioning action. Build commands and profiles are per-site config — ship them in `cms.config.ts` and they're active immediately. --- ## docs/mobile-app-da Title: Mobilapp (F07) Updated: 2026-04-15 Locale: da Native iOS & Android app der redigerer ethvert @webhouse/cms server. Capacitor shell, React 19, JWT-auth, QR-pairing, push-notifikationer. ## Hvad det er webhouse.app er en native mobilcompanion til CMS'et. Den redigerer indhold, gennemser medier, kører chat og modtager push-notifikationer — mod enhver @webhouse/cms server du peger den mod. Én TypeScript-kodebase kompilerer til iOS IPA og Android AAB via Capacitor 8. Den er en **server-agnostisk** klient: appen taler til `/api/mobile/*`-endpoints via Bearer JWT og gør ingen antagelser om et bestemt bundle id, server-version eller brand. Du (eller en whitelabel-forhandler) kan rebrande samme shell under et andet navn — cms-admin vil ikke mærke det. ## Stack | Lag | Valg | |---|---| | Shell | Capacitor 8 (native iOS + Android) | | UI | React 19 + Vite 5 + Tailwind v3 | | Routing | wouter (1.5 KB) | | State | TanStack Query | | Animation | framer-motion 11 | | Formularer | React Hook Form + Zod | | QR-scanning | jsQR (via getUserMedia, intet native plugin) | | Biometrik | @capgo/capacitor-native-biometric | | Push | @capacitor/push-notifications + Firebase Cloud Messaging | Ikke React Native, ikke Expo. Native-følelse via Capacitor, native performance på hver platform, én kodebase at vedligeholde. ## Hvad der er shippet - **Onboarding** — bruger indtaster sin egen CMS server-URL (BYO-server) - **QR pairing login** — live kamera-scanner læser en 5-minutters pairing-token fra desktop-admin, udveksles for JWT - **Email/kodeord fallback** — `/api/mobile/login` for konti uden TOTP - **Biometric unlock** — Face ID / Touch ID ved 2. launch, silent re-auth med gemt JWT - **Home-skærm** — avatar, org-dropdown, site-liste - **Live preview** — telefon-sikker preview af localhost dev-servere via signeret proxy (`/api/mobile/preview-proxy`) - **Indholdsredigering** — pages, posts, collections. Richtext, billedupload med AI-analyse, relation-picker, array/object-editors - **Media-browser** — Photos-style thumbnail-grid med AI-analyse - **AI chat** — konversationel CMS-adgang som floating action button - **Push-notifikationer** — 6 emner, per-bruger emne-præferencer (build_failed, build_succeeded, agent_completed, curation_pending, link_check_failed, scheduled_publish) - **Swipe-back navigation** — venstre-kant swipe for at gå tilbage (native iOS-følelse) - **Settings** — emne-toggles, tilladelsesstatus, enhedsstyring, log ud ## Authentication **Bearer JWT i `Authorization`-header — aldrig cookies.** Token'en mint'es af samme `createToken(user)` helper som web-admin, så audit-sporet er unified. Tokens gemmes i Capacitor Preferences (iOS Keychain / Android EncryptedSharedPreferences). To login-veje: - **QR pairing** — scan en QR fra desktop-admin, app'en kalder `POST /api/mobile/pair/exchange`, får en JWT tilbage på ét trin. Virker med TOTP-beskyttede konti fordi desktop allerede har gennemført hele 2FA-flow'et. - **Email/kodeord** — `POST /api/mobile/login`, returnerer JWT + brugerprofil. Begge routes validerer på samme session-sti som web — ingen parallel auth-system at vedligeholde. ## API-overflade Hvert mobile-endpoint ligger under `/api/mobile/*` og validerer Bearer JWT via `getMobileSession(req)`. | Route | Formål | |---|---| | `GET /api/mobile/ping` | Server-identitets-tjek (ingen auth — onboarding) | | `POST /api/mobile/login` | Email/kodeord → JWT | | `POST /api/mobile/pair` | Udsted 5-min QR pairing-token (desktop session) | | `POST /api/mobile/pair/exchange` | Udveksl pairing-token → JWT | | `GET /api/mobile/quick-pair` | Én-URL phone-safari pairing (auto-detekterer LAN IP) | | `GET /api/mobile/me` | Autentificeret bruger + sites + permissions | | `GET /api/mobile/preview-proxy?upstream=…&tok=…` | Signeret proxy for localhost-preview | | `POST /api/mobile/push/register` | Registrér FCM/APNs device-token | | `POST /api/mobile/push/preferences` | Emne opt-in/opt-out | | `POST /api/mobile/push/test` | Test push-levering | | `GET\|POST /api/mobile/content/*` | Hent/redigér docs, resolve collections | | `POST /api/mobile/uploads` | Media-upload med auto-analyse | | `POST /api/mobile/chat/*` | Chat-historik, hukommelse, streaming | Alle responses er JSON. Ingen HTML-redirects, ingen cookies, CORS-permissive for `capacitor://localhost`, `https://localhost`, `ionic://localhost`. ## LAN IP pairing (dev) En subtil detalje der sparer meget tid. Under lokal udvikling kører desktop på `https://localhost:3010`. En telefon på samme wifi kan ikke nå localhost — den har brug for din Macs LAN IP (f.eks. `192.168.1.42`). `/api/mobile/pair` og `/api/mobile/me` auto-detekterer Macens første non-loopback IPv4 og rewriter server-URLs i responsen. Telefonen får `https://192.168.1.42:3010` uden nogen manuel konfiguration. `/api/mobile/quick-pair` indbygger dette i det emittede deep link, så ét tryk på telefonen parrer både LAN IP og JWT. ## Push-notifikationer Seks emner konfigureret out of the box, hver med per-bruger opt-in/opt-out: | Emne | Default | Fyres når | |---|---|---| | `build_failed` | ON | Deploy fejler | | `build_succeeded` | OFF | Deploy lykkes | | `agent_completed` | ON | Agent-kørsel er færdig | | `curation_pending` | ON | Nyt element i curation-kø | | `link_check_failed` | ON | Brudt link opdaget | | `scheduled_publish` | ON | Indhold auto-publiceret | Levering via Firebase Cloud Messaging (iOS + Android). Enheden kalder `POST /api/mobile/push/register` efter OS'et har givet en token. Tokens gemmes per-bruger i `_data/device_tokens.json`. ## Build & kør Fra repo-rod: ```bash pnpm install pnpm webhouse.app:ios # booter iOS sim, builder Vite, cap syncer, åbner app pnpm webhouse.app:android # det samme for Android emulator ``` Fallback-navne hvis pnpm afviser prikker i aliaser: ```bash pnpm wha:ios pnpm wha:android ``` Inde i `packages/cms-mobile/`: ```bash pnpm build # Vite → dist/ pnpm cap:sync # sync native ændringer pnpm dev # lokal Vite dev-server (web-debugging) pnpm typecheck # TS-validering pnpm sim:login # auto-login helper til iOS sim ``` ## Aktuel status Fase 3+ er shippet — indholdsredigering, medier, chat, push, QR-login, LAN-pairing er alle live i dev-kanalen. **Endnu ikke i App Store eller Play Store.** Fase 8 er TestFlight-betaen + offentlig udgivelse. En blocker før App Store-submission: `NSAllowsArbitraryLoads` i `Info.plist` skal fjernes (HTTPS-only). `preflight-release.sh`-scriptet gater på dette. --- ## docs/mobile-app Title: Mobile app (F07) Updated: 2026-04-15 Locale: en Native iOS & Android app that edits any @webhouse/cms server. Capacitor shell, React 19, JWT auth, QR pairing, push notifications. ## What it is webhouse.app is a native mobile companion for the CMS. It edits content, browses media, runs chat, and receives push notifications — against any @webhouse/cms server you point it at. One TypeScript codebase compiles to iOS IPA and Android AAB via Capacitor 8. It's a **server-agnostic** client: the app talks to `/api/mobile/*` endpoints via Bearer JWT and makes no assumptions about a specific bundle id, server version, or brand. You (or a whitelabel reseller) can repackage the same shell under a different name — cms-admin won't notice. ## Stack | Layer | Choice | |---|---| | Shell | Capacitor 8 (native iOS + Android) | | UI | React 19 + Vite 5 + Tailwind v3 | | Routing | wouter (1.5 KB) | | State | TanStack Query | | Animation | framer-motion 11 | | Forms | React Hook Form + Zod | | QR scanning | jsQR (via getUserMedia, no native plugin) | | Biometric | @capgo/capacitor-native-biometric | | Push | @capacitor/push-notifications + Firebase Cloud Messaging | Not React Native, not Expo. Native-feeling via Capacitor, native performance on each platform, one codebase to maintain. ## What's shipped - **Onboarding** — user enters their own CMS server URL (BYO-server) - **QR pairing login** — live camera scanner reads a 5-minute pairing token from the desktop admin, exchanges for JWT - **Email/password fallback** — `/api/mobile/login` for non-TOTP accounts - **Biometric unlock** — Face ID / Touch ID on 2nd launch, silent re-auth with stored JWT - **Home screen** — avatar, org dropdown, site list - **Live preview** — phone-safe preview for localhost dev servers via signed proxy (`/api/mobile/preview-proxy`) - **Content editing** — pages, posts, collections. Richtext, image upload with AI analysis, relation picker, array/object editors - **Media browser** — Photos-style thumbnail grid with AI analysis - **AI chat** — conversational CMS access as a floating action button - **Push notifications** — 6 topics, per-user topic preferences (build_failed, build_succeeded, agent_completed, curation_pending, link_check_failed, scheduled_publish) - **Swipe-back navigation** — left-edge swipe to go back (native iOS feel) - **Settings** — topic toggles, permission status, device management, sign out ## Authentication **Bearer JWT in the `Authorization` header — never cookies.** The token is minted by the same `createToken(user)` helper as the web admin, so the audit trail is unified. Tokens are stored in Capacitor Preferences (iOS Keychain / Android EncryptedSharedPreferences). Two login paths: - **QR pairing** — scan a QR from the desktop admin, app calls `POST /api/mobile/pair/exchange`, gets a JWT back in one step. Works with TOTP-protected accounts because the desktop has already done the full 2FA flow. - **Email/password** — `POST /api/mobile/login`, returns JWT + user profile. Both routes validate on the same session path as web — no parallel auth system to maintain. ## API surface Every mobile endpoint lives under `/api/mobile/*` and validates Bearer JWT via `getMobileSession(req)`. | Route | Purpose | |---|---| | `GET /api/mobile/ping` | Server identity check (no auth — onboarding) | | `POST /api/mobile/login` | Email/password → JWT | | `POST /api/mobile/pair` | Issue 5-min QR pairing token (desktop session) | | `POST /api/mobile/pair/exchange` | Exchange pairing token → JWT | | `GET /api/mobile/quick-pair` | One-URL phone-safari pairing (auto-detects LAN IP) | | `GET /api/mobile/me` | Authenticated user + sites + permissions | | `GET /api/mobile/preview-proxy?upstream=…&tok=…` | Signed proxy for localhost preview | | `POST /api/mobile/push/register` | Register FCM/APNs device token | | `POST /api/mobile/push/preferences` | Topic opt-in/opt-out | | `POST /api/mobile/push/test` | Test push delivery | | `GET\|POST /api/mobile/content/*` | Fetch/edit docs, resolve collections | | `POST /api/mobile/uploads` | Media upload with auto-analysis | | `POST /api/mobile/chat/*` | Chat history, memory, streaming | All responses are JSON. No HTML redirects, no cookies, CORS-permissive for `capacitor://localhost`, `https://localhost`, `ionic://localhost`. ## LAN IP pairing (dev) A subtle detail that saves a lot of time. During local development the desktop runs on `https://localhost:3010`. A phone on the same wifi can't reach localhost — it needs your Mac's LAN IP (e.g. `192.168.1.42`). `/api/mobile/pair` and `/api/mobile/me` auto-detect the Mac's first non-loopback IPv4 and rewrite server URLs in the response. The phone gets `https://192.168.1.42:3010` without any manual configuration. `/api/mobile/quick-pair` bakes this into the emitted deep link so a single tap on the phone pairs both the LAN IP and the JWT. ## Push notifications Six topics configured out of the box, each with per-user opt-in/opt-out: | Topic | Default | Fires when | |---|---|---| | `build_failed` | ON | Deploy fails | | `build_succeeded` | OFF | Deploy succeeds | | `agent_completed` | ON | Agent run finishes | | `curation_pending` | ON | New item in curation queue | | `link_check_failed` | ON | Broken link detected | | `scheduled_publish` | ON | Content auto-published | Delivery via Firebase Cloud Messaging (iOS + Android). The device calls `POST /api/mobile/push/register` after the OS grants a token. Tokens are stored per-user in `_data/device_tokens.json`. ## Build & run From the repo root: ```bash pnpm install pnpm webhouse.app:ios # boots iOS sim, builds Vite, cap syncs, opens app pnpm webhouse.app:android # same for Android emulator ``` Fallback names if pnpm rejects dots in aliases: ```bash pnpm wha:ios pnpm wha:android ``` Inside `packages/cms-mobile/`: ```bash pnpm build # Vite → dist/ pnpm cap:sync # sync native changes pnpm dev # local Vite dev server (web debugging) pnpm typecheck # TS validation pnpm sim:login # auto-login helper for iOS sim ``` ## Current status Phase 3+ is shipped — content editing, media, chat, push, QR login, LAN pairing all live in the dev channel. **Not yet in the App Store or Play Store.** Phase 8 is the TestFlight beta + public release. A blocker before App Store submission: `NSAllowsArbitraryLoads` in `Info.plist` must be removed (HTTPS-only). The `preflight-release.sh` script gates on this. --- ## docs/brand-voice-da Title: Brand Voice Updated: 2026-04-15 Locale: da Per-site, locale-aware brand voice-konfiguration der indsprøjtes i enhver AI-prompt — chat, agenter, generering, SEO. ## Hvad det er Brand Voice er en struktureret beskrivelse af hvordan dit site skal lyde. Det er en per-site konfiguration som CMS'et indsprøjter i enhver AI-prompt — chat, agenter, AI-indholdsgenerering, SEO-optimering, omskrivninger. I stedet for at gentage "skriv i en venlig tone, undgå jargon, nævn vores pillars" i hver chat-besked, beskriver du din voice én gang, og så arver enhver downstream AI-opgave den. ## Felterne | Felt | Hvad det fanger | |---|---| | `name`, `industry`, `description` | Grundlæggende brand-metadata | | `language`, `targetAudience` | Lokaliseringskontekst — hvem sitet taler til | | `primaryTone` | Én-linje voice-opsummering (f.eks. "Teknisk, neutral, faktuel") | | `brandPersonality` | 3–5 adjektiver (f.eks. `["pragmatisk", "præcis", "varm"]`) | | `contentGoals` | Hvad indholdet forsøger at opnå | | `contentPillars` | Strategiske emner brandet dækker | | `avoidTopics` | Emner / formuleringer AI'en skal holde sig fra | | `seoKeywords` | Målkeywords givet til SEO-optimeren | | `examplePhrases` | Voice-eksempler — faktiske sætninger der lyder som brandet | ## Konfigurér via interview Den hurtigste vej er det guidede AI-interview på **Settings → Brand Voice**. Claude spiller rollen som brand-strateg og stiller spørgsmål om din virksomhed, målgruppe og mål. Til sidst genererer den JSON'en, viser en preview og gemmer som en ny version. Du kan også redigere felterne direkte i admin-formularen eller indsætte et JSON-payload. ## Lokaliserings-bevidst struktur Brand Voice har én primær post plus en cached variant per lokalitet. Når en AI-consumer anmoder om voicen for `locale=da`, gør CMS'et: 1. Hvis primærsprog matcher `da`, returnerer den primære post. 2. Ellers, tjek `brand-voice-da.json` i `_data/`. 3. Hvis ingen cache findes, kald `/api/cms/brand-voice/translate` for at auto-oversætte den primære post til dansk og skriv resultatet til `brand-voice-da.json`. 4. Returnér den lokaliserede post. Redigering af den primære post kan (valgfrit) invalidere lokalitets-caches — efterfølgende AI-kald regenererer oversættelsen on demand. ## Hvad der bruger Brand Voice - **Chat system prompt** — hele Brand Voice-blokken indsprøjtes øverst i enhver Chat-samtale via `gatherSiteContext()` og `buildChatSystemPrompt()`. - **Agent runner** — langvarige content-agenter (SEO, omskriv, oversæt, generér) inkluderer brand voicen i deres prompts automatisk. - **AI generate route** — `/api/cms/ai/generate` pakker brugerens request med voice-konteksten før den afsendes til LLM'en. - **SEO-optimizer** — bruger `seoKeywords` som keyword-listen der optimeres mod. Injection-formatet er en markdown `## Brand Voice`-blok produceret af `brandVoiceToPromptContext()`. Enhver AI-consumer kalder den helper — så at tilføje en ny consumer er én linje kode. ## Versioner Hver gem opretter en ny version. Admin viser historikken og lader dig rulle tilbage: `activeId` i `brand-voice.json` peger på den aktuelle version, `versions[]` holder den fulde ændringshistorik. Nyttigt når du itererer på tonen og vil fortryde — eller A/B-teste to voice-udkast. ## Lagring - **Primær**: `{dataDir}/brand-voice.json` — versioneret store - **Per-lokalitet cache**: `{dataDir}/brand-voice-{locale}.json` — flade filer, én per mål-lokalitet `dataDir` er sitets `_data/`-bibliotek (sibling til `content/`). ## API-endpoints | Route | Formål | |---|---| | `GET /api/cms/brand-voice` | Læs primær. `?locale=xx` returnerer lokalitets-varianten (auto-oversætter hvis nødvendigt) | | `POST /api/cms/brand-voice` | Gem en ny version | | `POST /api/cms/brand-voice/chat` | Stream interviewet — Claude som brand-strateg | | `POST /api/cms/brand-voice/translate` | Tving regenerering af en lokalitets-cache | | `PATCH /api/cms/brand-voice/versions/[id]` | Aktivér eller redigér en historisk version | Alle routes kræver en autentificeret admin-session. ## Hvornår det skal genbesøges - Launcher en ny lokalitet → oversæt voicen først, gennemgå auto-oversættelsen, redigér tone-ord (de oversættes sjældent 1:1). - Rebranding → opret en ny version, efterlad den gamle i `versions[]` som reference. - Bemærker at AI-output drifter → tjek hvilken version der er aktiv, og om en nylig redigering løsnede en constraint. Brand Voice er den ene knap der tuner enhver AI-overflade på samme tid. Et 30-minutters interview ved projektstart betaler sig på enhver chat-besked og enhver agent-kørsel bagefter. --- ## docs/brand-voice Title: Brand Voice Updated: 2026-04-15 Locale: en Per-site, locale-aware brand voice configuration that gets injected into every AI prompt — chat, agents, generation, SEO. ## What it is Brand Voice is a structured description of how your site should sound. It's a per-site config that the CMS injects into every AI prompt — chat, agents, AI content generation, SEO optimization, rewrites. Instead of repeating "write in a friendly tone, avoid jargon, mention our pillars" in every chat message, you describe your voice once and every downstream AI task inherits it. ## The fields | Field | What it captures | |---|---| | `name`, `industry`, `description` | Basic brand metadata | | `language`, `targetAudience` | Localization context — who the site speaks to | | `primaryTone` | One-sentence voice summary (e.g. "Technical, neutral, factual") | | `brandPersonality` | 3–5 adjectives (e.g. `["pragmatic", "precise", "warm"]`) | | `contentGoals` | What the content is trying to achieve | | `contentPillars` | Strategic topics the brand covers | | `avoidTopics` | Topics / phrasings the AI should steer clear of | | `seoKeywords` | Target keywords surfaced to the SEO optimizer | | `examplePhrases` | Voice samples — actual sentences that sound like the brand | ## Configuring via interview The fastest path is the guided AI interview at **Settings → Brand Voice**. Claude plays the role of a brand strategist and asks questions about your business, audience, and goals. At the end it generates the JSON, shows a preview, and saves it as a new version. You can also edit fields directly in the admin form or paste in a JSON payload. ## Locale-aware structure Brand Voice has one primary record plus a cached variant per locale. When an AI consumer requests the voice for `locale=da`, the CMS: 1. If the primary language matches `da`, returns the primary record. 2. Otherwise, checks `brand-voice-da.json` in `_data/`. 3. If no cache exists, calls `/api/cms/brand-voice/translate` to auto-translate the primary record into Danish and writes the result to `brand-voice-da.json`. 4. Returns the localized record. This means editing the primary record can (optionally) invalidate locale caches — subsequent AI calls will regenerate the translation on demand. ## What consumes Brand Voice - **Chat system prompt** — the full Brand Voice block is injected at the top of every Chat conversation via `gatherSiteContext()` and `buildChatSystemPrompt()`. - **Agent runner** — long-running content agents (SEO, rewrite, translate, generate) include the brand voice in their prompts automatically. - **AI generate route** — `/api/cms/ai/generate` wraps the user request with the voice context before dispatching to the LLM. - **SEO optimizer** — uses `seoKeywords` as the keyword list to optimize against. The injection format is a markdown `## Brand Voice` block produced by `brandVoiceToPromptContext()`. Every AI consumer calls that helper — so adding a new consumer is one line. ## Versions Every save creates a new version. The admin shows the history and lets you roll back: `activeId` in `brand-voice.json` points to the current version, `versions[]` holds the full record of changes. Useful when you iterate on tone and want to revert — or A/B test two voice drafts. ## Storage - **Primary**: `{dataDir}/brand-voice.json` — versioned store - **Per-locale cache**: `{dataDir}/brand-voice-{locale}.json` — flat files, one per target locale `dataDir` is the site's `_data/` directory (sibling of `content/`). ## API endpoints | Route | Purpose | |---|---| | `GET /api/cms/brand-voice` | Read primary. `?locale=xx` returns the locale variant (auto-translating if needed) | | `POST /api/cms/brand-voice` | Save a new version | | `POST /api/cms/brand-voice/chat` | Stream the interview — Claude as brand strategist | | `POST /api/cms/brand-voice/translate` | Force regenerate a locale cache | | `PATCH /api/cms/brand-voice/versions/[id]` | Activate or edit a historical version | All routes require an authenticated admin session. ## When to revisit - Launching a new locale → translate the voice first, review the auto-translation, edit the tone words (they often don't translate 1:1). - Rebranding → create a new version, leave the old one in `versions[]` for reference. - Noticing AI output drifting → check which version is active and whether a recent edit loosened a constraint. Brand Voice is the single knob that tunes every AI surface at once. A 30-minute interview at project start pays off on every chat message and every agent run after that. --- ## docs/svg-embeds-da Title: Inline SVG-figurer (svgEmbed) Updated: 2026-04-15 Locale: da Indsæt CSS-stylebare SVG-illustrationer i richtext via SVG figur-vælgeren eller {{svg:slug}}-shortcoden. Bygget på svgEmbed TipTap-noden. ## To måder at placere en SVG på en side CMS'et understøtter SVG'er på to forskellige måder, afhængigt af hvad du har brug for: | Type | Sådan indsættes den | Output | Bruges når | |---|---|---|---| | **Som billede** | Medievælger → Indsæt billede | `` | Standard logo/ikon, simpel skalering | | **Som inline figur** | Værktøjslinje → SVG figur-vælger | `……` | CSS-stylebar SVG, dark mode-klar, tilgængelig figur + billedtekst | Den inline figur-variant er det denne side handler om. Det er grunden til at du ville vælge SvgEmbed i stedet for en almindelig billedindsættelse. ## Hvorfor inline SVG? Når en SVG vises via ``, er den et lukket asset — du kan ikke nå ind i den fra CSS. Inline SVG er anderledes: når først den er en del af sidens DOM, er hver ``, `` og `` et CSS-mål. Du kan omfarve strokes i dark mode, animere delpath'es, eller anvende `currentColor` så illustrationen følger den omgivende teksts farve. Eksempler der kræver inline SVG: - Diagrammer i tekniske artikler der skal skifte streg-farve mellem light og dark theme - Illustrationer hvis accentfarve styres af samme `--accent` CSS-variabel som resten af brandet - SVG'er der skal arve `color` eller `font-size` fra parent via `currentColor` - Tilgængelige figurer med ``, `` og en tilhørende `` ## Indsæt via editoren 1. Upload din `.svg`-fil til **Media** (hvis den ikke allerede er der). 2. I et vilkårligt richtext-felt, klik på **SVG figur**-knappen i værktøjslinjen (shapes-ikonet ved siden af snippet-knappen). 3. Picker-modalen viser alle `.svg`-filer i dit mediebibliotek med thumbnail-preview. 4. Klik på en for at indsætte. Filens slug (filnavn uden `.svg`) bliver shortcode-nøglen. 5. Efter indsættelse kan du skrive en billedtekst direkte under SVG'en i editoren. Den er valgfri. Noden vises som en draggable blok med SVG-preview og et inline billedtekst-input. Sletning virker med standard inline-bekræftelsen (Remove? Yes / No). ## Markdown shortcode Under motorhjelmen serialiserer SvgEmbed-noden til en plain-tekst shortcode: ``` {{svg:memex-desk}} {{svg:memex-desk|En skematisk læsning af Bush's forslag til skrivebord}} ``` Shortcoden er den eneste repræsentation der overlever TipTap ↔ markdown roundtrippet — og det er netop derfor rå ``-blokke bliver fjernet ved gem, mens shortcodes ikke gør. Hvis du forfatter indhold i din IDE (i stedet for CMS-editoren), kan du skrive shortcoden direkte i din markdown, og editoren genopretter den som en node ved indlæsning. Slugs skal matche `[a-z0-9-]+`. Billedteksten efter `|` kan indeholde enhver tekst undtagen `}` — HTML escapes automatisk ved render-tid. ## Udvid shortcodes på build-tidspunkt `@webhouse/cms`-pakken leverer en delt shortcode-ekspander. Importér den i din `build.ts`: ```typescript import { expandShortcodes } from '@webhouse/cms'; import { join } from 'node:path'; const html = expandShortcodes(markdownHtml, { uploadsDir: join(import.meta.dirname, 'public', 'uploads'), basePath: process.env.BASE_PATH ?? '', }); ``` Optioner: | Option | Formål | Default | |---|---|---| | `uploadsDir` | Absolut sti til dit uploads-bibliotek. Påkrævet hvis standardinlineren skal læse SVG-filer. | `undefined` (falder tilbage til ``-reference) | | `svgDir` | Underbibliotek under `uploadsDir` hvor SVG'er ligger. | `"svg"` | | `svgCaptions` | `Record` til default-billedtekster når shortcoden ikke har en. | `{}` | | `basePath` | Foranstilles alle `/uploads/...`-URL'er som fallback-rendereren udsender. | `""` | | `renderSvg` | Fuld override — `(slug, caption, bp) => string`. Nyttig til custom wrappers eller CDN-rewrites. | indbygget | Den indbyggede renderer læser `{uploadsDir}/{svgDir}/{slug}.svg` og inliner filens indhold pakket ind i `…`. Hvis filen mangler, falder den tilbage til `` så siden stadig renderer. ## Asset-konventioner To praktiske placeringer afhængigt af hvem der ejer SVG'en: - **Bruger-uploadede SVG'er** — `public/uploads/svg/` (eller hvor mediebiblioteket end skriver). Det er normale media-assets, tilgængelige i picker. - **Dev-forfattede SVG'er** — et sibling-bibliotek som `figures/` der ikke er `.gitignore`-ed uploads. Angiv `uploadsDir: 'figures'` (eller pege på dit eget bibliotek med absolut sti) så expanderen læser derfra. Shortcode-syntaksen er den samme i begge tilfælde — kun resolutions-placeringen ændres. ## En-fils udvidelses-eksempel Minimal drop-in til et statisk site: ```typescript import { marked } from 'marked'; import { expandShortcodes } from '@webhouse/cms'; import { join } from 'node:path'; const UPLOADS = join(import.meta.dirname, 'public', 'uploads'); export function renderContent(md: string): string { const html = marked.parse(md, { async: false }) as string; return expandShortcodes(html, { uploadsDir: UPLOADS }); } ``` Hver `{{svg:slug}}` bliver en inline ``. Hver `{{svg:slug|billedtekst}}` tilføjer en ``. Alle fem indbyggede shortcodes (`!!INTERACTIVE`, `!!FILE`, `!!MAP`, `{{snippet:slug}}`, `{{svg:slug}}`) udvides i én gennemgang. ## Hvorfor den findes (bug'en den retter) TipTap gemmer et richtext-dokument som et ProseMirror-træ. Når editoren gemmer, serialiserer den træet tilbage til markdown via `tiptap-markdown`. Rå HTML-blokke som `…` der ikke er bundet til en TipTap-node, behandles som løs HTML og droppes stille ved re-serialisering. Sider med inline SVG-figurer ville miste deres illustrationer ved næste save uden fejl — bare et skrumpet `content`-felt. `svgEmbed`-noden retter dette ordentligt. SVG-indholdet lever på disk (refereret via slug); richtext'en bærer kun shortcoden. Roundtrippet er tabsfrit fordi shortcoden er plain tekst. ## Rendering på frontend Expanderen returnerer inline SVG, så ingen yderligere runtime-kode er nødvendig. Stil figurerne med normal CSS: ```css .cms-svg svg { max-width: 100%; height: auto; } .cms-svg figcaption { font-family: monospace; font-size: 0.8rem; color: var(--fg-muted); margin-top: 0.5rem; } @media (prefers-color-scheme: dark) { .cms-svg svg [stroke="#1a1715"] { stroke: #FAF9F5; } } ``` Den sidste regel er gevinsten — den er umulig med ``. --- ## docs/svg-embeds Title: Inline SVG figures (svgEmbed) Updated: 2026-04-15 Locale: en Embed CSS-stylable SVG illustrations in richtext via the SVG figure picker or the {{svg:slug}} shortcode. Backed by the svgEmbed TipTap node. ## Two ways to put an SVG on a page The CMS supports SVGs in two different modes depending on what you need: | Mode | How to insert | Output | Use when | |---|---|---|---| | **As image** | Media picker → Image insert | `` | Standard logo/icon, simple scaling | | **As inline figure** | Toolbar → SVG figure picker | `……` | CSS-stylable SVG, dark-mode ready, accessible figure + caption | The inline-figure mode is what this page is about. It's why you'd reach for SvgEmbed instead of the normal image insert. ## Why inline SVG? When an SVG is served as ``, it's a sealed asset — you can't reach into it from CSS. Inline SVG is different: once it's part of the page's DOM, every ``, ``, `` is a CSS target. You can recolor strokes for dark mode, animate sub-paths, or apply `currentColor` so the illustration follows the surrounding text color. Examples that require inline SVG: - Diagrams in technical articles that need to flip stroke color between light and dark themes - Illustrations whose accent color is driven by the same `--accent` CSS variable as the rest of the brand - SVGs that should inherit `color` or `font-size` from the parent via `currentColor` - Accessible figures with ``, ``, and a paired `` ## Inserting via the editor 1. Upload your `.svg` file to **Media** (if it's not already there). 2. In any richtext field, click the **SVG figure** button in the toolbar (the shapes icon next to the snippet button). 3. The picker modal lists every `.svg` in your media library with a thumbnail preview. 4. Click one to insert. The file's slug (filename without `.svg`) becomes the shortcode key. 5. After insertion, type a caption directly under the SVG in the editor. It's optional. The node appears as a draggable block with the SVG preview and an inline caption input. Delete works with the usual inline confirm (Remove? Yes / No). ## Markdown shortcode Under the hood, the SvgEmbed node serializes to a plain-text shortcode: ``` {{svg:memex-desk}} {{svg:memex-desk|A schematic reading of Bush's proposed desk}} ``` The shortcode is the only representation that survives the TipTap ↔ markdown roundtrip — which is exactly why raw `` blocks get stripped on save and shortcodes don't. If you author content in your IDE (instead of the CMS editor), drop the shortcode directly into your markdown and the editor will rehydrate it into a node on load. Slugs must match `[a-z0-9-]+`. The caption after `|` can contain any text except `}` — HTML is escaped automatically at render time. ## Expanding shortcodes at build time The `@webhouse/cms` package ships a shared shortcode expander. Import it from your `build.ts`: ```typescript import { expandShortcodes } from '@webhouse/cms'; import { join } from 'node:path'; const html = expandShortcodes(markdownHtml, { uploadsDir: join(import.meta.dirname, 'public', 'uploads'), basePath: process.env.BASE_PATH ?? '', }); ``` Options: | Option | Purpose | Default | |---|---|---| | `uploadsDir` | Absolute path to your uploads directory. Required for the default inliner to read SVG files. | `undefined` (falls back to `` reference) | | `svgDir` | Sub-directory under `uploadsDir` where SVGs live. | `"svg"` | | `svgCaptions` | `Record` for default captions when the shortcode has none. | `{}` | | `basePath` | Prepended to any `/uploads/...` URLs the fallback renderer emits. | `""` | | `renderSvg` | Full override — `(slug, caption, bp) => string`. Useful for custom wrappers or CDN rewrites. | built-in | The built-in renderer reads `{uploadsDir}/{svgDir}/{slug}.svg` and inlines the file content wrapped in `…`. If the file is missing, it falls back to `` so the page still renders. ## Asset conventions Two practical locations depending on who owns the SVG: - **User-uploaded SVGs** — `public/uploads/svg/` (or wherever the media library writes). These are normal media assets, available in the picker. - **Dev-authored SVGs** — a sibling directory like `figures/` that you exclude from `.gitignore`-ed uploads. Pass `uploadsDir: 'figures'` (or point at your own directory with absolute path) so the expander reads from there. The shortcode syntax is the same in both cases — only the resolution location changes. ## One-file expand example Minimal drop-in for a static site: ```typescript import { marked } from 'marked'; import { expandShortcodes } from '@webhouse/cms'; import { join } from 'node:path'; const UPLOADS = join(import.meta.dirname, 'public', 'uploads'); export function renderContent(md: string): string { const html = marked.parse(md, { async: false }) as string; return expandShortcodes(html, { uploadsDir: UPLOADS }); } ``` Every `{{svg:slug}}` becomes an inline ``. Every `{{svg:slug|caption}}` adds a ``. All five built-in shortcodes (`!!INTERACTIVE`, `!!FILE`, `!!MAP`, `{{snippet:slug}}`, `{{svg:slug}}`) expand in a single pass. ## Why this exists (the bug this fixes) TipTap stores a richtext document as a ProseMirror tree. When the editor saves, it serializes the tree back to markdown via `tiptap-markdown`. Raw HTML blocks like `…` that aren't bound to a TipTap node get treated as loose HTML and dropped silently on re-serialize. Pages with inline SVG figures would lose their illustrations on the next save with no error — just a shrunken `content` field. The `svgEmbed` node fixes this properly. The SVG content lives on disk (referenced by slug); the richtext only carries the shortcode. The roundtrip is lossless because the shortcode is plain text. ## Rendering on the frontend The expander returns inline SVG, so no additional runtime code is needed. Style the figures with normal CSS: ```css .cms-svg svg { max-width: 100%; height: auto; } .cms-svg figcaption { font-family: monospace; font-size: 0.8rem; color: var(--fg-muted); margin-top: 0.5rem; } @media (prefers-color-scheme: dark) { .cms-svg svg [stroke="#1a1715"] { stroke: #FAF9F5; } } ``` The last rule is the payoff — it's impossible with ``. --- ## docs/lighthouse-da Title: Lighthouse ydeevneaudit Updated: 2026-04-09 Locale: da Overvåg dit sites ydeevne, tilgængelighed, SEO og best practices med integreret Google PageSpeed Insights scanning. ## Overblik Lighthouse-siden i CMS admin scanner dit deployede site via Google PageSpeed Insights og giver dig fire scores: - **Performance** — sideindlæsningshastighed, rendering, interaktivitet - **Accessibility** — skærmlæsere, kontrast, ARIA, tastaturnavigation - **SEO** — metatags, crawlbarhed, struktureret data - **Best Practices** — HTTPS, billedformater, konsolfejl, forældede API'er Scores bruger Googles officielle tærskler: 🟢 90-100 (godt), 🟡 50-89 (brug for arbejde), 🔴 0-49 (dårligt). ## Core Web Vitals Core Web Vitals er de metrikker Google bruger til søgerangering. De måler virkelig brugeroplevelse: | Metrik | Hvad den måler | God | Behov | Dårlig | |--------|---------------|-----|-------|--------| | **LCP** (Largest Contentful Paint) | Hvor hurtigt hovedindholdet loader | 4,0s | | **CLS** (Cumulative Layout Shift) | Hvor meget layoutet skifter under load | 0,25 | | **FCP** (First Contentful Paint) | Hvornår første tekst/billede vises | 3,0s | | **TTFB** (Time to First Byte) | Serverens svartid | 1800ms | | **INP** (Interaction to Next Paint) | Svartid på brugerinput | 500ms | ## Sådan kører du en scanning 1. Gå til **Tools → Lighthouse** i sidebaren 2. Klik **Run Scan** 3. Både mobile og desktop audits kører parallelt — resultater vises side om side 4. Vent 10-30 sekunder mens Google analyserer dit site Sitet skal være offentligt tilgængeligt — localhost-URL'er kan ikke scannes. Sæt din produktions-URL som Preview URL i Site Settings. ## Opportunities & Diagnostics **Opportunities** er specifikke forbedringer med estimerede tidsbesparelser: - "Reduce unused JavaScript" — spar 0,6s - "Serve images in next-gen formats" — spar 1,2s - "Eliminate render-blocking resources" — spar 0,4s **Diagnostics** flager problemer der ikke har direkte tidsbesparelser men indikerer issues: - DOM-størrelse for stor - Manglende cache-headers - For mange netværksanmodninger ## Score-historik Hver scanning gemmes. Historik-tabellen viser dine scores over tid så du kan spore forbedringer efter optimeringer. ## API-nøgle opsætning Lighthouse virker ud af boksen — en delt API-nøgle er inkluderet. Hvis du kører mange scanninger og rammer dagskvoten, kan du tilføje din egen gratis Google PageSpeed Insights API-nøgle: ### Via Google Cloud Console (web) 1. Gå til [Google Cloud Console → PageSpeed Insights API](https://console.cloud.google.com/apis/library/pagespeedonline.googleapis.com) 2. Vælg eller opret et projekt 3. Klik **Enable** 4. Gå til [Credentials](https://console.cloud.google.com/apis/credentials) 5. Klik **Create Credentials → API Key** 6. Kopiér nøglen 7. I CMS admin: når du ser kvota-fejlen, indsæt din nøgle i opsætningsformularen Nøglen gemmes per site i `_data/site-config.json` som `psiApiKey`. ### Via gcloud CLI Hvis du har [gcloud CLI](https://cloud.google.com/sdk/docs/install) installeret: ```bash # Autentificér (åbner browser) gcloud auth login # Aktivér API'en gcloud services enable pagespeedonline.googleapis.com --project=DIT_PROJEKT # Opret API-nøgle begrænset til PSI gcloud services api-keys create \ --display-name="CMS Lighthouse" \ --project=DIT_PROJEKT \ --api-target=service=pagespeedonline.googleapis.com ``` Nøglen returneres som `keyString`. Indsæt den i CMS admin. ### Installation af gcloud CLI ```bash # macOS (Homebrew) brew install google-cloud-sdk # Eller download installer curl https://sdk.cloud.google.com | bash exec -l $SHELL gcloud init ``` For andre platforme, se den [officielle installationsguide](https://cloud.google.com/sdk/docs/install). --- ## docs/lighthouse Title: Lighthouse Performance Audit Updated: 2026-04-09 Locale: en Monitor your site's performance, accessibility, SEO, and best practices with integrated Google PageSpeed Insights scanning. ## Overview The Lighthouse page in CMS admin scans your deployed site via Google PageSpeed Insights and gives you four scores: - **Performance** — page load speed, rendering, interactivity - **Accessibility** — screen readers, contrast, ARIA, keyboard navigation - **SEO** — meta tags, crawlability, structured data - **Best Practices** — HTTPS, image formats, console errors, deprecated APIs Scores use Google's official thresholds: 🟢 90-100 (good), 🟡 50-89 (needs work), 🔴 0-49 (poor). ## Core Web Vitals Core Web Vitals are the metrics Google uses for search ranking. They measure real-world user experience: | Metric | What it measures | Good | Needs work | Poor | |--------|-----------------|------|------------|------| | **LCP** (Largest Contentful Paint) | How fast the main content loads | 4.0s | | **CLS** (Cumulative Layout Shift) | How much the layout shifts during load | 0.25 | | **FCP** (First Contentful Paint) | When first text/image appears | 3.0s | | **TTFB** (Time to First Byte) | Server response time | 1800ms | | **INP** (Interaction to Next Paint) | Response time to user input | 500ms | ## How to run a scan 1. Navigate to **Tools → Lighthouse** in the sidebar 2. Click **Run Scan** 3. Both mobile and desktop audits run in parallel — results appear side by side 4. Wait 10-30 seconds for Google to analyze your site The site must be publicly accessible — localhost URLs cannot be scanned. Set your production URL as Preview URL in Site Settings. ## Opportunities & Diagnostics **Opportunities** are specific improvements with estimated time savings: - "Reduce unused JavaScript" — save 0.6s - "Serve images in next-gen formats" — save 1.2s - "Eliminate render-blocking resources" — save 0.4s **Diagnostics** flag issues that don't have direct time savings but indicate problems: - DOM size too large - Missing cache headers - Excessive network requests ## Score History Every scan is saved. The history table shows your scores over time so you can track improvements after optimizations. ## API Key Setup Lighthouse works out of the box — a shared API key is included. If you run many scans and hit the daily quota, you can add your own free Google PageSpeed Insights API key: ### Via Google Cloud Console (web) 1. Go to [Google Cloud Console → PageSpeed Insights API](https://console.cloud.google.com/apis/library/pagespeedonline.googleapis.com) 2. Select or create a project 3. Click **Enable** 4. Go to [Credentials](https://console.cloud.google.com/apis/credentials) 5. Click **Create Credentials → API Key** 6. Copy the key 7. In CMS admin: when you see the quota error, paste your key in the setup form The key is stored per-site in `_data/site-config.json` as `psiApiKey`. ### Via gcloud CLI If you have [gcloud CLI](https://cloud.google.com/sdk/docs/install) installed: ```bash # Authenticate (opens browser) gcloud auth login # Enable the API gcloud services enable pagespeedonline.googleapis.com --project=YOUR_PROJECT # Create an API key restricted to PSI only gcloud services api-keys create \ --display-name="CMS Lighthouse" \ --project=YOUR_PROJECT \ --api-target=service=pagespeedonline.googleapis.com ``` The key is returned in the output as `keyString`. Paste it in CMS admin. ### Installing gcloud CLI ```bash # macOS (Homebrew) brew install google-cloud-sdk # Or download installer curl https://sdk.cloud.google.com | bash exec -l $SHELL gcloud init ``` For other platforms, see the [official install guide](https://cloud.google.com/sdk/docs/install). --- ## docs/form-embedding Title: Three Ways to Embed Forms Updated: 2026-04-09 Locale: en Embed CMS forms in your pages using a richtext shortcode, a content block, or a dedicated field type. ## Overview Once you've [defined a form](/docs/form-engine), you need to put it on a page. The CMS gives you three methods — pick the one that fits your content model. | Method | Best for | How it works | |--------|----------|-------------| | **A. Shortcode** | Richtext articles | Type `{{form:contact}}` in any richtext field | | **B. Block** | Block-based pages | Add a "Form Embed" block to any blocks field | | **C. Field type** | Dedicated form pages | Add a `type: "form"` field to a collection | All three render the same semantic `` HTML with honeypot, async submit, and JS-free fallback. ## A. Richtext shortcode Type `{{form:contact}}` anywhere in a richtext field. At build time, the CMS replaces it with the full `` HTML. ```markdown ## Get in touch Fill out the form below and we'll get back to you. {{form:contact}} ``` The shortcode follows the same pattern as [snippet embeds](/docs/snippet-embeds) (`{{snippet:slug}}`). The form name must match a form defined in `cms.config.ts` or created in the admin Form Builder. For Next.js dynamic sites: the shortcode appears as literal text in the JSON. Your renderer should replace `{{form:name}}` with a React form component at render time. ## B. Content block For pages that use the block editor (hero, features, testimonials, etc.), add a **Form Embed** block: 1. In the block editor, click **+ Add block** 2. Select **Form Embed** 3. Type the form name (e.g. `contact`) 4. The block renders the form inline with the other blocks The block is built in — no configuration needed. It stores: ```json { "_block": "form", "formName": "contact" } ``` Your site's block renderer should check for `_block === "form"` and render the form. For static builds, `cms build` handles this automatically. ## C. Dedicated field type For collections where a specific page always shows a form (e.g. a "Landing Pages" collection), add a `form` field: ```typescript defineCollection({ name: 'landing-pages', label: 'Landing Pages', fields: [ { name: 'title', type: 'text', required: true }, { name: 'heroText', type: 'richtext' }, { name: 'contactForm', type: 'form', label: 'Embedded form' }, // ... ], }); ``` The field value is the form name (a string like `"contact"`). In the admin editor, it renders as a dropdown of all available forms. In your site template: ```typescript // Next.js example import { generateFormHtml } from '@webhouse/cms'; function LandingPage({ page }) { const formHtml = page.data.contactForm ? generateFormHtml( forms.find(f => f.name === page.data.contactForm), process.env.CMS_ADMIN_URL ) : null; return ( {page.data.title} {formHtml && } ); } ``` ## Which method to choose - **You write blog posts with occasional forms** → use shortcodes (A). Zero config, type it inline. - **You build pages from blocks** → use the Form Embed block (B). Drag it where you want it. - **You have a collection where every entry has a form** → use the field type (C). Structured, explicit, queryable. - **You want to embed on a page not built by the CMS** → use the [embeddable widget script](/docs/form-engine#2-embeddable-widget-one-script-tag) instead. ## The generated form All three methods produce the same HTML: - Semantic `` with `action` pointing at the CMS admin's public endpoint - All fields with HTML5 types (`email`, `tel`, `date`, etc.) and validation attributes - Honeypot field (invisible to humans, catches bots) - Inline `` for async submit + success/error message - Works without JavaScript — plain POST + redirect as fallback Style the form with your site's CSS. The generated HTML uses minimal inline styles that are easy to override. ## See also - [Form Engine](/docs/form-engine) — define forms, inbox, notifications, spam protection - [Snippet Embeds](/docs/snippet-embeds) — the `{{snippet:slug}}` pattern this is based on - [Blocks](/docs/blocks) — how block-based content works --- ## docs/form-engine Title: Form Engine Updated: 2026-04-09 Locale: en Collect form submissions with zero third-party dependencies. Define forms in config, render at build time, receive submissions in the admin inbox. ## How it works The CMS admin itself is the form backend. Static sites POST cross-origin directly to the admin API. Submissions are stored as JSON files. The admin has an inbox with unread badges. No Formspree, no Netlify Forms, no vendor lock-in. ``` Static site → POST /api/forms/contact → CMS admin ↓ _data/submissions/contact/*.json ↓ Email + webhook notification ↓ Admin inbox with badge ``` ## Define a form Add a `forms` array to your `cms.config.ts`: ```typescript import { defineConfig } from '@webhouse/cms'; export default defineConfig({ collections: [/* ... */], forms: [ { name: 'contact', label: 'Contact Form', fields: [ { name: 'name', type: 'text', label: 'Name', required: true }, { name: 'email', type: 'email', label: 'Email', required: true }, { name: 'company', type: 'text', label: 'Company', placeholder: 'Optional' }, { name: 'message', type: 'textarea', label: 'Message', required: true }, ], successMessage: 'Thanks! We will get back to you within 24 hours.', notifications: { email: ['hello@example.com'], webhook: 'https://hooks.slack.com/services/T.../B.../xxx', }, spam: { honeypot: true, // default rateLimit: 5, // max per IP per hour, default }, }, ], }); ``` ## Field types | Type | HTML element | Notes | |------|-------------|-------| | `text` | `` | General text | | `email` | `` | Browser validates format | | `textarea` | `` | Multi-line, 4 rows default | | `select` | `` | Requires `options: [{ label, value }]` | | `checkbox` | `` | Boolean | | `number` | `` | Numeric | | `phone` | `` | Phone keyboard on mobile | | `url` | `` | URL format | | `date` | `` | Native date picker | | `hidden` | `` | Use `defaultValue` to set | All fields support: - `required` — browser + server validation - `placeholder` — hint text - `validation.pattern` — regex (e.g. `"^[A-Z]"` for must-start-with-capital) - `validation.minLength` / `maxLength` ## Three ways to use forms ### 1. Build output (zero effort) `cms build` generates `forms//index.html` in your output directory. The page contains a styled, accessible `` with async submit, honeypot, and a JS-free fallback. Link to it from your site: `Contact us`. Set `CMS_ADMIN_URL` to your production admin URL so the form action points at the right place: ```bash CMS_ADMIN_URL=https://cms.example.com cms build ``` ### 2. Embeddable widget (one script tag) Drop this on any page — even pages not built by the CMS: ```html ``` The script fetches the form schema, renders a styled form, handles submission via `fetch`, and shows success/error inline. ~4 KB, zero dependencies, includes honeypot. ### 3. Custom HTML (full control) Build your own `` and POST to the API: ```html Send ``` The endpoint accepts both `application/json` and `application/x-www-form-urlencoded`. For JSON: ```javascript fetch('https://cms.example.com/api/forms/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Jane', email: 'jane@x.com', message: 'Hi' }), }); ``` ## Spam protection Two layers enabled by default: 1. **Honeypot** — a hidden field (`_hp_email`) that's invisible to humans but bots auto-fill. If the field has any value, the submission is silently accepted (returns 200) but never stored — so bots think they succeeded. 2. **IP rate limiting** — max 5 submissions per IP per hour (configurable via `spam.rateLimit`). Returns 429 when exceeded. IP addresses are hashed (SHA-256, truncated to 8 hex chars) before any storage — GDPR-friendly. Optional third layer: **Cloudflare Turnstile**. Set `TURNSTILE_SECRET_KEY` in your admin's `.env`, add the Turnstile script to your form page, and include the `cf-turnstile-response` field in the POST body. The endpoint validates the token server-side. ## Admin inbox Open **Forms** in the sidebar (below Interactives). Each form shows its unread count. Click a form to open the inbox: - **Status dots**: blue = new, grey = read, faded = archived - **Filter tabs**: All / New / Read / Archived - **Detail panel**: click any submission to see all fields - **Actions**: Archive, Delete (with inline confirm) - **CSV export**: download button in the action bar The sidebar badge shows the total unread count across all forms. ## Notifications Configure per form in `cms.config.ts`: ```typescript notifications: { email: ['hello@example.com', 'sales@example.com'], webhook: 'https://hooks.slack.com/services/...', } ``` **Email**: uses Resend (`RESEND_API_KEY` env var) or falls back to console log. From address: `CMS_EMAIL_FROM` or `forms@webhouse.app`. **Webhook**: POSTs the full submission as JSON to the configured URL. Works with Slack, Discord, Zapier, Make, or any custom endpoint. **F35 event**: every submission also fires a `form.submitted` event through the site's webhook system, so existing Discord/Slack integrations receive it automatically. ## CORS The public `/api/forms/*` endpoints accept cross-origin requests from: - The site's `previewSiteUrl` (from Site Settings) - `localhost:3000`, `localhost:3009`, `localhost:3011` in development Production sites should set their `previewSiteUrl` in Site Settings to match the domain the form lives on. ## API reference ### Public | Method | Path | Description | |--------|------|-------------| | POST | `/api/forms/[name]` | Submit form | | GET | `/api/forms/[name]/schema` | Form field definitions | | GET | `/api/forms/[name]/widget.js` | Embeddable script | ### Admin (authenticated) | Method | Path | Description | |--------|------|-------------| | GET | `/api/admin/forms` | List forms + unread counts | | GET | `/api/admin/forms/[name]/submissions` | List submissions | | GET | `/api/admin/forms/[name]/submissions/[id]` | Single submission | | PATCH | `/api/admin/forms/[name]/submissions/[id]` | Update status | | DELETE | `/api/admin/forms/[name]/submissions/[id]` | Delete | | GET | `/api/admin/forms/[name]/export` | CSV download | ## Chat integration The CMS chat assistant can list forms and submissions: - *"Show me all forms"* → `list_forms` - *"Any new contact submissions?"* → `list_form_submissions` ## Data persistence Submissions live in `_data/submissions//` — the same `_data/` directory that stores users, team files, and backup metadata. This directory is **gitignored by default**, which means: - Submissions survive server restarts and redeployments (the directory persists on the volume). - Submissions are **not** included in git-based backups. Use [F27 Backup & Restore](/docs/backup-restore) with a cloud target (S3, pCloud) to include `_data/` in automated backups. - [F122 Beam](/docs/beam) teleports content but **not** `_data/`. Export submissions via CSV before beaming if you need to transfer them. - On Docker deploys (Fly.io), mount a persistent volume at the site root so `_data/` survives container recreation. If you need submissions in version control (e.g. for audit), add `!_data/submissions/` to your `.gitignore`. ## See also - [Webhooks](/docs/webhooks) — receive form.submitted events in Discord, Slack, or custom endpoints - [Deployment](/docs/deployment) — ensure `CMS_ADMIN_URL` is set for production form action URLs --- ## docs/schema-export-da Title: Schema-eksport — webhouse-schema.json Updated: 2026-04-09 Locale: da Generér et JSON Schema-dokument fra cms.config.ts så PHP-, Python-, Ruby-, Go-, Java- og .NET-reader-biblioteker kan introspektere din indholdsmodel. ## Hvad er schema-eksport? @webhouse/cms er en **framework-agnostisk indholdsplatform**. Admin UI'en er TypeScript, men indholdslaget er universelt: flade JSON-filer i `content/` der kan læses af ethvert sprog. Reader-biblioteker i PHP, Python, Ruby, Go, Java og .NET forbruger alle de samme filer. Men disse reader-biblioteker kan ikke eksekvere din `cms.config.ts` (det er TypeScript). De har brug for en sprog-agnostisk beskrivelse af indholdsmodellen — hvilke collections der findes, hvilke felter de har, hvilke typer felterne er. Den beskrivelse er **`webhouse-schema.json`**: et [JSON Schema draft 2020-12](https://json-schema.org/draft/2020-12/schema)-dokument med `x-webhouse-*`-extension keywords til type-hints (richtext, tags, blocks, relations) som JSON Schema ikke understøtter natively. Tænk på det som en **genereret lockfile** for din indholdsmodel — afledt af `cms.config.ts`, altid committet til git, altid holdt synkroniseret. ## Hvorfor det findes Uden `webhouse-schema.json` har en Java- eller PHP-udvikler der læser @webhouse/cms-indhold ingen måde at vide: - Hvilke collections der findes på dette site - Hvilke felter hver collection har - Hvilke felter er påkrævede vs valgfrie - Hvilke felter er richtext (markdown) vs plain text - Hvilke collections er oversættelige, hvilke er sider vs data-records - Hvilke blocks der er defineret og hvordan deres felter ser ud Med schema-filen kan hvert reader-bibliotek introspektere indholdsmodellen, generere typer, validere dokumenter og producere IDE-autocomplete — alt sammen uden at parse TypeScript. Det er også fundamentet for fremtidige værktøjer: type-generatorer, schema diff-tools, indholdsvalidatorer og automatiserede migrations-hjælpere. ## Tre måder at eksportere Der er tre ækvivalente måder at generere `webhouse-schema.json`. Vælg den der passer dit workflow. ### 1. CMS admin UI Den mest brugervenlige mulighed for menneskelige redaktører. 1. Åbn CMS admin → **Site Settings** 2. Find **Schema export**-sektionen lige under "Validate site" 3. Du har to knapper: - **⬇ Download schema** — gemmer `webhouse-schema.json` i din browsers Downloads-mappe. Brug denne til inspektion, deling eller manuel håndtering. - **💾 Save to project root** — skriver `webhouse-schema.json` direkte ved siden af din `cms.config.ts`. Brug denne når du vil committe den til git. Efter klik på Save vises en grøn bekræftelses-panel med: - Filstørrelse (f.eks. "4,5 KB") - Absolut sti hvor den blev skrevet - Antal collections og blocks inkluderet ### 2. CLI-kommando Den kanoniske mulighed for CI/CD-pipelines, AI-agenter (Claude Code, Cursor) og scriptbare workflows. ```bash cd /sti/til/dit-projekt npx cms export-schema --out webhouse-schema.json ``` Flags: | Flag | Default | Beskrivelse | |------|---------|-------------| | `--out ` | stdout | Skriv til fil i stedet for stdout | | `--baseUrl ` | (ingen) | Sætter `$id`-feltet på schemaet | | `--pretty` | true | Pretty-print JSON-output | | `--include-blocks` | true | Inkludér block-definitioner | | `--title ` | "Webhouse Content Schema" | Custom titel | Eksempel med alle options: ```bash npx cms export-schema \ --out webhouse-schema.json \ --baseUrl https://mit-site.example.com \ --title "Mit Sites Indholdsschema" ``` ### 3. HTTP API Til værktøjsintegration. Samme backend som UI-knapperne. ```bash # GET — returnerer schema'et som JSON (browser-session påkrævet) curl -H "Cookie: cms-session=..." \ "https://localhost:3010/api/cms/registry/export-schema?configPath=/abs/sti/til/cms.config.ts" # GET med download-flag — tilføjer Content-Disposition header til browser-download curl -H "Cookie: cms-session=..." \ "https://localhost:3010/api/cms/registry/export-schema?configPath=/abs/sti/til/cms.config.ts&download=1" \ -o webhouse-schema.json # POST — skriver filen til projekt-roden, returnerer metadata curl -X POST -H "Cookie: cms-session=..." \ -H "Content-Type: application/json" \ -d '{"configPath":"/abs/sti/til/cms.config.ts"}' \ https://localhost:3010/api/cms/registry/export-schema ``` Response fra POST-endpoint: ```json { "ok": true, "path": "/Users/cb/projects/mit-site/webhouse-schema.json", "bytes": 4567, "collections": 2, "blocks": 0, "generatedAt": "2026-04-09T12:34:56.789Z" } ``` ## Hvordan schemaet ser ud Et minimalt eksempel for en blog med en `posts`-collection: ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://min-blog.example.com/webhouse-schema.json", "title": "Webhouse Content Schema", "x-webhouse-version": "0.3.0", "x-generated-at": "2026-04-09T00:00:00.000Z", "$defs": { "Document": { "type": "object", "required": ["slug", "status", "data"], "properties": { "slug": { "type": "string", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" }, "status": { "enum": ["draft", "published", "archived", "expired", "trashed"] }, "locale": { "type": "string" }, "translationGroup": { "type": "string", "format": "uuid" } } } }, "collections": { "posts": { "allOf": [{ "$ref": "#/$defs/Document" }], "x-webhouse-collection": { "name": "posts", "label": "Blogindlæg", "kind": "page", "urlPrefix": "/blog", "translatable": true }, "properties": { "data": { "type": "object", "required": ["title"], "properties": { "title": { "type": "string", "x-webhouse-field-type": "text" }, "content": { "type": "string", "contentMediaType": "text/markdown", "x-webhouse-field-type": "richtext" }, "date": { "type": "string", "format": "date", "x-webhouse-field-type": "date" }, "tags": { "type": "array", "items": { "type": "string" }, "x-webhouse-field-type": "tags" } } } } } } } ``` `x-webhouse-*` extension keywords bærer semantisk information som JSON Schema mangler: - `x-webhouse-field-type` — den oprindelige felttype fra `cms.config.ts` (text, richtext, tags, image, blocks osv.) - `x-webhouse-collection` — collection-niveau metadata (label, kind, urlPrefix, translatable) - `x-webhouse-relation` — relation-feltets target collection - `x-webhouse-richtext-features` — toolbar-whitelist for richtext-felter - `x-webhouse-allowed-blocks` — hvilke blocks der må optræde i et `blocks`-felt Reader-biblioteker bruger disse hints til at rendere passende — for eksempel ved at behandle richtext-felter som markdown der skal konverteres til HTML, eller ved at behandle `blocks`-arrays som polymorft indhold med en `_block`-diskriminator. ## Hvornår man skal re-eksportere **Når du modificerer `cms.config.ts`, skal du regenerere `webhouse-schema.json`.** Ellers vil reader-biblioteker være ude af synk. | Ændring | Re-eksport påkrævet? | |---|---| | Tilføj en ny collection | ✅ JA | | Fjern en collection | ✅ JA | | Tilføj et felt til en eksisterende collection | ✅ JA | | Fjern et felt | ✅ JA | | Omdøb et felt | ✅ JA | | Skift et felts type (text → richtext) | ✅ JA | | Skift `urlPrefix`, `kind`, `description` på en collection | ✅ JA | | Tilføj en block-definition | ✅ JA | | Redigér en content JSON-fil (`content/posts/hello.json`) | ❌ Nej — schema beskriver form, ikke data | | Opdatér locale-indstillinger | Anbefalet | ## Hvad der skal committes til git ``` projekt-rod/ cms.config.ts ← committe altid webhouse-schema.json ← committe altid (genereret, men trackes som en lockfile) content/ ← committe altid public/uploads/ ← gitignored (per-deployment medier) ``` Schema-filen er **afledt** af `cms.config.ts` men skal stadig committes. Det er kontrakten mellem TypeScript-admin og ikke-TS-konsumenter. Behandl den som `package-lock.json` eller `composer.lock`. ## Sikkerhed - **Filnavn-validering:** API'et accepterer kun filnavne der matcher `^[a-zA-Z0-9._-]+\.(json|yaml|yml)$` for at forhindre injection. - **Path traversal-beskyttelse:** output-stien valideres som en subpath af projekt-mappen. - **GitHub-backed configs:** schema-eksport fra `github://`-configs er ikke understøttet endnu (Phase 2 af F125). - **Auth:** HTTP-API'et er bag samme session-baserede auth som resten af `/api/cms/*`. ## Se også - [Framework-agnostisk arkitektur](/docs/framework-agnostic-da) — det store billede - [Forbrug fra Java (Spring Boot)](/docs/consume-java-da) - [Forbrug fra C# / .NET](/docs/consume-dotnet-da) - [Forbrug fra Laravel (PHP)](/docs/consume-laravel-da) - [Forbrug fra Django (Python)](/docs/consume-django-da) - [Forbrug fra Rails (Ruby)](/docs/consume-rails-da) - [Forbrug fra Go](/docs/consume-go-da) - [Test af consumer-eksemplerne](/docs/testing-consumer-examples-da) - **Feature plan:** [F125 — Framework-agnostisk indholdsplatform](https://github.com/webhousecode/cms/blob/main/docs/features/F125-framework-agnostic-consumers.md) --- ## docs/schema-export Title: Schema Export — webhouse-schema.json Updated: 2026-04-09 Locale: en Generate a JSON Schema document from cms.config.ts so PHP, Python, Ruby, Go, Java, and .NET reader libraries can introspect your content model. ## What is schema export? @webhouse/cms is a **framework-agnostic content platform**. The admin UI is TypeScript, but the content layer is universal: flat JSON files in `content/` that can be read by any language. Reader libraries in PHP, Python, Ruby, Go, Java, and .NET all consume the same files. But these reader libraries can't execute your `cms.config.ts` (it's TypeScript). They need a language-agnostic description of the content model — what collections exist, what fields they have, what types those fields are. That description is **`webhouse-schema.json`**: a [JSON Schema draft 2020-12](https://json-schema.org/draft/2020-12/schema) document with `x-webhouse-*` extension keywords for type hints (richtext, tags, blocks, relations) that JSON Schema doesn't natively support. Think of it as a **generated lockfile** for your content model — derived from `cms.config.ts`, always committed to git, always kept in sync. ## Why it exists Without `webhouse-schema.json`, a Java or PHP developer reading @webhouse/cms content has no way to know: - What collections exist on this site - What fields each collection has - Which fields are required vs optional - Which fields are richtext (markdown) vs plain text - Which collections are translatable, which are pages vs data records - What blocks are defined and what their fields look like With the schema file, every reader library can introspect the content model, generate types, validate documents, and produce IDE autocomplete — all without parsing TypeScript. It's also the foundation for future tooling: type generators, schema diff tools, content validators, and automated migration helpers. ## Three ways to export There are three equivalent ways to generate `webhouse-schema.json`. Pick the one that fits your workflow. ### 1. CMS admin UI The friendliest option for human editors. 1. Open CMS admin → **Site Settings** 2. Find the **Schema export** section, just below "Validate site" 3. You have two buttons: - **⬇ Download schema** — saves `webhouse-schema.json` to your browser's Downloads folder. Use this for inspection, sharing, or manual handling. - **💾 Save to project root** — writes `webhouse-schema.json` directly next to your `cms.config.ts`. Use this when you want to commit it to git. After clicking Save, you'll see a green confirmation panel showing: - File size (e.g. "4.5 KB") - Absolute path where it was written - Number of collections and blocks included ### 2. CLI command The canonical option for CI/CD pipelines, AI agents (Claude Code, Cursor), and scriptable workflows. ```bash cd /path/to/your-project npx cms export-schema --out webhouse-schema.json ``` Flags: | Flag | Default | Description | |------|---------|-------------| | `--out ` | stdout | Write to file instead of stdout | | `--baseUrl ` | (none) | Sets the `$id` field on the schema | | `--pretty` | true | Pretty-print JSON output | | `--include-blocks` | true | Include block definitions | | `--title ` | "Webhouse Content Schema" | Custom title | Example with all options: ```bash npx cms export-schema \ --out webhouse-schema.json \ --baseUrl https://my-site.example.com \ --title "My Site Content Schema" ``` ### 3. HTTP API For tooling integration. Same backend as the UI buttons. ```bash # GET — returns the schema as JSON (browser session required) curl -H "Cookie: cms-session=..." \ "https://localhost:3010/api/cms/registry/export-schema?configPath=/abs/path/to/cms.config.ts" # GET with download flag — adds Content-Disposition header for browser download curl -H "Cookie: cms-session=..." \ "https://localhost:3010/api/cms/registry/export-schema?configPath=/abs/path/to/cms.config.ts&download=1" \ -o webhouse-schema.json # POST — writes the file to the project root, returns metadata curl -X POST -H "Cookie: cms-session=..." \ -H "Content-Type: application/json" \ -d '{"configPath":"/abs/path/to/cms.config.ts"}' \ https://localhost:3010/api/cms/registry/export-schema ``` Response from the POST endpoint: ```json { "ok": true, "path": "/Users/cb/projects/my-site/webhouse-schema.json", "bytes": 4567, "collections": 2, "blocks": 0, "generatedAt": "2026-04-09T12:34:56.789Z" } ``` ## What the schema looks like A minimal example for a blog with a `posts` collection: ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://my-blog.example.com/webhouse-schema.json", "title": "Webhouse Content Schema", "x-webhouse-version": "0.3.0", "x-generated-at": "2026-04-09T00:00:00.000Z", "$defs": { "Document": { "type": "object", "required": ["slug", "status", "data"], "properties": { "slug": { "type": "string", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" }, "status": { "enum": ["draft", "published", "archived", "expired", "trashed"] }, "locale": { "type": "string" }, "translationGroup": { "type": "string", "format": "uuid" } } } }, "collections": { "posts": { "allOf": [{ "$ref": "#/$defs/Document" }], "x-webhouse-collection": { "name": "posts", "label": "Blog Posts", "kind": "page", "urlPrefix": "/blog", "translatable": true }, "properties": { "data": { "type": "object", "required": ["title"], "properties": { "title": { "type": "string", "x-webhouse-field-type": "text" }, "content": { "type": "string", "contentMediaType": "text/markdown", "x-webhouse-field-type": "richtext" }, "date": { "type": "string", "format": "date", "x-webhouse-field-type": "date" }, "tags": { "type": "array", "items": { "type": "string" }, "x-webhouse-field-type": "tags" } } } } } } } ``` The `x-webhouse-*` extension keywords carry semantic information that JSON Schema lacks: - `x-webhouse-field-type` — the original field type from `cms.config.ts` (text, richtext, tags, image, blocks, etc.) - `x-webhouse-collection` — collection-level metadata (label, kind, urlPrefix, translatable) - `x-webhouse-relation` — relation field target collection - `x-webhouse-richtext-features` — toolbar whitelist for richtext fields - `x-webhouse-allowed-blocks` — which blocks can appear in a `blocks` field Reader libraries use these hints to render appropriately — for example, treating richtext fields as markdown that needs to be converted to HTML, or treating `blocks` arrays as polymorphic content with a `_block` discriminator. ## When to re-export **Whenever you modify `cms.config.ts`, you must regenerate `webhouse-schema.json`.** Otherwise reader libraries will be out of sync. | Change | Re-export required? | |---|---| | Add a new collection | ✅ YES | | Remove a collection | ✅ YES | | Add a field to an existing collection | ✅ YES | | Remove a field | ✅ YES | | Rename a field | ✅ YES | | Change a field's type (text → richtext) | ✅ YES | | Change `urlPrefix`, `kind`, `description` on a collection | ✅ YES | | Add a block definition | ✅ YES | | Edit a content JSON file (`content/posts/hello.json`) | ❌ No — schema describes shape, not data | | Update locale settings | Recommended | ## What to commit to git ``` project-root/ cms.config.ts ← always commit webhouse-schema.json ← always commit (generated, but tracked like a lockfile) content/ ← always commit public/uploads/ ← gitignored (per-deployment media) ``` The schema file is **derived** from `cms.config.ts` but should still be committed. It's the contract between the TypeScript admin and non-TS consumers. Treat it like `package-lock.json` or `composer.lock`. ## Security - **Filename validation:** the API only accepts filenames matching `^[a-zA-Z0-9._-]+\.(json|yaml|yml)$` to prevent injection. - **Path traversal protection:** the output path is validated as a subpath of the project directory. - **GitHub-backed configs:** schema export from `github://` configs is not supported yet (Phase 2 of F125). - **Auth:** the HTTP API is behind the same session-based auth as the rest of `/api/cms/*`. ## See also - [Framework-Agnostic Architecture](/docs/framework-agnostic) — the bigger picture - [Consume from Java (Spring Boot)](/docs/consume-java) - [Consume from C# / .NET](/docs/consume-dotnet) - [Consume from Laravel (PHP)](/docs/consume-laravel) - [Consume from Django (Python)](/docs/consume-django) - [Consume from Rails (Ruby)](/docs/consume-rails) - [Consume from Go](/docs/consume-go) - [Testing the Consumer Examples](/docs/testing-consumer-examples) - **Feature plan:** [F125 — Framework-Agnostic Content Platform](https://github.com/webhousecode/cms/blob/main/docs/features/F125-framework-agnostic-consumers.md) --- ## docs/passwordless-login Title: Passkeys & Two-Factor Login Updated: 2026-04-08 Locale: en Sign in to the CMS without a password using FaceID, TouchID, Windows Hello, or a hardware security key — and add an authenticator-app code on top for two-factor protection. ## Why passwordless Passwords leak, get reused, and slow you down on mobile. The webhouse.app CMS now supports two passwordless mechanisms that you can use alongside (or instead of) the email/password login you already have: 1. **Passkeys (WebAuthn)** — sign in with a biometric prompt or a hardware security key. Nothing to type, nothing to forget. 2. **Authenticator-app TOTP** — a six-digit code from Microsoft Authenticator, Google Authenticator, Authy, 1Password, Bitwarden, or any other RFC 6238 app, used as a second factor on top of your existing login. They are independent. You can enable one, both, or neither. Both work with the existing GitHub OAuth login and with email/password. ## Passkeys A passkey is a public/private keypair stored on your device (or in your password manager). The private key never leaves the device. The CMS only stores the public key, so even if our database leaks an attacker cannot impersonate you. When you sign in, the browser asks the operating system to prove you have the private key — that prompt is FaceID, TouchID, Windows Hello, a fingerprint sensor, or a tap on a YubiKey. No passwords cross the wire. ### Add a passkey 1. Sign in to the CMS the way you normally do (email/password or GitHub). 2. Open **Account → Security**. 3. In the **Passkeys** card, click **+ Add passkey**. 4. Your browser shows the platform passkey dialog. Choose where to save the credential — on this device, in your iCloud Keychain, in 1Password, on a security key, or on another phone via QR code. 5. Approve with your biometric. 6. The passkey appears in the list. The CMS picks a sensible default label based on your operating system ("Mac", "iPhone", "Windows", etc.) — you can rename it later. You can add as many passkeys as you want. A typical setup is one for your laptop, one for your phone (synced via iCloud / Google), and one hardware security key as a backup. ### Sign in with a passkey On the login page, click **Sign in with passkey**. The browser shows the same passkey picker. Approve with your biometric and you're in. No email, no password. If you have multiple accounts, type your email first — the CMS will tell the browser which credentials are eligible, so the picker only shows passkeys for that account. ### Remove a passkey Open **Account → Security**, find the passkey in the list, and click the **×** button. Confirm with **Yes**. The credential is deleted from the CMS immediately. The corresponding key on your device or in your password manager is unaffected — you can clean it up there separately. ### Cross-device sign-in Modern browsers support **cross-device WebAuthn**: on your laptop, click "Sign in with passkey" and choose "Use a phone, tablet, or security key". The browser shows a QR code. Scan it with your phone's camera (no app needed), approve with FaceID, and the laptop is signed in via a Bluetooth/relay handshake. The passkey itself stays on the phone — your laptop never sees it. This is particularly useful for one-off sign-ins on a borrowed computer. ## Authenticator app (TOTP) A passkey alone is already strong, but if you want belt-and-braces protection — or if your security policy requires two factors — you can layer a six-digit code on top of any login method. TOTP works with any authenticator app you already have on your phone. The CMS shows a QR code at enrollment, you scan it, and from then on the app generates a fresh six-digit code every 30 seconds. ### Enroll an authenticator app 1. Open **Account → Security**. 2. In the **Authenticator app** card, click **Add new app**. 3. Open Microsoft Authenticator, Google Authenticator, 1Password, Authy, or any other RFC 6238 compatible app. 4. Use the app's "add account" flow and scan the QR code shown by the CMS. (If you can't scan — for example because the QR is on the same device as the app — expand **Can't scan? Enter manually** and copy the base32 secret into the app instead.) 5. The app starts generating six-digit codes. Type the current code into the **123456** field and click **Verify & enable**. 6. The CMS shows you ten **backup codes**. Save them somewhere safe — a password manager, a printed sheet in a drawer, anywhere offline. Each backup code works exactly once. They are your way back in if you lose your phone. 7. Click **I've saved them** when you're done. ### Sign in with TOTP The next time you sign in with email/password or with a passkey, the CMS will pause after verifying your primary credential and ask for a six-digit code. Open your authenticator app, type the current code, and continue. If you've lost your phone, type one of your backup codes instead — it counts as one use and is then invalidated. The code prompt accepts both formats with or without spaces; backup codes can be entered with or without the dash. ### Disable TOTP If you want to remove TOTP protection (for example because you're switching to a new authenticator app): 1. Open **Account → Security**. 2. In the **Authenticator app** card, click **Disable**. 3. Type a current six-digit code (or a backup code) and click **Disable**. 4. The CMS removes the TOTP secret and all remaining backup codes. Your next sign-in will go straight to the dashboard without a code prompt. You cannot disable TOTP without a valid code — that prevents an attacker who has stolen your active session from removing your second factor. ## Recommended setup For most users: - **One passkey per device** you actually sign in from — laptop, phone, tablet. Keep them synced via iCloud Keychain / Google / 1Password so a lost device doesn't cost you access. - **One hardware security key** (YubiKey, SoloKey) stored somewhere safe, registered as a passkey, as a hardware backup. - **TOTP enabled** if you handle published content for paying clients or otherwise want a second factor that survives a lost device. - **Backup codes printed and stored offline.** With that setup you can sign in from any device in seconds, you have multiple recovery paths if any single factor is lost, and an attacker would need both your primary credential and your second factor to get in. ## Frequently asked questions **Can I still use email/password after adding a passkey?** Yes. Passkeys are additive — your password keeps working. If you want to remove the password, change it to something random in **Account → Security → Change password** and store it in your password manager as a recovery option. **What happens if I lose every passkey and my phone?** Your ten TOTP backup codes are the bottom layer. If you've also lost those, an admin on your team can delete your user record from the CMS and re-invite you, which generates a new password. **Does TOTP work with hardware tokens like YubiKey?** For TOTP-over-NFC tokens, yes — tap the YubiKey to your phone, the authenticator app reads the seed, and you get a code. For pure WebAuthn use, just register the YubiKey as a passkey directly — that's faster and more secure than TOTP. **Can I sync passkeys across two CMS sites?** A passkey is bound to the hostname it was registered on. A passkey for `cms.example.com` does not work on `cms.other.com`. Register a separate passkey on each site. **Is the TOTP secret recoverable from the QR code if I lose my phone?** No — once you've scanned the QR and clicked **Verify & enable**, the secret is locked into that authenticator app. The CMS deliberately doesn't let you re-display the QR. Use your backup codes. ## See also - [User invitations](/docs/user-invitations) — how new accounts get bootstrapped - [Sign in with GitHub](/docs/github-login) — OAuth as a third option alongside passkeys and TOTP --- ## docs/consume-django-da Title: Forbrug fra Django (Python) Updated: 2026-04-08 Locale: da Læs @webhouse/cms-indhold fra en Django-applikation. Hjælpemodul, views, skabeloner. ## Opsætning Placér dit Django-projekt og @webhouse/cms-indhold side om side: ``` mit-projekt/ cms.config.ts # Indholdsmodel content/ # JSON-dokumenter (læses af Django) public/uploads/ # Mediefiler mysite/ # Django-projekt blog/ # Django-app ``` ## Hjælpemodul Opret `blog/webhouse.py`: ```python from pathlib import Path from typing import Optional import json from django.conf import settings CONTENT_DIR = Path(settings.BASE_DIR) / 'content' def collection(name: str, locale: Optional[str] = None) -> list[dict]: """List alle publicerede dokumenter i en collection.""" folder = CONTENT_DIR / name if not folder.exists(): return [] docs = [] for f in folder.glob('*.json'): try: doc = json.loads(f.read_text(encoding='utf-8')) except json.JSONDecodeError: continue if doc.get('status') != 'published': continue if locale and doc.get('locale') != locale: continue docs.append(doc) docs.sort(key=lambda d: d.get('data', {}).get('date', ''), reverse=True) return docs def document(collection_name: str, slug: str) -> Optional[dict]: """Hent et enkelt dokument via slug.""" path = CONTENT_DIR / collection_name / f'{slug}.json' if not path.exists(): return None doc = json.loads(path.read_text(encoding='utf-8')) return doc if doc.get('status') == 'published' else None def find_translation(doc: dict, collection_name: str) -> Optional[dict]: """Find søskende-oversættelsen af et dokument via translationGroup.""" tg = doc.get('translationGroup') if not tg: return None for other in collection(collection_name): if other.get('translationGroup') == tg and other.get('locale') != doc.get('locale'): return other return None ``` ## Views ```python # blog/views.py from django.shortcuts import render from django.http import Http404 from . import webhouse def home(request): posts = webhouse.collection('posts', locale='da') return render(request, 'blog/home.html', {'posts': posts}) def post_detail(request, slug: str): post = webhouse.document('posts', slug) if not post: raise Http404() return render(request, 'blog/post.html', {'post': post}) ``` ## URLs ```python # blog/urls.py from django.urls import path from . import views urlpatterns = [ path('', views.home, name='home'), path('blog//', views.post_detail, name='post-detail'), ] ``` ## Skabelon ```django {# blog/templates/blog/post.html #} {% extends "base.html" %} {% load markdownify %} {% block content %} {{ post.data.title }} {{ post.data.date }} {{ post.data.content|markdownify }} {% for tag in post.data.tags %} #{{ tag }} {% endfor %} {% endblock %} ``` Installér `django-markdownify` for markdown-filteret: `pip install django-markdownify`. ## Servering af medier Tilføj `content/` og `public/` til Django's static dirs: ```python # settings.py STATICFILES_DIRS = [ BASE_DIR / 'public', # serverer /uploads/* herfra ] ``` ## i18n med translationGroup ```python post = webhouse.document('posts', 'hello-world') translation = webhouse.find_translation(post, 'posts') # Nu har du begge sprog — render en sprogskifter ``` ## Caching Brug Django's cache framework: ```python from django.core.cache import cache def collection_cached(name: str, locale: Optional[str] = None) -> list[dict]: key = f'webhouse:{name}:{locale or "all"}' cached = cache.get(key) if cached is not None: return cached docs = collection(name, locale) cache.set(key, docs, timeout=60) return docs ``` ## FastAPI-variant Den samme helper virker i FastAPI: ```python from fastapi import FastAPI, HTTPException from pathlib import Path import json app = FastAPI() CONTENT_DIR = Path('content') @app.get('/blog/{slug}') def post_detail(slug: str): path = CONTENT_DIR / 'posts' / f'{slug}.json' if not path.exists(): raise HTTPException(404) return json.loads(path.read_text()) ``` ## Næste skridt - Se [Django-eksemplet](https://github.com/webhousecode/cms/tree/main/examples/consumers/django-blog) - Læs om [Framework-agnostisk arkitektur](/docs/framework-agnostic) --- ## docs/consume-django Title: Consume from Django (Python) Updated: 2026-04-08 Locale: en Read @webhouse/cms content from a Django application. Helper module, views, templates. ## Setup Place your Django project and @webhouse/cms content side by side: ``` my-project/ cms.config.ts # Content model content/ # JSON documents (read by Django) public/uploads/ # Media files mysite/ # Django project blog/ # Django app ``` ## Helper module Create `blog/webhouse.py`: ```python from pathlib import Path from typing import Optional import json from functools import lru_cache from django.conf import settings CONTENT_DIR = Path(settings.BASE_DIR) / 'content' def collection(name: str, locale: Optional[str] = None) -> list[dict]: """List all published documents in a collection.""" folder = CONTENT_DIR / name if not folder.exists(): return [] docs = [] for f in folder.glob('*.json'): try: doc = json.loads(f.read_text(encoding='utf-8')) except json.JSONDecodeError: continue if doc.get('status') != 'published': continue if locale and doc.get('locale') != locale: continue docs.append(doc) docs.sort(key=lambda d: d.get('data', {}).get('date', ''), reverse=True) return docs def document(collection_name: str, slug: str) -> Optional[dict]: """Load a single document by slug.""" path = CONTENT_DIR / collection_name / f'{slug}.json' if not path.exists(): return None doc = json.loads(path.read_text(encoding='utf-8')) return doc if doc.get('status') == 'published' else None def find_translation(doc: dict, collection_name: str) -> Optional[dict]: """Find the sibling translation of a document via translationGroup.""" tg = doc.get('translationGroup') if not tg: return None for other in collection(collection_name): if other.get('translationGroup') == tg and other.get('locale') != doc.get('locale'): return other return None ``` ## Views ```python # blog/views.py from django.shortcuts import render from django.http import Http404 from . import webhouse def home(request): posts = webhouse.collection('posts', locale='en') return render(request, 'blog/home.html', {'posts': posts}) def post_detail(request, slug: str): post = webhouse.document('posts', slug) if not post: raise Http404() return render(request, 'blog/post.html', {'post': post}) ``` ## URLs ```python # blog/urls.py from django.urls import path from . import views urlpatterns = [ path('', views.home, name='home'), path('blog//', views.post_detail, name='post-detail'), ] ``` ## Template ```django {# blog/templates/blog/post.html #} {% extends "base.html" %} {% load markdownify %} {% block content %} {{ post.data.title }} {{ post.data.date }} {{ post.data.content|markdownify }} {% for tag in post.data.tags %} #{{ tag }} {% endfor %} {% endblock %} ``` Install `django-markdownify` for the markdown filter: `pip install django-markdownify`. ## Serving media Add `content/` and `public/` to Django's static dirs: ```python # settings.py STATICFILES_DIRS = [ BASE_DIR / 'public', # serves /uploads/* from here ] ``` ## i18n with translationGroup ```python post = webhouse.document('posts', 'hello-world') translation = webhouse.find_translation(post, 'posts') # Now you have both locales — render a language switcher ``` ## Caching Use Django's cache framework: ```python from django.core.cache import cache def collection_cached(name: str, locale: Optional[str] = None) -> list[dict]: key = f'webhouse:{name}:{locale or "all"}' cached = cache.get(key) if cached is not None: return cached docs = collection(name, locale) cache.set(key, docs, timeout=60) return docs ``` ## FastAPI variant The same helper works in FastAPI — just replace `django.conf.settings` with `os.environ` for the content path: ```python from fastapi import FastAPI, HTTPException from pathlib import Path import json app = FastAPI() CONTENT_DIR = Path('content') @app.get('/blog/{slug}') def post_detail(slug: str): path = CONTENT_DIR / 'posts' / f'{slug}.json' if not path.exists(): raise HTTPException(404) return json.loads(path.read_text()) ``` ## Next steps - See the [Django example](https://github.com/webhousecode/cms/tree/main/examples/consumers/django-blog) - Learn about [Framework-Agnostic Architecture](/docs/framework-agnostic) --- ## docs/consume-dotnet-da Title: Forbrug fra C# / .NET Updated: 2026-04-08 Locale: da Læs @webhouse/cms-indhold fra ASP.NET Core — Razor Pages, MVC eller minimal APIs. ## Opsætning ``` mit-projekt/ cms.config.ts content/ wwwroot/uploads/ Pages/ Services/ ``` ## Service-klasse Opret `Services/Webhouse.cs`: ```csharp using System.Text.Json; using System.Text.Json.Serialization; namespace MyApp.Services; public class WebhouseDocument { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("slug")] public string Slug { get; set; } = string.Empty; [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("locale")] public string? Locale { get; set; } [JsonPropertyName("translationGroup")] public string? TranslationGroup { get; set; } [JsonPropertyName("data")] public Dictionary Data { get; set; } = new(); } public class Webhouse { private readonly string _contentDir; private static readonly JsonSerializerOptions _options = new() { PropertyNameCaseInsensitive = true, }; public Webhouse(IWebHostEnvironment env) { _contentDir = Path.Combine(env.ContentRootPath, "content"); } public List Collection(string name, string? locale = null) { var dir = Path.Combine(_contentDir, name); if (!Directory.Exists(dir)) return new(); var docs = new List(); foreach (var file in Directory.GetFiles(dir, "*.json")) { try { var doc = JsonSerializer.Deserialize(File.ReadAllText(file), _options); if (doc == null || doc.Status != "published") continue; if (locale != null && doc.Locale != locale) continue; docs.Add(doc); } catch (JsonException) { /* spring over */ } } return docs .OrderByDescending(d => GetString(d, "date") ?? "") .ToList(); } public WebhouseDocument? Document(string collection, string slug) { var path = Path.Combine(_contentDir, collection, $"{slug}.json"); if (!File.Exists(path)) return null; var doc = JsonSerializer.Deserialize(File.ReadAllText(path), _options); return doc?.Status == "published" ? doc : null; } public static string? GetString(WebhouseDocument doc, string key) { return doc.Data.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; } } ``` ## Registrér i Program.cs ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); builder.Services.AddSingleton(); var app = builder.Build(); app.UseStaticFiles(); app.MapRazorPages(); app.Run(); ``` ## Razor Page ```csharp // Pages/Blog/Post.cshtml.cs using Microsoft.AspNetCore.Mvc.RazorPages; using MyApp.Services; public class PostModel : PageModel { private readonly Webhouse _webhouse; public WebhouseDocument? Post { get; private set; } public PostModel(Webhouse webhouse) => _webhouse = webhouse; public IActionResult OnGet(string slug) { Post = _webhouse.Document("posts", slug); return Post == null ? NotFound() : Page(); } } ``` ```razor @* Pages/Blog/Post.cshtml *@ @page "{slug}" @model PostModel @using MyApp.Services @Webhouse.GetString(Model.Post!, "title") @Webhouse.GetString(Model.Post!, "date") @Html.Raw(Markdig.Markdown.ToHtml(Webhouse.GetString(Model.Post!, "content") ?? "")) ``` Tilføj Markdig for markdown-rendering: `dotnet add package Markdig` ## Minimal API-alternativ ```csharp var app = WebApplication.Create(args); var wh = new Webhouse(app.Environment); app.MapGet("/", () => wh.Collection("posts", "da")); app.MapGet("/blog/{slug}", (string slug) => { var post = wh.Document("posts", slug); return post == null ? Results.NotFound() : Results.Ok(post); }); app.Run(); ``` ## Servering af medier ASP.NET Core's `UseStaticFiles()` serverer fra `wwwroot/` som standard. Enten: 1. Symlink `content/uploads` til `wwwroot/uploads`, eller 2. Tilføj en ekstra static files middleware: ```csharp app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(Path.Combine(builder.Environment.ContentRootPath, "public/uploads")), RequestPath = "/uploads", }); ``` ## Caching ```csharp builder.Services.AddMemoryCache(); public List CollectionCached(string name, string? locale, IMemoryCache cache) { var key = $"webhouse:{name}:{locale}"; return cache.GetOrCreate(key, entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); return Collection(name, locale); }) ?? new(); } ``` ## Næste skridt - Se [.NET-eksemplet](https://github.com/webhousecode/cms/tree/main/examples/consumers/dotnet-blog) - Læs om [Framework-agnostisk arkitektur](/docs/framework-agnostic) --- ## docs/consume-dotnet Title: Consume from C# / .NET Updated: 2026-04-08 Locale: en Read @webhouse/cms content from ASP.NET Core — Razor Pages, MVC, or minimal APIs. ## Setup ``` my-project/ cms.config.ts content/ wwwroot/uploads/ Pages/ Services/ ``` ## Service class Create `Services/Webhouse.cs`: ```csharp using System.Text.Json; using System.Text.Json.Serialization; namespace MyApp.Services; public class WebhouseDocument { [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("slug")] public string Slug { get; set; } = string.Empty; [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("locale")] public string? Locale { get; set; } [JsonPropertyName("translationGroup")] public string? TranslationGroup { get; set; } [JsonPropertyName("data")] public Dictionary Data { get; set; } = new(); } public class Webhouse { private readonly string _contentDir; private static readonly JsonSerializerOptions _options = new() { PropertyNameCaseInsensitive = true, }; public Webhouse(IWebHostEnvironment env) { _contentDir = Path.Combine(env.ContentRootPath, "content"); } public List Collection(string name, string? locale = null) { var dir = Path.Combine(_contentDir, name); if (!Directory.Exists(dir)) return new(); var docs = new List(); foreach (var file in Directory.GetFiles(dir, "*.json")) { try { var doc = JsonSerializer.Deserialize(File.ReadAllText(file), _options); if (doc == null || doc.Status != "published") continue; if (locale != null && doc.Locale != locale) continue; docs.Add(doc); } catch (JsonException) { /* skip malformed */ } } return docs .OrderByDescending(d => GetString(d, "date") ?? "") .ToList(); } public WebhouseDocument? Document(string collection, string slug) { var path = Path.Combine(_contentDir, collection, $"{slug}.json"); if (!File.Exists(path)) return null; var doc = JsonSerializer.Deserialize(File.ReadAllText(path), _options); return doc?.Status == "published" ? doc : null; } public WebhouseDocument? FindTranslation(WebhouseDocument doc, string collection) { if (string.IsNullOrEmpty(doc.TranslationGroup)) return null; return Collection(collection).FirstOrDefault(other => other.TranslationGroup == doc.TranslationGroup && other.Locale != doc.Locale); } public static string? GetString(WebhouseDocument doc, string key) { return doc.Data.TryGetValue(key, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; } } ``` ## Register in Program.cs ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); builder.Services.AddSingleton(); var app = builder.Build(); app.UseStaticFiles(); app.MapRazorPages(); app.Run(); ``` ## Razor Page ```csharp // Pages/Blog/Post.cshtml.cs using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using MyApp.Services; public class PostModel : PageModel { private readonly Webhouse _webhouse; public WebhouseDocument? Post { get; private set; } public PostModel(Webhouse webhouse) => _webhouse = webhouse; public IActionResult OnGet(string slug) { Post = _webhouse.Document("posts", slug); return Post == null ? NotFound() : Page(); } } ``` ```razor @* Pages/Blog/Post.cshtml *@ @page "{slug}" @model PostModel @using MyApp.Services @Webhouse.GetString(Model.Post!, "title") @Webhouse.GetString(Model.Post!, "date") @Html.Raw(Markdig.Markdown.ToHtml(Webhouse.GetString(Model.Post!, "content") ?? "")) ``` Add Markdig for markdown rendering: `dotnet add package Markdig` ## Minimal API alternative ```csharp var app = WebApplication.Create(args); var wh = new Webhouse(app.Environment); app.MapGet("/", () => wh.Collection("posts", "en")); app.MapGet("/blog/{slug}", (string slug) => { var post = wh.Document("posts", slug); return post == null ? Results.NotFound() : Results.Ok(post); }); app.Run(); ``` ## Serving media ASP.NET Core's `UseStaticFiles()` serves from `wwwroot/` by default. Either: 1. Symlink `content/uploads` to `wwwroot/uploads`, or 2. Add an additional static files middleware: ```csharp app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(Path.Combine(builder.Environment.ContentRootPath, "public/uploads")), RequestPath = "/uploads", }); ``` ## Caching ```csharp builder.Services.AddMemoryCache(); // In Webhouse class: public List CollectionCached(string name, string? locale, IMemoryCache cache) { var key = $"webhouse:{name}:{locale}"; return cache.GetOrCreate(key, entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); return Collection(name, locale); }) ?? new(); } ``` ## Next steps - See the [.NET example](https://github.com/webhousecode/cms/tree/main/examples/consumers/dotnet-blog) - Learn about [Framework-Agnostic Architecture](/docs/framework-agnostic) --- ## docs/consume-go-da Title: Forbrug fra Go Updated: 2026-04-08 Locale: da Læs @webhouse/cms-indhold fra Go — Hugo-temaer, Gin-handlers og standard bibliotek fil I/O. ## Opsætning ``` mit-projekt/ cms.config.ts content/ # JSON-dokumenter public/uploads/ main.go templates/ ``` ## Reader-pakke Opret `internal/webhouse/webhouse.go`: ```go package webhouse import ( "encoding/json" "os" "path/filepath" "sort" "strings" ) type Document struct { ID string `json:"id"` Slug string `json:"slug"` Status string `json:"status"` Locale string `json:"locale,omitempty"` TranslationGroup string `json:"translationGroup,omitempty"` Data map[string]interface{} `json:"data"` } type Client struct { ContentDir string } func New(contentDir string) *Client { return &Client{ContentDir: contentDir} } func (c *Client) Collection(name, locale string) ([]Document, error) { dir := filepath.Join(c.ContentDir, name) entries, err := os.ReadDir(dir) if err != nil { return nil, err } var docs []Document for _, e := range entries { if !strings.HasSuffix(e.Name(), ".json") { continue } raw, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { continue } var d Document if err := json.Unmarshal(raw, &d); err != nil { continue } if d.Status != "published" { continue } if locale != "" && d.Locale != locale { continue } docs = append(docs, d) } sort.Slice(docs, func(i, j int) bool { di, _ := docs[i].Data["date"].(string) dj, _ := docs[j].Data["date"].(string) return di > dj }) return docs, nil } func (c *Client) Document(collection, slug string) (*Document, error) { path := filepath.Join(c.ContentDir, collection, slug+".json") raw, err := os.ReadFile(path) if err != nil { return nil, err } var d Document if err := json.Unmarshal(raw, &d); err != nil { return nil, err } if d.Status != "published" { return nil, nil } return &d, nil } ``` ## Gin-handler ```go package main import ( "net/http" "github.com/gin-gonic/gin" "mitprojekt/internal/webhouse" ) func main() { wh := webhouse.New("content") r := gin.Default() r.LoadHTMLGlob("templates/*") r.Static("/uploads", "./public/uploads") r.GET("/", func(c *gin.Context) { posts, _ := wh.Collection("posts", "da") c.HTML(http.StatusOK, "home.html", gin.H{"Posts": posts}) }) r.GET("/blog/:slug", func(c *gin.Context) { post, _ := wh.Document("posts", c.Param("slug")) if post == nil { c.AbortWithStatus(404) return } c.HTML(http.StatusOK, "post.html", gin.H{"Post": post}) }) r.Run(":8080") } ``` ## Skabelon ```html {{ index .Post.Data "title" }} {{ index .Post.Data "date" }} {{ index .Post.Data "content" | markdown }} ``` ## Hugo-integration Hugo bruger sit eget content-system, men du kan skrive et lille script der konverterer @webhouse/cms JSON til Hugo's front matter + markdown format ved build-tid. ## Caching Brug en `sync.Map` eller in-memory cache med TTL: ```go var cache sync.Map func (c *Client) CollectionCached(name, locale string) ([]Document, error) { key := name + ":" + locale if v, ok := cache.Load(key); ok { return v.([]Document), nil } docs, err := c.Collection(name, locale) if err != nil { return nil, err } cache.Store(key, docs) return docs, nil } ``` ## Næste skridt - Se [Go-eksemplet](https://github.com/webhousecode/cms/tree/main/examples/consumers/go-gin) - Læs om [Framework-agnostisk arkitektur](/docs/framework-agnostic) --- ## docs/consume-go Title: Consume from Go Updated: 2026-04-08 Locale: en Read @webhouse/cms content from Go — Hugo themes, Gin handlers, and standard library file I/O. ## Setup ``` my-project/ cms.config.ts content/ # JSON documents public/uploads/ main.go templates/ ``` ## Reader package Create `internal/webhouse/webhouse.go`: ```go package webhouse import ( "encoding/json" "os" "path/filepath" "sort" "strings" ) type Document struct { ID string `json:"id"` Slug string `json:"slug"` Status string `json:"status"` Locale string `json:"locale,omitempty"` TranslationGroup string `json:"translationGroup,omitempty"` Data map[string]interface{} `json:"data"` CreatedAt string `json:"createdAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` } type Client struct { ContentDir string } func New(contentDir string) *Client { return &Client{ContentDir: contentDir} } func (c *Client) Collection(name, locale string) ([]Document, error) { dir := filepath.Join(c.ContentDir, name) entries, err := os.ReadDir(dir) if err != nil { return nil, err } var docs []Document for _, e := range entries { if !strings.HasSuffix(e.Name(), ".json") { continue } raw, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { continue } var d Document if err := json.Unmarshal(raw, &d); err != nil { continue } if d.Status != "published" { continue } if locale != "" && d.Locale != locale { continue } docs = append(docs, d) } // Sort by date desc sort.Slice(docs, func(i, j int) bool { di, _ := docs[i].Data["date"].(string) dj, _ := docs[j].Data["date"].(string) return di > dj }) return docs, nil } func (c *Client) Document(collection, slug string) (*Document, error) { path := filepath.Join(c.ContentDir, collection, slug+".json") raw, err := os.ReadFile(path) if err != nil { return nil, err } var d Document if err := json.Unmarshal(raw, &d); err != nil { return nil, err } if d.Status != "published" { return nil, nil } return &d, nil } func (c *Client) FindTranslation(doc *Document, collection string) (*Document, error) { if doc.TranslationGroup == "" { return nil, nil } docs, err := c.Collection(collection, "") if err != nil { return nil, err } for _, other := range docs { if other.TranslationGroup == doc.TranslationGroup && other.Locale != doc.Locale { return &other, nil } } return nil, nil } ``` ## Gin handler ```go package main import ( "html/template" "net/http" "github.com/gin-gonic/gin" "myproject/internal/webhouse" ) func main() { wh := webhouse.New("content") r := gin.Default() r.LoadHTMLGlob("templates/*") r.Static("/uploads", "./public/uploads") r.GET("/", func(c *gin.Context) { posts, _ := wh.Collection("posts", "en") c.HTML(http.StatusOK, "home.html", gin.H{"Posts": posts}) }) r.GET("/blog/:slug", func(c *gin.Context) { post, _ := wh.Document("posts", c.Param("slug")) if post == nil { c.AbortWithStatus(404) return } c.HTML(http.StatusOK, "post.html", gin.H{"Post": post}) }) r.Run(":8080") } ``` ## Template ```html {{ index .Post.Data "title" }} {{ index .Post.Data "date" }} {{ index .Post.Data "content" | markdown }} ``` ## Hugo integration Hugo uses its own content system, but you can write a small script that converts @webhouse/cms JSON to Hugo's front matter + markdown format at build time: ```go // scripts/sync-to-hugo.go func main() { wh := webhouse.New("content") posts, _ := wh.Collection("posts", "") for _, p := range posts { frontMatter := fmt.Sprintf("---\ntitle: %q\ndate: %s\n---\n\n%s\n", p.Data["title"], p.Data["date"], p.Data["content"]) os.WriteFile(fmt.Sprintf("hugo/content/posts/%s.md", p.Slug), []byte(frontMatter), 0644) } } ``` Run this before `hugo build` to sync content. ## Caching Use a `sync.Map` or in-memory cache with TTL: ```go var cache sync.Map func (c *Client) CollectionCached(name, locale string) ([]Document, error) { key := name + ":" + locale if v, ok := cache.Load(key); ok { return v.([]Document), nil } docs, err := c.Collection(name, locale) if err != nil { return nil, err } cache.Store(key, docs) return docs, nil } ``` ## Next steps - See the [Go example](https://github.com/webhousecode/cms/tree/main/examples/consumers/go-gin) - Learn about [Framework-Agnostic Architecture](/docs/framework-agnostic) --- ## docs/consume-java-da Title: Forbrug fra Java (Spring Boot) Updated: 2026-04-08 Locale: da Læs @webhouse/cms-indhold fra en Spring Boot-applikation. Reader-klasse, controllers, Thymeleaf-skabeloner. ## Opsætning Placér dit Spring Boot-projekt og @webhouse/cms-indhold side om side: ``` mit-projekt/ cms.config.ts # Skema (bruges af CMS admin) content/ # JSON-dokumenter (læses af Spring Boot) public/uploads/ # Mediefiler pom.xml src/main/java/... src/main/resources/templates/ ``` ## Reader-klasse Opret `src/main/java/app/webhouse/cmsreader/WebhouseReader.java`: ```java package app.webhouse.cmsreader; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.regex.Pattern; import java.util.stream.Stream; public final class WebhouseReader { private static final Pattern SAFE_NAME = Pattern.compile("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"); private final Path contentDir; private final ObjectMapper mapper = new ObjectMapper(); public WebhouseReader(String contentDir) { this.contentDir = Path.of(contentDir).toAbsolutePath().normalize(); } public List collection(String collection, String locale) { validateName(collection); Path dir = contentDir.resolve(collection); if (!Files.isDirectory(dir)) return List.of(); List docs = new ArrayList<>(); try (Stream stream = Files.list(dir)) { stream.filter(p -> p.getFileName().toString().endsWith(".json")).forEach(p -> { try { WebhouseDocument doc = mapper.readValue(p.toFile(), WebhouseDocument.class); if (!"published".equals(doc.status)) return; if (locale != null && !locale.equals(doc.locale)) return; docs.add(doc); } catch (IOException ignored) { /* spring over */ } }); } catch (IOException e) { return List.of(); } docs.sort(Comparator.comparing( (WebhouseDocument d) -> d.getStringOr("date", ""), Comparator.reverseOrder() )); return docs; } public Optional document(String collection, String slug) { validateName(collection); validateName(slug); Path file = contentDir.resolve(collection).resolve(slug + ".json"); if (!file.startsWith(contentDir) || !Files.isRegularFile(file)) { return Optional.empty(); } try { WebhouseDocument doc = mapper.readValue(file.toFile(), WebhouseDocument.class); return "published".equals(doc.status) ? Optional.of(doc) : Optional.empty(); } catch (IOException e) { return Optional.empty(); } } public Optional findTranslation(WebhouseDocument doc, String collection) { if (doc.translationGroup == null) return Optional.empty(); return collection(collection, null).stream() .filter(o -> doc.translationGroup.equals(o.translationGroup)) .filter(o -> doc.locale == null || !doc.locale.equals(o.locale)) .findFirst(); } private static void validateName(String name) { if (name == null || !SAFE_NAME.matcher(name).matches()) { throw new IllegalArgumentException("Invalid name: " + name); } } } ``` ## Spring-konfiguration Registrér reader'en som en bean i din `Application.java`: ```java @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } @Bean public WebhouseReader webhouseReader() { return new WebhouseReader("content"); } } ``` ## Controller ```java @Controller public class BlogController { private final WebhouseReader cms; public BlogController(WebhouseReader cms) { this.cms = cms; } @GetMapping("/") public String home(Model model) { model.addAttribute("posts", cms.collection("posts", "da")); return "home"; } @GetMapping("/blog/{slug}") public String post(@PathVariable String slug, Model model) { WebhouseDocument post = cms.document("posts", slug) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); model.addAttribute("post", post); return "post"; } } ``` ## Thymeleaf-skabelon ```html Post Titel dato indhold ``` ## Markdown-rendering Brug `commonmark-java` til richtext-felter. Tilføj til `pom.xml`: ```xml org.commonmark commonmark 0.24.0 ``` Opret en service der konverterer markdown til HTML og injicér den i din controller. ## Servering af uploadede medier I `application.properties`: ```properties spring.web.resources.static-locations=classpath:/static/,file:public/ ``` Nu serveres `/uploads/mit-billede.jpg` fra `public/uploads/mit-billede.jpg`. ## i18n: læs begge sprog ```java // Alle danske indlæg List daPosts = cms.collection("posts", "da"); // Find oversættelsen af et specifikt indlæg WebhouseDocument post = cms.document("posts", "hello-world").orElseThrow(); WebhouseDocument translation = cms.findTranslation(post, "posts").orElse(null); ``` ## Caching Brug Spring's `@Cacheable`: ```java @Service public class CachedWebhouse { private final WebhouseReader reader; @Cacheable("posts") public List posts(String locale) { return reader.collection("posts", locale); } } ``` Invalidér via CMS admin webhook når indhold ændres. ## Produktions-deployment - **Fly.io / Render / Heroku** — Spring Boot-apps virker ud af boksen - **Bare-metal Tomcat** — skift packaging til `war` i pom.xml - **Docker** — multi-stage build med `eclipse-temurin:21-jre-alpine` - **AWS Beanstalk / Azure App Service** — native Java-support ## Fremtid: Maven Central-pakke I F125 Phase 2 vil denne reader blive publiceret til Maven Central som `app.webhouse:cms-reader` så du kan: ```xml app.webhouse cms-reader 0.1.0 ``` Indtil videre, kopiér `WebhouseReader.java` og `WebhouseDocument.java` filerne ind i dit projekt (~150 linjer i alt). ## Næste skridt - Se [Java-eksemplet](https://github.com/webhousecode/cms/tree/main/examples/consumers/java-spring-blog) - Læs om [Framework-agnostisk arkitektur](/docs/framework-agnostic-da) --- ## docs/consume-java Title: Consume from Java (Spring Boot) Updated: 2026-04-08 Locale: en Read @webhouse/cms content from a Spring Boot application. Reader class, controllers, Thymeleaf templates. ## Setup Place your Spring Boot project and @webhouse/cms content side by side: ``` my-project/ cms.config.ts # Schema (used by CMS admin) content/ # JSON documents (read by Spring Boot) public/uploads/ # Media files pom.xml src/main/java/... src/main/resources/templates/ ``` ## Reader class Create `src/main/java/app/webhouse/cmsreader/WebhouseReader.java`: ```java package app.webhouse.cmsreader; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.regex.Pattern; import java.util.stream.Stream; public final class WebhouseReader { private static final Pattern SAFE_NAME = Pattern.compile("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"); private final Path contentDir; private final ObjectMapper mapper = new ObjectMapper(); public WebhouseReader(String contentDir) { this.contentDir = Path.of(contentDir).toAbsolutePath().normalize(); } public List collection(String collection, String locale) { validateName(collection); Path dir = contentDir.resolve(collection); if (!Files.isDirectory(dir)) return List.of(); List docs = new ArrayList<>(); try (Stream stream = Files.list(dir)) { stream.filter(p -> p.getFileName().toString().endsWith(".json")).forEach(p -> { try { WebhouseDocument doc = mapper.readValue(p.toFile(), WebhouseDocument.class); if (!"published".equals(doc.status)) return; if (locale != null && !locale.equals(doc.locale)) return; docs.add(doc); } catch (IOException ignored) { /* skip malformed */ } }); } catch (IOException e) { return List.of(); } docs.sort(Comparator.comparing( (WebhouseDocument d) -> d.getStringOr("date", ""), Comparator.reverseOrder() )); return docs; } public Optional document(String collection, String slug) { validateName(collection); validateName(slug); Path file = contentDir.resolve(collection).resolve(slug + ".json"); if (!file.startsWith(contentDir) || !Files.isRegularFile(file)) { return Optional.empty(); } try { WebhouseDocument doc = mapper.readValue(file.toFile(), WebhouseDocument.class); return "published".equals(doc.status) ? Optional.of(doc) : Optional.empty(); } catch (IOException e) { return Optional.empty(); } } public Optional findTranslation(WebhouseDocument doc, String collection) { if (doc.translationGroup == null) return Optional.empty(); return collection(collection, null).stream() .filter(o -> doc.translationGroup.equals(o.translationGroup)) .filter(o -> doc.locale == null || !doc.locale.equals(o.locale)) .findFirst(); } private static void validateName(String name) { if (name == null || !SAFE_NAME.matcher(name).matches()) { throw new IllegalArgumentException("Invalid name: " + name); } } } ``` And the document type: ```java package app.webhouse.cmsreader; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) public class WebhouseDocument { public String id; public String slug; public String status; public String locale; public String translationGroup; public Map data; public String getString(String key) { if (data == null) return null; Object v = data.get(key); return v instanceof String ? (String) v : null; } public String getStringOr(String key, String fallback) { String v = getString(key); return v != null ? v : fallback; } } ``` ## Spring configuration Register the reader as a bean in your `Application.java`: ```java @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } @Bean public WebhouseReader webhouseReader() { return new WebhouseReader("content"); } } ``` ## Controller ```java @Controller public class BlogController { private final WebhouseReader cms; public BlogController(WebhouseReader cms) { this.cms = cms; } @GetMapping("/") public String home(Model model) { model.addAttribute("posts", cms.collection("posts", "en")); return "home"; } @GetMapping("/blog/{slug}") public String post(@PathVariable String slug, Model model) { WebhouseDocument post = cms.document("posts", slug) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); model.addAttribute("post", post); return "post"; } } ``` ## Thymeleaf template ```html Post Title date content ``` ## Markdown rendering Use `commonmark-java` for richtext fields. Add to `pom.xml`: ```xml org.commonmark commonmark 0.24.0 ``` Then create a service: ```java @Service public class MarkdownService { private final Parser parser = Parser.builder().build(); private final HtmlRenderer renderer = HtmlRenderer.builder().build(); public String render(String markdown) { return renderer.render(parser.parse(markdown != null ? markdown : "")); } } ``` Inject it into your controller and pass `markdownService.render(post.getString("content"))` as `contentHtml`. ## Serving uploaded media In `application.properties`: ```properties spring.web.resources.static-locations=classpath:/static/,file:public/ ``` Now `/uploads/my-image.jpg` is served from `public/uploads/my-image.jpg`. ## i18n: reading both locales ```java // All Danish posts List daPosts = cms.collection("posts", "da"); // Find the translation of a specific post WebhouseDocument post = cms.document("posts", "hello-world").orElseThrow(); WebhouseDocument translation = cms.findTranslation(post, "posts").orElse(null); ``` ## Caching Use Spring's `@Cacheable`: ```java @Service public class CachedWebhouse { private final WebhouseReader reader; @Cacheable("posts") public List posts(String locale) { return reader.collection("posts", locale); } } ``` Invalidate via CMS admin webhook when content changes. ## Production deployment - **Fly.io / Render / Heroku** — Spring Boot apps work out of the box - **Bare-metal Tomcat** — change packaging to `war` in pom.xml - **Docker** — multi-stage build with `eclipse-temurin:21-jre-alpine` - **AWS Beanstalk / Azure App Service** — native Java support ## Future: Maven Central package In F125 Phase 2, this reader will be published to Maven Central as `app.webhouse:cms-reader` so you can: ```xml app.webhouse cms-reader 0.1.0 ``` For now, copy the `WebhouseReader.java` and `WebhouseDocument.java` files into your project (~150 lines total). ## Next steps - See the [Java example](https://github.com/webhousecode/cms/tree/main/examples/consumers/java-spring-blog) - Learn about [Framework-Agnostic Architecture](/docs/framework-agnostic) --- ## docs/consume-laravel-da Title: Forbrug fra Laravel (PHP) Updated: 2026-04-08 Locale: da Læs @webhouse/cms-indhold fra en Laravel-applikation. Hjælpeklasse, routes, Blade-skabeloner. ## Opsætning Placér dit Laravel-projekt og @webhouse/cms-indhold side om side: ``` mit-projekt/ cms.config.ts # Indholdsmodel content/ # JSON-dokumenter (læses af Laravel) public/uploads/ # Mediefiler (serveres af Laravel) app/ # Din Laravel-app resources/views/ # Blade-skabeloner ``` ## Hjælpeklasse Opret `app/Services/Webhouse.php`: ```php filter(fn($f) => $f->getExtension() === 'json') ->map(fn($f) => json_decode(File::get($f->getPathname()), true)) ->filter(fn($d) => ($d['status'] ?? null) === 'published') ->when($locale, fn($c) => $c->filter(fn($d) => ($d['locale'] ?? 'en') === $locale)) ->sortByDesc(fn($d) => $d['data']['date'] ?? '') ->values(); } /** * Hent et enkelt dokument via slug. */ public static function document(string $collection, string $slug): ?array { $path = self::contentDir() . "/{$collection}/{$slug}.json"; if (!File::exists($path)) return null; $doc = json_decode(File::get($path), true); return ($doc['status'] ?? null) === 'published' ? $doc : null; } } ``` ## Routes ```php // routes/web.php use App\Services\Webhouse; use Illuminate\Support\Facades\Route; Route::get('/', function () { $posts = Webhouse::collection('posts', 'da'); return view('home', ['posts' => $posts]); }); Route::get('/blog/{slug}', function (string $slug) { $post = Webhouse::document('posts', $slug); abort_unless($post, 404); return view('post', ['post' => $post]); }); ``` ## Blade-skabelon ```blade {{-- resources/views/post.blade.php --}} @extends('layouts.app') @section('content') {{ $post['data']['title'] }} {{ $post['data']['date'] ?? '' }} {!! \Illuminate\Support\Str::markdown($post['data']['content'] ?? '') !!} @foreach (($post['data']['tags'] ?? []) as $tag) #{{ $tag }} @endforeach @endsection ``` ## Servering af uploadede medier `@webhouse/cms` gemmer uploadede medier i `public/uploads/`. Laravel serverer allerede `public/`-mappen, så `/uploads/mit-billede.jpg` virker bare. ## i18n: læs begge sprog ```php // Hent alle danske indlæg $daPosts = Webhouse::collection('posts', 'da'); // Hent alle engelske indlæg $enPosts = Webhouse::collection('posts', 'en'); // Find oversættelsen af et specifikt indlæg $post = Webhouse::document('posts', 'hello-world'); $translationGroup = $post['translationGroup'] ?? null; $translation = Webhouse::collection('posts') ->firstWhere(fn($d) => ($d['translationGroup'] ?? null) === $translationGroup && ($d['locale'] ?? null) !== $post['locale']); ``` ## Caching I produktion, cache parsed JSON: ```php use Illuminate\Support\Facades\Cache; public static function collection(string $collection, ?string $locale = null): Collection { return Cache::remember("webhouse:{$collection}:{$locale}", 60, function () use ($collection, $locale) { // ... samme logik som før }); } ``` Ryd cachen når indhold ændres — enten via en fil-watcher eller et CMS admin webhook. ## Næste skridt - Se [Laravel-eksemplet](https://github.com/webhousecode/cms/tree/main/examples/consumers/laravel-blog) - Læs om [i18n](/docs/i18n) for flersprogsmønstre - Læs om [Framework-agnostisk arkitektur](/docs/framework-agnostic) --- ## docs/consume-laravel Title: Consume from Laravel (PHP) Updated: 2026-04-08 Locale: en Read @webhouse/cms content from a Laravel application. Helper class, routes, Blade templates. ## Setup Place your Laravel project and @webhouse/cms content side by side: ``` my-project/ cms.config.ts # Content model content/ # JSON documents (read by Laravel) public/uploads/ # Media files (served by Laravel) app/ # Your Laravel app resources/views/ # Blade templates ``` ## Helper class Create `app/Services/Webhouse.php`: ```php */ public static function collection(string $collection, ?string $locale = null): Collection { $dir = self::contentDir() . '/' . $collection; if (!File::isDirectory($dir)) { return collect(); } return collect(File::files($dir)) ->filter(fn($f) => $f->getExtension() === 'json') ->map(fn($f) => json_decode(File::get($f->getPathname()), true)) ->filter(fn($d) => ($d['status'] ?? null) === 'published') ->when($locale, fn($c) => $c->filter(fn($d) => ($d['locale'] ?? 'en') === $locale)) ->sortByDesc(fn($d) => $d['data']['date'] ?? '') ->values(); } /** * Load a single document by slug. */ public static function document(string $collection, string $slug): ?array { $path = self::contentDir() . "/{$collection}/{$slug}.json"; if (!File::exists($path)) return null; $doc = json_decode(File::get($path), true); return ($doc['status'] ?? null) === 'published' ? $doc : null; } } ``` ## Routes ```php // routes/web.php use App\Services\Webhouse; use Illuminate\Support\Facades\Route; Route::get('/', function () { $posts = Webhouse::collection('posts', 'en'); return view('home', ['posts' => $posts]); }); Route::get('/blog/{slug}', function (string $slug) { $post = Webhouse::document('posts', $slug); abort_unless($post, 404); return view('post', ['post' => $post]); }); ``` ## Blade template ```blade {{-- resources/views/post.blade.php --}} @extends('layouts.app') @section('content') {{ $post['data']['title'] }} {{ $post['data']['date'] ?? '' }} {!! \Illuminate\Support\Str::markdown($post['data']['content'] ?? '') !!} @foreach (($post['data']['tags'] ?? []) as $tag) #{{ $tag }} @endforeach @endsection ``` ## Serving uploaded media `@webhouse/cms` stores uploaded media in `public/uploads/`. Laravel already serves the `public/` directory, so `/uploads/my-image.jpg` just works. ## i18n: reading both locales ```php // Get all Danish posts $daPosts = Webhouse::collection('posts', 'da'); // Get all English posts $enPosts = Webhouse::collection('posts', 'en'); // Find the translation of a specific post $post = Webhouse::document('posts', 'hello-world'); $translationGroup = $post['translationGroup'] ?? null; $translation = Webhouse::collection('posts') ->firstWhere(fn($d) => ($d['translationGroup'] ?? null) === $translationGroup && ($d['locale'] ?? null) !== $post['locale']); ``` ## Caching For production, cache parsed JSON: ```php use Illuminate\Support\Facades\Cache; public static function collection(string $collection, ?string $locale = null): Collection { return Cache::remember("webhouse:{$collection}:{$locale}", 60, function () use ($collection, $locale) { // ... same logic as before }); } ``` Clear the cache when content changes — either via a file watcher or a CMS admin webhook. ## Next steps - See the [Laravel example](https://github.com/webhousecode/cms/tree/main/examples/consumers/laravel-blog) - Read about [i18n](/docs/i18n) for multi-language patterns - Learn about [Framework-Agnostic Architecture](/docs/framework-agnostic) --- ## docs/consume-rails-da Title: Forbrug fra Rails (Ruby) Updated: 2026-04-08 Locale: da Læs @webhouse/cms-indhold fra en Ruby on Rails-applikation. Hjælpemodul, controllers, ERB-views. ## Opsætning ``` mit-projekt/ cms.config.ts content/ public/uploads/ app/ controllers/ views/ helpers/ ``` ## Hjælpemodul Opret `app/lib/webhouse.rb`: ```ruby require 'json' module Webhouse CONTENT_DIR = Rails.root.join('content').freeze def self.collection(name, locale: nil) folder = CONTENT_DIR.join(name.to_s) return [] unless Dir.exist?(folder) Dir.glob(folder.join('*.json')) .map { |f| JSON.parse(File.read(f)) } .select { |d| d['status'] == 'published' } .then { |docs| locale ? docs.select { |d| d['locale'] == locale } : docs } .sort_by { |d| d.dig('data', 'date') || '' } .reverse end def self.document(collection, slug) path = CONTENT_DIR.join(collection.to_s, "#{slug}.json") return nil unless File.exist?(path) doc = JSON.parse(File.read(path)) doc['status'] == 'published' ? doc : nil end def self.find_translation(doc, collection) tg = doc['translationGroup'] return nil unless tg collection(collection).find do |other| other['translationGroup'] == tg && other['locale'] != doc['locale'] end end end ``` ## Controller ```ruby # app/controllers/blog_controller.rb class BlogController ``` Tilføj en markdown-helper i `app/helpers/application_helper.rb`: ```ruby require 'redcarpet' module ApplicationHelper def markdown(text) renderer = Redcarpet::Render::HTML.new(hard_wrap: true) Redcarpet::Markdown.new(renderer, fenced_code_blocks: true).render(text).html_safe end end ``` Tilføj til `Gemfile`: `gem 'redcarpet'` ## Servering af medier Tilføj `public/uploads/` til Rails public-mappen — det serveres automatisk af Rails i udvikling og af din webserver i produktion. ## i18n ```ruby post = Webhouse.document('posts', params[:slug]) translation = Webhouse.find_translation(post, 'posts') # I view if translation link_to "Læs på #{translation['locale']}", blog_post_path(translation['slug']) end ``` ## Caching ```ruby def self.collection_cached(name, locale: nil) Rails.cache.fetch("webhouse:#{name}:#{locale || 'all'}", expires_in: 1.minute) do collection(name, locale: locale) end end ``` ## Jekyll-alternativ Til statiske Jekyll-sites, læs de samme JSON-filer i en `_plugins/webhouse.rb`-generator: ```ruby module Jekyll class WebhouseGenerator < Generator def generate(site) Dir.glob('content/posts/*.json').each do |f| doc = JSON.parse(File.read(f)) next unless doc['status'] == 'published' # ... opret side end end end end ``` ## Næste skridt - Se [Rails-eksemplet](https://github.com/webhousecode/cms/tree/main/examples/consumers/rails-blog) - Læs om [Framework-agnostisk arkitektur](/docs/framework-agnostic) --- ## docs/consume-rails Title: Consume from Rails (Ruby) Updated: 2026-04-08 Locale: en Read @webhouse/cms content from a Ruby on Rails application. Helper module, controllers, ERB views. ## Setup ``` my-project/ cms.config.ts content/ public/uploads/ app/ controllers/ views/ helpers/ ``` ## Helper module Create `app/lib/webhouse.rb`: ```ruby require 'json' module Webhouse CONTENT_DIR = Rails.root.join('content').freeze def self.collection(name, locale: nil) folder = CONTENT_DIR.join(name.to_s) return [] unless Dir.exist?(folder) Dir.glob(folder.join('*.json')) .map { |f| JSON.parse(File.read(f)) } .select { |d| d['status'] == 'published' } .then { |docs| locale ? docs.select { |d| d['locale'] == locale } : docs } .sort_by { |d| d.dig('data', 'date') || '' } .reverse end def self.document(collection, slug) path = CONTENT_DIR.join(collection.to_s, "#{slug}.json") return nil unless File.exist?(path) doc = JSON.parse(File.read(path)) doc['status'] == 'published' ? doc : nil end def self.find_translation(doc, collection) tg = doc['translationGroup'] return nil unless tg collection(collection).find do |other| other['translationGroup'] == tg && other['locale'] != doc['locale'] end end end ``` ## Controller ```ruby # app/controllers/blog_controller.rb class BlogController ``` Add a markdown helper in `app/helpers/application_helper.rb`: ```ruby require 'redcarpet' module ApplicationHelper def markdown(text) renderer = Redcarpet::Render::HTML.new(hard_wrap: true) Redcarpet::Markdown.new(renderer, fenced_code_blocks: true).render(text).html_safe end end ``` Add to `Gemfile`: `gem 'redcarpet'` ## Serving media Add `public/uploads/` to the Rails public directory — it's served automatically by Rails in development and by your web server in production. ## i18n ```ruby # Show a post with a language switcher post = Webhouse.document('posts', params[:slug]) translation = Webhouse.find_translation(post, 'posts') # In the view if translation link_to "Read in #{translation['locale']}", blog_post_path(translation['slug']) end ``` ## Caching ```ruby def self.collection_cached(name, locale: nil) Rails.cache.fetch("webhouse:#{name}:#{locale || 'all'}", expires_in: 1.minute) do collection(name, locale: locale) end end ``` ## Jekyll alternative For static Jekyll sites, read the same JSON files in a `_plugins/webhouse.rb` generator: ```ruby module Jekyll class WebhouseGenerator < Generator def generate(site) Dir.glob('content/posts/*.json').each do |f| doc = JSON.parse(File.read(f)) next unless doc['status'] == 'published' site.pages << PageWithoutAFile.new(site, site.source, 'blog', "#{doc['slug']}.html").tap do |page| page.content = doc.dig('data', 'content') page.data['title'] = doc.dig('data', 'title') page.data['layout'] = 'post' end end end end end ``` ## Next steps - See the [Rails example](https://github.com/webhousecode/cms/tree/main/examples/consumers/rails-blog) - Learn about [Framework-Agnostic Architecture](/docs/framework-agnostic) --- ## docs/framework-agnostic-da Title: Framework-agnostisk arkitektur Updated: 2026-04-08 Locale: da Dit indhold som flade JSON-filer. Render det med ethvert sprog, enhver framework, enhver runtime. ## Den store idé **@webhouse/cms gemmer indhold som flade JSON-filer.** Ikke i en database. Ikke bag et API. Bare filer i en mappe: ``` content/ posts/ hello-world.json hello-world-da.json my-second-post.json pages/ about.json contact.json globals/ site.json ``` Ethvert sprog der kan læse en fil kan forbruge dette indhold. PHP, Python, Ruby, Go, C#, Rust, Elixir, Haskell, Bash — de taler alle JSON. ## Hvorfor det er vigtigt De fleste CMS-platforme låser dig fast. Contentful kræver deres SDK. Sanity vil have deres GROQ-forespørgsler. WordPress kræver PHP + MySQL. Strapi kører sin egen Node-server. Hvis du vil skifte stack, eksporterer du, migrerer og beder en bøn. Med @webhouse/cms er **dit indhold filer i dit git-repository.** Ingen lock-in. Ingen migration. Ingen leverandør-afhængighed. Hvis du i morgen vil udskifte @webhouse/cms med en anden admin-UI, er dit indhold allerede i et portabelt format. ## Hvad der er TypeScript-specifikt Kun **admin-laget**: - `cms.config.ts` — skemadefinitionen (TypeScript) - Admin UI (Next.js) - AI-agenter (TypeScript) - Valgfri `@webhouse/cms` Next.js-helpers Det er alt. Alt under admin-laget er framework-agnostisk. ## Hvad der er framework-agnostisk | Komponent | Format | Kan forbruges af | |-----------|--------|------------------| | Indhold | JSON-filer | Alt der kan læse filer | | Medier | Billedfiler i `public/uploads/` | Enhver webserver | | Skema | Kan eksporteres som JSON Schema ([schema-eksport →](/docs/schema-export-da)) | Ethvert JSON Schema-bibliotek | | SEO | `sitemap.xml`, `robots.txt`, `llms.txt` | Standard-compliant crawlers | | MCP | Offentlig read-only MCP-server | Enhver AI-agent | ## Indholdsformatet Hvert dokument følger den samme struktur: ```json { "slug": "hello-world", "status": "published", "locale": "en", "translationGroup": "uuid-delt-med-oversættelser", "data": { "title": "Hello, World!", "content": "## Velkommen\n\nDette er mit første indlæg.", "date": "2026-04-08", "tags": ["intro", "hej"] }, "id": "unique-id", "_fieldMeta": {} } ``` For at filtrere publiceret indhold, spring alt over hvor `status !== "published"`. For at læse kun ét sprog, filtrér efter `locale`. ## Læs indhold fra ethvert sprog Mønsteret er altid det samme: 1. Liste filer i `content//` 2. Parse hver JSON 3. Filtrér hvor `status === "published"` 4. Valgfrit filtrér efter `locale` 5. Sortér / render som nødvendigt Se de framework-specifikke guides: - [Forbrug fra Laravel (PHP)](/docs/consume-laravel) - [Forbrug fra Django (Python)](/docs/consume-django) - [Forbrug fra Rails (Ruby)](/docs/consume-rails) - [Forbrug fra Go](/docs/consume-go) - [Forbrug fra C# / .NET](/docs/consume-dotnet) ## Admin forbliver den samme Uanset hvilken framework du bruger til at rendere sitet, er CMS admin-UI'en den samme Next.js-applikation. Redaktører logger ind, redigerer indhold, trykker publish — og JSON-filerne opdateres i dit git-repository. ## Hvad med build? **F126** (planlagt) vil lade CMS admin kalde ENHVER build-kommando — `php artisan build`, `hugo --minify`, `bundle exec jekyll build`, `python manage.py collectstatic` — ikke kun vores native TypeScript-pipeline. I dag kan du stadig trigge builds via git hooks, CI/CD eller dine egne scripts. ## Schema-eksport Ikke-TypeScript runtimes kan ikke eksekvere `cms.config.ts`, så CMS'et eksporterer et [JSON Schema-dokument](/docs/schema-export-da) (`webhouse-schema.json`) der beskriver indholdsmodellen på en sprog-agnostisk måde. Reader-biblioteker bruger denne fil til at introspektere collections, generere typer og validere dokumenter. Generér det fra **CMS admin UI** (Site Settings → Schema export → Save to project root) eller fra **CLI**: ```bash npx cms export-schema --out webhouse-schema.json ``` [Læs den fulde schema-eksport guide →](/docs/schema-export-da) ## Afvejninger **Fil-baseret indhold er ikke for alle.** Overvej: - **Skala** — tusinder af dokumenter virker fint. Millioner gør ikke. Brug en database-adapter hvis du har brug for skala. - **Samtidige skrivninger** — filesystem-adapteren er single-writer. Hvis du har brug for real-time multi-editor, brug Supabase-adapteren. - **Søgning** — ingen indbygget fuldtekst-søgning. Brug et separat søgeindex (Algolia, Meilisearch, Typesense). For 95% af indholdsdrevne sites er fil-baseret indhold hurtigere, simplere og sikrere end enhver database. ## Næste skridt - Læs [Introduktion](/docs/introduction) for den overordnede arkitektur - Vælg din framework fra forbruger-guides ovenfor - Kig på [eksemplerne](https://github.com/webhousecode/cms/tree/main/examples) for fungerende kode --- ## docs/framework-agnostic Title: Framework-Agnostic Architecture Updated: 2026-04-08 Locale: en Your content as flat JSON files. Render it with any language, any framework, any runtime. ## The big idea **@webhouse/cms stores content as flat JSON files.** Not in a database. Not behind an API. Just files in a directory: ``` content/ posts/ hello-world.json hello-world-da.json my-second-post.json pages/ about.json contact.json globals/ site.json ``` Every language that can read a file can consume this content. PHP, Python, Ruby, Go, C#, Rust, Elixir, Haskell, Bash — they all speak JSON. ## Why this matters Most CMS platforms lock you in. Contentful requires their SDK. Sanity wants their GROQ queries. WordPress needs PHP + MySQL. Strapi runs its own Node server. If you want to switch stacks, you export, migrate, and pray. With @webhouse/cms, **your content is files in your git repository.** No lock-in. No migration. No vendor dependency. If you want to replace @webhouse/cms tomorrow with a different admin UI, your content is already in a portable format. ## What's TypeScript-specific Only the **admin layer**: - `cms.config.ts` — the schema definition (TypeScript) - Admin UI (Next.js) - AI agents (TypeScript) - Optional `@webhouse/cms` Next.js helpers That's it. Everything below the admin layer is framework-agnostic. ## What's framework-agnostic | Component | Format | Consumable by | |-----------|--------|---------------| | Content | JSON files | Anything that reads files | | Media | Image files in `public/uploads/` | Any web server | | Schema | Exportable as JSON Schema ([schema export →](/docs/schema-export)) | Any JSON Schema library | | SEO | `sitemap.xml`, `robots.txt`, `llms.txt` | Standards-compliant crawlers | | MCP | Public read-only MCP server | Any AI agent | ## The content format Every document follows the same structure: ```json { "slug": "hello-world", "status": "published", "locale": "en", "translationGroup": "uuid-shared-with-translations", "data": { "title": "Hello, World!", "content": "## Welcome\n\nThis is my first post.", "date": "2026-04-08", "tags": ["intro", "hello"] }, "id": "unique-id", "_fieldMeta": {} } ``` To filter published content, skip anything where `status !== "published"`. To read only one language, filter by `locale`. ## Reading content from any language The pattern is always the same: 1. List files in `content//` 2. Parse each JSON 3. Filter where `status === "published"` 4. Optionally filter by `locale` 5. Sort / render as needed See the framework-specific guides: - [Consume from Laravel (PHP)](/docs/consume-laravel) - [Consume from Django (Python)](/docs/consume-django) - [Consume from Rails (Ruby)](/docs/consume-rails) - [Consume from Go](/docs/consume-go) - [Consume from C# / .NET](/docs/consume-dotnet) ## The admin stays the same Regardless of which framework you use to render the site, the CMS admin UI is the same Next.js application. Editors log in, edit content, hit publish — and the JSON files update in your git repository. ## What about building? **F126** (planned) will let CMS admin invoke ANY build command — `php artisan build`, `hugo --minify`, `bundle exec jekyll build`, `python manage.py collectstatic` — not just our native TypeScript pipeline. Today you can still trigger builds via git hooks, CI/CD, or your own scripts. ## Schema export Non-TypeScript runtimes can't execute `cms.config.ts`, so the CMS exports a [JSON Schema document](/docs/schema-export) (`webhouse-schema.json`) that describes the content model in a language-agnostic way. Reader libraries use this file to introspect collections, generate types, and validate documents. Generate it from the **CMS admin UI** (Site Settings → Schema export → Save to project root) or from the **CLI**: ```bash npx cms export-schema --out webhouse-schema.json ``` [Read the full schema export guide →](/docs/schema-export) ## Trade-offs **File-based content is not for everyone.** Consider: - **Scale** — thousands of documents work fine. Millions do not. Use a database adapter if you need scale. - **Concurrent writes** — filesystem adapter is single-writer. If you need real-time multi-editor, use the Supabase adapter. - **Search** — no built-in full-text search. Use a separate search index (Algolia, Meilisearch, Typesense). For 95% of content-driven sites, file-based content is faster, simpler, and safer than any database. ## Next steps - Read [Introduction](/docs/introduction) for the overall architecture - Pick your framework from the consumer guides above - Look at the [examples](https://github.com/webhousecode/cms/tree/main/examples) for working code --- ## docs/testing-consumer-examples-da Title: Test af consumer-eksemplerne Updated: 2026-04-08 Locale: da Sådan testes Java- og .NET-consumer-eksemplerne. Test-opsætning, fixtures, sikkerhedstjek og hvordan du kører dem lokalt. ## Hvorfor teste consumer-eksemplerne Consumer-eksemplerne i `examples/consumers/` er reference-implementeringer af F125 reader-mønsteret. Hvis de bryder, er dokumentationen forkert og udviklere der kopierer koden vil ramme de samme bugs. Hvert consumer-eksempel leveres med: 1. **Unit tests** for reader-klassen (collection, document, findTranslation, sikkerhed) 2. **En test-fixture content-mappe** så testene er hermetiske — ingen afhængighed af rigtige `content/`-filer 3. **En end-to-end smoke test** der bygger eksemplet, starter app'en og verificerer HTTP-responses Denne side dokumenterer hvordan det er sat op. ## Java-eksempel: `java-spring-blog` ### Stack - **Build:** Maven 3.9 (eller enhver 3.6+) - **Test-runner:** JUnit 5 (Jupiter) — bundled med `spring-boot-starter-test` - **Temp-mapper:** `@TempDir` for hermetiske fil-fixtures - **Java:** 21 (LTS) ### Test-fil `src/test/java/app/webhouse/cmsreader/WebhouseReaderTest.java` — 26 tests der dækker: ``` ├── Collection listing │ ├── returnerer alle publicerede uanset locale │ ├── filtrerer efter locale │ ├── returnerer kun danske indlæg │ ├── springer draft-status over │ ├── springer malformed JSON over │ ├── sorterer efter dato faldende │ └── returnerer tom for manglende mappe │ ├── Document loading │ ├── indlæser publiceret indlæg │ ├── returnerer tom for draft │ └── returnerer tom for manglende │ ├── Translation resolution │ ├── løser via translationGroup │ ├── returnerer tom for ikke-oversat indlæg │ └── returnerer tom når translationGroup mangler │ ├── Sikkerhed (path traversal) │ ├── afviser path traversal slug (../../etc/passwd) │ ├── afviser path traversal collection │ ├── afviser absolut sti │ ├── afviser slug med prikker (hello..world) │ ├── afviser slug med slash (hello/world) │ ├── accepterer gyldige slugs (kebab-case) │ ├── afviser uppercase slug │ ├── afviser tom slug │ └── afviser null slug │ └── Document helpers ├── getString returnerer værdi når til stede ├── getString returnerer null for manglende nøgle ├── getStringOr returnerer fallback └── isPublished true for publiceret ``` ### Sådan virker fixtures JUnit's `@TempDir` opretter en frisk midlertidig mappe før hver test. Setup'en skriver mock JSON-filer til den, og testen kører derefter mod den hermetiske mappe: ```java @TempDir Path contentDir; private WebhouseReader reader; @BeforeEach void setUp() throws IOException { reader = new WebhouseReader(contentDir.toString()); Path postsDir = Files.createDirectory(contentDir.resolve("posts")); Files.writeString(postsDir.resolve("hello-world.json"), """ { "id": "hello-en", "slug": "hello-world", "status": "published", "locale": "en", "translationGroup": "tg-1", "data": { "title": "Hello", "date": "2026-01-15" } } """); // ... flere fixtures // Malformed fil — må ikke crashe reader'en Files.writeString(postsDir.resolve("bad.json"), "{ this is not json"); } ``` Ingen rigtig `content/`-mappe røres. Tests er fuldt isolerede. ### Kør testene ```bash cd examples/consumers/java-spring-blog mvn test ``` Forventet output: ``` [INFO] Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` ### Kør kun én test ```bash mvn test -Dtest=WebhouseReaderTest#document_rejectsPathTraversalSlug ``` ### End-to-end smoke test Efter `mvn package` kan du køre hele Spring Boot-app'en og curl'e den: ```bash mvn -B package -DskipTests java -jar target/java-spring-blog-0.1.0.jar --server.port=8080 & sleep 8 for url in "/" "/da/" "/blog/hello-world" "/blog/hello-world-da" \ "/blog/does-not-exist" "/blog/..%2F..%2Fetc%2Fpasswd"; do code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080$url") echo " $code $url" done ``` Forventet: ``` 200 / ← Engelsk forside, lister 2 indlæg 200 /da/ ← Dansk forside, lister 2 indlæg 200 /blog/hello-world ← Engelsk indlæg detalje 200 /blog/hello-world-da ← Dansk indlæg detalje 404 /blog/does-not-exist ← Venlig 404-side 400 /blog/..%2F..%2Fetc%2Fpasswd ← Sikkerhed: path traversal blokeret ``` ## .NET-eksempel: `dotnet-blog` ### Stack - **Build:** dotnet CLI (bundled med .NET 9 SDK) - **Test-runner:** xUnit (eller MSTest hvis du foretrækker) - **Temp-mapper:** `Path.GetTempPath()` + `Guid.NewGuid()` for hermetiske fixtures - **.NET:** 9 (LTS) ### Fremtidig test-fil (Phase 2) .NET-eksemplet leveres endnu ikke med xUnit-tests fordi reader'en i bund og grund er en 1:1 port af Java-reader'en. Phase 2 af F125 vil publicere `Webhouse.Cms.Reader` som en NuGet-pakke med sin egen test-suite. For at tilføje tests i dag, opret et test-projekt sammen med eksemplet: ```bash cd examples/consumers/dotnet-blog dotnet new xunit -o tests dotnet add tests/tests.csproj reference DotnetBlog.csproj ``` Kør derefter med: ```bash dotnet test ``` ### .NET smoke test ```bash cd examples/consumers/dotnet-blog dotnet run --urls http://localhost:5000 & sleep 5 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/da curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/blog/hello-world ``` ## CI-integration I `webhousecode/cms`-repoet vil consumer-eksemplerne til sidst blive bygget på hver PR via GitHub Actions med setup-java + setup-dotnet. ## Hvorfor disse tests betyder noget Consumer-eksemplerne er **beviset** på at @webhouse/cms er framework-agnostisk. Hvis de ikke kører rent: - Den framework-agnostiske historie kollapser - Udviklere der kopierer koden vil ramme de samme bugs som vi gjorde - Dokumentation kommer ud af synk med virkeligheden 26 JUnit-tests + manuel smoke-testing betyder: 1. **Reader'en virker** — collection, document, findTranslation er korrekte 2. **Sikkerheden holder** — path traversal, ugyldige slugs, malformed JSON afvises 3. **Spring Boot-integrationen er reel** — bean-registrering, controller-routing, Thymeleaf-rendering 4. **Edge cases er dækket** — drafts, manglende filer, tomme translationGroups Hvis du ændrer Java-reader'en, kør `mvn test` først. Hvis du ændrer .NET-reader'en, tilføj tests før merge. ## Relateret - **F125 — Framework-Agnostisk Indholdsplatform** ([feature plan](https://github.com/webhousecode/cms/blob/main/docs/features/F125-framework-agnostic-consumers.md)) - [Forbrug fra Java (Spring Boot)](/docs/consume-java-da) - [Forbrug fra C# / .NET](/docs/consume-dotnet-da) - [Framework-agnostisk arkitektur](/docs/framework-agnostic-da) --- ## docs/testing-consumer-examples Title: Testing the Consumer Examples Updated: 2026-04-08 Locale: en How the Java and .NET consumer examples are tested. Test setup, fixtures, security checks, and how to run them locally. ## Why test the consumer examples The consumer examples in `examples/consumers/` are reference implementations of the F125 reader pattern. If they break, the docs are wrong and developers copying the code will hit the same bugs. Every consumer example ships with: 1. **Unit tests** for the reader class (collection, document, findTranslation, security) 2. **A test fixture content directory** so tests are hermetic — no dependency on real `content/` files 3. **An end-to-end smoke test** that builds the example, starts the app, and verifies HTTP responses This page documents how it's set up. ## Java example: `java-spring-blog` ### Stack - **Build:** Maven 3.9 (or any 3.6+) - **Test runner:** JUnit 5 (Jupiter) — bundled with `spring-boot-starter-test` - **Temp dirs:** `@TempDir` for hermetic file fixtures - **Java:** 21 (LTS) ### Test file `src/test/java/app/webhouse/cmsreader/WebhouseReaderTest.java` — 26 tests covering: ``` ├── Collection listing │ ├── returns all published regardless of locale │ ├── filters by locale │ ├── returns Danish posts only │ ├── skips draft status │ ├── skips malformed JSON │ ├── sorts by date descending │ └── returns empty for missing dir │ ├── Document loading │ ├── loads published post │ ├── returns empty for draft │ └── returns empty for missing │ ├── Translation resolution │ ├── resolves via translationGroup │ ├── returns empty for untranslated post │ └── returns empty when translationGroup missing │ ├── Security (path traversal) │ ├── rejects path traversal slug (../../etc/passwd) │ ├── rejects path traversal collection │ ├── rejects absolute path │ ├── rejects slug with dots (hello..world) │ ├── rejects slug with slash (hello/world) │ ├── accepts valid slugs (kebab-case) │ ├── rejects uppercase slug │ ├── rejects empty slug │ └── rejects null slug │ └── Document helpers ├── getString returns value when present ├── getString returns null for missing key ├── getStringOr returns fallback └── isPublished true for published ``` ### How fixtures work JUnit's `@TempDir` creates a fresh temporary directory before each test. The setup writes mock JSON files to it, then the test runs against that hermetic directory: ```java @TempDir Path contentDir; private WebhouseReader reader; @BeforeEach void setUp() throws IOException { reader = new WebhouseReader(contentDir.toString()); Path postsDir = Files.createDirectory(contentDir.resolve("posts")); Files.writeString(postsDir.resolve("hello-world.json"), """ { "id": "hello-en", "slug": "hello-world", "status": "published", "locale": "en", "translationGroup": "tg-1", "data": { "title": "Hello", "date": "2026-01-15" } } """); // ... more fixtures // Malformed file — must not crash the reader Files.writeString(postsDir.resolve("bad.json"), "{ this is not json"); } ``` No real `content/` directory is touched. Tests are fully isolated. ### Running the tests ```bash cd examples/consumers/java-spring-blog mvn test ``` Expected output: ``` [INFO] Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` ### Running just one test ```bash mvn test -Dtest=WebhouseReaderTest#document_rejectsPathTraversalSlug ``` ### End-to-end smoke test After `mvn package`, you can run the full Spring Boot app and curl it: ```bash mvn -B package -DskipTests java -jar target/java-spring-blog-0.1.0.jar --server.port=8080 & sleep 8 for url in "/" "/da/" "/blog/hello-world" "/blog/hello-world-da" \ "/blog/does-not-exist" "/blog/..%2F..%2Fetc%2Fpasswd"; do code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080$url") echo " $code $url" done ``` Expected: ``` 200 / ← English home, lists 2 posts 200 /da/ ← Danish home, lists 2 posts 200 /blog/hello-world ← English post detail 200 /blog/hello-world-da ← Danish post detail 404 /blog/does-not-exist ← Friendly 404 page 400 /blog/..%2F..%2Fetc%2Fpasswd ← Security: path traversal blocked ``` ## .NET example: `dotnet-blog` ### Stack - **Build:** dotnet CLI (bundled with .NET 9 SDK) - **Test runner:** xUnit (or MSTest if you prefer) - **Temp dirs:** `Path.GetTempPath()` + `Guid.NewGuid()` for hermetic fixtures - **.NET:** 9 (LTS) ### Future test file (Phase 2) The .NET example does not yet ship with xUnit tests because the reader is essentially a 1:1 port of the Java reader. Phase 2 of F125 will publish `Webhouse.Cms.Reader` as a NuGet package with its own test suite. To add tests today, create a test project alongside the example: ```bash cd examples/consumers/dotnet-blog dotnet new xunit -o tests dotnet add tests/tests.csproj reference DotnetBlog.csproj ``` Then create `tests/WebhouseReaderTests.cs`: ```csharp using System.IO; using DotnetBlog.Services; using Xunit; public class WebhouseReaderTests : IDisposable { private readonly string _contentDir; private readonly WebhouseReader _reader; public WebhouseReaderTests() { _contentDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(Path.Combine(_contentDir, "posts")); File.WriteAllText( Path.Combine(_contentDir, "posts", "hello-world.json"), """ { "slug": "hello-world", "status": "published", "locale": "en", "data": { "title": "Hello" } } """); _reader = new WebhouseReader(_contentDir); } public void Dispose() => Directory.Delete(_contentDir, recursive: true); [Fact] public void Collection_ReturnsPublishedPosts() { var posts = _reader.Collection("posts"); Assert.Single(posts); } [Fact] public void Document_RejectsPathTraversal() { Assert.Throws( () => _reader.Document("posts", "../../etc/passwd")); } } ``` Run with: ```bash dotnet test ``` ### .NET smoke test ```bash cd examples/consumers/dotnet-blog dotnet run --urls http://localhost:5000 & sleep 5 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/da curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/blog/hello-world ``` ## CI integration In the `webhousecode/cms` repo, the consumer examples will eventually be built on every PR: ```yaml # .github/workflows/consumer-examples.yml name: Consumer Examples on: [push, pull_request] jobs: java: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: { java-version: '21', distribution: 'temurin' } - run: mvn -B package working-directory: examples/consumers/java-spring-blog dotnet: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: { dotnet-version: '9.0.x' } - run: dotnet build working-directory: examples/consumers/dotnet-blog ``` ## Why these tests matter The consumer examples are the **proof** that @webhouse/cms is framework-agnostic. If they don't run cleanly: - The framework-agnostic story collapses - Developers copying the code will hit the same bugs we hit - Documentation gets out of sync with reality 26 JUnit tests + manual smoke testing means: 1. **The reader works** — collection, document, findTranslation are correct 2. **Security holds** — path traversal, invalid slugs, malformed JSON are rejected 3. **The Spring Boot integration is real** — bean registration, controller routing, Thymeleaf rendering 4. **Edge cases are covered** — drafts, missing files, empty translationGroups If you change the Java reader, run `mvn test` first. If you change the .NET reader, add tests before merging. ## Related - **F125 — Framework-Agnostic Content Platform** ([feature plan](https://github.com/webhousecode/cms/blob/main/docs/features/F125-framework-agnostic-consumers.md)) - [Consume from Java (Spring Boot)](/docs/consume-java) - [Consume from C# / .NET](/docs/consume-dotnet) - [Framework-Agnostic Architecture](/docs/framework-agnostic) --- ## docs/agent-cost-guards-da Title: Per-agent budgetkontrol Updated: 2026-04-08 Locale: da Sæt et loft over en agents forbrug pr. dag, uge eller måned. Stopper løbske agenter før de brænder hele budgettet. ## Hvorfor per-agent budgetter? Cockpit har ét globalt månedligt budget der dækker alle LLM-kald på sitet. Det fungerer fint indtil en agent kommer i et loop eller en kurator har planlagt et dagligt job de har glemt — og hele budgettet forsvinder på én agent mens alt andet sulter. Per-agent budgetkontrol løser det. Hver agent kan have et valgfrit **dagligt**, **ugentligt** og **månedligt** udgiftsloft. Når agentens faktiske forbrug i den periode når loftet, stopper både manuelle og planlagte kørsler før det næste LLM-kald foretages. ## Sådan sætter du lofterne Åbn en agents detaljeside og find **Cost guards (USD)** kortet under skema-sektionen. Alle tre felter er valgfrie — efterlad et felt tomt for at betyde "intet loft for den periode". | Felt | Periode | Nulstilles | |------|---------|------------| | Daily | Siden 00:00 i dag (lokal tid) | Midnat | | Weekly | Rullende 7 dage | Løbende — ældste kørsel falder af efter 7 dage | | Monthly | Siden den 1. i indeværende kalendermåned | Den 1. i måneden | Lofterne er **uafhængige**. Hvis du sætter daily=1 og monthly=10, kan agenten bruge $1 om dagen i 10 dage før noget loft trippes. Begge grænser alene er nok til at stoppe en kørsel. ## Sådan virker tjekket Kontrollen kører som et **pre-flight tjek** tre steder: 1. **`runAgent`** (manuelle eller API-kørsler) — tjekker budgettet før LLM-kaldet. Hvis overskredet, kastes en klar fejl med periode og beløb. 2. **`scheduler`** (planlagte kørsler) — tjekker før hver due agent. Springer kørslen over og logger hvilken periode der trippede, så du kan se det i instrumentation-logs. 3. **`runWorkflow`** — hver step's agent budget-tjekkes uafhængigt før dens LLM-kald. Det beløb der bruges er agentens **faktiske analytics-forbrug** for perioden (summeret fra `recordRun`-poster), ikke et estimat. Så loftet afspejler hvad der allerede er brugt, ikke hvad der måske endnu vil blive brugt på den aktuelle kørsel. ## Fejlmeddelelser Når en manuel kørsel rammer et loft, returnerer API'et 500 med en besked som: > Agent "Content Writer" has reached its daily budget ($1.5234 of $1.50 cap). Increase the cap on the agent's settings page or wait for the period to reset. Scheduleren logger samme besked og springer kørslen over uden at fyre en `agent.failed` webhook — fordi intet rent faktisk fejlede. ## Anbefalede værdier Fornuftige startpunkter: - **Daily $0.50** — fint til en daglig blogskribent der producerer ét indlæg. - **Weekly $5** — dækker et par re-runs eller justeringer i løbet af ugen. - **Monthly $20** — generøst månedligt sikkerhedsnet. Hvis du har en agent der genererer billeder, bump det daglige loft med ~$0.04 per billede du forventer pr. kørsel. Nano Banana koster i øjeblikket $0.039 pr. billede. ## Samspil med det globale Cockpit-budget Per-agent lofter **erstatter ikke** det globale Cockpit månedsbudget — de lægges ovenpå. Scheduleren tjekker begge: 1. Globalt Cockpit budget (95% headroom-regel, legacy) 2. Per-agent budget (Phase 4) Det der trippes først stopper kørslen. Så en agent uden per-agent lofter stoppes stadig af det globale loft, og en agent med per-agent lofter kan stoppes tidligt før det globale loft er i nærheden. ## Se også - [Performance dashboard](/docs/ai-analytics) — historiske omkostninger og kørselsdata pr. agent. - [Agent feedback loop](/docs/agent-feedback-loop) — rettelser der reducerer omkostning pr. kørsel over tid. --- ## docs/agent-cost-guards Title: Per-Agent Cost Guards Updated: 2026-04-08 Locale: en Cap an agent's spend per day, week, or month. Stops runaway agents before they burn the global budget. ## Why per-agent budgets? The Cockpit has a single global monthly budget that covers every LLM call across the site. That works fine until one agent gets stuck in a loop or a curator schedules a daily job they forget about — and the entire budget evaporates on a single agent while everything else starves. Per-agent cost guards solve this. Each agent can have an optional **daily**, **weekly**, and **monthly** spending cap. When the agent's actual spend in that period reaches the cap, both manual runs and scheduled runs bail out before another LLM call is made. ## Setting the caps Open any agent's detail page and find the **Cost guards (USD)** card under the schedule section. All three fields are optional — leave a field blank to mean "no cap for that period". | Field | Period | Resets | |-------|--------|--------| | Daily | Since 00:00 today (local time) | Midnight | | Weekly | Rolling 7 days | Continuously — the oldest run drops off after 7 days | | Monthly | Since the 1st of the current calendar month | First of the month | The caps are **independent**. If you set daily=1 and monthly=10, the agent can spend $1 per day for 10 days before any cap trips. Either limit alone is enough to stop a run. ## How the check works The guard runs as a **pre-flight check** in three places: 1. **`runAgent`** (manual or API runs) — checks the budget before the LLM call. If exceeded, throws a clear error with the period and amount. 2. **`scheduler`** (scheduled runs) — checks before each due agent. Skips the run and logs which period tripped, so you can see the skip in instrumentation logs. 3. **`runWorkflow`** — each step's agent is independently budget-checked before its LLM call. The cost number used is the agent's **actual analytics spend** for the period (summed from `recordRun` entries), not an estimate. So the cap reflects what's already been spent, not what might still be spent on the current run. ## Error messages When a manual run hits a cap, the API returns a 500 with a message like: > Agent "Content Writer" has reached its daily budget ($1.5234 of $1.50 cap). Increase the cap on the agent's settings page or wait for the period to reset. The scheduler logs the same message and skips the run silently — it does not fire a `agent.failed` webhook for budget-skip cases, because nothing actually failed. ## Recommended values Sensible starting points: - **Daily $0.50** — fine for a once-a-day blog writer producing one post. - **Weekly $5** — covers a few re-runs or tweaks during the week. - **Monthly $20** — generous monthly safety net. If you have an image-generating agent, bump the daily cap by ~$0.04 per image you expect per run. Nano Banana is currently $0.039 per image. ## Interaction with the global Cockpit budget Per-agent caps **do not replace** the global Cockpit monthly budget — they layer on top. The scheduler checks both: 1. Global Cockpit budget (95% headroom rule, legacy) 2. Per-agent budget (Phase 4) Whichever trips first stops the run. So an agent without per-agent caps still gets stopped by the global cap, and an agent with per-agent caps can be stopped early before the global cap is anywhere near. ## See also - [Performance dashboard](/docs/ai-analytics) — historical cost and run data per agent. - [Agent feedback loop](/docs/agent-feedback-loop) — corrections that reduce per-run cost over time. --- ## docs/agent-feedback-loop-da Title: Agent feedback-loop Updated: 2026-04-08 Locale: da Kuratorrettelser og afvisningsnoter gemmes pr. agent og injectes i næste kørsel som few-shot eksempler — agenter lærer automatisk af rettelser. ## Problemet Tidligt læste agent-runneren tidligere rettelser fra `_data/agents/{id}/feedback.json` og injectede dem i systempromp'ten som few-shot eksempler. Idéen var at agenter ville lære af kuratorrettelser over tid. Hagen: **intet sted i kodebasen blev der nogensinde skrevet til den fil.** Kuratorer kunne rette hver kladde og afvise hver dårlig én og agenten ville glad gentage de samme fejl næste dag. Feedback-loop'en lukker det hul. Hver kuratorhandling — redigering af et felt før godkendelse, afvisning med en note — persisteres nu automatisk til agentens feedback-fil. Næste gang agenten kører, bages de seneste rettelser ind i dens systemprompt som konkrete eksempler. ## Sådan registreres rettelser Når en queue item oprettes af en agent, snapshotter runneren det oprindelige `contentData` på queue item under `originalContentData`. Når en kurator godkender, diff'er kuratering-routen det aktuelle `contentData` mod det snapshot og skriver én **correction** post pr. ændret string-felt: ```json { "id": "fb-...-...", "type": "correction", "queueItemId": "qi-...", "field": "title", "original": "Why TypeScript Generics Matter", "corrected": "How TypeScript Generics Save You From Refactoring Hell", "createdAt": "2026-04-08T..." } ``` Hvis du ikke redigerer noget før godkendelse, registreres ingen rettelser — det oprindelige output var godt nok. ## Sådan registreres afvisninger Når du afviser et queue item med en note, skriver afvisning-routen en **rejection**-post med kuratorens noter: ```json { "id": "fb-...-...", "type": "rejection", "queueItemId": "qi-...", "notes": "Tonen er for tør — mangler personlighed og specifikke eksempler", "createdAt": "2026-04-08T..." } ``` Afvisningsnoter er synlige på agentens detaljeside men **injectes ikke aktuelt i næste systemprompt** (kun `correction` og `edit` poster med både `original` og `corrected` strings bruges som few-shot eksempler). De er stadig nyttige som audit trail og kan blive foldet ind i prompt-konteksten i en fremtidig revision. ## Hvad bliver injectet i næste kørsel Agent-runneren kalder `loadFeedbackForPrompt(agentId, 5)` og henter de **seneste 5 correction-eksempler** i kronologisk rækkefølge. De tilføjes til systemprompt'en under en `## Learn from past corrections` sektion: ``` ## Learn from past corrections Example 1: Original: Why TypeScript Generics Matter Corrected: How TypeScript Generics Save You From Refactoring Hell Example 2: Original: A serene sunrise over snow-capped mountains Corrected: Snow-covered alpine valley with frozen lake at golden hour ``` Modellen behandler dem som konkrete redaktionelle præferencer og har tendens til at efterligne mønstrene i sin næste kørsel. ## Recent feedback panelet Agentens detaljeside viser et **Recent feedback** kort med de seneste 5 poster. Hver post har et farvet type-badge (grøn for correction, blå for edit, rød for rejection), feltnavn hvor relevant, et strikethrough diff for corrections og en timestamp. Footeren noterer det maksimale antal injectede så kuratorer forstår hvad der faktisk flyder tilbage i agenten. ## API Endpointet er på `POST /api/cms/agents/[id]/feedback` og accepterer: ```json { "type": "correction" | "rejection" | "edit", "queueItemId": "qi-...", "field": "title", "original": "...", "corrected": "...", "notes": "..." } ``` De fleste kuratorer kalder aldrig dette direkte — kuraterings-routes for godkendelse/afvisning håndterer det automatisk. Det findes til programmatiske submissions og til in-page-panelet. ## Lagring og grænser - Fil: `_data/agents/{agentId}/feedback.json` - Maks poster: **200** (ældste droppes ved append) - Format: JSON-array af `FeedbackEntry` objekter - Bagudkompatibel: legacy `{ original, corrected }` shape læses som en `correction` type ## Hvorfor kun 5 eksempler? Few-shot eksempler er den dyreste del af systempromp'ten — hvert er hundredevis af tokens, og prisen betales på hver kørsel. Fem er nok til at kommunikere konsistente redaktionelle præferencer uden at bloate prompten til token-spild. Hvis du vil have agenten til at lære en *ny* præference hurtigt, lander den i top 5 inden for få godkendelser. ## Se også - [Per-agent budgetkontrol](/docs/agent-cost-guards) — hold en for-ivrig agent fra at ignorere dine rettelser ved at begrænse dens budget. - [Kurateringskøen](/docs/curation-queue) — hvor rettelser sker. --- ## docs/agent-feedback-loop Title: Agent Feedback Loop Updated: 2026-04-08 Locale: en Curator edits and rejection notes are saved per agent and injected into the next run as few-shot examples — agents learn from corrections automatically. ## The problem Early on, the agent runner read past corrections from `_data/agents/{id}/feedback.json` and injected them into the system prompt as few-shot examples. The intent was that agents would learn from curator edits over time. The catch: **nothing in the codebase ever wrote to that file.** Curators could fix every draft and reject every bad one and the agent would happily repeat the same mistakes the next day. The feedback loop closes that gap. Every curator action — edit a field before approving, reject with a note — is now persisted to the agent's feedback file automatically. The next time that agent runs, the most recent corrections are baked into its system prompt as concrete examples. ## How corrections are recorded When a queue item is created by an agent, the runner snapshots the original `contentData` onto the queue item under `originalContentData`. When a curator approves the item, the curation route diffs the current `contentData` against that snapshot, and writes one **correction** entry per changed string field: ```json { "id": "fb-...-...", "type": "correction", "queueItemId": "qi-...", "field": "title", "original": "Why TypeScript Generics Matter", "corrected": "How TypeScript Generics Save You From Refactoring Hell", "createdAt": "2026-04-08T..." } ``` If you don't edit anything before approving, no corrections are recorded — the original output was good enough. ## How rejections are recorded When you reject a queue item with a note, the rejection route writes a **rejection** entry containing the curator's notes: ```json { "id": "fb-...-...", "type": "rejection", "queueItemId": "qi-...", "notes": "Tone is too dry — needs more personality and specific examples", "createdAt": "2026-04-08T..." } ``` Rejection notes are visible on the agent detail page but **not currently injected into the next system prompt** (only `correction` and `edit` entries with both `original` and `corrected` strings are used as few-shot examples). They're still useful as an audit trail, and may be folded into the prompt context in a future revision. ## What gets injected into the next run The agent runner calls `loadFeedbackForPrompt(agentId, 5)` and pulls the **last 5 correction examples** in chronological order. They're appended to the system prompt under a `## Learn from past corrections` section, like this: ``` ## Learn from past corrections Example 1: Original: Why TypeScript Generics Matter Corrected: How TypeScript Generics Save You From Refactoring Hell Example 2: Original: A serene sunrise over snow-capped mountains Corrected: Snow-covered alpine valley with frozen lake at golden hour ``` The model treats these as concrete editorial preferences and tends to mimic the patterns on its next run. ## The Recent feedback panel The agent detail page shows a **Recent feedback** card with the last 5 entries. Each entry has a colored type badge (green for correction, blue for edit, red for rejection), the field name where applicable, a strikethrough diff for corrections, and a timestamp. The footer notes the maximum injected count so curators understand what's actually flowing back into the agent. ## API The endpoint is at `POST /api/cms/agents/[id]/feedback` and accepts: ```json { "type": "correction" | "rejection" | "edit", "queueItemId": "qi-...", "field": "title", "original": "...", "corrected": "...", "notes": "..." } ``` Most curators never call this directly — the curation approve/reject routes handle it automatically. It exists for programmatic submissions and for the in-page panel. ## Storage and limits - File: `_data/agents/{agentId}/feedback.json` - Max entries: **200** (oldest are dropped on append) - Format: JSON array of `FeedbackEntry` objects - Backwards compatible: legacy `{ original, corrected }` shape is read as a `correction` type ## Why only 5 examples? Few-shot examples are the most expensive part of the system prompt — each one is hundreds of tokens, and the cost is paid on every run. Five is enough to communicate consistent editorial preferences without bloating the prompt to the point of token waste. If you want the agent to learn a *new* preference quickly, that one will land in the top 5 within a few approvals. ## See also - [Per-agent cost guards](/docs/agent-cost-guards) — keep an over-eager agent from ignoring your feedback by limiting its budget. - [Curation queue](/docs/curation-queue) — where corrections happen. --- ## docs/agent-image-generation-da Title: Billedgenerering Updated: 2026-04-08 Locale: da Agenter kan generere billeder via Google Gemini 3 Pro Image. Output går gennem samme media pipeline som user uploads — varianter, EXIF, AI alt-tekst — og hvert billede markeres som AI-genereret. ## Hvad er `generate_image` tool'et? Når du aktiverer **Image generation** på en agent, får agenten et nyt tool kaldet `generate_image`. Tool'et kalder Googles Gemini 3 Pro Image model (almindeligt kendt som **Nano Banana 2**) og producerer et rigtigt billede fra en tekstprompt. Billedet gemmes til sitets media library, optimeres, analyseres og stemples med provenance metadata — alt sammen i ét tool-kald. Agenten beslutter selv hvornår den kalder tool'et. En typisk kørsel ser sådan ud: 1. Agenten læser sin prompt ("Skriv et kort indlæg om bjerg-solopgange"). 2. Agenten kalder `generate_image` med sin egen beskrivende prompt ("En rolig solopgang over snedækkede bjerge, blødt golden hour-lys, fotorealistisk"). 3. Gemini returnerer billed-bytes. 4. Billedet gemmes, processes, analyseres for alt-tekst, og tool'et returnerer et Markdown billed-tag der peger på den gemte fil. 5. Agenten embedder tag'et øverst i artikelens body. 6. Det færdige indlæg lander i kurateringskøen med billedet allerede på plads. ## Pipeline-paritet med uploads Det vigtigste designvalg: et genereret billede går gennem **præcis samme processing pipeline** som et user-uploaded billede. Specifikt: | Trin | Samme som upload? | |------|-------------------| | Gem bytes til `public/uploads/` via media adapter | ✓ | | Generér WebP-varianter (Sharp, default 400 / 800 / 1200 / 1600 widths) | ✓ | | Ekstrahér EXIF-metadata | ✓ (vil være tom på syntetiske billeder) | | Kør F44 AI vision-analyse for at producere caption + alt-tekst + tags | ✓ | | Tilføj til `media-meta.json` | ✓ | Det eneste der adskiller sig er **provenance**. Genererede billeder får fire ekstra felter på deres `MediaMeta`-entry: ```json { "generatedByAi": true, "generatedByModel": "gemini-3-pro-image-preview", "generatedAt": "2026-04-08T...", "generatedPrompt": "En rolig solopgang over snedækkede bjerge..." } ``` ## Markering og filtrering i media library Hvert AI-genereret billede får et distinkt **lilla AI-badge** på media-kortet (separat fra det gyldne sparkles-badge der markerer AI-analyserede-men-uploadede billeder). Badge-tooltip'et viser den oprindelige prompt for hurtig kontekst. Media-listens sidebar får et nyt **AI generated** filter under AI Analysis-sektionen. Klik det for at se kun de billeder dine agenter har produceret. Både grid-view og list-view renderer badget. ## Pris Nano Banana 2 (Gemini 3 Pro Image Preview) koster **$0.039 pr. billede**. Prisen trækkes på Cockpit-budgettet via `addCost()` umiddelbart efter en succesfuld generering. Hvis du har per-agent cost guards aktiveret, tæller billed-prisen også med i de lofter. ## Fejl-mode: ingen hallucinerede placeholders Tool-beskrivelsen har en streng regel indbygget: **hvis generering fejler, skal agenten udelade billedet helt.** Ingen placeholder URLs, ingen "billede kommer snart" tekst, ingen stockbilleder. Når `generate_image` fejler, returnerer handleren en streng der starter med `Image generation failed:`. Agentens tool-beskrivelse fortæller den at enhver sådan streng betyder "medtag IKKE noget billede i dit endelige output. Fortsæt med at skrive artiklen uden et." Selve fejlmeddelelsen minder agenten om reglen inline. Kurateringens Preview-modal forsvarer sig også mod dette — hvis en Markdown billed-URL ikke starter med `http(s)://`, `/` eller `data:`, renderer den en lille "⚠ Invalid image URL" warning chip i stedet for et brækket ``. ## Konfiguration 1. Sørg for at Gemini API-nøglen er sat på org eller site (org-niveau `aiGeminiApiKey` arves via F87). 2. Åbn agenten du vil aktivere, scroll til **Tools**, sæt flueben i **Image generation (Gemini Nano Banana)**, gem. 3. Kør agenten med en prompt der har gavn af et billede. Nøgler resolves i denne rækkefølge: `ai-config.json` → `GOOGLE_GENERATIVE_AI_API_KEY` env → `GEMINI_API_KEY` env. Hvis ingen er sat, returnerer tool'et `null` fra `buildToolRegistry` og springes stille over — agenten kan stadig køre, bare uden billed-option. ## Webhook og kuraterings-embed Når agenten er færdig, renderer `agent.completed` webhook-embed'et det genererede billede inline (Discord `embed.image`) forudsat at billed-URL'en er offentligt nåbar. Lokalt-genererede billeder kan ikke nås af Discord, så embed'et falder tilbage til et klikbart link i description'en indtil dokumentet er godkendt og deployet. ## Se også - [Per-agent budgetkontrol](/docs/agent-cost-guards) — sæt loft over billed-forbrug på agent-niveau. - [Kurateringskø Preview](/docs/curation-queue) — se dine genererede billeder renderet før godkendelse. - [Media library](/docs/media) — filter og badge for AI-genererede billeder. --- ## docs/agent-image-generation Title: Image Generation Updated: 2026-04-08 Locale: en Agents can generate images via Google Gemini 3 Pro Image. Output goes through the same media pipeline as user uploads — variants, EXIF, AI alt-text — and every image is tagged as AI-generated. ## What is the `generate_image` tool? When you enable **Image generation** on an agent, the agent gains a new tool called `generate_image`. The tool calls Google's Gemini 3 Pro Image model (commonly known as **Nano Banana 2**) and produces a real image from a text prompt. The image is saved to the site's media library, optimised, analyzed, and stamped with provenance metadata — all in one tool call. The agent decides on its own when to call the tool. A typical run looks like: 1. The agent reads its prompt ("Write a short post about mountain sunrises"). 2. The agent calls `generate_image` with a descriptive prompt of its own ("A serene sunrise over snow-capped mountains, soft golden hour light, photorealistic"). 3. Gemini returns the image bytes. 4. The image is saved, processed, analyzed for alt-text, and the tool returns a Markdown image tag pointing at the saved file. 5. The agent embeds that tag at the top of the article body. 6. The final post lands in the curation queue with the image already in place. ## Pipeline parity with uploads The key design decision: a generated image goes through the **exact same processing pipeline** as a user-uploaded image. Specifically: | Step | Same as upload? | |------|-----------------| | Save bytes to `public/uploads/` via the media adapter | ✓ | | Generate WebP variants (Sharp, default 400 / 800 / 1200 / 1600 widths) | ✓ | | Extract EXIF metadata | ✓ (will be empty on synthetic images) | | Run F44 AI vision analysis to produce caption + alt-text + tags | ✓ | | Append to `media-meta.json` | ✓ | The only thing that differs is **provenance**. Generated images get four extra fields on their `MediaMeta` entry: ```json { "generatedByAi": true, "generatedByModel": "gemini-3-pro-image-preview", "generatedAt": "2026-04-08T...", "generatedPrompt": "A serene sunrise over snow-capped mountains..." } ``` ## Marking and filtering in the media library Every AI-generated image gets a distinct **purple AI badge** on the media card (separate from the gold sparkles badge that marks AI-analyzed-but-uploaded images). The badge tooltip shows the original prompt for quick context. The media list sidebar gets a new **AI generated** filter under the AI Analysis section. Click it to see only the images your agents have produced. Both grid view and list view render the badge. ## Cost Nano Banana 2 (Gemini 3 Pro Image Preview) costs **$0.039 per image**. The cost is charged to the Cockpit budget via `addCost()` immediately after a successful generation. If you have per-agent cost guards enabled, the image cost counts toward those caps too. ## Failure mode: no hallucinated placeholders The tool description has a strict rule baked in: **if generation fails, the agent must omit the image entirely.** No placeholder URLs, no "image coming soon" text, no stock images. When `generate_image` fails, the handler returns a string that begins with `Image generation failed:`. The agent's tool description tells it that any such string means "do NOT include any image in your final output. Continue writing the article without one." The error message itself reminds the agent of this rule inline. The curation Preview modal also defends against this — if a Markdown image URL doesn't start with `http(s)://`, `/`, or `data:`, it renders a small "⚠ Invalid image URL" warning chip instead of a broken ``. ## Configuration 1. Make sure the Gemini API key is set on the org or site (org-level `aiGeminiApiKey` is inherited via F87). 2. Open the agent you want to enable, scroll to **Tools**, tick **Image generation (Gemini Nano Banana)**, save. 3. Run the agent with a prompt that benefits from an image. Keys resolved in order: `ai-config.json` → `GOOGLE_GENERATIVE_AI_API_KEY` env → `GEMINI_API_KEY` env. If none are set, the tool returns `null` from `buildToolRegistry` and is silently skipped — the agent can still run, just without the image option. ## Webhook and curation embed When the agent finishes, the `agent.completed` webhook embed renders the generated image inline (Discord `embed.image`) provided the image URL is publicly reachable. Locally-generated images can't be reached by Discord, so the embed falls back to a clickable link in the description until the document is approved and deployed. ## See also - [Per-agent cost guards](/docs/agent-cost-guards) — cap image-spend at the agent level. - [Curation queue Preview](/docs/curation-queue) — see your generated images rendered before approval. - [Media library](/docs/media) — filter and badge for AI-generated images. --- ## docs/agent-multi-locale-da Title: Multi-locale agenter Updated: 2026-04-08 Locale: da Vælg hvilket sprog hver agent skriver på, og lad auto-translate udfylde de andre når en default-locale kladde godkendes. ## To ortogonale indstillinger Multi-locale support til agenter har to uafhængige knapper der arbejder sammen. Forveksl dem og det forkerte indhold lander i den forkerte sprog-bucket — så det er værd at forstå dem hver for sig først. | Indstilling | Hvor | Hvad den styrer | |-------------|------|-----------------| | **Agent locale** (per-agent) | Agent detail → "Output language" dropdown | Hvilket sprog denne specifikke agent skriver sin **primære kladde** på | | **Auto-translate** (per-site) | Site Settings → Language → toggle | Om de **andre locales** udfyldes automatisk når en default-locale kladde godkendes | De er ortogonale. Du kan have: - Et single-language site med én agent (defaults — ingen af indstillingerne betyder noget) - Et multi-language site med én DA-agent + auto-translate ON (agent skriver dansk, EN og DE lander i køen automatisk når godkendt) - Et multi-language site med separate DA + EN agenter (hver skriver sin egen native kladde, ingen auto-translate fra én til den anden) - Et multi-language site med én DA-agent og auto-translate OFF (kun dansk oprettes, du oversætter manuelt senere) ## Per-agent locale **Output language** dropdown'en vises kun på agentens detaljeside når sitet har mere end én konfigureret locale. Single-locale sites ser den slet ikke — der er intet at vælge. Når sat: - Agent-runneren bruger `agent.locale` i stedet for `siteConfig.defaultLocale` til `buildLocaleInstruction`. LLM'en får de locale-specifikke skriveregler (tegnbegrænsninger, tone-konventioner osv.). - Queue item bærer locale'en igennem til godkendelse. Når kuratoren godkender, bruger `cms.content.create` `item.locale` så dokumentet lander i den korrekte sprog-bucket. - En blank værdi betyder "arv site default" — den eksisterende adfærd, fuldt bagudkompatibel. Dette lader ét site hoste parallelle agenter på forskellige sprog. For eksempel: et dansk site med en engelsktalende expat-målgruppe kan have: - **Content Writer DA** — skriver danske indlæg til den primære målgruppe - **Content Writer EN** — skriver engelske indlæg direkte, uden at gå gennem oversættelse Begge lander i samme `posts` collection, i deres respektive sprog-buckets. ## Auto-translate ved godkendelse Sitets **autoRetranslateOnUpdate** flag (Site Settings → Language → Auto-translate) var allerede wired ind i dokumentets PUT route, men kuraterings-**approve** routen gik gennem `cms.content.create` direkte og sprang hooket helt over. Phase 6 plumbede det igennem. Når alle fire betingelser er sande på godkendelses-tidspunktet: 1. Godkendelsen er en rigtig publish (ikke `asDraft`) 2. `siteConfig.autoRetranslateOnUpdate` er on 3. Det godkendte dokuments locale matcher site default (`siteConfig.defaultLocale`) 4. Sitet har mere end én konfigureret locale …så fyrer kuraterings-routen `POST /api/cms/[collection]/[slug]/translate` for hver non-default locale, fire-and-forget. Oversættelserne lander i samme collection i deres respektive sprog-buckets, klar til review. ## Hvorfor kun oversætte fra default locale? Den defensive guard ved betingelse 3 er vigtig. Forestil dig et dansk-default site med en engelsk Content Writer agent. Hvis vi lod auto-translate fyre på det engelske dokuments godkendelse, ville det oversætte EN→DA og **clobbe den menneske-kuraterede danske primary version**. Så oversættelse flyder kun ud fra default-locale, aldrig ind i den. Hvis du har en non-default-locale agent, hævder du at denne agents output er uafhængig af default-locale-historien for dette dokument. Default-locale-versionen efterlades urørt. ## At sætte det hele sammen Det fulde flow på et `da` + `en` + `de` site med `autoRetranslateOnUpdate: true`: 1. **Content Writer DA** er konfigureret med `locale: ""` (defaulter til `da`). 2. Kurator kører agenten → dansk kladde lander i køen. 3. Kurator godkender → dansk dokument oprettes. 4. Approve-route detekterer: locale = default + autoTranslate on + multi-locale site → fyrer `/translate` for `en` og `de`. 5. Translate-routes producerer engelske og tyske dokumenter i samme collection, også publiceret. 6. Resultat: ét godkendelses-klik, tre locales live. Hvis du også har **Content Writer EN** med `locale: "en"`: 1. Kurator kører den agent → engelsk kladde lander i køen. 2. Kurator godkender → engelsk dokument oprettes. 3. Approve-route tjekker betingelserne: locale ≠ default → auto-translate **springes over**. Den danske version efterlades præcis som den var. ## Se også - [Agents oversigt](/docs/ai-agents) — det grundlæggende. - [Kurateringskøen](/docs/curation-queue) — hvor godkendelse trigger auto-translate. --- ## docs/agent-multi-locale Title: Multi-Locale Agents Updated: 2026-04-08 Locale: en Pick the language each agent writes in, and let auto-translate fill in the others when a default-locale draft is approved. ## Two orthogonal settings Multi-locale support for agents has two independent knobs that work together. Get them confused and the wrong content lands in the wrong language bucket — so it's worth understanding them separately first. | Setting | Where | What it controls | |---------|-------|------------------| | **Agent locale** (per-agent) | Agent detail → "Output language" dropdown | Which language this specific agent writes its **primary draft** in | | **Auto-translate** (per-site) | Site Settings → Language → toggle | Whether the **other locales** are filled in automatically when a default-locale draft is approved | They're orthogonal. You can have: - A single-language site with one agent (defaults — neither setting matters) - A multi-language site with one DA agent + auto-translate ON (agent writes Danish, EN and DE land in queue automatically when approved) - A multi-language site with separate DA + EN agents (each writes its own native draft, no auto-translate from one to the other) - A multi-language site with one DA agent and auto-translate OFF (only Danish is created, you translate manually later) ## Per-agent locale The **Output language** dropdown only appears on the agent detail page when the site has more than one configured locale. Single-locale sites don't see it at all — there's nothing to choose. When set: - The agent runner uses `agent.locale` instead of `siteConfig.defaultLocale` for `buildLocaleInstruction`. The LLM gets the locale-specific writing rules (character limits, tone conventions, etc). - The queue item carries the locale through to approval. When the curator approves, `cms.content.create` uses `item.locale` so the document lands in the correct language bucket. - A blank value means "inherit the site default" — the existing behaviour, fully backwards-compatible. This lets one site host parallel agents in different languages. For example: a Danish site with an English-speaking expat audience can have: - **Content Writer DA** — writes Danish posts for the primary audience - **Content Writer EN** — writes English posts directly, without going through translation Both land in the same `posts` collection, in their respective locale buckets. ## Auto-translate on approval The site's **autoRetranslateOnUpdate** flag (Site Settings → Language → Auto-translate) was already wired into the document PUT route, but the curation **approve** route went through `cms.content.create` directly and skipped the hook entirely. Phase 6 plumbed it through. When all four conditions are true at approval time: 1. The approval is a real publish (not `asDraft`) 2. `siteConfig.autoRetranslateOnUpdate` is on 3. The approved document's locale matches the site default (`siteConfig.defaultLocale`) 4. The site has more than one configured locale …then the curation route fires `POST /api/cms/[collection]/[slug]/translate` for each non-default locale, fire-and-forget. The translations land in the same collection in their respective locale buckets, ready for review. ## Why only translate from the default locale? The defensive guard at condition 3 is important. Imagine a Danish-default site with an English Content Writer agent. If we let auto-translate fire on the English document's approval, it would translate EN→DA and **clobber the human-curated Danish primary version**. So translation only ever flows out from the default locale, never into it. If you have a non-default-locale agent, you're asserting that this agent's output is independent of the default-locale story for this document. The default-locale version stays untouched. ## Putting it all together The full flow on a `da` + `en` + `de` site with `autoRetranslateOnUpdate: true`: 1. **Content Writer DA** is configured with `locale: ""` (defaults to `da`). 2. Curator runs the agent → Danish draft lands in queue. 3. Curator approves → Danish document is created. 4. Approve route detects: locale = default + autoTranslate on + multi-locale site → fires `/translate` for `en` and `de`. 5. The translate routes produce English and German documents in the same collection, also published. 6. Result: one approval click, three locales live. If you also have **Content Writer EN** with `locale: "en"`: 1. Curator runs that agent → English draft lands in queue. 2. Curator approves → English document is created. 3. Approve route checks the conditions: locale ≠ default → auto-translate is **skipped**. The Danish version is left exactly as it was. ## See also - [Agents overview](/docs/ai-agents) — the basics. - [Curation queue](/docs/curation-queue) — where approval triggers the auto-translate. --- ## docs/agent-templates-da Title: Agent template-bibliotek Updated: 2026-04-08 Locale: da Genbrugelige agent-presets — gem dine egne som lokale templates, eller browse den kuraterede marketplace på github.com/webhousecode/cms-agents. ## To kilder, én picker Template-biblioteket har to lag, begge synlige på **Agents → Templates** tab'en og på **Start from a template** sektionen af new-agent siden. | Lag | Hvor det ligger | Hvem kuraterer det | |-----|-----------------|--------------------| | **Local** | `_admin/_data/agent-templates/{orgId}/{tplId}.json` | Dig — gemt fra eksisterende agenter via "Save as template" | | **Marketplace** | `github.com/webhousecode/cms-agents` | webhouse.app teamet — åbne PRs velkomne | Lokale templates er scoped til **orgen**, ikke sitet, så en template du gemmer på ét site er tilgængelig på alle sites under samme org. Marketplace templates er globale. ## Gemme en lokal template Åbn en agents detaljeside. Action bar'en har en **Save as template** knap ved siden af **Clone**. Klik den og agentens nuværende konfiguration — navn, role, system prompt, behavior sliders, tools, autonomy, target collections, field defaults — kopieres ind i en ny template. Hvad der **ikke** kopieres: stats, schedule, budgets, locale, active flag. Det hører til en kørende agent-instans, ikke til en genbrugelig preset. Når du senere instantierer templaten, sætter du de felter friske på den nye agent. Templaten gemmes under den aktive org og er synlig for alle sites i den orgs Templates tab og new-agent picker. ## Slette en lokal template Templates-tabben på `/admin/agents` lister alle dine lokale templates som kort. Hver har et trash-ikon der åbner standard inline confirm-mønsteret ("Remove? [Yes] [No]"). Klik Yes og template-filen slettes. Marketplace templates kan ikke slettes fra admin — de er read-only mirrors af GitHub-kilden. ## Marketplace Når admin loader template-listen, prøver den marketplace i denne rækkefølge: 1. **Primary**: `https://webhouse.app/api/agent-templates` — den kuraterede API på hovedsitet (5-sekund timeout). 2. **Fallback**: `https://raw.githubusercontent.com/webhousecode/cms-agents/main/manifest.json` plus per-template fetches fra samme repo. 3. **Empty**: hvis begge kilder fejler, viser marketplace-sektionen en soft warning og picker'en virker stadig for lokale templates. Resultater caches i 5 minutter pr. CMS-proces så picker'en ikke re-fetcher på hver keystroke. ## Oprette en ny agent fra en template På new-agent siden viser **Start from a template** sektionen to grids: "Your org" med dine lokale templates, og "Marketplace" med de kuraterede. Klik et hvilket som helst kort og formen nedenfor pre-fyldes med den templates payload. Tweak hvad du vil, giv agenten et navn, gem. Formen er stadig fuldt redigerbar efter pre-fill — templates er startpunkter, ikke begrænsninger. ## Bidrage til marketplace Repo'et `webhousecode/cms-agents` er MIT-licenseret og accepterer pull requests. For at tilføje en template: 1. Tilføj en ny JSON-fil under `templates/` der matcher `AgentTemplate` shape. 2. Tilføj en matchende entry til `manifest.json`. 3. Åbn en PR med ét afsnit om hvad templaten er til og hvilket slags site du forestillede dig mens du skrev den. Accepterede templates skal: - Have en opinionated voice (ingen generiske "skriv et indlæg om X" prompts) - Eksplicit afvise hype-ord i systempromp'ten - Defaulte til `autonomy: "draft"` (publish aldrig uden kurator-review) - Være ærlige om pris — nævn hvis `webSearch` eller `imageGeneration` er on - Virke uden per-site kontekst Se repo'ets `README.md` for det fulde template-format-reference og kurateringsprincipper. ## Se også - [Agents oversigt](/docs/ai-agents) — hvad en agent er. - [Workflows](/docs/agent-workflows) — chain templatede agenter ind i pipelines. --- ## docs/agent-templates Title: Agent Template Library Updated: 2026-04-08 Locale: en Reusable agent presets — save your own as local templates, or browse the curated marketplace at github.com/webhousecode/cms-agents. ## Two sources, one picker The template library has two tiers, both visible on the **Agents → Templates** tab and on the **Start from a template** section of the new-agent page. | Tier | Where it lives | Who curates it | |------|---------------|----------------| | **Local** | `_admin/_data/agent-templates/{orgId}/{tplId}.json` | You — saved from existing agents via "Save as template" | | **Marketplace** | `github.com/webhousecode/cms-agents` | webhouse.app team — open PRs welcome | Local templates are scoped to the **org**, not the site, so a template you save on one site is available on every site under the same org. Marketplace templates are global. ## Saving a local template Open any agent's detail page. The action bar has a **Save as template** button next to **Clone**. Click it and the agent's current configuration — name, role, system prompt, behavior sliders, tools, autonomy, target collections, field defaults — is copied into a new template. What's **not** copied: stats, schedule, budgets, locale, active flag. Those belong to a running agent instance, not to a reusable preset. When you instantiate the template later, you set those fields fresh on the new agent. The template is stored under the active org and visible to every site in that org's Templates tab and new-agent picker. ## Deleting a local template The Templates tab on `/admin/agents` lists all your local templates as cards. Each has a trash icon that opens the standard inline confirm pattern ("Remove? [Yes] [No]"). Click Yes and the template file is deleted. Marketplace templates can't be deleted from the admin — they're read-only mirrors of the GitHub source. ## The marketplace When the admin loads the template list, it tries the marketplace in this order: 1. **Primary**: `https://webhouse.app/api/agent-templates` — the curated API on the main site (5-second timeout). 2. **Fallback**: `https://raw.githubusercontent.com/webhousecode/cms-agents/main/manifest.json` plus per-template fetches from the same repo. 3. **Empty**: if both sources fail, the marketplace section shows a soft warning and the picker still works for local templates. Results are cached for 5 minutes per CMS process so the picker doesn't refetch on every keystroke. ## Creating a new agent from a template On the new-agent page, the **Start from a template** section shows two grids: "Your org" with your local templates, and "Marketplace" with the curated ones. Click any card and the form below is pre-filled with that template's payload. Tweak whatever you like, give the agent a name, save. The form is still fully editable after pre-fill — templates are starting points, not constraints. ## Contributing to the marketplace The `webhousecode/cms-agents` repo is MIT-licensed and accepts pull requests. To add a template: 1. Add a new JSON file under `templates/` matching the `AgentTemplate` shape. 2. Add a matching entry to `manifest.json`. 3. Open a PR with one paragraph on what the template is for and what kind of site you imagined while writing it. Accepted templates must: - Have an opinionated voice (no generic "write a post about X" prompts) - Refuse hype words explicitly in the system prompt - Default to `autonomy: "draft"` (never publish without curator review) - Be honest about cost — mention if `webSearch` or `imageGeneration` is on - Work without per-site context See the repo's `README.md` for the full template format reference and curation principles. ## See also - [Agents overview](/docs/ai-agents) — what an agent is. - [Workflows](/docs/agent-workflows) — chain templated agents into pipelines. --- ## docs/agent-workflows-da Title: Agent workflows Updated: 2026-04-08 Locale: da Chain flere agenter ind i en enkelt pipeline. Én prompt ind, ét kurateringskø-item ud — selv om flere agenter rørte det. ## Hvad er en workflow? En workflow er en **ordnet kæde af agenter** der kører som en enkelt pipeline. Hvert step er en reference til en eksisterende agent på dit site. Det første step modtager user prompt'en; hvert efterfølgende step modtager forrige steps output og applicerer sin egen role ovenpå. Det kanoniske eksempel: **Writer → SEO Optimizer → Translator**. 1. Writer tager "Skriv en artikel om TypeScript generics" → producerer en kladde 2. SEO Optimizer tager den kladde → returnerer samme kladde omstruktureret til søgning 3. Translator tager den optimerede kladde → returnerer den på et andet sprog Kun det **sidste** steps output lander i kurateringskøen. Kuratorer ser ét item per workflow-kørsel, ikke ét per step. Hvert steps pris summes og trækkes på Cockpit-budgettet én gang til sidst. Én `agent.completed` webhook fyrer. ## Sådan flyder indhold mellem steps Runneren passerer en synthesized Markdown-prompt fra et step til det næste. For step 2 og fremad ser user prompt'en sådan ud: ``` You are processing existing content as part of a multi-step workflow. Apply your role to the draft below and return the improved version using the same JSON schema. ## Current draft Title: Excerpt: Tags: Body: ``` Det betyder at hver agent i kæden ser *hele* kladden som den aktuelt står, ikke bare et diff. Agenter er stateless på tværs af kørsler alligevel — dette giver bare den næste nok kontekst til at gøre sit job. ## Oprette en workflow 1. Åbn `/admin/agents` og skift til **Workflows**-tabben. 2. Klik **New workflow**. 3. Giv den et navn (fx "Writer → SEO → Translator"). 4. Klik agent-knapperne i den rækkefølge du vil have dem til at køre. Hvert klik tilføjer et step. 5. Træk grip-håndtagene (⋮⋮) for at omarrangere. Klik × på en række for at fjerne et step. 6. (Valgfrit) Sæt flueben i **Run on a schedule** og sæt frekvens, tid, max-per-run, og en default prompt der sendes til step 1 på hver scheduled run. 7. Klik **Create**. Redigering virker på samme måde — klik blyant-ikonet på et eksisterende workflow-kort for at genåbne formen pre-fyldt med dens nuværende state. Gem ændringer og workflow'en opdateres in place (dens stats og createdAt bevares). ## Køre en workflow Hvert workflow-kort har en prompt-textarea og en **Run** knap. Skriv en prompt, klik Run, vent. Runneren itererer steps i rækkefølge, hver kalder sin agents LLM gennem `executeAgentRaw` (samme code path som `runAgent` men uden mellemliggende side effects). Når sidste step er færdigt: - Ét queue item oprettes med det endelige `contentData` - Cockpit-budgettet trækkes summen af alle step-omkostninger - Én `recordRun` analytics-række skrives, med `model: " → → "` - Én `agent.completed` webhook fyrer med `agentName: "Workflow: "` En enkelt 3-steps workflow tager nogenlunde samme wall time som 3 separate agent-kørsler. Besparelserne er i oprydning, ikke gennemløb. ## Scheduling Workflows har samme `schedule` shape som individuelle agenter — `enabled`, `frequency` (`daily` / `weekly` / `manual`), `time`, `maxPerRun`. Scheduleren itererer workflows ved siden af agenter i samme 5-minutters tick. Workflow lastRun-nøgler er namespaced under `wf:` i `_data/scheduler-state.json` så de ikke kolliderer med agent-ids. Scheduled workflow-runs bruger workflow'ens `defaultPrompt`-felt som input til step 1. Manuelle runs bruger altid hvad kuratoren skriver i per-kort textarea'en. ## Per-step budgetkontrol Der er ikke noget separat workflow-niveau budget. I stedet tjekkes hvert steps agent uafhængigt mod sine egne per-agent cost guards (Phase 4) før sit LLM-kald. Hvis et steps agent er over sit budget, halts workflow'en ved det step og kaster. Det er generelt hvad du vil have — det betyder at en løbsk agent i en kæde stadig stoppes af sit eget budget uden at du behøver at sætte workflow-specifikke lofter. ## JSON mode Workflow create/edit-formen har en **JSON / UI** mode-toggle i sin header (samme `ModeToggle`-komponent som structured array editoren i collections bruger). Skift til JSON for at se hele workflow-body'en som formateret JSON. Rediger den direkte hvis du har brug for en struktur den visuelle editor ikke eksponerer. Skift tilbage og formen re-validerer fra dine ændringer. Save-knappen virker fra begge modes — den parser JSON-body'en før submission. ## Se også - [Agents oversigt](/docs/ai-agents) — hvad en agent er. - [Per-agent budgetkontrol](/docs/agent-cost-guards) — hvordan step-niveau budgetter virker. - [Agent templates](/docs/agent-templates) — start hvert step fra en template i stedet for en one-off agent. --- ## docs/agent-workflows Title: Agent Workflows Updated: 2026-04-08 Locale: en Chain multiple agents into a single pipeline. One prompt in, one curation queue item out — even though several agents touched it. ## What's a workflow? A workflow is an **ordered chain of agents** that runs as a single pipeline. Each step is a reference to an existing agent on your site. The first step receives the user prompt; every subsequent step receives the previous step's output and applies its own role on top. The canonical example: **Writer → SEO Optimizer → Translator**. 1. Writer takes "Write an article about TypeScript generics" → produces a draft 2. SEO Optimizer takes that draft → returns the same draft restructured for search 3. Translator takes the optimised draft → returns it in another language Only the **final** step's output lands in the curation queue. Curators see one item per workflow run, not one per step. Every step's cost is summed and charged to the Cockpit budget once at the end. One `agent.completed` webhook fires. ## How content flows between steps The runner passes a synthesized Markdown prompt from one step to the next. For step 2 onwards, the user prompt looks like: ``` You are processing existing content as part of a multi-step workflow. Apply your role to the draft below and return the improved version using the same JSON schema. ## Current draft Title: Excerpt: Tags: Body: ``` This means each agent in the chain sees the *whole* draft as it currently stands, not just a diff. Agents are stateless across runs anyway — this just gives the next one enough context to do its job. ## Creating a workflow 1. Open `/admin/agents` and switch to the **Workflows** tab. 2. Click **New workflow**. 3. Give it a name (e.g. "Writer → SEO → Translator"). 4. Click the agent buttons in the order you want them to run. Each click adds a step. 5. Drag the grip handles (⋮⋮) to reorder. Click the × on a row to remove a step. 6. (Optional) Tick **Run on a schedule** and set frequency, time, max-per-run, and a default prompt to send to step 1 on each scheduled run. 7. Click **Create**. Editing works the same way — click the pencil icon on an existing workflow card to reopen the form pre-populated with its current state. Save changes and the workflow is updated in place (its stats and createdAt are preserved). ## Running a workflow Each workflow card has a prompt textarea and a **Run** button. Type a prompt, click Run, wait. The runner iterates the steps in order, each calling its agent's LLM through `executeAgentRaw` (the same code path as `runAgent` but without intermediate side effects). When the last step finishes: - One queue item is created with the final `contentData` - The Cockpit budget is charged the sum of all step costs - One `recordRun` analytics row is written, with `model: " → → "` - One `agent.completed` webhook fires with `agentName: "Workflow: "` A single 3-step workflow takes roughly the same wall time as 3 separate agent runs. The savings are in cleanup, not throughput. ## Scheduling Workflows have the same `schedule` shape as individual agents — `enabled`, `frequency` (`daily` / `weekly` / `manual`), `time`, `maxPerRun`. The scheduler iterates workflows alongside agents in the same 5-minute tick. Workflow lastRun keys are namespaced under `wf:` in `_data/scheduler-state.json` so they don't collide with agent ids. Scheduled workflow runs use the workflow's `defaultPrompt` field as the input to step 1. Manual runs always use whatever the curator types into the per-card textarea. ## Per-step budget guards There's no separate workflow-level budget. Instead, each step's agent is independently checked against its own per-agent cost guards (Phase 4) before its LLM call. If any step's agent is over its budget, the workflow halts at that step and throws. This is generally what you want — it means a runaway agent in a chain still gets stopped by its own budget without you having to set workflow-specific caps. ## JSON mode The workflow create/edit form has a **JSON / UI** mode toggle in its header (the same `ModeToggle` component used by the structured array editor in collections). Switch to JSON to see the entire workflow body as formatted JSON. Edit it directly if you need a structure the visual editor doesn't expose. Switch back and the form re-validates from your edits. The Save button works from either mode — it parses the JSON body before submission. ## See also - [Agents overview](/docs/ai-agents) — what an agent is. - [Per-agent cost guards](/docs/agent-cost-guards) — how step-level budgets work. - [Agent templates](/docs/agent-templates) — start each step from a template instead of a one-off agent. --- ## docs/collection-metadata Title: Collection Metadata (kind & description) Updated: 2026-04-08 Locale: en Tell AI tools what each collection is FOR with `kind` and `description` fields. ## Why this matters When you write a `cms.config.ts`, the schema fields tell the CMS **what** a collection holds — but they don't tell anything **what it's for**. Is a `team` collection a page (with its own URL)? A data source for another page's template? Or a form for visitor submissions? The inline chat, MCP tools, and any AI agent building or editing your site need this context to make good decisions. Without it, they guess — and often guess wrong. **Example of what goes wrong without metadata:** - Chat generates SEO metadata for a `team` collection that has no URL (wasted tokens, never used) - Chat adds a "View" button that leads to 404 because there's no rendered page - Chat remaps `body` → `bio` for a team member, corrupting the field - Chat triggers a full site build after updating a single `globals` record (unnecessary) ## Two new fields As of F127, `CollectionConfig` accepts two optional fields: ```typescript defineCollection({ name: 'team', label: 'Team Members', kind: 'data', // ← NEW description: 'Team members rendered on /about.', // ← NEW fields: [/* ... */], }); ``` Both are **optional and backwards compatible**. Collections without `kind` default to `page` behavior — exactly what the chat does today. But you should populate them on every new collection. ## The five kinds ### `page` (default) The collection produces indexable pages with URLs. Each document is a standalone page that appears in the sitemap, needs SEO metadata, and has a preview URL. **Use for:** blog posts, landing pages, documentation articles, marketing pages. **Chat behavior:** Full treatment — SEO generation, View pill, `body`/`content` remapping, site rebuild after changes. ```typescript defineCollection({ name: 'posts', label: 'Blog Posts', urlPrefix: '/blog', kind: 'page', description: 'Long-form blog articles. Each post has its own URL and appears in the RSS feed.', fields: [/* ... */], }); ``` ### `snippet` Reusable text fragments that get embedded in other content via the `{{snippet:slug}}` token (see [Snippet Embeds](/docs/snippet-embeds)). They have no standalone URL. **Use for:** CTAs, disclaimers, author bios, boilerplate text reused across posts. **Chat behavior:** No SEO, no View pill, still builds (host pages need to re-render with the updated snippet). ```typescript defineCollection({ name: 'snippets', label: 'Snippets', kind: 'snippet', description: 'Reusable text fragments embedded in posts via `{{snippet:slug}}`. Used for disclaimers, CTAs, and author bios.', fields: [ { name: 'title', type: 'text', required: true }, { name: 'content', type: 'richtext', required: true }, ], }); ``` ### `data` Records that are rendered on OTHER pages via loops. They're data sources, not pages themselves. **Use for:** team members, testimonials, FAQ items, products, portfolio projects, job openings. **Chat behavior:** No SEO, no View pill, no `body`/`content` remapping (uses exact field names from your schema), still builds. ```typescript defineCollection({ name: 'team', label: 'Team Members', kind: 'data', description: 'Team members. Rendered in a grid on /about and as bylines on blog posts.', fields: [ { name: 'name', type: 'text', required: true }, { name: 'role', type: 'text' }, { name: 'bio', type: 'textarea' }, { name: 'photo', type: 'image' }, ], }); ``` ### `form` Form submissions — contact forms, lead capture, applications. These are created by end users via frontend forms, never by AI or editors. **Use for:** `contact-submissions`, `lead-forms`, `applications`, `newsletter-signups`. **Chat behavior:** READ-ONLY — AI cannot create, update, or delete documents in form collections. Listing and searching is allowed. ```typescript defineCollection({ name: 'contact-submissions', label: 'Contact Form', kind: 'form', description: 'Submissions from the /contact form. Created by visitors via frontend form. Reviewed by sales team.', fields: [ { name: 'name', type: 'text', required: true }, { name: 'email', type: 'text', required: true }, { name: 'message', type: 'textarea', required: true }, { name: 'submittedAt', type: 'date' }, ], }); ``` ### `global` Site-wide configuration stored as a single record. No URL, no indexing, just settings. **Use for:** footer content, social links, analytics IDs, site-wide announcements. **Chat behavior:** Treated as settings. No SEO, no View pill, single-record mode. ```typescript defineCollection({ name: 'globals', label: 'Site Settings', kind: 'global', description: 'Site-wide configuration: footer text, social links, analytics IDs, cookie banner copy. Single record only.', fields: [/* ... */], }); ``` > **Note:** The name `globals` is a CMS convention. Never use `settings`, `config`, `admin`, `media`, or `interactives` as collection names — they conflict with built-in admin UI panels. ## Writing good descriptions A good `description` answers three questions: 1. **What is this?** ("Team members.", "Customer testimonials.") 2. **Where does it appear?** ("Rendered on /about.", "Looped on the homepage hero.") 3. **What references it?** ("Referenced by posts.author field.") **Good:** > "Team members. Referenced by posts.author field. Rendered on /about and as bylines on posts." **Bad:** > "Team stuff" — too vague, tells AI nothing > "A collection of team members" — restates the name, no new information ## Backwards compatibility Both fields are optional. Sites written before F127 continue working exactly as before — undefined `kind` defaults to `page` behavior. But there's no reason not to populate them on every collection you add going forward. The [Site Config Validator](/docs/site-config-validator) will show a soft warning when a collection is missing `description`. It's advisory, not blocking. ## Related - [Collections](/docs/collections) — managing documents - [Collection Naming](/docs/collection-naming) — reserved names to avoid - [Snippet Embeds](/docs/snippet-embeds) — the `snippet` kind in action - [AI Builder Guide](/docs/ai-builder-guide) — scaffolding sites with AI --- ## docs/nextjs-helpers-da Title: Next.js SEO-hjælpere Updated: 2026-04-01 Locale: da Drop-in sitemap, robots.txt, llms.txt, metadata, JSON-LD, RSS feed og statiske params — alt auto-genereret fra CMS-indhold via @webhouse/cms/next. ## Overblik `@webhouse/cms/next` er en sub-path eksport der giver dit Next.js-site fuld SEO og synlighed — sitemap, robots.txt, llms.txt, metadata, JSON-LD, RSS feed — alt auto-genereret fra CMS-indhold og `_seo`-felter. Ingen ny logik nødvendig. Det wrapper den eksisterende CMS build pipeline til Next.js-brug. ```bash # Allerede inkluderet i @webhouse/cms — ingen ekstra installation import { cmsSitemap, cmsRobots, cmsMetadata } from '@webhouse/cms/next'; ``` ## Sitemap Auto-genererer `sitemap.xml` med hreflang-alternativer til flersprogede sites. ```typescript // app/sitemap.ts import { cmsSitemap } from '@webhouse/cms/next'; export default cmsSitemap({ baseUrl: 'https://example.com', collections: [ { name: 'pages', urlPrefix: '/' }, { name: 'posts', urlPrefix: '/blog' }, ], // Valgfri i18n locales: ['en', 'da'], defaultLocale: 'en', localeStrategy: 'prefix-other', }); ``` **Valgmuligheder:** | Valgmulighed | Type | Standard | Beskrivelse | |--------------|------|----------|-------------| | `baseUrl` | `string` | — | Site-URL (uden trailing slash) | | `collections` | `SitemapCollection[]` | — | Collections der inkluderes | | `locales` | `string[]` | — | Tilgængelige sprog til hreflang | | `defaultLocale` | `string` | — | Standardsprog (får x-default) | | `localeStrategy` | `string` | `"prefix-other"` | URL-sprogstrategi | | `changeFrequency` | `string` | `"weekly"` | Standard ændringsfrekvens | | `defaultPriority` | `number` | `0.7` | Standard prioritet | ## Robots.txt Genererer `robots.txt` med AI-bot-håndtering fra F112 GEO. ```typescript // app/robots.ts import { cmsRobots } from '@webhouse/cms/next'; export default cmsRobots({ baseUrl: 'https://example.com', strategy: 'maximum', // eller 'balanced', 'restrictive', 'custom' }); ``` **Strategier:** | Strategi | Adfærd | |----------|--------| | `maximum` | Tillad alle bots inkl. AI-crawlere | | `balanced` | Tillad søgemaskiner, bloker aggressive AI-scrapere | | `restrictive` | Bloker alle AI-bots, tillad kun søgemaskiner | | `custom` | Dine egne regler via `customRules` | ## llms.txt Maskinlæsbart site-indeks til AI-agenter — hjælper AI-crawlere med at forstå din site-struktur. ```typescript // app/llms.txt/route.ts import { cmsLlmsTxt } from '@webhouse/cms/next'; export const GET = cmsLlmsTxt({ baseUrl: 'https://example.com', siteTitle: 'Mit Site', siteDescription: 'Et fantastisk site bygget med webhouse.app', collections: [ { name: 'posts', label: 'Blogindlæg', urlPrefix: '/blog' }, ], }); ``` Til den fulde indholds-eksport (al dokumenttekst): ```typescript // app/llms-full.txt/route.ts import { cmsLlmsFullTxt } from '@webhouse/cms/next'; export const GET = cmsLlmsFullTxt({ baseUrl: 'https://example.com', siteTitle: 'Mit Site', collections: [{ name: 'posts', label: 'Blogindlæg', urlPrefix: '/blog' }], }); ``` ## Metadata Udtrækker `_seo`-felter fra ethvert CMS-dokument til et Next.js `Metadata`-objekt. ```typescript // app/blog/[slug]/page.tsx import { cmsMetadata } from '@webhouse/cms/next'; import { getDocument } from '@/lib/content'; export async function generateMetadata({ params }: { params: Promise }) { const { slug } = await params; const doc = getDocument('posts', slug); if (!doc) return {}; return cmsMetadata({ baseUrl: 'https://example.com', siteName: 'Mit Site', doc, collection: 'posts', urlPrefix: '/blog', }); } ``` **Hvad den genererer:** - `title` fra `_seo.metaTitle` eller `data.title` - `description` fra `_seo.metaDescription` eller `data.excerpt` - `keywords` fra `_seo.keywords` - `openGraph` med billede, sitenavn, type (article/website), publiceringstidspunkt - `alternates.canonical` fra `_seo.canonical` eller beregnet URL - `robots` fra `_seo.robots` - Geo-metatags fra kortfelter (F96) ## JSON-LD Udtrækker struktureret data fra `_seo.jsonLd` til søgemaskine rich results. ```typescript // I din sidekomponent import { cmsJsonLd } from '@webhouse/cms/next'; export default function BlogPost({ doc }) { const jsonLd = cmsJsonLd(doc); return ( {jsonLd && ( )} {doc.data.title} ); } ``` CMS admin genererer JSON-LD fra 12 skema-skabeloner (Article, BlogPosting, FAQ, Product, LocalBusiness osv.) via SEO-panelet. ## RSS Feed Auto-genererer RSS 2.0 XML-feed fra CMS-indhold. ```typescript // app/feed.xml/route.ts import { cmsFeed } from '@webhouse/cms/next'; export const GET = cmsFeed({ baseUrl: 'https://example.com', title: 'Min Blog', description: 'Seneste indlæg fra Min Blog', collections: [{ name: 'posts', urlPrefix: '/blog' }], maxItems: 50, }); ``` ## generateStaticParams Factory til Next.js `generateStaticParams()` — returnerer alle publicerede slugs for en collection. ```typescript // app/blog/[slug]/page.tsx import { cmsGenerateStaticParams } from '@webhouse/cms/next'; export const generateStaticParams = cmsGenerateStaticParams({ collection: 'posts', paramName: 'slug', // standard }); ``` ## Fly.io Deployment Next.js boilerplates inkluderer en produktionsklar `Dockerfile` til Fly.io-deploy: ```bash # Deploy via CLI flyctl deploy --remote-only --ha=false ``` Eller brug **Deploy-fanen** i CMS admin — den registrerer automatisk Dockerfile og deployer til Fly.io med: - Region: `arn` (Stockholm) - Port: 3000 - Hukommelse: 512MB - Auto-genereret `fly.toml` Dockerfile bruger Next.js standalone output (`output: "standalone"` i `next.config.ts`) for minimal image-størrelse. Indholdsfiler kopieres ind i imaget til `fs`-baserede læsninger ved runtime. ## Komplet opsætning Sådan ser et fuldt SEO-optimeret Next.js-site ud: ```text app/ sitemap.ts ← cmsSitemap() robots.ts ← cmsRobots() llms.txt/route.ts ← cmsLlmsTxt() llms-full.txt/route.ts ← cmsLlmsFullTxt() feed.xml/route.ts ← cmsFeed() blog/ [slug]/page.tsx ← cmsMetadata() + cmsJsonLd() [slug]/page.tsx ← cmsMetadata() + cmsJsonLd() ``` Alt dette er forudkonfigureret i både `nextjs-boilerplate` og `nextjs-github-boilerplate` skabelonerne. --- ## docs/nextjs-helpers Title: Next.js SEO Helpers Updated: 2026-04-01 Locale: en Drop-in sitemap, robots.txt, llms.txt, metadata, JSON-LD, RSS feed, and static params — all auto-generated from CMS content via @webhouse/cms/next. ## Overview `@webhouse/cms/next` is a sub-path export that gives your Next.js site full SEO and discoverability — sitemap, robots.txt, llms.txt, metadata, JSON-LD, RSS feed — all auto-generated from CMS content and `_seo` fields. No new logic needed. It wraps the existing CMS build pipeline for Next.js consumption. ```bash # Already included with @webhouse/cms — no extra install import { cmsSitemap, cmsRobots, cmsMetadata } from '@webhouse/cms/next'; ``` ## Sitemap Auto-generates `sitemap.xml` with hreflang alternates for multi-locale sites. ```typescript // app/sitemap.ts import { cmsSitemap } from '@webhouse/cms/next'; export default cmsSitemap({ baseUrl: 'https://example.com', collections: [ { name: 'pages', urlPrefix: '/' }, { name: 'posts', urlPrefix: '/blog' }, ], // Optional i18n locales: ['en', 'da'], defaultLocale: 'en', localeStrategy: 'prefix-other', }); ``` **Options:** | Option | Type | Default | Description | |--------|------|---------|-------------| | `baseUrl` | `string` | — | Site URL (no trailing slash) | | `collections` | `SitemapCollection[]` | — | Collections to include | | `locales` | `string[]` | — | Available locales for hreflang | | `defaultLocale` | `string` | — | Default locale (gets x-default) | | `localeStrategy` | `string` | `"prefix-other"` | URL locale strategy | | `changeFrequency` | `string` | `"weekly"` | Default change frequency | | `defaultPriority` | `number` | `0.7` | Default priority | ## Robots.txt Generates `robots.txt` with AI bot management strategies from F112 GEO. ```typescript // app/robots.ts import { cmsRobots } from '@webhouse/cms/next'; export default cmsRobots({ baseUrl: 'https://example.com', strategy: 'maximum', // or 'balanced', 'restrictive', 'custom' }); ``` **Strategies:** | Strategy | Behavior | |----------|----------| | `maximum` | Allow all bots including AI crawlers | | `balanced` | Allow search engines, block aggressive AI scrapers | | `restrictive` | Block all AI bots, allow only search engines | | `custom` | Your own rules via `customRules` | ## llms.txt Machine-readable site index for AI agents — helps AI crawlers understand your site structure. ```typescript // app/llms.txt/route.ts import { cmsLlmsTxt } from '@webhouse/cms/next'; export const GET = cmsLlmsTxt({ baseUrl: 'https://example.com', siteTitle: 'My Site', siteDescription: 'A great site built with webhouse.app', collections: [ { name: 'posts', label: 'Blog Posts', urlPrefix: '/blog' }, ], }); ``` For the full content export (all document text): ```typescript // app/llms-full.txt/route.ts import { cmsLlmsFullTxt } from '@webhouse/cms/next'; export const GET = cmsLlmsFullTxt({ baseUrl: 'https://example.com', siteTitle: 'My Site', collections: [{ name: 'posts', label: 'Blog Posts', urlPrefix: '/blog' }], }); ``` ## Metadata Extracts `_seo` fields from any CMS document into a Next.js `Metadata` object. ```typescript // app/blog/[slug]/page.tsx import { cmsMetadata } from '@webhouse/cms/next'; import { getDocument } from '@/lib/content'; export async function generateMetadata({ params }: { params: Promise }) { const { slug } = await params; const doc = getDocument('posts', slug); if (!doc) return {}; return cmsMetadata({ baseUrl: 'https://example.com', siteName: 'My Site', doc, collection: 'posts', urlPrefix: '/blog', }); } ``` **What it generates:** - `title` from `_seo.metaTitle` or `data.title` - `description` from `_seo.metaDescription` or `data.excerpt` - `keywords` from `_seo.keywords` - `openGraph` with image, site name, type (article/website), published time - `alternates.canonical` from `_seo.canonical` or computed URL - `robots` from `_seo.robots` - Geo meta tags from map fields (F96) ## JSON-LD Extracts structured data from `_seo.jsonLd` for search engine rich results. ```typescript // In your page component import { cmsJsonLd } from '@webhouse/cms/next'; export default function BlogPost({ doc }) { const jsonLd = cmsJsonLd(doc); return ( {jsonLd && ( )} {doc.data.title} ); } ``` CMS admin generates JSON-LD from 12 schema templates (Article, BlogPosting, FAQ, Product, LocalBusiness, etc.) via the SEO panel. ## RSS Feed Auto-generates RSS 2.0 XML feed from CMS content. ```typescript // app/feed.xml/route.ts import { cmsFeed } from '@webhouse/cms/next'; export const GET = cmsFeed({ baseUrl: 'https://example.com', title: 'My Blog', description: 'Latest posts from My Blog', collections: [{ name: 'posts', urlPrefix: '/blog' }], maxItems: 50, }); ``` ## generateStaticParams Factory for Next.js `generateStaticParams()` — returns all published slugs for a collection. ```typescript // app/blog/[slug]/page.tsx import { cmsGenerateStaticParams } from '@webhouse/cms/next'; export const generateStaticParams = cmsGenerateStaticParams({ collection: 'posts', paramName: 'slug', // default }); ``` ## Fly.io Deployment Next.js boilerplates include a production-ready `Dockerfile` for Fly.io deploy: ```bash # Deploy via CLI flyctl deploy --remote-only --ha=false ``` Or use the **Deploy tab** in CMS admin — it auto-detects the Dockerfile and deploys to Fly.io with: - Region: `arn` (Stockholm) - Port: 3000 - Memory: 512MB - Auto-generated `fly.toml` The Dockerfile uses Next.js standalone output (`output: "standalone"` in `next.config.ts`) for minimal image size. Content files are copied into the image for `fs`-based reads at runtime. ## Complete Setup Here's what a fully SEO-optimized Next.js site looks like: ```text app/ sitemap.ts ← cmsSitemap() robots.ts ← cmsRobots() llms.txt/route.ts ← cmsLlmsTxt() llms-full.txt/route.ts ← cmsLlmsFullTxt() feed.xml/route.ts ← cmsFeed() blog/ [slug]/page.tsx ← cmsMetadata() + cmsJsonLd() [slug]/page.tsx ← cmsMetadata() + cmsJsonLd() ``` All of this is pre-configured in both the `nextjs-boilerplate` and `nextjs-github-boilerplate` templates. --- ## docs/docker-deployment-da Title: Docker-udrulning Updated: 2026-03-31 Locale: da Deploy @webhouse/cms med Docker — to muligheder: færdigbygget image fra GHCR, eller custom Dockerfile til dit site. CMS admin er tilgængelig som et færdigbygget Docker image på GitHub Container Registry. Der er to måder at køre det på: - **Mulighed A: Færdigbygget image** — pull og kør på 2 minutter, inkluderer et demo-site out of the box - **Mulighed B: Custom Dockerfile** — byg dit eget image med site + CMS bundlet sammen --- ## Mulighed A: Færdigbygget image (anbefalet) Den hurtigste vej til at komme i gang. Imaget inkluderer et komplet demo-site (18 dokumenter, engelsk + dansk) så du kan udforske CMS'et med det samme. ### Forudsætninger - Docker installeret på din maskine eller server - Det er det. ### Kør det ```bash docker run -d \ --name cms \ -p 3010:3010 \ -e ADMIN_EMAIL=dig@example.com \ -e ADMIN_PASSWORD=din-sikre-adgangskode \ ghcr.io/webhousecode/cms-admin:latest ``` Åbn [http://localhost:3010](http://localhost:3010) og log ind. Docker puller imaget automatisk ved første kørsel. Ved første opstart seeder CMS'et et demo-site: ``` ✦ First boot — seeding CMS Demo site... ✓ CMS Demo site ready (18 documents, EN + DA) ✓ Open http://localhost:3010 to get started ``` ### Udforsk demoen Demo-sitet inkluderer: - **3 collections:** Pages, Posts, Globals - **18 dokumenter** på engelsk og dansk - **i18n** med oversættelsesgrupper og sprog-skifter - **Rich text**-indhold med overskrifter, links og formatering Rediger indhold, opret nye dokumenter, prøv AI-funktioner — alt virker out of the box. ### Trin 3: Forbind dit eget indhold (valgfrit) Når du er klar til at bruge dit eget site, har du to muligheder: **A) Mount en lokal mappe:** ```bash docker run -d -p 3010:3010 \ -v $(pwd)/my-site:/site \ -e ADMIN_EMAIL=dig@example.com \ ghcr.io/webhousecode/cms-admin:latest ``` **B) Forbind et GitHub repo** (anbefalet til produktion): 1. Gå til **Site Settings** i admin 2. Klik **Add Site** og vælg **GitHub adapter** 3. Forbind din GitHub-konto (OAuth) 4. Vælg dit repo der indeholder `cms.config.ts` 5. Indhold er nu synkroniseret — redigeringer i CMS pusher til GitHub, nye containere puller fra GitHub ### Hvordan indhold persisteres ![Docker-container synkroniseret med GitHub repo](/diagrams/docker-github-sync-da.svg) - **GitHub repo** er source of truth — dit indhold overlever container-genstarter, opgraderinger og gendeployments - **Lokal cache** (`.cache/sites/{id}/`) giver hurtige læsninger — genopbygges automatisk fra GitHub ved opstart - **Ny `docker run`** = frisk container, tom cache, henter indhold fra GitHub inden for sekunder - **Redigeringer i CMS** skrives til GitHub med det samme — hvert save er et Git commit ### Trin 4: Deploy til en cloud-server Samme `docker run`-kommando virker på enhver server. For Fly.io: ```bash # Installér flyctl curl -L https://fly.io/install.sh | sh # Opret app i Stockholm (EU) fly apps create my-cms --region arn # Sæt secrets fly secrets set \ ADMIN_EMAIL=dig@example.com \ ADMIN_PASSWORD=$(openssl rand -hex 16) \ -a my-cms # Deploy det færdigbyggede image direkte fly deploy \ --image ghcr.io/webhousecode/cms-admin:latest \ --region arn \ -a my-cms ``` Dit CMS admin er nu live på `https://my-cms.fly.dev`. ### Opgradering For at opgradere til en ny CMS-version: ```bash # Lokalt docker pull ghcr.io/webhousecode/cms-admin:latest docker stop cms-admin && docker rm cms-admin docker run -d --name cms-admin -p 3010:3010 \ -e ADMIN_EMAIL=dig@example.com \ -e ADMIN_PASSWORD=din-adgangskode \ ghcr.io/webhousecode/cms-admin:latest # Fly.io fly deploy --image ghcr.io/webhousecode/cms-admin:latest -a my-cms ``` Dit indhold er sikkert i GitHub — den nye container henter det automatisk. --- ## Mulighed B: Custom Dockerfile Når du vil have CMS admin + dit site bundlet i én container, eller har brug for custom build-trin. ### Forudsætninger - Et site-projekt med `cms.config.ts` (opret med `npm create @webhouse/cms`) - Docker installeret ### Trin 1: Opret et site ```bash npm create @webhouse/cms my-site cd my-site ``` ### Trin 2: Tilføj en Dockerfile Opret `Dockerfile` i dit sites rod: ```dockerfile # ── Byg CMS admin ── FROM node:22-alpine AS cms RUN corepack enable && corepack prepare pnpm@10 --activate WORKDIR /build # Klon CMS monorepo og byg admin RUN apk add --no-cache git python3 make g++ \ && git clone --depth 1 https://github.com/webhousecode/cms.git . \ && pnpm install --frozen-lockfile \ && pnpm --filter @webhouse/cms build \ && pnpm --filter @webhouse/cms-ai build \ && pnpm --filter @webhouse/cms-mcp-client build \ && pnpm --filter @webhouse/cms-mcp-server build \ && pnpm --filter @webhouse/cms-admin build # ── Byg dit site ── FROM node:22-alpine AS site WORKDIR /build COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # ── Runner ── FROM node:22-alpine RUN apk add --no-cache libc6-compat WORKDIR /app ENV NODE_ENV=production # CMS Admin (standalone) COPY --from=cms /build/packages/cms-admin/.next/standalone ./admin/ COPY --from=cms /build/packages/cms-admin/.next/static ./admin/packages/cms-admin/.next/static COPY --from=cms /build/packages/cms-admin/public ./admin/packages/cms-admin/public # Dit site COPY --from=site /build ./site/ # Indhold + config COPY cms.config.ts ./ COPY content/ ./content/ # Start-script RUN printf '#!/bin/sh\ncd /app/admin && CMS_CONFIG_PATH=/app/cms.config.ts PORT=3010 node packages/cms-admin/server.js &\ncd /app/site && PORT=3000 node server.js &\nwait\n' > /start.sh && chmod +x /start.sh EXPOSE 3000 3010 CMD ["/start.sh"] ``` ### Trin 3: Byg og kør ```bash docker build -t my-site . docker run -d -p 3000:3000 -p 3010:3010 \ -e ADMIN_EMAIL=dig@example.com \ -e ADMIN_PASSWORD=din-adgangskode \ my-site ``` - Site: `http://localhost:3000` - CMS Admin: `http://localhost:3010` ### Trin 4: Deploy til Fly.io Opret `fly.toml`: ```toml app = "my-site" primary_region = "arn" [build] dockerfile = "Dockerfile" [http_service] internal_port = 3000 force_https = true auto_stop_machines = "stop" auto_start_machines = true [[vm]] memory = "512mb" cpu_kind = "shared" cpus = 1 ``` ```bash fly launch --no-deploy fly secrets set ADMIN_EMAIL=dig@example.com fly secrets set ADMIN_PASSWORD=$(openssl rand -hex 16) fly deploy --now ``` ### Indhold-persistens i Mulighed B Med custom Dockerfile er indhold bagt ind i imaget ved build-tid. For persistens mellem deploys: - **Brug et Fly.io volume** til at mounte `/app/content` som persistent storage - **Eller brug GitHub adapter** (samme som Mulighed A) — indhold lever i dit repo, ikke i containeren - **Eller aktivér cloud backup (F95)** — auto-backup til Cloudflare R2 eller pCloud --- ## Miljøvariabler | Variabel | Påkrævet | Beskrivelse | |----------|----------|-------------| | `ADMIN_EMAIL` | Første opstart | Admin-kontoens email | | `ADMIN_PASSWORD` | Første opstart | Admin-kontoens adgangskode (genereres hvis udeladt) | | `CMS_CONFIG_PATH` | Mulighed A | Sti til cms.config.ts (standard: `/site/cms.config.ts`) | | `ANTHROPIC_API_KEY` | AI-funktioner | Claude API-nøgle til AI-skrivning, korrektur, oversættelse | | `GITHUB_TOKEN` | GitHub adapter | Fine-grained PAT eller OAuth-token | --- ## Auto-oprettet admin-konto Ved første opstart, hvis `ADMIN_EMAIL` er sat og ingen brugere eksisterer, opretter CMS automatisk en admin-konto: ``` ✓ Admin account created Email: dig@example.com Password: a1b2c3d4e5f6... ⚠ Change this after first login! ``` Hvis `ADMIN_PASSWORD` ikke er sat, genereres en tilfældig adgangskode og udskrives til container-loggen: ```bash # Se den genererede adgangskode docker logs cms-admin | grep Password # Eller på Fly.io fly logs -a my-cms | grep Password ``` --- ## Tilgængelige image-tags | Tag | Beskrivelse | |-----|-------------| | `latest` | Seneste stabile udgivelse | | `0.2.15` | Specifik version (matcher npm-pakkeversion) | ```bash # Pull specifik version docker pull ghcr.io/webhousecode/cms-admin:0.2.15 # Brug altid seneste docker pull ghcr.io/webhousecode/cms-admin:latest ``` --- ## Hurtigreference | Opgave | Kommando | |--------|---------| | Pull seneste | `docker pull ghcr.io/webhousecode/cms-admin:latest` | | Kør lokalt | `docker run -d -p 3010:3010 -e ADMIN_EMAIL=me@x.com ghcr.io/webhousecode/cms-admin` | | Se logs | `docker logs cms-admin` | | Stop | `docker stop cms-admin` | | Opgradér | `docker pull ...latest && docker stop && docker rm && docker run ...` | | Deploy til Fly.io | `fly deploy --image ghcr.io/webhousecode/cms-admin:latest` | --- ## docs/docker-deployment Title: Docker Deployment Updated: 2026-03-31 Locale: en Deploy @webhouse/cms with Docker — two options: pre-built image from GHCR, or custom Dockerfile for your site. The CMS admin is available as a pre-built Docker image on GitHub Container Registry. There are two ways to run it: - **Option A: Pre-built image** — pull and run in 2 minutes, includes a demo site out of the box - **Option B: Custom Dockerfile** — build your own image with site + CMS bundled together --- ## Option A: Pre-built image (recommended) The fastest way to get started. The image includes a complete demo site (18 documents, English + Danish) so you can explore the CMS immediately. ### Prerequisites - Docker installed on your machine or server - That's it. ### Run it ```bash docker run -d \ --name cms \ -p 3010:3010 \ -e ADMIN_EMAIL=you@example.com \ -e ADMIN_PASSWORD=your-secure-password \ ghcr.io/webhousecode/cms-admin:latest ``` Open [http://localhost:3010](http://localhost:3010) and log in. Docker pulls the image automatically on first run. On first boot, the CMS seeds a demo site: ``` ✦ First boot — seeding CMS Demo site... ✓ CMS Demo site ready (18 documents, EN + DA) ✓ Open http://localhost:3010 to get started ``` ### Explore the demo The demo site includes: - **3 collections:** Pages, Posts, Globals - **18 documents** in English and Danish - **i18n** with translation groups and locale switcher - **Rich text** content with headings, links, and formatting Edit content, create new documents, try AI features — everything works out of the box. ### Step 3: Connect your own content (optional) When you're ready to use your own site, you have two options: **A) Mount a local directory:** ```bash docker run -d -p 3010:3010 \ -v $(pwd)/my-site:/site \ -e ADMIN_EMAIL=you@example.com \ ghcr.io/webhousecode/cms-admin:latest ``` **B) Connect a GitHub repo** (recommended for production): 1. Go to **Site Settings** in the admin 2. Click **Add Site** and choose **GitHub adapter** 3. Connect your GitHub account (OAuth) 4. Select your repo containing `cms.config.ts` 5. Content is now synced — edits in CMS push to GitHub, new containers pull from GitHub ### How content persistence works ![Docker container syncing with GitHub repo](/diagrams/docker-github-sync.svg) - **GitHub repo** is the source of truth — your content survives container restarts, upgrades, and redeployments - **Local cache** (`.cache/sites/{id}/`) gives fast reads — rebuilt automatically from GitHub on startup - **New `docker run`** = fresh container, empty cache, pulls content from GitHub within seconds - **Edits in CMS** write to GitHub immediately — every save is a Git commit ### Step 4: Deploy to a cloud server The same `docker run` command works on any server. For Fly.io: ```bash # Install flyctl curl -L https://fly.io/install.sh | sh # Create app in Stockholm (EU) fly apps create my-cms --region arn # Set secrets fly secrets set \ ADMIN_EMAIL=you@example.com \ ADMIN_PASSWORD=$(openssl rand -hex 16) \ -a my-cms # Deploy the pre-built image directly fly deploy \ --image ghcr.io/webhousecode/cms-admin:latest \ --region arn \ -a my-cms ``` Your CMS admin is now live at `https://my-cms.fly.dev`. ### Upgrading To upgrade to a new CMS version: ```bash # Local docker pull ghcr.io/webhousecode/cms-admin:latest docker stop cms-admin && docker rm cms-admin docker run -d --name cms-admin -p 3010:3010 \ -e ADMIN_EMAIL=you@example.com \ -e ADMIN_PASSWORD=your-password \ ghcr.io/webhousecode/cms-admin:latest # Fly.io fly deploy --image ghcr.io/webhousecode/cms-admin:latest -a my-cms ``` Your content is safe in GitHub — the new container pulls it automatically. --- ## Option B: Custom Dockerfile For when you want CMS admin + your site bundled in one container, or need custom build steps. ### Prerequisites - A site project with `cms.config.ts` (create one with `npm create @webhouse/cms`) - Docker installed ### Step 1: Create a site ```bash npm create @webhouse/cms my-site cd my-site ``` ### Step 2: Add a Dockerfile Create `Dockerfile` in your site's root: ```dockerfile # ── Build CMS admin ── FROM node:22-alpine AS cms RUN corepack enable && corepack prepare pnpm@10 --activate WORKDIR /build # Clone CMS monorepo and build admin RUN apk add --no-cache git python3 make g++ \ && git clone --depth 1 https://github.com/webhousecode/cms.git . \ && pnpm install --frozen-lockfile \ && pnpm --filter @webhouse/cms build \ && pnpm --filter @webhouse/cms-ai build \ && pnpm --filter @webhouse/cms-mcp-client build \ && pnpm --filter @webhouse/cms-mcp-server build \ && pnpm --filter @webhouse/cms-admin build # ── Build your site ── FROM node:22-alpine AS site WORKDIR /build COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # ── Runner ── FROM node:22-alpine RUN apk add --no-cache libc6-compat WORKDIR /app ENV NODE_ENV=production # CMS Admin (standalone) COPY --from=cms /build/packages/cms-admin/.next/standalone ./admin/ COPY --from=cms /build/packages/cms-admin/.next/static ./admin/packages/cms-admin/.next/static COPY --from=cms /build/packages/cms-admin/public ./admin/packages/cms-admin/public # Your site COPY --from=site /build ./site/ # Content + config COPY cms.config.ts ./ COPY content/ ./content/ # Start script RUN printf '#!/bin/sh\ncd /app/admin && CMS_CONFIG_PATH=/app/cms.config.ts PORT=3010 node packages/cms-admin/server.js &\ncd /app/site && PORT=3000 node server.js &\nwait\n' > /start.sh && chmod +x /start.sh EXPOSE 3000 3010 CMD ["/start.sh"] ``` ### Step 3: Build and run ```bash docker build -t my-site . docker run -d -p 3000:3000 -p 3010:3010 \ -e ADMIN_EMAIL=you@example.com \ -e ADMIN_PASSWORD=your-password \ my-site ``` - Site: `http://localhost:3000` - CMS Admin: `http://localhost:3010` ### Step 4: Deploy to Fly.io Create `fly.toml`: ```toml app = "my-site" primary_region = "arn" [build] dockerfile = "Dockerfile" [http_service] internal_port = 3000 force_https = true auto_stop_machines = "stop" auto_start_machines = true [[vm]] memory = "512mb" cpu_kind = "shared" cpus = 1 ``` ```bash fly launch --no-deploy fly secrets set ADMIN_EMAIL=you@example.com fly secrets set ADMIN_PASSWORD=$(openssl rand -hex 16) fly deploy --now ``` ### Content persistence in Option B With the custom Dockerfile, content is baked into the image at build time. For persistence between deploys: - **Use a Fly.io volume** to mount `/app/content` as persistent storage - **Or use the GitHub adapter** (same as Option A) — content lives in your repo, not in the container - **Or enable cloud backup (F95)** — auto-backup to Cloudflare R2 or pCloud --- ## Environment variables | Variable | Required | Description | |----------|----------|-------------| | `ADMIN_EMAIL` | First boot | Admin account email | | `ADMIN_PASSWORD` | First boot | Admin account password (generated if omitted) | | `CMS_CONFIG_PATH` | Option A | Path to cms.config.ts (default: `/site/cms.config.ts`) | | `ANTHROPIC_API_KEY` | AI features | Claude API key for AI writing, proofreading, translation | | `GITHUB_TOKEN` | GitHub adapter | Fine-grained PAT or OAuth token | --- ## Auto-created admin account On first boot, if `ADMIN_EMAIL` is set and no users exist, the CMS auto-creates an admin account: ``` ✓ Admin account created Email: you@example.com Password: a1b2c3d4e5f6... ⚠ Change this after first login! ``` If `ADMIN_PASSWORD` is not set, a random password is generated and printed to the container logs: ```bash # View the generated password docker logs cms-admin | grep Password # Or on Fly.io fly logs -a my-cms | grep Password ``` --- ## Available image tags | Tag | Description | |-----|-------------| | `latest` | Most recent stable release | | `0.2.15` | Specific version (matches npm package version) | ```bash # Pull specific version docker pull ghcr.io/webhousecode/cms-admin:0.2.15 # Always use latest docker pull ghcr.io/webhousecode/cms-admin:latest ``` --- ## Quick reference | Task | Command | |------|---------| | Pull latest | `docker pull ghcr.io/webhousecode/cms-admin:latest` | | Run locally | `docker run -d -p 3010:3010 -e ADMIN_EMAIL=me@x.com ghcr.io/webhousecode/cms-admin` | | View logs | `docker logs cms-admin` | | Stop | `docker stop cms-admin` | | Upgrade | `docker pull ...latest && docker stop && docker rm && docker run ...` | | Deploy to Fly.io | `fly deploy --image ghcr.io/webhousecode/cms-admin:latest` | --- ## docs/i18n-da Title: Internationalisering (i18n) Updated: 2026-03-31 Locale: da Flersproget indhold med automatisk AI-oversættelse, locale-routing og hreflang. ## ⚠️ KRITISK: translationGroup er OBLIGATORISK på flersprogede sites **Hvis du bygger et site med 2+ sprog, SKAL hvert dokument der har en oversættelse have et `translationGroup`-felt.** Uden det: - **Side-by-side-editoren er ødelagt** — redaktører kan ikke se EN og DA side om side - **Sprogskifteren i UI'et virker ikke** — dokumenter fremstår som urelaterede elementer - **AI-masseoversættelse** kan ikke finde hvilke dokumenter der hører sammen - **Hreflang-generering** producerer forkerte eller ufuldstændige links Dette er den hyppigste fejl AI-builders laver på flersprogede sites. Sæt det på hvert dokument FØR du skriver indhold. ```json // EN-variant { "slug": "about-us", "locale": "en", "translationGroup": "550e8400-e29b-41d4-a716-446655440000", ... } // DA-variant — SAMME translationGroup-værdi { "slug": "om-os", "locale": "da", "translationGroup": "550e8400-e29b-41d4-a716-446655440000", ... } ``` Generér ét nyt UUID per side/post (IKKE per oversættelse): `import { randomUUID } from 'crypto'; const groupId = randomUUID();` ## Konfigurér sprog ```typescript export default defineConfig({ defaultLocale: 'en', locales: ['en', 'da'], collections: [ defineCollection({ name: 'posts', sourceLocale: 'en', locales: ['en', 'da'], translatable: true, fields: [ { name: 'title', type: 'text', required: true }, { name: 'content', type: 'richtext' }, ], }), ], }); ``` ## Sådan fungerer oversættelser Hver oversættelse er et **separat dokument** forbundet via `translationGroup` — et fælles UUID der forbinder alle sprogversioner: ```json // content/posts/hello-world.json (engelsk) { "slug": "hello-world", "locale": "en", "translationGroup": "abc-123", "data": { "title": "Hello, World!" } } // content/posts/hello-world-da.json (dansk) { "slug": "hello-world-da", "locale": "da", "translationGroup": "abc-123", "data": { "title": "Hej, Verden!" } } ``` ## Oversættelsesworkflow i admin I admin-brugerfladen: 1. Åbn et dokument — se sprogbadget der viser det aktuelle sprog 2. Klik **"+ Tilføj oversættelse"** for at oprette en ny sprogversion 3. AI-oversætteren oversætter automatisk alle felter 4. Gennemgå og publicér oversættelsen ## AI-oversættelse ```typescript import { createAi } from '@webhouse/cms-ai'; const ai = await createAi(); const result = await ai.content.translate( sourceDoc.data, 'da', { collection: collectionConfig }, ); ``` ## Sprogrutning i Next.js Brug et `[locale]`-rutesegment med middleware til sprogdetektering. ## Oversættelsesgrupper (translationGroup) Hvert oversat dokument er forbundet til sin kilde via et fælles `translationGroup` UUID. Dette er kernemekanismen der forbinder EN og DA (eller ethvert sprogpar) i CMS'et. ### Sådan fungerer det 1. Når du opretter et dokument, får det et unikt `translationGroup` UUID 2. Når du opretter en oversættelse, får det nye dokument det **samme** `translationGroup` 3. CMS admin bruger dette til at vise sprog-badges, sprogskifter og side-by-side redigering ### Dokumentstruktur ```json // content/posts/hello-world.json (Engelsk — kilde) { "slug": "hello-world", "locale": "en", "translationGroup": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "data": { "title": "Hello, World!" } } // content/posts/hello-world-da.json (Dansk — oversættelse) { "slug": "hello-world-da", "locale": "da", "translationGroup": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "data": { "title": "Hej, Verden!" } } ``` Begge dokumenter deler det samme `translationGroup` UUID. Det er det **eneste** link mellem dem. ### Regler for AI builders og scripts 1. **Sæt altid `locale`** på hvert dokument (`"en"`, `"da"` osv.) 2. **Sæt altid `translationGroup`** — brug `crypto.randomUUID()` for nye dokumenter 3. **Del det samme `translationGroup`** på tværs af alle sprogversioner 4. **Slug-konvention**: kilde-slug + sprog-suffiks (f.eks. `hello-world` → `hello-world-da`) 5. **Duplikér aldrig `translationGroup`** på tværs af urelaterede dokumenter ### Site-konfiguration for i18n I CMS admin Site Settings → Language: - **Standardsprog**: `en` - **Understøttede sprog**: tilføj `da` - **Lokaliseringsstrategi**: - `prefix-other` — URL-præfiks for ikke-standard sprog: `/da/blog/slug` - `prefix-all` — URL-præfiks for alle sprog: `/en/blog/slug` - `none` — intet præfiks, sprog i slug: `/blog/slug-da` ## Lokaliseringsstrategi & standardsprog Disse to indstillinger styrer hvordan lokaliseret indhold vises i URL'er og hvordan CMS admin konstruerer preview-links. ### defaultLocale Det primære forfattersprog. Sæt i Site Settings → Language. Dokumenter på standardsproget har ingen URL-præfiks eller suffiks — de bruger rene slugs. ### localeStrategy Styrer hvordan ikke-standard sprog vises i URL'er: | Strategi | EN (standard) URL | DA URL | Hvornår | |----------|------------------|--------|---------| | `prefix-other` | `/blog/my-post` | `/da/blog/my-post` | De fleste sites | | `prefix-all` | `/en/blog/my-post` | `/da/blog/my-post` | Eksplicit locale i alle URL'er | | `none` | `/blog/my-post` | `/blog/my-post-da` | Locale bagt ind i slug | ### Sådan påvirker det preview CMS admin læser `localeStrategy` og `defaultLocale` for at konstruere preview-URL'er korrekt: - **prefix-other**: DA-dokument `about-da` → preview åbner `/da/about` - **none**: DA-dokument `about-da` → preview åbner `/docs/about-da` ### Konfiguration I CMS admin → Site Settings → Language, eller i `_data/site-config.json`: ```json { "defaultLocale": "en", "locales": ["en", "da"], "localeStrategy": "prefix-other" } ``` --- ## docs/i18n Title: Internationalization (i18n) Updated: 2026-03-31 Locale: en Multi-language content with automatic AI translation, locale routing, and hreflang. ## ⚠️ CRITICAL: translationGroup is MANDATORY for multilingual sites **If you are building a site with 2+ languages, every document that has a translation MUST have a `translationGroup` field.** Without it: - The admin **side-by-side editor is broken** — editors cannot see EN and DA next to each other - The **language switcher in the UI does not work** — documents appear as unrelated orphans - **AI bulk translate** cannot find which documents belong together - **Hreflang generation** produces incomplete or wrong alternate links This is the #1 mistake AI builders make on multilingual sites. Set it on every document before writing any content. ```json // EN variant { "slug": "about-us", "locale": "en", "translationGroup": "550e8400-e29b-41d4-a716-446655440000", ... } // DA variant — SAME translationGroup value { "slug": "om-os", "locale": "da", "translationGroup": "550e8400-e29b-41d4-a716-446655440000", ... } ``` Generate a new UUID per page/post (NOT per translation): `import { randomUUID } from 'crypto'; const groupId = randomUUID();` ## Configure locales ```typescript export default defineConfig({ defaultLocale: 'en', locales: ['en', 'da'], collections: [ defineCollection({ name: 'posts', sourceLocale: 'en', locales: ['en', 'da'], translatable: true, fields: [ { name: 'title', type: 'text', required: true }, { name: 'content', type: 'richtext' }, ], }), ], }); ``` ## How translations work Each translation is a **separate document** linked via `translationGroup` — a shared UUID connecting all language versions: ```json // content/posts/hello-world.json (English) { "slug": "hello-world", "locale": "en", "translationGroup": "abc-123", "data": { "title": "Hello, World!" } } // content/posts/hello-world-da.json (Danish) { "slug": "hello-world-da", "locale": "da", "translationGroup": "abc-123", "data": { "title": "Hej, Verden!" } } ``` ## Admin UI translation workflow In the admin UI: 1. Open a document — see the locale badge showing current language 2. Click **"+ Add translation"** to create a new locale version 3. The AI translator auto-translates all fields 4. Review and publish the translation Translations appear grouped in the document list and the editor shows a locale switcher. ## AI translation ```typescript import { createAi } from '@webhouse/cms-ai'; const ai = await createAi(); const result = await ai.content.translate( sourceDoc.data, 'da', { collection: collectionConfig }, ); // result.fields contains translated data ``` ## Locale routing in Next.js Use a `[locale]` route segment: ``` app/ [locale]/ blog/ [slug]/page.tsx page.tsx layout.tsx ``` With a middleware for locale detection: ```typescript // middleware.ts import { NextRequest, NextResponse } from 'next/server'; const LOCALES = ['en', 'da']; const DEFAULT = 'en'; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const hasLocale = LOCALES.some(l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`); if (hasLocale) return; const preferred = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0] ?? DEFAULT; const locale = LOCALES.includes(preferred) ? preferred : DEFAULT; return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url)); } ``` ## Translation Groups (translationGroup) Every translated document is linked to its source via a shared `translationGroup` UUID. This is the core mechanism that connects EN and DA (or any locale pair) in the CMS. ### How it works 1. When you create a document, it gets a unique `translationGroup` UUID 2. When you create a translation (via admin UI or script), the new document gets the **same** `translationGroup` 3. CMS admin uses this to show locale badges, language switcher, and side-by-side editing ### Document structure ```json // content/posts/hello-world.json (English — source) { "slug": "hello-world", "locale": "en", "translationGroup": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "data": { "title": "Hello, World!" } } // content/posts/hello-world-da.json (Danish — translation) { "slug": "hello-world-da", "locale": "da", "translationGroup": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "data": { "title": "Hej, Verden!" } } ``` Both documents share the same `translationGroup` UUID. That is the **only** link between them — there is no parent/child relationship, no `translationOf` field needed. ### Rules for AI builders and scripts 1. **Always set `locale`** on every document (`"en"`, `"da"`, etc.) 2. **Always set `translationGroup`** — use `crypto.randomUUID()` for new documents 3. **Share the same `translationGroup`** across all locale versions of a document 4. **Slug convention**: source slug + locale suffix (e.g. `hello-world` → `hello-world-da`) 5. **Never duplicate `translationGroup`** across unrelated documents ### Pairing script example ```typescript import { readFileSync, writeFileSync, readdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; const DIR = 'content/docs'; const files = readdirSync(DIR).filter(f => f.endsWith('.json')); // Build EN → DA map const enDocs = new Map(); const daDocs = new Map(); for (const f of files) { const slug = f.replace('.json', ''); if (slug.endsWith('-da')) daDocs.set(slug.replace(/-da$/, ''), join(DIR, f)); else enDocs.set(slug, join(DIR, f)); } // Pair them for (const [slug, enPath] of enDocs) { const daPath = daDocs.get(slug); if (!daPath) continue; const enDoc = JSON.parse(readFileSync(enPath, 'utf-8')); const daDoc = JSON.parse(readFileSync(daPath, 'utf-8')); const tg = enDoc.translationGroup || randomUUID(); enDoc.translationGroup = tg; enDoc.locale = 'en'; daDoc.translationGroup = tg; daDoc.locale = 'da'; writeFileSync(enPath, JSON.stringify(enDoc, null, 2)); writeFileSync(daPath, JSON.stringify(daDoc, null, 2)); } ``` ### Site config for i18n In CMS admin Site Settings → Language: - **Default language**: `en` (or your source locale) - **Supported languages**: add `da` (or any target locales) - **Locale strategy**: - `prefix-other` — URL prefix for non-default locales: `/da/blog/slug` - `prefix-all` — URL prefix for all locales: `/en/blog/slug`, `/da/blog/slug` - `none` — no URL prefix, locale is in the slug: `/blog/slug-da` ### What CMS admin does with translationGroup - **Locale badge** on documents showing current language - **Language switcher** in editor to jump between translations - **"+ Add translation"** button to create a new locale version (triggers AI translation) - **Side-by-side editing** when comparing source and translation - **Translation status** tracking (stale, up-to-date, missing) ## Locale Strategy & Default Locale These two settings control how localized content appears in URLs and how CMS admin constructs preview links. ### defaultLocale The primary authoring language. Set in Site Settings → Language. ``` defaultLocale: "en" ``` Documents in the default locale have no URL prefix or suffix — they use plain slugs: - EN (default): `/blog/my-post` - DA (non-default): depends on localeStrategy ### localeStrategy Controls how non-default locales appear in URLs: | Strategy | EN (default) URL | DA URL | When to use | |----------|-----------------|--------|-------------| | `prefix-other` | `/blog/my-post` | `/da/blog/my-post` | Most sites — clean default URLs, prefix for other locales | | `prefix-all` | `/en/blog/my-post` | `/da/blog/my-post` | When you want explicit locale in ALL URLs | | `none` | `/blog/my-post` | `/blog/my-post-da` | When locale is baked into the slug (no URL prefix) | ### How it affects preview CMS admin reads `localeStrategy` and `defaultLocale` to construct preview URLs correctly: - **prefix-other**: DA document `about-da` → preview opens `/da/about` (strips `-da` suffix, adds `/da/` prefix) - **prefix-all**: same but also adds `/en/` for default locale - **none**: DA document `about-da` → preview opens `/docs/about-da` (slug used as-is) ### How it affects homepage For documents with slug `home` or `home-da` and urlPrefix `/`: | Strategy | EN homepage | DA homepage | |----------|------------|-------------| | `prefix-other` | `/` | `/da/` | | `prefix-all` | `/en/` | `/da/` | | `none` | `/` | `/home-da` | ### Configuration Set in CMS admin → Site Settings → Language, or in `_data/site-config.json`: ```json { "defaultLocale": "en", "locales": ["en", "da"], "localeStrategy": "prefix-other" } ``` ### For site builders Your Next.js routing must match the strategy: **prefix-other** → `app/[locale]/blog/[slug]/page.tsx` with middleware for locale detection **prefix-all** → same, but default locale also has prefix **none** → `app/blog/[slug]/page.tsx` where slug includes locale suffix --- ## docs/ai-analytics-da Title: AI Analytics Updated: 2026-03-31 Locale: da Spor AI-agentperformance, omkostninger og indholdsgodkendelsesrater. ## Overblik AI Analytics giver dig detaljeret indsigt i hvordan dine AI-agenter performer. Spor omkostninger, mål kvalitet gennem godkendelsesrater, og identificer hvilke agenter der leverer de bedste resultater. ## Performance-dashboard Hovedvisningen viser nøgletal: - **Samlede genereringer** — hvor mange dokumenter AI har oprettet i den valgte periode - **Godkendelsesrate** — procentdel af genereret indhold godkendt i [Curation Queue](/docs/curation-queue-da) - **Afvisningsrate** — procentdel afvist, med typiske afvisningsårsager - **Gennemsnitlig genereringstid** — hvor lang tid hver generering tager ## Omkostningssporing Overvåg dit AI-forbrug: - **Samlede omkostninger** — aggregerede omkostninger for den valgte periode - **Pris per dokument** — gennemsnitlig pris for at generere ét stykke indhold - **Pris per agent** — hvilke agenter der er dyrest og billigst - **Omkostningstrend** — linjediagram der viser forbrug over tid ## Agentperformance En per-agent-opdeling der viser: - **Genererede dokumenter** — samlet output per agent - **Godkendelsesrate** — kvalitetsindikator per agent - **Gennemsnitligt ordantal** — indholdslængde per agent - **Anvendt model** — hvilken AI-model agenten er konfigureret til at bruge ## Filtrering Opdel data efter: - **Datointerval** — sidste 7 dage, 30 dage eller brugerdefineret - **Agent** — fokuser på en bestemt agent - **Collection** — se analytics for en bestemt indholdstype - **Status** — godkendt, afvist eller afventende ## Brug analytics til forbedring - Lav godkendelsesrate? Gennemgå agentens prompt og [brandvoice-indstillinger](/docs/cockpit-da) - Høj pris per dokument? Overvej en anden model eller kortere mållængde - Langsom generering? Tjek om agentens prompt er for kompleks ## Relateret - [Cockpit](/docs/cockpit-da) — konfigurer AI-genereringsparametre - [AI-agenter](/docs/ai-agents-da) — administrer individuelle agenter - [Curation Queue](/docs/curation-queue-da) — hvor genereret indhold gennemgås --- ## docs/ai-analytics Title: AI Analytics Updated: 2026-03-31 Locale: en Track AI agent performance, costs, and content acceptance rates. ## Overview AI Analytics gives you detailed insights into how your AI agents perform. Track costs, measure quality through acceptance rates, and identify which agents deliver the best results. ## Performance Dashboard The main view shows key metrics: - **Total generations** — how many documents AI has created in the selected period - **Acceptance rate** — percentage of generated content approved in the [Curation Queue](/docs/curation-queue) - **Rejection rate** — percentage rejected, with common rejection reasons - **Average generation time** — how long each generation takes ## Cost Tracking Monitor your AI spending: - **Total cost** — aggregated cost for the selected period - **Cost per document** — average cost to generate one piece of content - **Cost by agent** — which agents are most and least expensive - **Cost trend** — line chart showing spending over time ## Agent Performance A per-agent breakdown showing: - **Documents generated** — total output per agent - **Acceptance rate** — quality indicator per agent - **Average word count** — content length per agent - **Model used** — which AI model the agent is configured to use ## Filtering Slice the data by: - **Date range** — last 7 days, 30 days, or custom - **Agent** — focus on a specific agent - **Collection** — see analytics for a specific content type - **Status** — approved, rejected, or pending ## Using Analytics to Improve - Low acceptance rate? Review the agent's prompt and [brand voice settings](/docs/cockpit) - High cost per document? Consider a different model or shorter target length - Slow generation? Check if the agent's prompt is too complex ## Related - [Cockpit](/docs/cockpit) — configure AI generation parameters - [AI Agents](/docs/ai-agents) — manage individual agents - [Curation Queue](/docs/curation-queue) — where generated content is reviewed --- ## docs/backup-da Title: Backup Updated: 2026-03-31 Locale: da Opret, gendan og synkroniser indholdssnapshots — lokalt eller til cloud-udbydere som pCloud og S3. ## Overblik Backup-siden lader dig oprette øjebliksbilleder af alt dit siteindhold. Hvis noget går galt — utilsigtede sletninger, fejlagtige masseredigeringer eller beskadigede data — kan du gendanne til en tidligere tilstand. Backups kan gemmes lokalt eller synkroniseres til cloud-udbydere for ekstern sikkerhed. ## Opret en backup Klik **Opret Backup** for at tage et snapshot. Backuppen inkluderer: - Alle dokumenter på tværs af alle collections - Mediemetadata (filreferencer og alt-tekst) - Dokumentstatusser, planlægninger og oversættelsesgrupper - Siteindstillinger og konfiguration Backups gemmes som komprimerede arkiver ved siden af dit indhold (lokalt) eller uploades til din konfigurerede cloud-udbyder. ## Backupliste Siden viser alle tilgængelige backups med: - **Dato og tid** for oprettelse - **Størrelse** — samlet arkivstørrelse - **Dokumentantal** — antal inkluderede dokumenter - **Label** — valgfri beskrivelse du kan tilføje ved oprettelse - **Cloud-badge** — viser hvor backuppen er gemt: local, pcloud eller s3 ## Cloud backup-udbydere Backup-destinationer er pluggbare. Konfigurer dem under fanen **Backup** i Indstillinger. ### Lokal (standard) Backups gemmes på serverens filsystem ved siden af dit indhold. Ingen konfiguration nødvendig. ### pCloud (WebDAV) pCloud tilbyder 10 GB gratis lagring med EU-dataopbevaring. CMS'et forbinder via WebDAV-protokollen. Sådan konfigurerer du: 1. Gå til **Indstillinger → Backup** 2. Vælg **pCloud** som backup-udbyder 3. Indtast dine pCloud WebDAV-loginoplysninger 4. Klik **Test forbindelse** for at verificere ### S3-kompatibel Enhver S3-kompatibel lagringsudbyder virker. Anbefalede muligheder: | Udbyder | Gratis tier | |----------|----------| | **Cloudflare R2** | 10 GB inkluderet | | **Backblaze B2** | 10 GB gratis | | **Scaleway** | 75 GB gratis | | **Hetzner** | Betal pr. forbrug | | **AWS S3** | Betal pr. forbrug | Sådan konfigurerer du: 1. Gå til **Indstillinger → Backup** 2. Vælg **S3** som backup-udbyder 3. Indtast endpoint, bucket, access key og secret key 4. Klik **Test forbindelse** for at verificere ### Lagerkvote-styring Sæt `backupMaxStorageGB` for automatisk at fjerne de ældste backups, når grænsen er nået. Dette forhindrer cloud-lagring i at vokse ubegrænset. ## Gendan en backup Klik **Gendan** på en backup for at rulle dit indhold tilbage til det tidspunkt. Denne operation: 1. Erstatter alt nuværende indhold med backuppens indhold 2. Bevarer den aktuelle backupliste (så du kan fortryde gendannelsen) 3. Opretter automatisk en pre-restore backup for sikkerhed **Advarsel:** Gendannelse erstatter ALT nuværende indhold. Ændringer foretaget efter backuppen blev oprettet, vil gå tabt. ### GitHub-backede sites Gendannelse fungerer fuldt ud for GitHub-backede sites. Ved gendannelse: - Hvert dokument pushes tilbage til GitHub-repositoryet via GitHub Contents API - CMS'et henter den aktuelle SHA for eksisterende filer for at håndtere oprettelse vs. opdatering korrekt - `_data/`-filer (siteindstillinger mv.) gendannes altid lokalt uanset adapter Dette betyder at en gendannelse på et GitHub-backed site opdaterer både den lokale cache og det eksterne repository. ## Bedste praksis - Opret en backup før større operationer (masseredigeringer, AI-genereringskørsler, schemaændringer) - Giv dine backups beskrivende labels (f.eks. "Før SEO-omskrivning" eller "Pre-launch indholdsfrost") - Brug cloud-udbydere for ekstern sikkerhed — lokale backups går tabt hvis serveren fejler - Sæt en lagerkvote for at holde cloud-omkostninger forudsigelige ## Relateret - [Papirkurv](/docs/trash-da) — gendan individuelle slettede dokumenter - [Siteindstillinger](/docs/site-settings-da) — konfigurer backup-opbevaring - [Lagringsadaptere](/docs/storage-adapters-da) — filsystem vs. GitHub-lagring --- ## docs/backup Title: Backup Updated: 2026-03-31 Locale: en Create, restore, and sync content snapshots — locally or to cloud providers like pCloud and S3. ## Overview The Backup page lets you create point-in-time snapshots of your entire site content. If something goes wrong — accidental deletions, bad bulk edits, or corrupted data — you can restore to a previous state. Backups can be stored locally or synced to cloud providers for off-site safety. ## Creating a Backup Click **Create Backup** to take a snapshot. The backup includes: - All documents across every collection - Media metadata (file references and alt text) - Document statuses, schedules, and translation groups - Site settings and configuration Backups are stored as compressed archives alongside your content (local) or uploaded to your configured cloud provider. ## Backup List The page shows all available backups with: - **Date and time** of creation - **Size** — total archive size - **Document count** — number of documents included - **Label** — optional description you can add when creating - **Cloud badge** — shows where the backup is stored: local, pcloud, or s3 ## Cloud Backup Providers Backup destinations are pluggable. Configure them in the **Backup** tab under Settings. ### Local (default) Backups are stored on the server filesystem alongside your content. No configuration needed. ### pCloud (WebDAV) pCloud offers 10 GB of free storage with EU data residency. The CMS connects via WebDAV protocol. To configure: 1. Go to **Settings → Backup** 2. Select **pCloud** as the backup provider 3. Enter your pCloud WebDAV credentials 4. Click **Test Connection** to verify ### S3-compatible Any S3-compatible storage provider works. Recommended options: | Provider | Free tier | |----------|----------| | **Cloudflare R2** | 10 GB included | | **Backblaze B2** | 10 GB free | | **Scaleway** | 75 GB free | | **Hetzner** | Pay-per-use | | **AWS S3** | Pay-per-use | To configure: 1. Go to **Settings → Backup** 2. Select **S3** as the backup provider 3. Enter endpoint, bucket, access key, and secret key 4. Click **Test Connection** to verify ### Storage Quota Management Set `backupMaxStorageGB` to automatically prune the oldest backups when the limit is reached. This prevents cloud storage from growing unboundedly. ## Restoring a Backup Click **Restore** on any backup to roll your content back to that point in time. This operation: 1. Replaces all current content with the backup's content 2. Preserves the current backup list (so you can undo the restore) 3. Creates an automatic pre-restore backup for safety **Warning:** Restoring replaces ALL current content. Any changes made after the backup was created will be lost. ### GitHub-backed Sites Restore works fully for GitHub-backed sites. When restoring: - Each document is pushed back to the GitHub repository via the GitHub Contents API - The CMS fetches the current SHA for existing files to handle create vs. update correctly - `_data/` files (site settings, etc.) are always restored locally regardless of adapter This means a restore on a GitHub-backed site updates both the local cache and the remote repository. ## Best Practices - Create a backup before major operations (bulk edits, AI generation runs, schema changes) - Label your backups descriptively (e.g., "Before SEO rewrite" or "Pre-launch content freeze") - Use cloud providers for off-site safety — local backups are lost if the server fails - Set a storage quota to keep cloud costs predictable ## Related - [Trash](/docs/trash) — recover individual deleted documents - [Site Settings](/docs/site-settings) — configure backup retention - [Storage Adapters](/docs/storage-adapters) — filesystem vs. GitHub storage --- ## docs/calendar-da Title: Kalender Updated: 2026-03-31 Locale: da Planlæg publicering eller afpublicering af indhold på bestemte datoer og tidspunkter. ## Overblik Kalenderen giver dig en visuel tidslinje over alle planlagte indholdshandlinger. Planlæg hvornår dokumenter går live eller tages ned uden manuel indgriben. ## Planlagte handlinger To typer planlagte events understøttes: - **Planlagt publicering** — et kladde-dokument går live på det angivne tidspunkt - **Planlagt afpublicering** — et publiceret dokument tages offline automatisk ## Kalendervisning Kalenderen viser events i en månedsvisning: - **Grønne markeringer** — planlagte publiceringer - **Røde markeringer** — planlagte afpubliceringer - **Klik på et event** for at se detaljer og redigere planen ## Sådan planlægger du et dokument 1. Åbn et dokument i editoren 2. Find **Planlæg**-sektionen i sidepanelet 3. Sæt en publiceringsdato, afpubliceringsdato eller begge 4. Gem dokumentet Dokumentet vises automatisk i Kalenderen. ## Administration af planlagt indhold Fra Kalendersiden kan du: - **Se alle kommende events** i kronologisk rækkefølge - **Rediger planer** — ændr datoer eller annuller planlagte handlinger - **Filtrer efter collection** — fokuser på bestemte indholdstyper ## Tidszoner Alle planlagte tidspunkter bruger den tidszone der er konfigureret i dine [Siteindstillinger](/docs/site-settings-da). Sørg for at denne matcher din målgruppes primære tidszone. ## Relateret - [Collections](/docs/collections-da) — hvor du opretter og redigerer dokumenter - [Siteindstillinger](/docs/site-settings-da) — konfigurer tidszone og andre standarder --- ## docs/calendar Title: Calendar Updated: 2026-03-31 Locale: en Schedule content to publish or unpublish at specific dates and times. ## Overview The Calendar gives you a visual timeline of all scheduled content actions. Plan when documents go live or come down without manual intervention. ## Scheduling Actions Two types of scheduled events are supported: - **Scheduled publish** — a draft document goes live at the specified date and time - **Scheduled unpublish** — a published document is taken offline automatically ## Calendar View The calendar displays events in a monthly view: - **Green markers** — scheduled publishes - **Red markers** — scheduled unpublishes - **Click any event** to see details and edit the schedule ## How to Schedule a Document 1. Open any document in the editor 2. In the sidebar, find the **Schedule** section 3. Set a publish date, unpublish date, or both 4. Save the document The document will appear on the Calendar automatically. ## Managing Scheduled Content From the Calendar page you can: - **View all upcoming events** in chronological order - **Edit schedules** — change dates or cancel scheduled actions - **Filter by collection** — focus on specific content types ## Time Zones All scheduled times use the time zone configured in your [Site Settings](/docs/site-settings). Make sure this matches your audience's primary time zone. ## Related - [Collections](/docs/collections) — where you create and edit documents - [Site Settings](/docs/site-settings) — configure time zone and other defaults --- ## docs/cockpit-da Title: Cockpit Updated: 2026-03-31 Locale: da AI-kommandocentralen — konfigurer genereringsparametre og start AI-opgaver. ## Overblik Cockpittet er din AI-kommandocentral. Her konfigurerer du hvordan CMS'et genererer, optimerer og oversætter indhold med AI. ## Genereringsparametre Sæt globale standarder der gælder for alle AI-operationer: - **Brandvoice** — definer tone, stil og ordforrådsretningslinjer som alle agenter følger - **Målgruppe** — beskriv hvem dit indhold er til - **Sprogpræferencer** — standardsprog og oversættelsesmål - **Indholdslængde** — foretrukne ordantal for forskellige indholdstyper Disse parametre nedarves af alle [AI-agenter](/docs/ai-agents-da) medmindre de tilsidesættes på agentniveau. ## AI-modelkonfiguration Vælg hvilken AI-model der driver din indholdsgenerering: - Vælg mellem tilgængelige modeller (GPT-4o, Claude, m.fl.) - Indstil temperatur og kreativitetsniveau - Konfigurer maksimale tokenlimits ## Hurtige handlinger Start almindelige AI-opgaver direkte fra Cockpittet: - **Generer indhold** — opret nye dokumenter med dine agenter - **Masseoptimer** — kør SEO-optimering på tværs af flere dokumenter - **Oversæt site** — oversæt alt indhold til et nyt sprog - **Korrekturlæs** — tjek indhold for grammatik- og stilproblemer ## Omkostningssporing Cockpittet viser et løbende resumé af AI-forbrug og omkostninger. For detaljerede opdelinger, se [AI Analytics](/docs/ai-analytics-da). ## Relateret - [AI-agenter](/docs/ai-agents-da) — opret og administrer individuelle agenter - [Curation Queue](/docs/curation-queue-da) — gennemgå AI-genereret indhold før publicering - [AI Analytics](/docs/ai-analytics-da) — detaljeret omkostnings- og performancesporing --- ## docs/cockpit Title: Cockpit Updated: 2026-03-31 Locale: en The AI command center — configure generation parameters and launch AI tasks. ## Overview The Cockpit is your AI command center. It's where you configure how the CMS generates, optimizes, and translates content using AI. ## Generation Parameters Set global defaults that apply to all AI operations: - **Brand voice** — define tone, style, and vocabulary guidelines that every agent follows - **Target audience** — describe who your content is for - **Language preferences** — default locale and translation targets - **Content length** — preferred word counts for different content types These parameters are inherited by all [AI Agents](/docs/ai-agents) unless overridden at the agent level. ## AI Model Configuration Choose which AI model powers your content generation: - Select from available models (GPT-4o, Claude, etc.) - Set temperature and creativity levels - Configure maximum token limits ## Quick Actions Launch common AI tasks directly from the Cockpit: - **Generate content** — create new documents using your agents - **Bulk optimize** — run SEO optimization across multiple documents - **Translate site** — translate all content to a new locale - **Proofread** — check content for grammar and style issues ## Cost Tracking The Cockpit shows a running summary of AI usage and costs. For detailed breakdowns, see [AI Analytics](/docs/ai-analytics). ## Related - [AI Agents](/docs/ai-agents) — create and manage individual agents - [Curation Queue](/docs/curation-queue) — review AI-generated content before publishing - [AI Analytics](/docs/ai-analytics) — detailed cost and performance tracking --- ## docs/collections-da Title: Collections Updated: 2026-03-31 Locale: da Gennemse, filtrer, opret og administrer dokumenter i dine indholdscollections. ## Overblik Collections er hjertet af dit CMS. Hver collection indeholder dokumenter af en bestemt type — blogindlæg, sider, produkter, teammedlemmer eller hvad dit site har brug for. Collectionssiden lader dig gennemse, oprette og administrere alt dit indhold. ## Visninger Skift mellem to layouttilstande: - **Gittervisning** — visuelle kort med titel, billede og status. Godt til mediefyldte collections. - **Listevisning** — kompakt tabel med sorterbare kolonner. Bedre til store collections. ## Filtrering og søgning Find dokumenter hurtigt med: - **Tekstsøgning** — søger på tværs af titel- og indholdsfelter - **Statusfilter** — publiceret, kladde eller planlagt - **Sprogfilter** — filtrer efter sprog (for flersprogede sites) - **Kategorifilter** — indsnæv efter kategorifelt (hvis konfigureret) ## Oprettelse af dokumenter Klik **Ny** for at oprette et dokument. Editoren åbner med alle felter defineret i din collection-schema. Se [Indholdsstruktur](/docs/content-structure-da) for hvordan du definerer collections og [Felttyper](/docs/field-types-da) for tilgængelige felttyper. ## AI-generering Klik **Generer** for at oprette indhold med AI: - Vælg en [AI-agent](/docs/ai-agents-da) eller brug standardskribenten - Angiv et emne eller en kort brief - Det genererede dokument lander i [Curation Queue](/docs/curation-queue-da) til review ## Preview Klik på preview-ikonet på et dokument for at se hvordan det ser ud på dit live site. Preview-URL'er konstrueres ud fra dit sites `previewSiteUrl`-indstilling. Se [Siteindstillinger](/docs/site-settings-da) for konfiguration. ## Massehandlinger Vælg flere dokumenter for at: - **Publicer** eller **afpublicer** i bulk - **Flyt til papirkurv** — kan gendannes fra [Papirkurven](/docs/trash-da) - **Eksporter** som JSON ## Relateret - [Indholdsstruktur](/docs/content-structure-da) — definition af collection-schemas - [Felttyper](/docs/field-types-da) — tilgængelige felttyper - [Interaktive elementer](/docs/interactives-da) — datadrevne HTML-widgets --- ## docs/collections Title: Collections Updated: 2026-03-31 Locale: en Browse, filter, create, and manage documents in your content collections. ## Overview Collections are the heart of your CMS. Each collection holds documents of a specific type — blog posts, pages, products, team members, or whatever your site needs. The Collections page lets you browse, create, and manage all your content. ## Views Switch between two layout modes: - **Grid view** — visual cards showing title, image, and status. Great for media-heavy collections. - **List view** — compact table with sortable columns. Better for large collections. ## Filtering and Search Find documents quickly with: - **Text search** — searches across title and content fields - **Status filter** — published, draft, or scheduled - **Locale filter** — filter by language (for multilingual sites) - **Category filter** — narrow down by category field (if configured) ## Creating Documents Click **New** to create a document. The editor opens with all fields defined in your collection schema. See [Content Structure](/docs/content-structure) for how to define collections and [Field Types](/docs/field-types) for available field types. ## AI Generation Click **Generate** to create content using AI: - Choose an [AI Agent](/docs/ai-agents) or use the default writer - Provide a topic or brief - The generated document enters the [Curation Queue](/docs/curation-queue) for review ## Preview Click the preview icon on any document to see how it looks on your live site. Preview URLs are constructed from your site's `previewSiteUrl` setting. See [Site Settings](/docs/site-settings) for configuration. ## Bulk Actions Select multiple documents to: - **Publish** or **unpublish** in bulk - **Move to trash** — recoverable from the [Trash](/docs/trash) - **Export** as JSON ## Related - [Content Structure](/docs/content-structure) — defining collection schemas - [Field Types](/docs/field-types) — available field types - [Interactives](/docs/interactives) — data-driven HTML widgets --- ## docs/curation-queue-da Title: Curation Queue Updated: 2026-03-31 Locale: da Gennemgå, godkend eller afvis AI-genereret indhold før det går live. ## Overblik Curation Queue er stedet hvor AI-genereret indhold lander før det når dit site. Hvert dokument oprettet eller ændret af en AI-agent går gennem dette review-trin, så du har fuld redaktionel kontrol. ## Sådan fungerer det 1. En [AI-agent](/docs/ai-agents-da) genererer eller ændrer et dokument 2. Dokumentet vises i Curation Queue med status "afventende" 3. Du gennemgår indholdet og redigerer om nødvendigt 4. Du **godkender** (publicerer dokumentet) eller **afviser** (kasserer det) ## Køens brugerflade Hvert element i køen viser: - **Titel og collection** — hvilket dokument og hvor det hører til - **Agentnavn** — hvilken AI-agent der oprettede det - **Genereringsdato** — hvornår indholdet blev produceret - **Forhåndsvisning** — klik for at se det fulde indhold før du beslutter ## Handlinger - **Godkend** — accepterer indholdet og publicerer det (eller gemmer som kladde, afhængigt af din arbejdsgang) - **Afvis** — kasserer det genererede indhold med en valgfri begrundelse - **Rediger** — åbn dokumentet i editoren for at foretage ændringer før godkendelse - **Massehandlinger** — godkend eller afvis flere elementer på én gang ## Filtrering Filtrer køen efter: - Agentnavn - Collection - Datointerval - Status (afventende, godkendt, afvist) ## Bedste praksis - Gennemgå køen dagligt for at holde indholdet i flow - Brug afvisningsårsager til at forbedre agentprompter over tid - Tjek [AI Analytics](/docs/ai-analytics-da)-siden for at spore godkendelsesrater per agent ## Relateret - [AI-agenter](/docs/ai-agents-da) — konfigurer hvilket indhold der genereres - [Cockpit](/docs/cockpit-da) — AI-kommandocentralen --- ## docs/curation-queue Title: Curation Queue Updated: 2026-03-31 Locale: en Review, approve, or reject AI-generated content before it goes live. ## Overview The Curation Queue is where AI-generated content lands before it reaches your site. Every document created or modified by an AI agent goes through this review step, giving you full editorial control. ## How It Works 1. An [AI Agent](/docs/ai-agents) generates or modifies a document 2. The document appears in the Curation Queue with status "pending" 3. You review the content, make edits if needed 4. You **approve** (publishes the document) or **reject** (discards it) ## Queue Interface Each item in the queue shows: - **Title and collection** — which document and where it belongs - **Agent name** — which AI agent created it - **Generated date** — when the content was produced - **Preview** — click to see the full content before deciding ## Actions - **Approve** — accepts the content and publishes it (or saves as draft, depending on your workflow) - **Reject** — discards the generated content with an optional reason - **Edit** — open the document in the editor to make changes before approving - **Bulk actions** — approve or reject multiple items at once ## Filtering Filter the queue by: - Agent name - Collection - Date range - Status (pending, approved, rejected) ## Best Practices - Review the queue daily to keep content flowing - Use rejection reasons to improve agent prompts over time - Check the [AI Analytics](/docs/ai-analytics) page to track acceptance rates per agent ## Related - [AI Agents](/docs/ai-agents) — configure what content gets generated - [Cockpit](/docs/cockpit) — AI command center --- ## docs/dashboard-da Title: Dashboard Updated: 2026-03-31 Locale: da Dit indholdsoverblik — statistik, genveje og sitesundhed samlet ét sted. ## Overblik Dashboardet er dit udgangspunkt hver gang du åbner CMS-admin. Det giver dig et fugleperspektiv over dit sites indhold og sundhed. ## Indholdsstatistik Øverst ser du nøgletal for dit site: - **Publicerede dokumenter** — samlet antal på tværs af alle collections - **Kladder** — dokumenter der endnu ikke er publiceret - **Planlagte** — dokumenter i kø til fremtidig publicering eller afpublicering - **Mediefiler** — samlet antal uploadede filer Tallene opdateres i realtid mens du arbejder. ## Genveje Dashboardet giver hurtig adgang til hyppige opgaver: - **Opret nyt dokument** — spring direkte til editoren for en collection - **Seneste ændringer** — en liste over dine sidst redigerede dokumenter - **Afventer review** — emner i [Curation Queue](/docs/curation-queue-da) der venter på godkendelse ## Sitesundhed Et resumépanel viser: - **Brudte links** — antal problemer fundet af [Link Checker](/docs/link-checker-da) - **SEO-score** — gennemsnitlig score på tværs af publicerede sider (se [SEO](/docs/seo-da)) - **Visibility-score** — kombineret SEO + GEO-performance (se [Visibility](/docs/visibility-da)) ## Tips - Brug Dashboardet som dit daglige tjek — det synliggør problemer før de rammer dine besøgende. - Klik på en statistik for at gå i dybden med den relevante side. - Dashboardet tilpasser sig dine konfigurerede collections, så du kun ser det relevante. --- ## docs/dashboard Title: Dashboard Updated: 2026-03-31 Locale: en Your content overview — stats, quick links, and site health at a glance. ## Overview The Dashboard is your starting point every time you open the CMS admin. It gives you a bird's-eye view of your site's content and health. ## Content Statistics At the top you'll see key numbers for your site: - **Published documents** — total count across all collections - **Drafts** — documents not yet published - **Scheduled** — documents queued for future publish or unpublish - **Media files** — total uploaded assets These counters update in real time as you work. ## Quick Links The Dashboard provides shortcuts to frequent tasks: - **Create new document** — jump straight to the editor for any collection - **Recent edits** — a list of your most recently modified documents - **Pending reviews** — items in the [Curation Queue](/docs/curation-queue) waiting for approval ## Site Health A summary panel shows: - **Broken links** — number of issues found by the [Link Checker](/docs/link-checker) - **SEO score** — average score across published pages (see [SEO](/docs/seo)) - **Visibility score** — combined SEO + GEO performance (see [Visibility](/docs/visibility)) ## Tips - Use the Dashboard as your daily check-in — it surfaces problems before they reach your visitors. - Click any stat to drill down into the relevant page. - The Dashboard adapts to your site's configured collections, so you only see what's relevant. --- ## docs/deploy-da Title: Deploy Updated: 2026-03-31 Locale: da Publicer dit site til hostingudbydere og administrer Instant Content Deployment. ## Overblik Deploy-siden i CMS-admin er din ét-klik publiceringsoverflade. Den forbinder til din konfigurerede hostingudbyder og starter builds eller pusher indhold direkte. ## Understøttede udbydere Deploy-siden fungerer med fire hostingudbydere: - **GitHub Pages** — statisk eksport pushet til et GitHub-repository - **Fly.io** — Docker-baseret deployment med valgfri [ICD](/docs/instant-content-deployment-da) - **Vercel** — automatiske builds via Git-integration - **Netlify** — automatiske builds via Git-integration Se [Deployment](/docs/deployment-da) for detaljerede opsætningsinstruktioner for hver udbyder. ## Start en deploy Klik **Deploy** for at starte en build- og publiceringscyklus. Siden viser: - **Build-status** — realtids fremgangsindikator - **Build-log** — output fra build-processen - **Deploy-historik** — liste over tidligere deploys med tidsstempler og status ## Instant Content Deployment (ICD) For sites på Fly.io kan du aktivere ICD for næsten øjeblikkelige indholdsopdateringer uden fuld rebuild: - Indholdsændringer pushes direkte til den kørende instans - Ændringer er live på cirka 2 sekunder - Ingen Docker-rebuild påkrævet Se [ICD-guiden](/docs/instant-content-deployment-da) for fuld opsætning. ## Deploy-historik Historiktabellen viser: - **Dato og tid** for hver deploy - **Trigger** — manuel, planlagt eller ICD - **Status** — succes, fejlet eller i gang - **Varighed** — hvor lang tid buildet tog ## Fejlfinding Hvis en deploy fejler: - Tjek build-loggen for fejlmeddelelser - Sørg for at miljøvariabler er sat korrekt - Verificer dine hostingudbyder-legitimationsoplysninger i [Siteindstillinger](/docs/site-settings-da) - Se [Fejlfinding](/docs/troubleshooting-da) for almindelige problemer ## Relateret - [Deployment](/docs/deployment-da) — opsætningsguides for udbydere - [Instant Content Deployment](/docs/instant-content-deployment-da) — ICD-konfiguration - [Siteindstillinger](/docs/site-settings-da) — deploymål-konfiguration --- ## docs/deploy Title: Deploy Updated: 2026-03-31 Locale: en Publish your site to hosting providers and manage Instant Content Deployment. ## Overview The Deploy page in CMS admin is your one-click publishing interface. It connects to your configured hosting provider and triggers builds or pushes content directly. ## Supported Providers The Deploy page works with four hosting providers: - **GitHub Pages** — static export pushed to a GitHub repository - **Fly.io** — Docker-based deployment with optional [ICD](/docs/instant-content-deployment) - **Vercel** — automatic builds via Git integration - **Netlify** — automatic builds via Git integration See [Deployment](/docs/deployment) for detailed setup instructions for each provider. ## Triggering a Deploy Click **Deploy** to start a build and publish cycle. The page shows: - **Build status** — real-time progress indicator - **Build log** — output from the build process - **Deploy history** — list of previous deploys with timestamps and status ## Instant Content Deployment (ICD) For sites on Fly.io, you can enable ICD for near-instant content updates without a full rebuild: - Content changes are pushed directly to the running instance - Changes are live in approximately 2 seconds - No Docker rebuild required See the [ICD guide](/docs/instant-content-deployment) for full setup. ## Deploy History The history table shows: - **Date and time** of each deploy - **Trigger** — manual, scheduled, or ICD - **Status** — success, failed, or in progress - **Duration** — how long the build took ## Troubleshooting If a deploy fails: - Check the build log for error messages - Ensure environment variables are set correctly - Verify your hosting provider credentials in [Site Settings](/docs/site-settings) - See [Troubleshooting](/docs/troubleshooting) for common issues ## Related - [Deployment](/docs/deployment) — provider setup guides - [Instant Content Deployment](/docs/instant-content-deployment) — ICD configuration - [Site Settings](/docs/site-settings) — deploy target configuration --- ## docs/link-checker-da Title: Link Checker Updated: 2026-03-31 Locale: da Find og ret brudte links på tværs af dit publicerede indhold. ## Overblik Link Checker scanner alle publicerede dokumenter for brudte links — både interne og eksterne. Brudte links skader din SEO-placering og frustrerer besøgende, så regelmæssig kontrol er vigtig. ## Kør en scanning Klik **Kør Tjek** for at starte en fuld scanning. Checkeren: 1. Indsamler alle URL'er fra publicerede dokumenter (links i richtext, URL-felter osv.) 2. Tester hver URL for tilgængelighed 3. Rapporterer resultater grupperet efter statuskode ## Resultater Hvert resultat viser: - **URL** — det brudte eller problematiske link - **Status** — HTTP-statuskode (404, 500, timeout osv.) - **Placering** — hvilket dokument og felt der indeholder linket - **Type** — internt link eller eksternt link ## Statuskategorier - **Brudt (4xx)** — siden eksisterer ikke. Ret eller fjern linket. - **Serverfejl (5xx)** — målserveren har et problem. Kan være midlertidigt. - **Timeout** — målet svarede ikke i tide. Kan være midlertidigt. - **Redirect (3xx)** — linket virker men omdirigerer. Overvej at opdatere til den endelige URL. ## Ret links Klik på et resultat for at springe direkte til dokumenteditoren hvor det brudte link findes. Ret URL'en og gem. ## Bedste praksis - Kør Link Checker ugentligt, eller efter store indholdsændringer - Fokuser på interne brudte links først — dem kontrollerer du - Tjek [Dashboardet](/docs/dashboard-da) for et hurtigt overblik over brudte links - Eksterne links kan være midlertidigt nede — dobbelttjek før du fjerner dem ## Relateret - [SEO](/docs/seo-da) — SEO-scoring og optimering - [Richtext-editor](/docs/richtext-da) — hvor de fleste links oprettes --- ## docs/link-checker Title: Link Checker Updated: 2026-03-31 Locale: en Find and fix broken links across your published content. ## Overview The Link Checker scans all published documents for broken links — both internal and external. Broken links hurt your SEO ranking and frustrate visitors, so regular checking is important. ## Running a Scan Click **Run Check** to start a full scan. The checker: 1. Collects all URLs from published documents (links in richtext, URL fields, etc.) 2. Tests each URL for availability 3. Reports results grouped by status code ## Results Each result shows: - **URL** — the broken or problematic link - **Status** — HTTP status code (404, 500, timeout, etc.) - **Location** — which document and field contains the link - **Type** — internal link or external link ## Status Categories - **Broken (4xx)** — the page doesn't exist. Fix or remove the link. - **Server error (5xx)** — the target server has a problem. May be temporary. - **Timeout** — the target didn't respond in time. May be temporary. - **Redirect (3xx)** — the link works but redirects. Consider updating to the final URL. ## Fixing Links Click on any result to jump directly to the document editor where the broken link lives. Fix the URL and save. ## Best Practices - Run the Link Checker weekly, or after large content changes - Focus on internal broken links first — you control those - Check the [Dashboard](/docs/dashboard) for a quick broken-link count - External links may be temporarily down — recheck before removing them ## Related - [SEO](/docs/seo) — SEO scoring and optimization - [Richtext Editor](/docs/richtext) — where most links are created --- ## docs/site-settings-da Title: Siteindstillinger Updated: 2026-03-31 Locale: da Konfigurer dit site — generelle indstillinger, sprog, preview, deploy og mere. ## Overblik Siteindstillinger er det centrale konfigurationshub for dit CMS-site. Det indeholder flere faneblade der dækker alle aspekter af site-opsætning. ## Generelt Grundlæggende siteinformation: - **Sitenavn** — vises i admin-headeren og bruges i metatags - **Site-URL** — dit produktionsdomæne - **Beskrivelse** — kort sitebeskrivelse til SEO-standarder - **Tidszone** — bruges til planlagt publicering/afpublicering (se [Kalender](/docs/calendar-da)) ## Sprog Konfigurer flersproget understøttelse: - **Standardsprog** — dit sites primære sprog - **Yderligere sprog** — sprog du oversætter indhold til - **Sprogstrategi** — præfiksbaseret (`/en/`, `/da/`) eller domænebaseret routing Se [Internationalisering](/docs/i18n-da) for den fulde i18n-guide. ## Preview Konfigurer hvordan dokumentforhåndsvisning fungerer: - **Preview site-URL** — basis-URL'en for preview-links - **URL-mønstre** — hvordan preview-URL'er konstrueres per collection Se guiden [Indholdsstruktur](/docs/content-structure-da) for URL-mønsterkonfiguration. ## Deploy Opsæt deploymål: - **Udbyder** — GitHub Pages, Fly.io, Vercel eller Netlify - **Repository** — for GitHub-backede sites - **Build-kommando** — brugerdefinerede build-kommandoer - **ICD** — aktivér [Instant Content Deployment](/docs/instant-content-deployment-da) for sub-sekund publiceringer Se [Deployment](/docs/deployment-da) for fulde opsætningsinstruktioner. ## AI Konfigurer AI-funktioner: - **API-nøgler** — opsæt OpenAI- eller Anthropic-legitimationsoplysninger - **Standardmodel** — hvilken model der bruges til generering - **Brandvoice** — globale tone- og stilindstillinger Se [Cockpit](/docs/cockpit-da) for AI-kommandocentralen. ## Lagring Vælg og konfigurer din lagringsadapter: - **Filsystem** — indhold gemt lokalt på disk - **GitHub** — indhold gemt i et Git-repository Se [Lagringsadaptere](/docs/storage-adapters-da) for detaljeret konfiguration. ## Relateret - [Konfigurationsreference](/docs/config-reference-da) — fuld `cms.config.ts`-reference - [Deployment](/docs/deployment-da) — deploy dit site --- ## docs/site-settings Title: Site Settings Updated: 2026-03-31 Locale: en Configure your site — general settings, locales, preview, deploy, and more. ## Overview Site Settings is the central configuration hub for your CMS site. It contains multiple tabs covering every aspect of site setup. ## General Basic site information: - **Site name** — displayed in the admin header and used in meta tags - **Site URL** — your production domain - **Description** — brief site description for SEO defaults - **Time zone** — used for scheduled publish/unpublish (see [Calendar](/docs/calendar)) ## Locales Configure multilingual support: - **Default locale** — the primary language of your site - **Additional locales** — languages you translate content into - **Locale strategy** — prefix-based (`/en/`, `/da/`) or domain-based routing See [Internationalization](/docs/i18n) for the full i18n guide. ## Preview Configure how document previews work: - **Preview site URL** — the base URL for preview links - **URL patterns** — how preview URLs are constructed per collection See the [Content Structure](/docs/content-structure) guide for URL pattern configuration. ## Deploy Set up deployment targets: - **Provider** — GitHub Pages, Fly.io, Vercel, or Netlify - **Repository** — for GitHub-backed sites - **Build command** — custom build commands - **ICD** — enable [Instant Content Deployment](/docs/instant-content-deployment) for sub-second publishes See [Deployment](/docs/deployment) for full setup instructions. ## AI Configure AI features: - **API keys** — set up OpenAI or Anthropic credentials - **Default model** — which model to use for generation - **Brand voice** — global tone and style settings See [Cockpit](/docs/cockpit) for the AI command center. ## Storage Choose and configure your storage adapter: - **Filesystem** — content stored locally on disk - **GitHub** — content stored in a Git repository See [Storage Adapters](/docs/storage-adapters) for detailed configuration. ## Related - [Configuration Reference](/docs/config-reference) — full `cms.config.ts` reference - [Deployment](/docs/deployment) — deploy your site --- ## docs/trash-da Title: Papirkurv Updated: 2026-03-31 Locale: da Gendan slettede dokumenter eller fjern dem permanent. ## Overblik Når du sletter et dokument i CMS'et, flyttes det til Papirkurven i stedet for at blive fjernet permanent. Det giver dig et sikkerhedsnet til at gendanne indhold der blev slettet ved en fejl. ## Se slettede dokumenter Papirkurvsiden lister alle slettede dokumenter med: - **Titel** — dokumentets titel på slettetidspunktet - **Collection** — hvilken collection det tilhørte - **Slettet dato** — hvornår det blev flyttet til papirkurven - **Slettet af** — hvem der udførte sletningen (hvis brugersporing er aktiveret) ## Gendan dokumenter Klik **Gendan** på et slettet dokument for at bringe det tilbage. Dokumentet vender tilbage til sin oprindelige collection med sin oprindelige status (kladde eller publiceret). Gendannede dokumenter bevarer alt deres indhold, metadata og oversættelsesgrupperelationer. ## Permanent sletning For permanent at fjerne dokumenter fra Papirkurven: - **Individuelt** — klik på sletteknappen på et specifikt slettet dokument - **Tøm Papirkurv** — fjerner alle slettede dokumenter på én gang **Advarsel:** Permanent sletning kan ikke fortrydes. Hvis du har brug for et sikkerhedsnet, opret en [Backup](/docs/backup-da) først. ## Automatisk oprydning Slettede dokumenter beholdes som standard på ubestemt tid. Du kan konfigurere automatisk oprydning i [Siteindstillinger](/docs/site-settings-da) til permanent at slette elementer efter et bestemt antal dage. ## Relateret - [Collections](/docs/collections-da) — hvor dokumenter bor før sletning - [Backup](/docs/backup-da) — opret fulde indholdssnapshots for ekstra sikkerhed --- ## docs/trash Title: Trash Updated: 2026-03-31 Locale: en Recover deleted documents or permanently remove them. ## Overview When you delete a document in the CMS, it moves to the Trash instead of being permanently removed. This gives you a safety net to recover content that was deleted by mistake. ## Viewing Trashed Documents The Trash page lists all deleted documents with: - **Title** — the document's title at the time of deletion - **Collection** — which collection it belonged to - **Deleted date** — when it was moved to trash - **Deleted by** — who performed the deletion (if user tracking is enabled) ## Restoring Documents Click **Restore** on any trashed document to bring it back. The document returns to its original collection with its original status (draft or published). Restored documents retain all their content, metadata, and translation group connections. ## Permanent Deletion To permanently remove documents from the Trash: - **Individual** — click the delete button on a specific trashed document - **Empty Trash** — removes all trashed documents at once **Warning:** Permanent deletion cannot be undone. If you need a safety net, create a [Backup](/docs/backup) first. ## Automatic Cleanup Trashed documents are retained indefinitely by default. You can configure automatic cleanup in [Site Settings](/docs/site-settings) to permanently delete items after a set number of days. ## Related - [Collections](/docs/collections) — where documents live before deletion - [Backup](/docs/backup) — create full content snapshots for extra safety --- ## docs/visibility-da Title: Visibility Updated: 2026-03-31 Locale: da Kombineret SEO- og GEO-score der viser hvor synligt dit indhold er. ## Overblik Visibility kombinerer to scores til én metrik der fortæller dig hvor opdageligt dit indhold er — både af traditionelle søgemaskiner (SEO) og af AI-systemer som ChatGPT og Claude (GEO). ## Visibility-scoren Hvert publiceret dokument får en score fra 0 til 100: - **SEO-komponent** — baseret på metatags, søgeordsanvendelse, overskrifter, indholdslængde og tekniske faktorer. Se [SEO](/docs/seo-da) for detaljer. - **GEO-komponent** — baseret på strukturerede data, faktuel klarhed, kildehenvisning og AI-venlig formatering. Den kombinerede score vægter begge komponenter for at give ét tal der repræsenterer samlet synlighed. ## Scoreopdeling Klik på et dokument for at se en detaljeret opdeling: - **Individuelle regelscores** — hver SEO- og GEO-regel bedømt separat - **Forslag** — specifikke forbedringer du kan foretage - **Prioritet** — hvilke rettelser der vil have størst effekt ## Sitebredt overblik Visibility-siden viser aggregerede data: - **Gennemsnitlig visibility-score** på tværs af alle publicerede dokumenter - **Scorefordeling** — hvor mange dokumenter der er fremragende, gode, behøver arbejde eller dårlige - **Trend over tid** — om din synlighed forbedres eller falder - **Dårligst præsterende** — dokumenter der har mest brug for opmærksomhed ## Forbedr synlighed Siden fremhæver handlingsrettede forbedringer: - Manglende metabeskrivelser eller titler - Indhold for kort til emnet - Manglende strukturerede data (JSON-LD) - Manglende kildehenvisninger (skader GEO-score) - Dårlig overskriftshierarki ## AI-optimering Brug knappen **AI Optimer** til automatisk at forbedre et dokuments SEO- og GEO-scores. AI'en foreslår forbedringer som du kan gennemgå og anvende. ## Relateret - [SEO](/docs/seo-da) — detaljeret SEO-scoring og søgeordssporing - [Dashboard](/docs/dashboard-da) — hurtigt visibility-score-resumé - [Cockpit](/docs/cockpit-da) — AI-drevet masseoptimering --- ## docs/visibility Title: Visibility Updated: 2026-03-31 Locale: en Combined SEO and GEO score showing how discoverable your content is. ## Overview Visibility combines two scores into one metric that tells you how discoverable your content is — both by traditional search engines (SEO) and by AI systems like ChatGPT and Claude (GEO). ## The Visibility Score Each published document gets a score from 0 to 100: - **SEO component** — based on meta tags, keyword usage, headings, content length, and technical factors. See [SEO](/docs/seo) for details. - **GEO component** — based on structured data, factual clarity, source attribution, and AI-friendly formatting. The combined score weights both components to give a single number representing overall discoverability. ## Score Breakdown Click on any document to see a detailed breakdown: - **Individual rule scores** — each SEO and GEO rule scored separately - **Suggestions** — specific improvements you can make - **Priority** — which fixes will have the biggest impact ## Site-Wide View The Visibility page shows aggregate data: - **Average visibility score** across all published documents - **Score distribution** — how many documents are excellent, good, needs work, or poor - **Trend over time** — whether your visibility is improving or declining - **Worst performers** — documents that need the most attention ## Improving Visibility The page highlights actionable improvements: - Missing meta descriptions or titles - Content too short for the topic - Missing structured data (JSON-LD) - Lack of source citations (hurts GEO score) - Poor heading hierarchy ## AI Optimization Use the **AI Optimize** button to automatically improve a document's SEO and GEO scores. The AI will suggest improvements that you can review and apply. ## Related - [SEO](/docs/seo) — detailed SEO scoring and keyword tracking - [Dashboard](/docs/dashboard) — quick visibility score summary - [Cockpit](/docs/cockpit) — AI-powered bulk optimization --- ## docs/collection-naming-da Title: Best practices for collection-navngivning Updated: 2026-03-30 Locale: da Reserverede navne du skal undgå, anbefalede navngivningsmønstre, og hvorfor validatoren fanger konflikter. ## Reserverede navne — brug aldrig disse Følgende navne konflikter med CMS admin's indbyggede UI-paneler. Brug dem **aldrig** som collection-navne eller labels: | Reserveret navn | Konflikter med | |----------------|----------------| | `settings` | Site Settings-panelet | | `site-settings` | Site Settings-panelet | | `config` | Site-konfiguration | | `admin` | Admin UI-routes | | `media` | Mediebiblioteks-panelet | | `interactives` | Interaktive-panelet | ### Hvad sker der hvis du bruger et reserveret navn Hvis du navngiver en collection "Site Settings", ser redaktører **to** "Site Settings" i sidebaren: 1. Din collection (indholdsdokumenter) 2. CMS admin's indbyggede indstillingspanel Det forvirrer alle. ## Anbefalet navngivning ### Til site-brede indstillinger Brug `globals` eller `global` — ikke "settings" eller "config": ```typescript defineCollection({ name: 'globals', // ✓ sikkert label: 'Globale indstillinger', // ✓ label kan sige "indstillinger" fields: [ { name: 'siteTitle', type: 'text' }, { name: 'tagline', type: 'textarea' }, { name: 'footerText', type: 'text' }, ], }) ``` ### Til indholdscollections Brug beskrivende, indholdsfokuserede navne: | Godt | Dårligt | |------|---------| | `posts` | `blog-settings` | | `projects` | `admin-projects` | | `team` | `config-team` | | `testimonials` | `media-events` | ## Validatoren fanger det 1. Gå til **Site Settings** i sidebaren 2. Klik **Validate site** 3. Hvis en collection bruger et reserveret navn, ser du en advarsel med et omdøbningsforslag ### Rette en konflikt ```typescript // Før (dårligt) defineCollection({ name: 'settings', ... }) // Efter (godt) defineCollection({ name: 'globals', label: 'Site Settings', ... }) ``` Omdøb også content-mappen: ```bash mv content/settings content/globals ``` ## Opsummering - **`name`** = mappenavn + URL-route → skal undgå reserverede ord - **`label`** = visningsnavn i admin → kan være hvad som helst - **Brug `globals`** til site-brede indstillinger - **Kør validatoren** efter enhver konfigurationsændring --- ## docs/collection-naming Title: Collection Naming Best Practices Updated: 2026-03-30 Locale: en Reserved names to avoid, recommended naming patterns, and why the validator catches conflicts. ## Reserved names — never use these The following names conflict with CMS admin's built-in UI panels. **Never** use them as collection names or labels: | Reserved name | Conflicts with | |--------------|----------------| | `settings` | Site Settings panel | | `site-settings` | Site Settings panel | | `config` | Site configuration | | `admin` | Admin UI routes | | `media` | Media library panel | | `interactives` | Interactives panel | ### What happens if you use a reserved name If you name a collection "Site Settings", editors see **two** "Site Settings" entries in the sidebar: 1. Your collection (content documents) 2. CMS admin's built-in settings panel This confuses everyone. Editors click the wrong one, content gets lost, and support tickets pile up. The same applies to "Media" — editors can't tell if they're opening the media library or your "Media" content collection. ## Recommended naming ### For site-wide settings Use `globals` or `global` — not "settings" or "config": ```typescript defineCollection({ name: 'globals', // ✓ safe label: 'Global Settings', // ✓ label can say "settings" fields: [ { name: 'siteTitle', type: 'text' }, { name: 'tagline', type: 'textarea' }, { name: 'socialLinks', type: 'array', fields: [ { name: 'platform', type: 'text' }, { name: 'url', type: 'text' }, ]}, { name: 'footerText', type: 'text' }, ], }) ``` > **Note:** The `label` can include "Settings" — it's the `name` (used for routes and directory names) that must avoid reserved words. ### For content collections Use descriptive, content-focused names: | Good | Bad | |------|-----| | `posts` | `blog-settings` | | `projects` | `admin-projects` | | `team` | `config-team` | | `testimonials` | `settings-testimonials` | | `events` | `media-events` | | `products` | `interactives-products` | | `services` | `site-settings-services` | ## The validator catches it CMS admin includes a built-in validator that checks for reserved name conflicts. ### How to use it 1. Go to **Site Settings** in the sidebar 2. Scroll to the **Site** section 3. Click **Validate site** 4. If any collection uses a reserved name, you'll see a warning with a rename suggestion ### What the validator checks - Collection `name` matches a reserved word - Collection `label` is identical to a built-in panel name - Suggests safe alternatives (e.g. "settings" → "globals") ### Fixing a conflict If the validator flags a collection: 1. Rename the collection in `cms.config.ts`: ```typescript // Before (bad) defineCollection({ name: 'settings', ... }) // After (good) defineCollection({ name: 'globals', label: 'Site Settings', ... }) ``` 2. Rename the content directory: ```bash mv content/settings content/globals ``` 3. Update slug references in any JSON files that reference the old collection name ## Summary - **`name`** = directory name + URL route → must avoid reserved words - **`label`** = display name in admin → can be anything descriptive - **Use `globals`** for site-wide settings, not "settings" or "config" - **Run the validator** after any config change to catch conflicts early --- ## docs/mcp-client-da Title: MCP Client — Offentlig læseadgang Updated: 2026-03-30 Locale: da Hvert site får en offentlig MCP-server som AI-platforme kan forespørge — ingen API-nøgler, ingen konfiguration. ## Hvad er MCP Client? `@webhouse/cms-mcp-client` er en **offentlig, skrivebeskyttet MCP-server** der følger med hvert @webhouse/cms site. Enhver AI-platform kan forespørge dit publicerede indhold uden API-nøgler. ## 6 Værktøjer | Værktøj | Beskrivelse | |---------|-------------| | `get_site_summary` | Siteoversigt: navn, beskrivelse, sprog, collections, antal | | `list_collection` | List publicerede dokumenter i en collection | | `search_content` | Fuldtekstsøgning på tværs af alt indhold | | `get_page` | Hent fuld sideindhold som markdown | | `get_schema` | Feltskema for en collection | | `export_all` | Eksportér alt publiceret indhold som JSON | ## Opsætning MCP-clienten er automatisk tilgængelig. For Claude Desktop: ```json { "mcpServers": { "my-site": { "command": "npx", "args": ["@webhouse/cms-cli", "mcp"] } } } ``` ## Hvorfor dette er vigtigt Traditionelle CMS-platforme kræver API-nøgler for AI-adgang. @webhouse/cms gør dit indhold **opdageligt som standard**. AI-platforme kan læse, forstå og citere dine sider — uden at du sætter noget op. --- ## docs/mcp-server-da Title: MCP Server — Autentificeret indholdsproduktion Updated: 2026-03-30 Locale: da Fuld læse+skrive CMS-adgang fra Claude, Cursor eller enhver MCP-klient — 43 værktøjer med scope-baseret adgangskontrol. ## Hvad er MCP Server? `@webhouse/cms-mcp-server` er en **autentificeret MCP-server** til indholdsproduktion. Den giver AI-værktøjer som Claude Desktop, Cursor og Claude Code fuld læse+skrive-adgang til dit CMS — med scope-baseret adgangskontrol. I modsætning til den offentlige MCP-klient (6 læse-værktøjer) eksponerer serveren **43 værktøjer** der dækker enhver CMS-operation. ## 43 Værktøjer efter kategori ### Læsning (6) — `read` scope Alle offentlige værktøjer: `get_site_summary`, `list_collection`, `search_content`, `get_page`, `get_schema`, `export_all` ### Indhold CRUD (8) — `write` scope `create_document`, `update_document`, `trash_document`, `clone_document`, `restore_from_trash`, `empty_trash`, `publish_document`, `unpublish_document` ### AI-generering (4) — `write`+`ai` scopes `generate_with_ai`, `rewrite_field`, `generate_content`, `generate_interactive` ### Oversættelse (2) — `write`+`ai` scopes `translate_document`, `translate_site` ### Build & Deploy (3) — `deploy` scope `trigger_build`, `trigger_deploy`, `list_deploy_history` ### Masseoperationer (2) — `write` scope `bulk_publish`, `bulk_update` ### Agenter & kuratering (6) — `read`/`write`+`ai` scopes `list_agents`, `create_agent`, `run_agent`, `list_curation_queue`, `approve_queue_item`, `reject_queue_item` ## Scope-baseret adgangskontrol | Scope | Tillader | |-------|----------| | `read` | Vis indhold, søg, list, eksportér | | `write` | Opret, opdatér, slet, backup | | `publish` | Publicér, afpublicér, planlæg | | `deploy` | Byg, deploy | | `ai` | AI-generering, oversættelse, agenter | Generér nøgler: ```bash npx cms mcp keygen --label "Min App" --scopes "read,write,publish" ``` ## Opsætning for Claude Desktop ```json { "mcpServers": { "my-site-admin": { "command": "npx", "args": ["@webhouse/cms-cli", "mcp", "--admin", "--key", "din-api-nøgle"] } } } ``` ## Klient vs server | | MCP Client | MCP Server | |-|-----------|------------| | **Auth** | Ingen (offentlig) | API-nøgle + scopes | | **Værktøjer** | 6 læse-kun | 43 læse+skrive+AI | | **Brug** | AI-platforme citerer dit indhold | Indholdsproduktion fra AI-værktøjer | | **Audit** | Nej | Ja | --- ## docs/mcp-server Title: MCP Server — Authenticated Content Production Updated: 2026-03-30 Locale: en Full read+write CMS access from Claude, Cursor, or any MCP client — 43 tools with scope-based access control. ## What is the MCP Server? `@webhouse/cms-mcp-server` is an **authenticated MCP server** for content production. It gives AI tools like Claude Desktop, Cursor, and Claude Code full read+write access to your CMS — with scope-based access control. Unlike the public MCP client (6 read-only tools), the server exposes **43 tools** covering every CMS operation: create, edit, publish, deploy, translate, generate with AI, manage agents, and more. ## 43 Tools by category ### Read (6 tools) All public tools: `get_site_summary`, `list_collection`, `search_content`, `get_page`, `get_schema`, `export_all` ### Content CRUD (8 tools) | Tool | Scope | Description | |------|-------|-------------| | `create_document` | write | Create new document | | `update_document` | write | Update fields (respects AI Lock) | | `trash_document` | write | Move to trash | | `clone_document` | write | Duplicate as draft | | `restore_from_trash` | write | Restore trashed document | | `empty_trash` | write | Permanently delete all trash | | `publish_document` | publish | Set status to published | | `unpublish_document` | publish | Revert to draft | ### AI Generation (4 tools) | Tool | Scope | Description | |------|-------|-------------| | `generate_with_ai` | write+ai | Generate document from intent | | `rewrite_field` | write+ai | AI-rewrite a field (respects AI Lock) | | `generate_content` | write+ai | Generate content for specific field | | `generate_interactive` | write+ai | Create HTML interactive component | ### Translation (2 tools) | Tool | Scope | Description | |------|-------|-------------| | `translate_document` | write+ai | Translate one document to target locale | | `translate_site` | write+ai | Translate ALL untranslated documents | ### Build & Deploy (3 tools) | Tool | Scope | Description | |------|-------|-------------| | `trigger_build` | deploy | Run static site build | | `trigger_deploy` | deploy | Deploy to provider | | `list_deploy_history` | deploy | Recent deployments | ### Bulk Operations (2 tools) | Tool | Scope | Description | |------|-------|-------------| | `bulk_publish` | write | Publish all drafts | | `bulk_update` | write | Update field across multiple docs | ### Scheduling (2 tools) | Tool | Scope | Description | |------|-------|-------------| | `schedule_publish` | publish | Schedule future publish/unpublish | | `list_scheduled` | read | List scheduled content | ### Agents & Curation (6 tools) | Tool | Scope | Description | |------|-------|-------------| | `list_agents` | read | List configured AI agents | | `create_agent` | write+ai | Create new agent | | `run_agent` | write+ai | Execute agent → curation queue | | `list_curation_queue` | read | Items awaiting review | | `approve_queue_item` | write | Approve for publishing | | `reject_queue_item` | write | Reject with feedback | ### Media (2 tools) | Tool | Scope | Description | |------|-------|-------------| | `list_media` | read | Browse media with AI analysis | | `search_media` | read | Search by caption, tags, filename | ### Other (6 tools) | Tool | Scope | Description | |------|-------|-------------| | `list_drafts` | read | All unpublished drafts | | `content_stats` | read | Word counts, doc counts | | `get_site_config` | read | Site settings | | `update_site_settings` | write | Change settings | | `list_revisions` | read | Document edit history | | `list_trash` | read | Trashed items | | `run_link_check` | read | Check for broken links | | `create_backup` | write | Backup all content | ## Scope-based access control Each API key has specific scopes: | Scope | Allows | |-------|--------| | `read` | View content, search, list, export | | `write` | Create, update, delete, backup | | `publish` | Publish, unpublish, schedule | | `deploy` | Build, deploy | | `ai` | AI generation, translation, agents | Generate keys with scopes: ```bash npx cms mcp keygen --label "My App" --scopes "read,write,publish" ``` ## Setup ### Claude Desktop / Cursor ```json { "mcpServers": { "my-site-admin": { "command": "npx", "args": ["@webhouse/cms-cli", "mcp", "--admin", "--key", "your-api-key"] } } } ``` ### Claude Code The scaffolder auto-generates `.mcp.json`: ```json { "mcpServers": { "cms": { "command": "npx", "args": ["@webhouse/cms-cli", "mcp"] } } } ``` ## Audit logging Every operation through the MCP server is logged with timestamp, actor, tool, and parameters. View logs in CMS admin → AI Analytics. ## Client vs Server comparison | | MCP Client | MCP Server | |-|-----------|------------| | **Auth** | None (public) | API key + scopes | | **Tools** | 6 read-only | 43 read+write+AI | | **Use case** | AI platforms citing your content | Content production from AI tools | | **Package** | @webhouse/cms-mcp-client | @webhouse/cms-mcp-server | | **Audit** | No | Yes | --- ## docs/mcp-client Title: MCP Client — Public Read-Only Access Updated: 2026-03-30 Locale: en Every site gets a public MCP server that AI platforms can query — no API keys, no configuration needed. ## What is the MCP Client? `@webhouse/cms-mcp-client` is a **public, read-only MCP server** bundled with every @webhouse/cms site. Any AI platform — Claude, ChatGPT, Cursor, Copilot — can discover and query your published content without API keys. When someone asks an AI "What does webhouse.app do?", the AI can connect to your MCP endpoint and read your actual content to formulate an accurate answer. ## 6 Tools | Tool | Description | |------|-------------| | `get_site_summary` | Site overview: name, description, language, collections, document count | | `list_collection` | List published documents in a collection (limit, offset, sort) | | `search_content` | Full-text search across all published content | | `get_page` | Get full content of a page as markdown + metadata | | `get_schema` | Field schema for a collection (field names, types) | | `export_all` | Export all published content as structured JSON | ## Setup The MCP client is automatically available when your site runs. No configuration needed. For Claude Desktop or Cursor, add to your MCP config: ```json { "mcpServers": { "my-site": { "command": "npx", "args": ["@webhouse/cms-cli", "mcp"] } } } ``` Or connect via SSE transport to a running site: ``` https://your-site.com/api/mcp ``` ## Tool details ### get_site_summary Returns site name, description, default locale, all collections with document counts. ``` No parameters required. → { name, description, locale, collections: [{ name, count }], lastBuild } ``` ### list_collection ```typescript { collection: "posts", // required limit: 20, // optional, max 100 offset: 0, // optional sort: "date_desc" // optional: date_desc, date_asc, title_asc } ``` Returns published documents with title, slug, excerpt, date. Content body excluded for performance. ### search_content ```typescript { query: "typescript generics", // required collection: "posts", // optional: scope to one collection limit: 10 // optional, max 50 } ``` Full-text search across all fields. Returns matching documents with relevance score. ### get_page ```typescript { slug: "getting-started", // required collection: "docs" // optional: scope the lookup } ``` Returns full markdown content + all metadata fields. ### get_schema ```typescript { collection: "posts" // required } ``` Returns field definitions: name, type, required, options. Useful for AI agents that need to understand content structure. ### export_all ```typescript { include_body: true // optional, default true. false = metadata only } ``` Exports everything. Use with caution on large sites. ## Rate limiting The public MCP client includes built-in rate limiting to prevent abuse. No configuration needed. ## Why this matters Traditional CMS platforms require API keys and documentation for AI access. @webhouse/cms makes your content **discoverable by default**. AI platforms can read your content, understand your schema, and cite your pages accurately — all without you setting anything up. --- ## docs/ai-lock-da Title: AI Lock — Feltbaseret indholdsbeskyttelse Updated: 2026-03-30 Locale: da Hvordan AI Lock forhindrer AI-agenter i at overskrive menneskelige redigeringer — feltbaseret beskyttelse håndhævet på motorniveau. ## Problemet Du redigerer omhyggeligt en blogindlæg-titel. En AI-agent kører natten over og overskriver den med en genereret version. Dit arbejde er væk. ## Sådan fungerer AI Lock @webhouse/cms sporer **hvem der sidst redigerede hvert felt** — menneske eller AI. Når et menneske redigerer et felt, bliver det **låst**. AI-agenter kan aldrig overskrive et låst felt. Kun et menneske kan låse det op. ### _fieldMeta-systemet Hvert dokument har et `_fieldMeta`-objekt der sporer låsestatus pr. felt: ```json { "data": { "title": "Min omhyggeligt skrevne titel", "content": "AI-genereret indhold..." }, "_fieldMeta": { "title": { "lockedBy": "user", "lockedAt": "2026-03-30T10:00:00Z" }, "content": { "lockedBy": null, "lastEditedBy": "ai:content-writer" } } } ``` - **title** er låst — et menneske redigerede det. Ingen AI kan røre det. - **content** er ulåst — AI genererede det. AI kan opdatere frit. ### Hvad sker der når AI prøver at skrive et låst felt 1. AI-agent kalder `update_document` med nye data 2. CMS-motoren læser `_fieldMeta` for hvert felt 3. Låste felter **springes stille over** — AI-data ignoreres 4. Ulåste felter opdateres normalt 5. Ingen fejl kastes — agenten behøver ikke vide om låse ## Konfiguration ```typescript { name: 'title', type: 'text', aiLock: { autoLockOnEdit: true, // Lås ved menneskelig redigering lockable: true, // Om feltet kan låses requireApproval: false, // Kræv godkendelse før AI skriver } } ``` ### Oplåsning af et felt I editoren viser hvert felt et låseikon: - 🔒 **Låst** — menneske-redigeret, AI kan ikke ændre - 🔓 **Ulåst** — AI kan opdatere frit Klik på ikonet for at skifte. Kun mennesker kan låse op. ## Brugscases ### Content Writer-agent - AI genererer blogindlæg → alle felter ulåst - Menneske redigerer titlen → titel låser - AI kører SEO → opdaterer alt **undtagen titel** ### Oversætteragent - AI oversætter alle felter - Menneske retter en specifik sætning → det felt låser - AI genoversætter → springer det rettede felt over ## Hvorfor dette er anderledes Andre CMS'er med AI enten: - Lader AI overskrive alt (farligt) - Kræver manuel "godkend hver ændring" (langsomt) - Har intet koncept om feltejerskab (groft) @webhouse/cms AI Lock er: - **Automatisk** — låser ved menneskelig redigering - **Granulært** — pr. felt, ikke pr. dokument - **Ikke-blokerende** — AI kører frit, låste felter springes over - **Transparent** — låsestatus synlig i editor og `_fieldMeta` - **Motorniveau** — håndhævet i enhver skrivesti --- ## docs/ai-lock Title: AI Lock — Field-Level Content Protection Updated: 2026-03-30 Locale: en How AI Lock prevents AI agents from overwriting human edits — field-level protection enforced at the engine level. ## The problem You carefully edit a blog post title. An AI agent runs overnight and overwrites it with a generated version. Your work is gone. This happens in every CMS that bolts AI onto existing content workflows. AI and humans fight over the same fields with no rules about who wins. ## How AI Lock works @webhouse/cms tracks **who last edited each field** — human or AI. When a human edits a field, it becomes **locked**. AI agents can never overwrite a locked field. Only a human can unlock it. This isn't a setting you toggle. It's enforced at the engine level, in every write operation, through the `WriteContext` actor system. ### The _fieldMeta system Every document has a `_fieldMeta` object that tracks the lock state per field: ```json { "slug": "my-post", "data": { "title": "My Carefully Written Title", "content": "AI-generated content here..." }, "_fieldMeta": { "title": { "lockedBy": "user", "lockedAt": "2026-03-30T10:00:00Z", "lastEditedBy": "cb@webhouse.dk" }, "content": { "lockedBy": null, "lastEditedBy": "ai:content-writer" } } } ``` In this example: - **title** is locked — a human edited it. No AI agent can touch it. - **content** is unlocked — AI generated it. AI agents can update it freely. ### Write context actors Every write operation carries a `WriteContext` that identifies who's making the change: ```typescript interface WriteContext { actor: "user" | "ai" | "system" | "import"; userId?: string; agentId?: string; source?: string; } ``` When `actor: "user"`, the CMS auto-locks edited fields. When `actor: "ai"`, the CMS checks locks and skips locked fields. ### What happens when AI tries to write a locked field 1. AI agent calls `update_document` with new data for all fields 2. CMS engine reads `_fieldMeta` for each field 3. Locked fields are **silently skipped** — AI data is ignored for those fields 4. Unlocked fields are updated normally 5. No error thrown — the agent doesn't need to know about locks This means AI agents can run bulk operations across the entire site without any risk of overwriting human work. ## Configuration ### Per-field AI lock behavior ```typescript { name: 'title', type: 'text', aiLock: { autoLockOnEdit: true, // Lock when human edits (default: true) lockable: true, // Whether field can be locked at all (default: true) requireApproval: false, // Require human approval before AI writes } } ``` ### Unlocking a field In the editor, each field shows a lock icon when locked: - 🔒 **Locked** — human-edited, AI cannot change - 🔓 **Unlocked** — AI can update freely Click the lock icon to toggle. Only humans can unlock fields. ## Use cases ### Content Writer agent - AI generates blog posts → all fields unlocked - Human edits the title → title locks - AI runs SEO optimization → updates description, keywords, but **skips title** ### Translator agent - AI translates all fields to Danish - Human corrects a specific phrase → that field locks - AI re-translates the page → skips the corrected field ### Bulk SEO optimization - AI runs `bulk_update` on 100 documents - Documents with human-edited meta titles → titles preserved - Documents with AI-generated titles → titles updated ## For AI builders When building sites or scripts that write content: ```typescript // Creating content as AI (fields stay unlocked) await cms.content.create('posts', { slug: 'new-post', data: { title: 'AI Title', content: '...' }, }, { actor: 'ai', agentId: 'content-writer' }); // Creating content as user (fields auto-lock) await cms.content.create('posts', { slug: 'new-post', data: { title: 'My Title', content: '...' }, }, { actor: 'user', userId: 'cb@webhouse.dk' }); ``` ## Why this is different Other CMS platforms with AI features either: - Let AI overwrite everything (dangerous) - Require manual "approve each change" workflows (slow) - Have no concept of field-level ownership (crude) @webhouse/cms AI Lock is: - **Automatic** — locks on human edit, no manual step - **Granular** — per-field, not per-document - **Non-blocking** — AI agents run freely, locked fields are silently skipped - **Transparent** — lock state visible in editor and `_fieldMeta` - **Engine-level** — enforced in every write path, not just the UI --- ## docs/chat-da Title: Chat med dit site Updated: 2026-03-30 Locale: da Administrér hele dit CMS via naturligt sprog — opret indhold, redigér sider, kør agenter, deploy og mere ved bare at tale til det. ![Chat-interface](/screenshots/chat-interface.png) ## Hvad er Chat? @webhouse/cms har et indbygget samtaleinterface der lader dig administrere hele dit site via naturligt sprog. I stedet for at klikke igennem menuer og formularer, beskriver du hvad du vil have, og CMS'et gør det. Klik **Chat** i admin-headeren for at skifte fra det traditionelle panel til chat-interfacet. Klik **Admin** for at skifte tilbage. Din chathistorik bevares. Dette er ikke en chatbot boltet på et CMS. Chatten har **direkte adgang til 49 værktøjer** der dækker enhver CMS-operation — de samme API'er der driver den traditionelle admin-brugerflade. ![Chat-samtale](/screenshots/chat-conversation.png) ## Hvad du kan gøre ### Indholdsstyring ``` "Opret et nyt blogindlæg om TypeScript generics" "Opdatér about-sidens titel til 'Om os'" "Publicér alle kladder" "Vis mig hvad der er ændret i denne uge" "Oversæt getting-started siden til dansk" ``` ### Site-operationer ``` "Kør et fuldt site-build" "Deploy til produktion" "Tjek alle links på sitet" "Opret en backup" ``` ### AI-indhold ``` "Generér et blogindlæg om vores nye feature" "Omskriv introen til at være mere kortfattet" "Kør SEO-optimering på alle publicerede indlæg" ``` ### Oversættelse ``` "Oversæt dette indlæg til dansk" "Oversæt hele sitet til dansk" "Vis mig hvilke oversættelser der mangler" ``` ## De 49 værktøjer ### Læse-operationer | Værktøj | Hvad det gør | |---------|-------------| | `site_summary` | Oversigt: collections, antal dokumenter, adapter, config | | `list_documents` | List dokumenter med filtrering | | `get_document` | Læs et specifikt dokument | | `search_content` | Fuldtekstsøgning på tværs af alle collections | | `get_schema` | Hent feltdefinitioner og blokke | | `list_drafts` | Vis alle upublicerede kladder | ### Skrive-operationer | Værktøj | Hvad det gør | |---------|-------------| | `create_document` | Opret nyt dokument | | `update_document` | Redigér felter | | `publish_document` | Sæt status til published | | `trash_document` | Flyt til papirkurv | | `bulk_publish` | Publicér flere dokumenter på én gang | | `translate_document` | AI-oversæt et dokument | | `translate_site` | AI-oversæt alle dokumenter | ### AI-operationer | Værktøj | Hvad det gør | |---------|-------------| | `generate_content` | AI-generér nyt dokument | | `rewrite_field` | AI-omskriv et specifikt felt | | `generate_interactive` | Opret interaktiv HTML-komponent | | `run_agent` | Kør en AI-agent | ### Operationer | Værktøj | Hvad det gør | |---------|-------------| | `trigger_build` | Kør build-pipeline | | `trigger_deploy` | Deploy til udbyder | | `run_link_check` | Scan for brudte links | | `create_backup` | Snapshot alt indhold | ### Web-research | Værktøj | Hvad det gør | |---------|-------------| | `web_search` | Live websøgning via Brave eller Tavily API — henter aktuelle artikler, docs, reference-links | | `web_fetch` | Hent og læs en hvilken som helst URL — udtræk artikeltekst, citer den, referer i genereret indhold | De to værktøjer gør Chat til en research-assistent. Spørg "find tre kilder om TypeScript decorators og skriv et indlæg der citerer dem" — Chat søger, læser, citerer og skriver udkast på én samtaletur. De åbner også AI-indholds-pipelinen fra at være en closed-world-model: du kan basere output på live-kilder i stedet for at være afhængig af pretraining. ### Hukommelse | Værktøj | Hvad det gør | |---------|-------------| | `search_memories` | Søg i samtalehukommelse | | `add_memory` | Gem information til fremtidige samtaler | | `forget_memory` | Fjern en hukommelse | ## Chat-hukommelse Chatten husker kontekst på tværs af samtaler: - Dine præferencer ("Brug altid formel tone") - Projektkontekst ("Vi launcher v2 næste uge") - Beslutninger ("Vi valgte det blå tema") ## Konfiguration Chat bruger AI-modellen konfigureret i Site Settings → AI: - **Chat-model**: Claude Sonnet 4.6 (standard) - **Max tokens**: 16.384 (konfigurerbar) - **Max tool-iterationer**: 25 Ingen ekstra opsætning nødvendig. Hvis du har en Anthropic API-nøgle, virker Chat. ## Tips - **Vær specifik**: "Opret et blogindlæg med titlen 'Kom i gang med TypeScript' med 3 sektioner" - **Brug det til masseoperationer**: "Publicér alle kladder i blog-collectionen" - **Bed den forklare**: "Hvilke collections har jeg?" eller "Hvordan beregnes SEO-scoren?" - **Kæd operationer**: "Opret et indlæg, optimér til SEO, oversæt til dansk" - **Brug hukommelse**: "Husk at vores brandvoice er professionel men venlig" --- ## docs/chat Title: Chat with Your Site Updated: 2026-03-30 Locale: en Manage your entire CMS through natural language — create content, edit pages, run agents, deploy, and more by just talking to it. ![Chat interface](/screenshots/chat-interface.png) ## What is Chat? @webhouse/cms has a built-in conversational interface that lets you manage your entire site through natural language. Instead of clicking through menus and forms, you describe what you want and the CMS does it. Click **Chat** in the admin header to switch from the traditional panel to the chat interface. Click **Admin** to switch back. Your chat history is preserved. This isn't a chatbot bolted onto a CMS. The chat has **direct access to 49 tools** that cover every CMS operation — the same APIs that power the traditional admin UI. It can read your schema, understand your collections, and execute operations with full context. ![Chat conversation](/screenshots/chat-conversation.png) ## What you can do ### Content management ``` "Create a new blog post about TypeScript generics" "Update the about page title to 'About Us'" "Publish all drafts" "Show me what changed this week" "Translate the getting-started page to Danish" "List all posts tagged 'tutorial'" ``` ### Site operations ``` "Run a full site build" "Deploy to production" "Check all links on the site" "Create a backup" "Show me the SEO score for all posts" ``` ### AI content ``` "Generate a blog post about our new feature" "Rewrite the intro to be more concise" "Run the SEO optimizer on all published posts" "Create an interactive pricing calculator" ``` ### Media & assets ``` "Show me all images without alt text" "List media uploaded this week" ``` ### Translation ``` "Translate this post to Danish" "Translate the entire site to Danish" "Show me which translations are missing" ``` ## The 49 tools Chat has access to these tool categories: ### Read operations | Tool | What it does | |------|-------------| | `site_summary` | Overview: collections, document counts, adapter, config | | `list_documents` | List documents in a collection with filtering | | `get_document` | Read a specific document by slug | | `search_content` | Full-text search across all collections | | `get_schema` | Get collection fields and block definitions | | `list_drafts` | Show all unpublished drafts | | `get_site_config` | Read site settings | | `list_media` | Browse media library | | `search_media` | Search images, videos, files | | `list_scheduled` | Show scheduled publishes/unpublishes | | `list_agents` | Show configured AI agents | | `list_curation_queue` | Show AI-generated content awaiting review | | `list_revisions` | Show edit history for a document | | `list_trash` | Show trashed documents | | `content_stats` | Content statistics and counts | | `list_deploy_history` | Recent deployments | ### Write operations | Tool | What it does | |------|-------------| | `create_document` | Create a new document | | `update_document` | Edit fields on an existing document | | `publish_document` | Set status to published | | `unpublish_document` | Revert to draft | | `trash_document` | Move to trash | | `clone_document` | Duplicate a document | | `restore_from_trash` | Recover a trashed document | | `empty_trash` | Permanently delete all trashed items | | `bulk_publish` | Publish multiple documents at once | | `bulk_update` | Update a field across multiple documents | | `schedule_publish` | Set a future publish date | | `update_site_settings` | Change site configuration | | `show_edit_form` | Render an inline edit form in chat | ### AI operations | Tool | What it does | |------|-------------| | `generate_content` | AI-generate a new document | | `rewrite_field` | AI-rewrite a specific field | | `generate_interactive` | Create an HTML interactive component | | `create_agent` | Configure a new AI agent | | `run_agent` | Execute an agent on content | | `translate_document` | AI-translate a single document | | `translate_site` | AI-translate all documents to a locale | ### Operations | Tool | What it does | |------|-------------| | `trigger_build` | Run the static site build pipeline | | `trigger_deploy` | Deploy to configured provider | | `run_link_check` | Scan for broken links | | `create_backup` | Snapshot all content | | `approve_queue_item` | Approve AI-generated content | | `reject_queue_item` | Reject AI-generated content | ### Web research | Tool | What it does | |------|-------------| | `web_search` | Live web search via Brave or Tavily API — pulls current articles, docs, reference links | | `web_fetch` | Fetch and read any URL — extract article text, quote it, cite it in generated content | These two tools turn Chat into a research assistant. Ask "find three sources on TypeScript decorators and write a post that cites them" — Chat searches, reads, quotes, and drafts, all in one conversation turn. They also unblock the AI content pipeline from being a closed-world model: you can ground output on live material instead of relying on pretraining. ### Memory | Tool | What it does | |------|-------------| | `search_memories` | Search conversation memory | | `add_memory` | Save information for future conversations | | `forget_memory` | Remove a memory | ## Chat memory The chat remembers context across conversations. It stores: - Your preferences ("I prefer formal tone", "Always use Danish for blog posts") - Project context ("We're launching v2 next week", "The pricing page needs updating") - Decisions ("We decided to use the blue theme", "SEO keywords: typescript, cms, headless") Memory is stored locally per site and can be exported/imported. ## How it works under the hood 1. You type a message 2. Chat sends it to Claude with your site's schema, collections, and recent context 3. Claude decides which tools to call (e.g. `list_documents` → `update_document` → `publish_document`) 4. Each tool calls the same internal API routes as the traditional admin UI 5. Results are streamed back with markdown formatting 6. Destructive actions (delete, publish, deploy) ask for confirmation first ## Configuration Chat uses the AI model configured in Site Settings → AI: - **Chat model**: Claude Sonnet 4.6 (default) — fast, capable - **Max tokens**: 16,384 (configurable) - **Max tool iterations**: 25 (how many tools per conversation turn) No additional setup needed. If you have an Anthropic API key configured, Chat works. ## Tips - **Be specific**: "Create a blog post titled 'Getting Started with TypeScript' with 3 sections" works better than "write something about TypeScript" - **Use it for bulk operations**: "Publish all drafts in the blog collection" saves clicking through each one - **Ask it to explain**: "What collections do I have?" or "How is the SEO score calculated?" - **Chain operations**: "Create a new post, optimize it for SEO, then translate it to Danish" — Chat handles multi-step workflows - **Leverage memory**: "Remember that our brand voice is professional but friendly" — it'll apply this to future content generation --- ## docs/side-by-side-editing-da Title: Side-by-side oversættelsesredigering Updated: 2026-03-30 Locale: da Redigér kilde og oversættelse samtidigt i en split-screen editor — killer-featuren for flersproget indhold. ## Problemet med traditionelle oversættelsesworkflows De fleste CMS-platforme behandler oversættelse som en eftertanke. Du skriver indhold på ét sprog, eksporterer det, sender det til en oversætter (eller en AI), importerer resultatet og håber at intet går i stykker. Du kan ikke se kilde og oversættelse sammen. Du kan ikke sammenligne afsnit for afsnit. ## Side-by-side redigering ![Side-by-side oversættelsesredigering](/screenshots/side-by-side-full.png) @webhouse/cms løser dette med en indbygget split-screen editor. Åbn ethvert dokument der har oversættelser, klik **Side-by-side**, og du ser kilde og oversættelse ved siden af hinanden — felt for felt, afsnit for afsnit. ### Hvad du ser Editoren deles i to paneler: - **Venstre panel** — oversættelsen du redigerer (f.eks. dansk) - **Højre panel** — kildedokumentet (f.eks. engelsk), skrivebeskyttet som reference Begge paneler viser de samme felter i samme rækkefølge. Du scroller dem sammen. Du sammenligner dem linje for linje. ### Sådan bruger du det 1. Åbn et dokument i editoren 2. Hvis oversættelser findes, ser du en **TRANSLATIONS** bar øverst 3. Klik **Side-by-side** for at starte split-screen 4. Kildesprog vises til højre, din oversættelse til venstre 5. Redigér oversættelsen mens du læser kilden ### Oversættelsesgrupper gør det muligt Magien bag side-by-side er `translationGroup`-feltet. Hvert dokument der er en oversættelse af et andet deler det samme UUID: ```json // Engelsk kilde { "slug": "getting-started", "locale": "en", "translationGroup": "a1b2c3d4-..." } // Dansk oversættelse { "slug": "getting-started-da", "locale": "da", "translationGroup": "a1b2c3d4-..." } ``` ## Oprettelse af oversættelser ### Fra editoren 1. Åbn et dokument 2. Klik **+ Tilføj oversættelse** 3. Vælg målsprog (f.eks. dansk) 4. CMS'et opretter et nyt dokument med AI-oversat indhold 5. Gennemgå og redigér oversættelsen ### Fra et script ```typescript const sourceDoc = { slug: "my-page", locale: "en", translationGroup: randomUUID(), data: { title: "My Page" }, }; const translationDoc = { slug: "my-page-da", locale: "da", translationGroup: sourceDoc.translationGroup, // ← samme UUID! data: { title: "Min side" }, }; ``` ## Lokaliseringsstrategi | Strategi | Engelsk URL | Dansk URL | Bedst til | |----------|-------------|-----------|-----------| | `prefix-other` | `/blog/my-post` | `/da/blog/my-post` | De fleste sites | | `prefix-all` | `/en/blog/my-post` | `/da/blog/my-post` | Eksplicit locale | | `none` | `/blog/my-post` | `/blog/my-post-da` | Locale i slug | ## Hvorfor dette er vigtigt ### For indholdsteams - Ingen kontekstskift mellem faner eller vinduer - Se præcis hvad kilden siger mens du oversætter - Fang uoverensstemmelser øjeblikkeligt ### For udviklere - `translationGroup` er et simpelt UUID — intet komplekst relationelt skema - Dokumenter er uafhængige filer — ingen forælder/barn-kobling - Virker med enhver storage-adapter ### For AI-agenter - AI-oversættelsesagenter opretter korrekt linkede dokumenter automatisk - AI Lock sikrer at menneskelige oversættelser aldrig overskrives ## Konfiguration ```typescript defineCollection({ name: 'posts', sourceLocale: 'en', locales: ['en', 'da'], fields: [ { name: 'title', type: 'text', required: true }, { name: 'content', type: 'richtext' }, ], }) ``` Ingen plugins, ingen tredjeparts oversættelsesstyring. Bare `translationGroup` og den indbyggede editor. --- ## docs/side-by-side-editing Title: Side-by-Side Translation Editing Updated: 2026-03-30 Locale: en Edit source and translation simultaneously in a split-screen editor — the killer feature for multilingual content. ## The problem with traditional translation workflows Most CMS platforms treat translation as an afterthought. You write content in one language, export it, send it to a translator (or an AI), import the result, and hope nothing breaks. You can't see source and translation together. You can't compare paragraph by paragraph. And when you update the source, you have no idea which translations are stale. ## Side-by-side editing ![Side-by-side translation editing](/screenshots/side-by-side-full.png) @webhouse/cms solves this with a built-in split-screen editor. Open any document that has translations, click **Side-by-side**, and you see source and translation side by side — field by field, paragraph by paragraph. ### What you see The editor splits into two panels: - **Left panel** — the translation you're editing (e.g. Danish) - **Right panel** — the source document (e.g. English), read-only for reference Both panels show the same fields in the same order: title, description, content, and all custom fields. You scroll them together. You compare them line by line. ### How to use it 1. Open any document in the editor 2. If translations exist, you'll see a **TRANSLATIONS** bar at the top showing linked locale versions 3. Click **Side-by-side** to enter split-screen mode 4. The source language appears on the right, your translation on the left 5. Edit the translation while reading the source — no tab switching, no copy-pasting ### Translation groups make it work The magic behind side-by-side editing is the `translationGroup` field. Every document that is a translation of another shares the same UUID: ```json // English source { "slug": "getting-started", "locale": "en", "translationGroup": "a1b2c3d4-..." } // Danish translation { "slug": "getting-started-da", "locale": "da", "translationGroup": "a1b2c3d4-..." } ``` CMS admin reads the `translationGroup` and finds all documents that share it. That's how it knows which documents are translations of each other — and how it can show them side by side. ## Creating translations ### From the editor 1. Open a document 2. Click **+ Add translation** in the translations bar 3. Choose the target locale (e.g. Danish) 4. The CMS creates a new document with: - A slug based on the source (e.g. `getting-started-da`) - The same `translationGroup` UUID - AI-translated content (if an AI provider is configured) 5. The new translation opens in the editor, ready for review ### From a script ```typescript import { randomUUID } from 'crypto'; // Create the source document const sourceDoc = { slug: "my-page", locale: "en", translationGroup: randomUUID(), status: "published", data: { title: "My Page", content: "Hello world" }, }; // Create the translation with the SAME translationGroup const translationDoc = { slug: "my-page-da", locale: "da", translationGroup: sourceDoc.translationGroup, // ← same UUID! status: "published", data: { title: "Min side", content: "Hej verden" }, }; ``` ### Via AI ```bash # The CMS AI agent can translate documents automatically npx cms ai rewrite posts/hello-world "Translate to Danish" ``` Or use the built-in translation agent from the admin UI — it respects field types, preserves markdown formatting, and links the new document with the correct `translationGroup`. ## Locale strategy How locales appear in URLs depends on your site's `localeStrategy` setting: | Strategy | English URL | Danish URL | Best for | |----------|-------------|------------|----------| | `prefix-other` | `/blog/my-post` | `/da/blog/my-post` | Most sites | | `prefix-all` | `/en/blog/my-post` | `/da/blog/my-post` | Explicit locale | | `none` | `/blog/my-post` | `/blog/my-post-da` | Locale in slug | Configure in CMS admin → Site Settings → Language. ## Why this matters ### For content teams - No context switching between tabs or windows - See exactly what the source says while you translate - Catch mismatches instantly (missing paragraphs, wrong tone, outdated sections) ### For developers - `translationGroup` is a simple UUID — no complex relational schema - Documents are independent files — no parent/child coupling - Works with any storage adapter (filesystem, GitHub, SQLite, Supabase) - Easy to script: just share the UUID between documents ### For AI agents - AI translation agents create properly linked documents automatically - The translation agent reads the source via `translationGroup`, translates field by field - AI Lock ensures human-edited translations are never overwritten by AI ## Configuration ### cms.config.ts ```typescript defineCollection({ name: 'posts', label: 'Blog Posts', sourceLocale: 'en', locales: ['en', 'da'], fields: [ { name: 'title', type: 'text', required: true }, { name: 'content', type: 'richtext' }, ], }) ``` ### Site settings - **Default language**: English (en) - **Supported languages**: add Danish (da), or any BCP 47 locale - **Locale strategy**: choose how URLs are structured That's it. No plugins, no third-party translation management. Just `translationGroup` and the built-in editor. --- ## docs/build-guide-da Title: Guide til build.ts — Statisk site-generering Updated: 2026-03-30 Locale: da Trin-for-trin guide til at bygge den perfekte build.ts til et statisk HTML-site drevet af @webhouse/cms. ## Hvad er build.ts? `build.ts` er en custom statisk site-generator der læser dit CMS-indhold (JSON-filer) og outputter ren HTML. Intet framework, intet runtime-JavaScript — bare HTML + CSS der virker overalt. ```bash npx tsx build.ts # Generér dist/ ``` ## Lektion 1: Indlæs indhold Fundamentet — læs JSON-filer fra `content/`: {{snippet:content-loader}} ## Lektion 2: Rendér markdown Konvertér richtext-indhold til HTML: ```typescript import { marked } from 'marked'; function renderMarkdown(content: string): string { return marked.parse(content, { async: false }) as string; } ``` ## Lektion 3: HTML-skabelon Wrap indhold i et komplet HTML-dokument: ```typescript function htmlPage(title: string, body: string, css: string): string { return ` ${title} ${css} ${body} `; } ``` ## Lektion 4: Byg en side Kombinér indholdsindlæsning, markdown-rendering og HTML-skabelon. ## Lektion 5: Rendér blokke Hvis dine sider bruger blokke (hero, features, CTA), rendér hver bloktype: {{snippet:block-renderer}} ## Lektion 6: SEO-metadata Udtrk SEO-felter og generér meta-tags fra `_seo`-feltet. ## Lektion 7: Skriv output Generér filer til `dist/` med `writeFileSync` og `mkdirSync`. ## Lektion 8: Sitemap Generér `sitemap.xml` til søgemaskiner. ## Lektion 9: Resolve snippets Tilføj snippet-support til dit build — se [Delte Snippets](/docs/shared-snippets-da). ## Lektion 10: Kopiér statiske assets Kopiér uploads og public-filer til dist/. ## Næste skridt - [Skabeloner](/docs/templates-da) — start fra en fungerende boilerplate - [Next.js-mønstre](/docs/nextjs-patterns-da) — hvis du vil have React - [Delte Snippets](/docs/shared-snippets-da) — genbrugelige kodeblokke --- ## docs/nextjs-guide-da Title: Next.js integrationsguide Updated: 2026-03-30 Locale: da Komplet guide til at bygge en Next.js App Router-site med @webhouse/cms — fra indholdsindlæsning til deployment. ## Oversigt Denne guide gennemgår opbygningen af et komplet Next.js-site med @webhouse/cms. CMS'et håndterer indholdslagring og redigering. Next.js håndterer rendering og routing. ## Trin 1: Projektopsætning {{snippet:create-project}} Eller start fra Next.js-boilerplaten: ```bash npm create @webhouse/cms my-site -- --template nextjs ``` ## Trin 2: Indholdslag Indholdslaget læser JSON-filer ved build/request-tid: {{snippet:content-loader}} ## Trin 3: Root layout Root layout læser globale indstillinger og renderer navbar + footer. ## Trin 4: Forside med blokke Forsiden læser `content/pages/home.json` og renderer blokke. ## Trin 5: Blog med statisk generering {{snippet:nextjs-blog-page}} Individuelle indlæg med SEO: {{snippet:nextjs-post-page}} ## Trin 6: Richtext-rendering {{snippet:richtext-renderer}} > **Brug aldrig `dangerouslySetInnerHTML`** med regex-baserede parsere. ## Trin 7: Blok-rendering {{snippet:block-renderer}} ## Trin 8: SEO-metadata {{snippet:seo-metadata}} ## Trin 9: i18n (valgfrit) {{snippet:i18n-config}} ## Trin 10: Deployment {{snippet:deploy-fly}} ## Vigtige mønstre 1. **Server Components som standard** — al indholdsindlæsning sker server-side 2. **`"use client"` kun hvor nødvendigt** — tema-toggle, søgning, markdown 3. **`generateStaticParams`** — prægenerér alle sider ved build-tid 4. **`generateMetadata`** — SEO fra CMS `_seo`-felter med fallbacks 5. **Hardkod aldrig indhold** — alt fra CMS JSON-filer 6. **Filtrér på published** — tjek altid `status === "published"` --- ## docs/nextjs-guide Title: Next.js Integration Guide Updated: 2026-03-30 Locale: en Complete guide to building a Next.js App Router site with @webhouse/cms — from content reading to deployment. ## Overview This guide walks you through building a complete Next.js site with @webhouse/cms. The CMS handles content storage and editing. Next.js handles rendering and routing. ## Step 1: Project setup {{snippet:create-project}} Or start from the Next.js boilerplate: ```bash npm create @webhouse/cms my-site -- --template nextjs ``` ## Step 2: Content layer The content layer reads JSON files at build/request time: {{snippet:content-loader}} ## Step 3: Root layout ```typescript // app/layout.tsx export default function RootLayout({ children }) { const global = getDocument('global', 'global'); return ( {/* render global.data.navLinks */} {children} {global?.data.footerText} ); } ``` ## Step 4: Homepage with blocks ```typescript // app/page.tsx import { getDocument } from '@/lib/content'; export default function Home() { const page = getDocument('pages', 'home'); if (!page) return Create content/pages/home.json; return ( {page.data.sections?.map((block, i) => ( ))} ); } ``` ## Step 5: Blog with static generation {{snippet:nextjs-blog-page}} Individual posts with SEO: {{snippet:nextjs-post-page}} ## Step 6: Richtext rendering {{snippet:richtext-renderer}} > **Never use `dangerouslySetInnerHTML`** with regex-based markdown parsers. Use `react-markdown` with `remark-gfm`. ## Step 7: Block rendering {{snippet:block-renderer}} ## Step 8: SEO metadata {{snippet:seo-metadata}} ## Step 9: i18n (optional) {{snippet:i18n-config}} ## Step 10: Deployment {{snippet:deploy-fly}} ## The build pipeline integration When you run `next build`, Next.js: 1. Reads all content from `content/` via your loader functions 2. Pre-renders all pages via `generateStaticParams` 3. Generates SEO metadata via `generateMetadata` 4. Outputs to `.next/` (or `.next/standalone` for Docker) The CMS build pipeline (`npx cms build`) generates additional files: - `sitemap.xml`, `robots.txt`, `feed.xml` - `llms.txt`, `llms-full.txt` for AI discovery - Per-page `.md` files For a Next.js site, you typically use Next.js's own `app/sitemap.ts` and `app/robots.ts` instead of the CMS build pipeline. ## Key patterns 1. **Server Components by default** — all content reads happen server-side 2. **`"use client"` only where needed** — theme toggle, search, markdown renderer 3. **`generateStaticParams`** — pre-generate all pages at build time 4. **`generateMetadata`** — SEO from CMS `_seo` fields with fallbacks 5. **Never hardcode content** — everything from CMS JSON files 6. **Filter by published** — always check `status === "published"` --- ## docs/build-guide Title: Guide to build.ts — Static Site Generation Updated: 2026-03-30 Locale: en Step-by-step guide to building the perfect build.ts for a static HTML site powered by @webhouse/cms. ## What is build.ts? `build.ts` is a custom static site generator that reads your CMS content (JSON files) and outputs plain HTML. No framework, no runtime JavaScript — just HTML + CSS that works everywhere. ```bash npx tsx build.ts # Generate dist/ ``` ## Lesson 1: Load content The foundation — read JSON files from `content/`: ```typescript import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; const CONTENT = join(import.meta.dirname, 'content'); function getCollection(name: string) { const dir = join(CONTENT, name); if (!existsSync(dir)) return []; return readdirSync(dir) .filter(f => f.endsWith('.json')) .map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8'))) .filter(d => d.status === 'published'); } function getDocument(collection: string, slug: string) { const file = join(CONTENT, collection, slug + '.json'); if (!existsSync(file)) return null; return JSON.parse(readFileSync(file, 'utf-8')); } ``` ## Lesson 2: Render markdown Convert richtext content to HTML: ```typescript import { marked } from 'marked'; function renderMarkdown(content: string): string { return marked.parse(content, { async: false }) as string; } ``` ## Lesson 3: HTML template Wrap content in a complete HTML document: ```typescript function htmlPage(title: string, body: string, css: string): string { return ` ${title} ${css} ${body} `; } ``` ## Lesson 4: Build a page Combine content loading, markdown rendering, and HTML template: ```typescript function buildPage(doc: any, css: string): string { const title = doc.data.title; const content = renderMarkdown(doc.data.content || ''); const body = ` ${title} ${content} `; return htmlPage(title, body, css); } ``` ## Lesson 5: Render blocks If your pages use blocks (hero, features, CTA), render each block type: ```typescript interface Block { _block: string; [key: string]: unknown; } function renderBlock(block: Block): string { switch (block._block) { case 'hero': return ` ${block.tagline} ${block.description} `; case 'features': const items = (block.items as any[]) || []; return ` ${block.title} ${items.map(i => `${i.title}${i.description}`).join('')} `; case 'cta': return ` ${block.title} ${block.buttonText} `; default: return ''; } } function renderBlocks(blocks: Block[]): string { return blocks.map(renderBlock).join('\n'); } ``` ## Lesson 6: SEO metadata Extract SEO fields and generate meta tags: ```typescript function seoTags(doc: any): string { const seo = doc.data._seo || {}; const title = seo.metaTitle || doc.data.title; const desc = seo.metaDescription || doc.data.excerpt || ''; return ` ${title} ${seo.ogImage ? `` : ''} `; } ``` ## Lesson 7: Write output Generate files to `dist/`: ```typescript import { writeFileSync, mkdirSync } from 'node:fs'; const DIST = join(import.meta.dirname, 'dist'); function writePage(urlPath: string, html: string) { const dir = join(DIST, urlPath); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), html); } // Build all pages const posts = getCollection('posts'); for (const post of posts) { const html = buildPage(post, css); writePage(`/blog/${post.slug}`, html); } ``` ## Lesson 8: Sitemap Generate `sitemap.xml` for search engines: ```typescript function generateSitemap(baseUrl: string, pages: string[]): string { const urls = pages.map(p => ` ${baseUrl}${p}` ).join('\n'); return ` ${urls} `; } ``` ## Lesson 9: Resolve snippets Add snippet support to your build: ```typescript function resolveSnippets(markdown: string): string { return markdown.replace( /\{\{snippet:([a-z0-9-]+)\}\}/g, (_match, slug) => { const snippet = getDocument('snippets', slug); if (!snippet) return ''; return '\x60\x60\x60' + (snippet.data.lang || 'text') + '\n' + snippet.data.code + '\n\x60\x60\x60'; } ); } // Use in your build pipeline: const content = resolveSnippets(doc.data.content); const html = renderMarkdown(content); ``` ## Lesson 10: Copy static assets Copy uploads and public files: ```typescript import { cpSync } from 'node:fs'; // Copy uploads cpSync(join(import.meta.dirname, 'public', 'uploads'), join(DIST, 'uploads'), { recursive: true }); // Copy favicon cpSync(join(import.meta.dirname, 'public', 'favicon.svg'), join(DIST, 'favicon.svg')); ``` ## The complete build pipeline Putting it all together: ```typescript // 1. Load CSS (inline in HTML) const css = readFileSync('styles.css', 'utf-8'); // 2. Build collection index pages const posts = getCollection('posts'); writePage('/blog', buildListPage('Blog', posts, css)); // 3. Build individual pages for (const post of posts) { writePage(`/blog/${post.slug}`, buildPage(post, css)); } // 4. Build homepage const home = getDocument('pages', 'home'); if (home) writePage('/', buildPage(home, css)); // 5. Generate sitemap const allPaths = ['/'].concat(posts.map(p => `/blog/${p.slug}`)); writeFileSync(join(DIST, 'sitemap.xml'), generateSitemap(BASE_URL, allPaths)); // 6. Copy assets cpSync('public/uploads', join(DIST, 'uploads'), { recursive: true }); console.log('Built ' + allPaths.length + ' pages'); ``` ## Next steps - [Templates](/docs/templates) — start from a working boilerplate instead of scratch - [Next.js Patterns](/docs/nextjs-patterns) — if you want React instead of static HTML - [Shared Snippets](/docs/shared-snippets) — reusable code blocks across pages --- ## docs/shared-snippets-da Title: Delte Snippets — Genbrugelige kodeblokke Updated: 2026-03-30 Locale: da Sådan byggede vi et delt snippet-system til docs.webhouse.app med vores eget CMS — og hvordan du kan gøre det samme. ## Problemet Dokumentationssites gentager de samme kodeeksempler på tværs af flere sider. "Hurtig start" viser installationskommandoen. "Skabeloner" viser den også. "CLI-reference" viser den igen. Når kommandoen ændres, opdaterer du ét sted og glemmer de andre to. ## Løsningen: en snippets-collection Vi tilføjede en `snippets`-collection til vores CMS-konfiguration: ```typescript defineCollection({ name: "snippets", label: "Delte Snippets", fields: [ { name: "title", type: "text", required: true }, { name: "description", type: "textarea" }, { name: "code", type: "textarea", required: true }, { name: "lang", type: "text" }, ], }) ``` ## Brug af snippets i markdown I enhver doc-sides indholdsfeld, referér en snippet med: ``` {{snippet:create-project}} ``` Ved render-tid opløses tokenet til den faktiske kodeblok fra snippets-collectionen. ## Implementering i build.ts (statiske sites) Tilføj en snippet-resolver før markdown-rendering: ```typescript function resolveSnippets(markdown: string): string { return markdown.replace( /\{\{snippet:([a-z0-9-]+)\}\}/g, (_match, slug) => { const file = join(SNIPPETS_DIR, slug + '.json'); if (!existsSync(file)) return ''; const snippet = JSON.parse(readFileSync(file, 'utf-8')); return '\x60\x60\x60' + snippet.data.lang + '\n' + snippet.data.code + '\n\x60\x60\x60'; } ); } ``` ## Ud over kode: andre brugsmuligheder Snippets behøver ikke være kode: - **Ansvarsfraskrivelser** — juridisk tekst der vises på flere sider - **Versionsbadges** — aktuelt versionsnummer opdateret ét sted - **Funktionsmatricer** — sammenligningstabeller delt på tværs af produktsider - **Kontaktinfo** — adresse, telefon, email brugt i footer og kontaktside - **Priser** — prispoints refereret i features, prissætning og FAQ-sider ## Hvorfor dette er vigtigt Dette er dogfooding i sin bedste form. Vi byggede docs.webhouse.app med @webhouse/cms, og når vi havde brug for genbrugelige indholdsblokke, brugte vi CMS'ets eget collection-system. Ingen plugins, ingen custom infrastruktur — bare endnu en collection. --- ## docs/shared-snippets Title: Shared Snippets — Reusable Code Blocks Updated: 2026-03-30 Locale: en How we built a shared snippets system for docs.webhouse.app using our own CMS — and how you can do the same. ## The problem Documentation sites repeat the same code examples across multiple pages. The "Quick Start" page shows the install command. The "Templates" page shows it too. The "CLI Reference" shows it again. When the command changes, you update it in one place and forget the other two. ## The solution: a snippets collection We added a `snippets` collection to our CMS config: ```typescript defineCollection({ name: "snippets", label: "Shared Snippets", fields: [ { name: "title", type: "text", required: true }, { name: "description", type: "textarea" }, { name: "code", type: "textarea", required: true }, { name: "lang", type: "text" }, ], }) ``` Each snippet is a JSON file in `content/snippets/`: ```json { "slug": "create-project", "status": "published", "data": { "title": "Create a new project", "code": "npm create @webhouse/cms my-site", "lang": "bash" } } ``` ## Using snippets in markdown In any doc page's content field, reference a snippet with: ``` {{snippet:create-project}} ``` At render time, the token is resolved to the actual code block from the snippets collection. The snippet's `lang` field determines syntax highlighting. ## Implementation in build.ts (static sites) For static HTML sites using a custom `build.ts`, add a snippet resolver before markdown rendering: ```typescript import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; const SNIPPETS_DIR = join(process.cwd(), 'content', 'snippets'); function resolveSnippets(markdown: string): string { return markdown.replace( /\{\{snippet:([a-z0-9-]+)\}\}/g, (_match, slug) => { const file = join(SNIPPETS_DIR, slug + '.json'); if (!existsSync(file)) return ''; const snippet = JSON.parse(readFileSync(file, 'utf-8')); const lang = snippet.data.lang || 'text'; const code = snippet.data.code || ''; return '\x60\x60\x60' + lang + '\n' + code + '\n\x60\x60\x60'; } ); } // In your build pipeline: const rawContent = doc.data.content; const withSnippets = resolveSnippets(rawContent); const html = marked.parse(withSnippets); ``` ## Implementation in Next.js (server components) For Next.js sites, resolve snippets in your content renderer: ```typescript // components/doc-content.tsx import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; const SNIPPETS_DIR = join(process.cwd(), 'content', 'snippets'); function resolveSnippets(content: string): string { return content.replace( /\{\{snippet:([a-z0-9-]+)\}\}/g, (_match, slug) => { const file = join(SNIPPETS_DIR, slug + '.json'); if (!existsSync(file)) return ''; const snippet = JSON.parse(readFileSync(file, 'utf-8')); return '\x60\x60\x60' + (snippet.data.lang || 'text') + '\n' + snippet.data.code + '\n\x60\x60\x60'; } ); } // Use before rendering markdown export function DocContent({ content }) { const resolved = resolveSnippets(content); // ... render resolved markdown } ``` ## Beyond code: other use cases Snippets don't have to be code. You could use them for: - **Disclaimers** — legal text that appears on multiple pages - **Version badges** — current version number updated in one place - **Feature matrices** — comparison tables shared across product pages - **Contact info** — address, phone, email used in footer and contact page - **Pricing** — price points referenced in features, pricing, and FAQ pages ## Why this matters This is dogfooding at its best. We built docs.webhouse.app with @webhouse/cms, and when we needed reusable content blocks, we used the CMS's own collection system. No plugins, no custom infrastructure — just another collection. The same pattern works for any CMS-powered site. If you find yourself copy-pasting content between pages, create a snippets collection and reference it. --- ## docs/config-reference-da Title: Konfigurationsreference Updated: 2026-03-30 Locale: da Komplet reference for cms.config.ts — collections, felter, lagring, build og API-indstillinger. ## cms.config.ts Konfigurationsfilen bruger hjælpefunktioner for typesikkerhed: ```typescript import { defineConfig, defineCollection, defineBlock, defineField } from '@webhouse/cms'; export default defineConfig({ collections: [ /* ... */ ], blocks: [ /* ... */ ], defaultLocale: 'en', locales: ['en', 'da'], autolinks: [ /* ... */ ], storage: { /* ... */ }, // PÅKRÆVET build: { outDir: 'dist', baseUrl: '/' }, api: { port: 3000 }, }); ``` > **Vigtigt:** Du SKAL altid angive `storage`-adapteren. Hvis den udelades, bruges SQLite som standard — ikke filsystemet. Dette er den mest almindelige konfigurationsfejl. ## Collection-konfiguration ```typescript defineCollection({ name: 'posts', // Påkrævet: unik identifikator label: 'Blogindlæg', // Valgfri: visningsnavn i admin slug: 'posts', // Valgfri: URL-slug override urlPrefix: '/blog', // Valgfri: URL-præfiks for sider sourceLocale: 'en', // Valgfri: primært forfattersprog locales: ['en', 'da'], // Valgfri: oversættelige sprog translatable: true, // Valgfri: aktivér oversættelsesstøtte fields: [ /* ... */ ], // Påkrævet: array af FieldConfig hooks: { // Valgfri: livscyklus-hooks beforeCreate: 'sti/til/hook.js', afterCreate: 'sti/til/hook.js', }, }) ``` ## Build-konfiguration ```typescript build: { outDir: 'dist', // Output-mappe baseUrl: 'https://eksempel.dk', // Site-URL til absolutte links siteTitle: 'Mit Site', siteDescription: 'Et fantastisk site', robots: { strategy: 'maximum', // 'maximum' | 'balanced' | 'restrictive' | 'custom' }, rss: { title: 'Mit Site RSS', collections: ['posts'], // Filtrer til specifikke collections maxItems: 50, }, } ``` ## Lagringskonfiguration {{snippet:storage-filesystem}} --- ## docs/config-reference Title: Configuration Reference Updated: 2026-03-30 Locale: en Complete reference for cms.config.ts — collections, fields, storage, build, and API settings. ## cms.config.ts The configuration file uses helper functions for type safety: ```typescript import { defineConfig, defineCollection, defineBlock, defineField } from '@webhouse/cms'; export default defineConfig({ collections: [ /* ... */ ], blocks: [ /* ... */ ], defaultLocale: 'en', locales: ['en', 'da'], autolinks: [ /* ... */ ], storage: { /* ... */ }, // REQUIRED build: { outDir: 'dist', baseUrl: '/' }, api: { port: 3000 }, }); ``` > **Important:** You MUST always specify the `storage` adapter. If omitted, it defaults to SQLite — not filesystem. This is the most common configuration mistake. ## Collection config ```typescript defineCollection({ name: 'posts', // Required: unique identifier label: 'Blog Posts', // Optional: display name in admin UI slug: 'posts', // Optional: URL slug override urlPrefix: '/blog', // Optional: URL prefix for pages sourceLocale: 'en', // Optional: primary authoring locale locales: ['en', 'da'], // Optional: translatable locales translatable: true, // Optional: enable translation support fields: [ /* ... */ ], // Required: array of FieldConfig hooks: { // Optional: lifecycle hooks beforeCreate: 'path/to/hook.js', afterCreate: 'path/to/hook.js', beforeUpdate: 'path/to/hook.js', afterUpdate: 'path/to/hook.js', beforeDelete: 'path/to/hook.js', afterDelete: 'path/to/hook.js', }, }) ``` ## Build config ```typescript build: { outDir: 'dist', // Output directory baseUrl: 'https://example.com', // Site URL for absolute links siteTitle: 'My Site', siteDescription: 'A great site', robots: { strategy: 'maximum', // 'maximum' | 'balanced' | 'restrictive' | 'custom' }, rss: { title: 'My Site RSS', description: 'Latest updates', language: 'en', collections: ['posts'], // Filter to specific collections maxItems: 50, }, } ``` ## Storage config {{snippet:storage-filesystem}} ## API config ```typescript api: { port: 3000, // Dev server port } ``` --- ## docs/nextjs-patterns-da Title: Next.js-mønstre Updated: 2026-03-30 Locale: da Sådan læser du CMS-indhold i Next.js — loader-funktioner, sider, statisk generering og metadata. ## Læsning af indhold Alt indhold læses server-side med `fs`: {{snippet:content-loader}} ## Blog-listeside {{snippet:nextjs-blog-page}} ## Vigtige mønstre 1. **Kun Server Components** — indhold læses ved build/request-tid 2. **`generateStaticParams`** — prægenerer alle sider ved build-tid 3. **`generateMetadata`** — SEO-metadata fra CMS `_seo`-felter 4. **Filtrer altid på published** — `status === 'published'` for at skippe kladder 5. **Hardkod aldrig indhold** — alt fra CMS JSON-filer --- ## docs/nextjs-patterns Title: Next.js Patterns Updated: 2026-03-30 Locale: en How to read CMS content in Next.js — loader functions, pages, static generation, and metadata. ## Reading content All content is read server-side using `fs`: {{snippet:content-loader}} ## Blog listing page {{snippet:nextjs-blog-page}} ## Dynamic page with static generation ```typescript // app/blog/[slug]/page.tsx import { getCollection, getDocument } from '@/lib/content'; import { notFound } from 'next/navigation'; export function generateStaticParams() { return getCollection('posts').map(d => ({ slug: d.slug })); } export async function generateMetadata({ params }: { params: Promise }) { const { slug } = await params; const doc = getDocument('posts', slug); if (!doc) return {}; return { title: doc.data._seo?.metaTitle ?? doc.data.title, description: doc.data._seo?.metaDescription ?? doc.data.excerpt, }; } export default async function PostPage({ params }: { params: Promise }) { const { slug } = await params; const doc = getDocument('posts', slug); if (!doc) notFound(); return ( {doc.data.title} {/* Render doc.data.content with react-markdown */} ); } ``` ## Key patterns 1. **Server Components only** — content reads happen at build/request time 2. **`generateStaticParams`** — pre-generate all pages at build time 3. **`generateMetadata`** — SEO metadata from CMS `_seo` fields 4. **Always filter published** — `status === 'published'` to skip drafts 5. **Never hardcode content** — everything from CMS JSON files --- ## docs/quick-start-da Title: Hurtig start Updated: 2026-03-30 Locale: da Opret et nyt projekt og hav en CMS-drevet side kørende på under 5 minutter. ## Opret et nyt projekt {{snippet:create-project}} Dette genererer: ``` my-site/ cms.config.ts # Collection- og feltdefinitioner package.json # Afhængigheder .env # AI-udbyders nøgler content/ posts/ hello-world.json # Eksempeldokument ``` ## Installér og kør {{snippet:dev-and-build}} Udviklingsserveren starter på `http://localhost:3000` og admin-brugerfladen åbner automatisk. ## Opret indhold Åbn admin-brugerfladen og opret dit første dokument. Det gemmes som en JSON-fil i `content/posts/`. Hvert dokument følger denne struktur: ```json { "slug": "mit-forste-indlaeg", "status": "published", "data": { "title": "Mit første indlæg", "content": "Hej, verden!" }, "id": "unikt-id", "_fieldMeta": {} } ``` ## Byg til produktion ```bash npx cms build # Byg statisk site npx cms serve # Forhåndsvis bygget lokalt ``` Build-pipelinen genererer: - HTML-sider for alle publicerede dokumenter - `sitemap.xml` til søgemaskiner - `robots.txt` med AI-crawler-regler - `llms.txt` og `llms-full.txt` til AI-discovery - `feed.xml` RSS-feed - Per-side `.md`-filer til AI-forbrug ## Næste skridt - [Konfigurationsreference](/docs/config-reference-da) — definér dine egne collections - [Felttyper](/docs/field-types-da) — udforsk alle tilgængelige felttyper - [Lagringsadaptere](/docs/storage-adapters-da) — vælg hvor indhold gemmes - [Udrulning](/docs/deployment-da) — deploy til Vercel, Fly.io eller Netlify --- ## docs/quick-start Title: Quick Start Updated: 2026-03-30 Locale: en Scaffold a new project and have a CMS-powered site running in under 5 minutes. ## Create a new project {{snippet:create-project}} This generates: ``` my-site/ cms.config.ts # Collection + field definitions package.json # Dependencies .env # AI provider keys content/ posts/ hello-world.json # Example document ``` ## Install and run {{snippet:dev-and-build}} The dev server starts at `http://localhost:3000` and the admin UI opens automatically. ## Create content Open the admin UI and create your first document. It will be saved as a JSON file in `content/posts/`. Every document follows this structure: ```json { "slug": "my-first-post", "status": "published", "data": { "title": "My First Post", "content": "Hello, world!" }, "id": "unique-id", "_fieldMeta": {} } ``` ## Build for production ```bash npx cms build # Build static site npx cms serve # Preview the build locally ``` The build pipeline generates: - HTML pages for all published documents - `sitemap.xml` for search engines - `robots.txt` with AI crawler rules - `llms.txt` and `llms-full.txt` for AI discovery - `feed.xml` RSS feed - Per-page `.md` files for AI consumption ## Next steps - [Configuration Reference](/docs/config-reference) — define your own collections - [Field Types](/docs/field-types) — explore all available field types - [Storage Adapters](/docs/storage-adapters) — choose where content is stored - [Deployment](/docs/deployment) — deploy to Vercel, Fly.io, or Netlify --- ## docs/templates-da Title: Skabeloner & Boilerplates Updated: 2026-03-30 Locale: da Tre klar-til-brug boilerplates og otte eksempelsites til at komme hurtigt i gang. ## Hurtig start Den hurtigste måde at oprette et nyt @webhouse/cms-projekt: ```bash # Standard (minimalt projekt) npm create @webhouse/cms my-site # Med en skabelon npm create @webhouse/cms my-site -- --template nextjs # npx genvej npx create-@webhouse/cms my-site --template nextjs ``` ## Boilerplates Tre produktionsklare udgangspunkter med fungerende site, eksempelindhold og konfigureret `cms.config.ts`. ### Next.js Boilerplate — det anbefalede udgangspunkt ![Next.js Boilerplate](/screenshots/boilerplate-nextjs-dark.png) [Live demo →](https://nextjs-boilerplate-1x3txthik-webhhouse.vercel.app/) Full-stack React med App Router, Tailwind CSS v4, dark mode, `react-markdown`, SEO metadata, blog og blokbaserede sider. ```bash npm create @webhouse/cms my-site -- --template nextjs ``` ### Static Boilerplate — nul framework Ren HTML-output. Custom `build.ts` med Marked. Intet React, ingen bundler, intet runtime-JS. ```bash npm create @webhouse/cms my-site -- --template static ``` ### Next.js GitHub Boilerplate — live-opdateringer Alt fra Next.js-boilerplaten + GitHub storage-adapter, LiveRefresh SSE-webhooks, HMAC-signeret revalidering. ```bash npm create @webhouse/cms my-site -- --template nextjs-github ``` --- ## Eksempelsites Produktionskvalitets-sites bygget med @webhouse/cms. Brug som skabeloner eller inspiration. ### Thinking in Pixels — Blog ![Thinking in Pixels](/screenshots/example-blog.png) [Live demo →](https://thinking-in-pixels.fly.dev/) Ren blog med indlæg, tags, forsidebilleder og about-side. Bygget med CMS CLI. ```bash npm create @webhouse/cms my-site -- --template blog ``` ### Sarah Mitchell — Freelancer ![Freelancer](/screenshots/example-freelancer-ghpages.png) [Live demo →](https://cbroberg.github.io/freelancer-site/) Freelancer-portfolio med services, prispakker, testimonials, blog og kontaktsektion. ```bash npm create @webhouse/cms my-site -- --template freelancer ``` ### Meridian Studio ![Studio](/screenshots/example-studio.png) [Live demo →](https://cbroberg.github.io/meridian-studio-site/) Kreativt studie med services, featured work, team og CTA-sektion. ```bash npm create @webhouse/cms my-site -- --template studio ``` ### AURA — Boutique ![Boutique](/screenshots/example-boutique.png) [Live demo →](https://boutique.webhouse.app/) Produkt/shop-showcase med kollektioner, editorial indhold og nyhedsbrev. ```bash npm create @webhouse/cms my-site -- --template boutique ``` ### Elena Vasquez — Portfolio ![Portfolio](/screenshots/example-portfolio.png) Visuelt portfolio med fuldskærms billedgrid, about og kontakt. ```bash npm create @webhouse/cms my-site -- --template portfolio ``` ### Elina Voss — Portfolio Squared ![Portfolio Squared](/screenshots/example-freelancer.png) Alternativt portfolio-layout med 2x2 billedgrid. --- ## Vælg en skabelon | Behov | Kommando | |-------|----------| | Simplest setup | `npm create @webhouse/cms my-site` | | React / Next.js | `-- --template nextjs` | | GitHub-samarbejde | `-- --template nextjs-github` | | Ren HTML | `-- --template static` | | Blog | `-- --template blog` | | Freelancer | `-- --template freelancer` | | Studie / bureau | `-- --template studio` | | Shop / boutique | `-- --template boutique` | | Portfolio | `-- --template portfolio` | --- ## docs/templates Title: Templates & Boilerplates Updated: 2026-03-30 Locale: en Three ready-to-use boilerplates and eight example sites to jump-start your project. ## Quick Start The fastest way to create a new @webhouse/cms project: ```bash # Default (minimal project) npm create @webhouse/cms my-site # With a template npm create @webhouse/cms my-site -- --template nextjs # npx shorthand npx create-@webhouse/cms my-site --template nextjs ``` ## Boilerplates Three production-ready starting points with a working site, example content, and configured `cms.config.ts`. ### Next.js Boilerplate — the recommended starting point ![Next.js Boilerplate](/screenshots/boilerplate-nextjs-dark.png) [Live demo →](https://nextjs-boilerplate-1x3txthik-webhhouse.vercel.app/) Full-stack React with App Router, Tailwind CSS v4, dark mode, `react-markdown`, SEO metadata, blog and block-based pages. ```bash npm create @webhouse/cms my-site -- --template nextjs ``` ### Static Boilerplate — zero framework Pure HTML output. Custom `build.ts` with Marked. No React, no bundler, no runtime JS. ```bash npm create @webhouse/cms my-site -- --template static ``` ### Next.js GitHub Boilerplate — live updates Everything from the Next.js boilerplate + GitHub storage adapter, LiveRefresh SSE webhooks, HMAC-signed revalidation. ```bash npm create @webhouse/cms my-site -- --template nextjs-github ``` --- ## Example Sites Production-quality sites built with @webhouse/cms. Use as templates or inspiration. ### Thinking in Pixels — Blog ![Thinking in Pixels](/screenshots/example-blog.png) [Live demo →](https://thinking-in-pixels.fly.dev/) Clean blog with posts, tags, cover images, and about page. Built with CMS CLI. ```bash npm create @webhouse/cms my-site -- --template blog ``` ### Sarah Mitchell — Freelancer ![Freelancer](/screenshots/example-freelancer-ghpages.png) [Live demo →](https://cbroberg.github.io/freelancer-site/) Freelancer portfolio with services, pricing packages, testimonials, blog, and contact section. ```bash npm create @webhouse/cms my-site -- --template freelancer ``` ### Meridian Studio ![Studio](/screenshots/example-studio.png) [Live demo →](https://cbroberg.github.io/meridian-studio-site/) Creative studio with services, featured work, team, and CTA section. ```bash npm create @webhouse/cms my-site -- --template studio ``` ### AURA — Boutique ![Boutique](/screenshots/example-boutique.png) [Live demo →](https://boutique.webhouse.app/) Product/shop showcase with collections, editorial content, and newsletter. ```bash npm create @webhouse/cms my-site -- --template boutique ``` ### Elena Vasquez — Portfolio ![Portfolio](/screenshots/example-portfolio.png) Visual portfolio with fullscreen image grid, about, and contact. ```bash npm create @webhouse/cms my-site -- --template portfolio ``` ### Elina Voss — Portfolio Squared ![Portfolio Squared](/screenshots/example-freelancer.png) Alternative portfolio layout with 2x2 image grid. --- ## Choosing a Template | Need | Command | |------|---------| | Simplest setup | `npm create @webhouse/cms my-site` | | React / Next.js | `-- --template nextjs` | | GitHub collaboration | `-- --template nextjs-github` | | Pure HTML | `-- --template static` | | Blog | `-- --template blog` | | Freelancer | `-- --template freelancer` | | Studio / agency | `-- --template studio` | | Shop / boutique | `-- --template boutique` | | Portfolio | `-- --template portfolio` | --- ## docs/instant-content-deployment-da Title: Instant Content Deployment (ICD) Updated: 2026-03-30 Locale: da Push indholdsændringer til deployede Next.js-sites på ~2 sekunder via signeret webhook, uden fuld Docker-rebuild. ## Hvad er ICD? Instant Content Deployment pusher indholdsændringer fra CMS admin direkte til dit deployede Next.js-site via en signeret webhook. I stedet for at trigge et fuldt Docker-rebuild (~73 sekunder), modtager sitet det opdaterede dokument, skriver det til disk og kalder `revalidatePath()` — alt sammen på ca. **2 sekunder**. ## Hvornår skal ICD bruges? ICD virker med **Next.js SSR-sites der har et skrivbart filsystem**: - Fly.io med volumes - Selvhostet Docker - Ethvert miljø hvor content-mappen er skrivbar ved runtime ICD gælder **ikke** for statiske builds (Vercel, Netlify, GitHub Pages) hvor indhold bages ved build-tid. ## Sådan fungerer det 1. Bruger gemmer indhold i CMS admin 2. CMS sender dokumentets JSON som en HMAC-SHA256-signeret POST til sitets `/api/revalidate`-endpoint 3. Endpointet verificerer signaturen, skriver dokumentet til disk og kalder `revalidatePath()` 4. Next.js serverer nyt indhold ved næste request 5. Fuldt Docker-deploy springes over når revalidation-webhook'en lykkes ## Opsætning ### 1. Tilføj revalidation-endpoint Opret `app/api/revalidate/route.ts` i dit Next.js-site: ```typescript import { revalidatePath } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; import crypto from "node:crypto"; import { writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; const SECRET = process.env.REVALIDATE_SECRET; const CONTENT_DIR = process.env.CONTENT_DIR ?? join(process.cwd(), "content"); export async function POST(request: NextRequest) { const signature = request.headers.get("x-cms-signature"); const body = await request.text(); if (SECRET) { if (!signature) { return NextResponse.json({ error: "Missing signature" }, { status: 401 }); } const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(body).digest("hex"); const sigBuf = Buffer.from(signature); const expBuf = Buffer.from(expected); if ( sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf) ) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } } const payload = JSON.parse(body); if (payload.collection && payload.slug) { const filePath = join(CONTENT_DIR, payload.collection, `${payload.slug}.json`); if (payload.action === "deleted") { if (existsSync(filePath)) unlinkSync(filePath); } else if (payload.document) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, JSON.stringify(payload.document, null, 2), "utf-8"); } } const paths = payload.paths ?? ["/"]; for (const p of paths) { revalidatePath(p); } return NextResponse.json({ revalidated: true, paths }); } ``` ### 2. Sæt miljøvariablen Generer en hemmelighed og sæt den på dit deployede site: ```bash # Generer openssl rand -hex 32 # Fly.io fly secrets set REVALIDATE_SECRET= ``` ### 3. Konfigurer CMS admin I **Site Settings** → site registry: | Felt | Værdi | |---|---| | `revalidateUrl` | `https://dit-site.fly.dev/api/revalidate` | | `revalidateSecret` | Samme hemmelighed som `REVALIDATE_SECRET` | ## Fallback Hvis webhook'en fejler (site nede, autentificeringsfejl, timeout), falder CMS automatisk tilbage til et fuldt Docker-deploy. ## Sikkerhed - Hemmeligheden skal genereres med `openssl rand -hex 32` (64 hex-tegn) - Hardkod aldrig hemmeligheden i kildekode — brug altid miljøvariabler - Endpointet bruger HMAC-SHA256 med timing-safe sammenligning --- ## docs/instant-content-deployment Title: Instant Content Deployment (ICD) Updated: 2026-03-30 Locale: en Push content changes to deployed Next.js sites in ~2 seconds via signed webhook, without full Docker rebuilds. ## What is ICD? Instant Content Deployment pushes content changes from the CMS admin directly to your deployed Next.js site via a signed webhook. Instead of triggering a full Docker rebuild (~73 seconds), the site receives the updated document, writes it to disk, and calls `revalidatePath()` — all in about **2 seconds**. ## When to use ICD ICD works with **Next.js SSR sites that have a persistent filesystem**: - Fly.io with volumes - Self-hosted Docker - Any environment where the content directory is writable at runtime ICD does **not** apply to static builds (Vercel, Netlify, GitHub Pages) where content is baked at build time. ## How it works 1. User saves content in CMS admin 2. CMS sends the document JSON as an HMAC-SHA256 signed POST to the site's `/api/revalidate` endpoint 3. The endpoint verifies the signature, writes the document to disk, and calls `revalidatePath()` 4. Next.js serves fresh content on the next request 5. Full Docker deploy is skipped when the revalidation webhook succeeds ## Setup ### 1. Add the revalidation endpoint Create `app/api/revalidate/route.ts` in your Next.js site: ```typescript import { revalidatePath } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; import crypto from "node:crypto"; import { writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; const SECRET = process.env.REVALIDATE_SECRET; const CONTENT_DIR = process.env.CONTENT_DIR ?? join(process.cwd(), "content"); export async function POST(request: NextRequest) { const signature = request.headers.get("x-cms-signature"); const body = await request.text(); if (SECRET) { if (!signature) { return NextResponse.json({ error: "Missing signature" }, { status: 401 }); } const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(body).digest("hex"); const sigBuf = Buffer.from(signature); const expBuf = Buffer.from(expected); if ( sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf) ) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } } const payload = JSON.parse(body) as { paths?: string[]; collection?: string; slug?: string; action?: string; document?: Record | null; }; if (payload.collection && payload.slug) { const filePath = join(CONTENT_DIR, payload.collection, `${payload.slug}.json`); if (payload.action === "deleted") { if (existsSync(filePath)) unlinkSync(filePath); } else if (payload.document) { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, JSON.stringify(payload.document, null, 2), "utf-8"); } } const paths: string[] = payload.paths ?? ["/"]; for (const p of paths) { revalidatePath(p); } return NextResponse.json({ revalidated: true, paths, collection: payload.collection, slug: payload.slug, timestamp: new Date().toISOString(), }); } ``` ### 2. Set the environment variable Generate a secret and set it on your deployed site: ```bash # Generate openssl rand -hex 32 # Fly.io fly secrets set REVALIDATE_SECRET= # Docker / .env REVALIDATE_SECRET= ``` ### 3. Configure CMS admin In **Site Settings**, add these fields to the site registry entry: | Field | Value | |---|---| | `revalidateUrl` | `https://your-site.fly.dev/api/revalidate` | | `revalidateSecret` | The same secret set as `REVALIDATE_SECRET` | ## Payload format The CMS sends a POST request with: - **Header** `x-cms-signature`: `sha256=` - **Body** (JSON): - `collection` — collection name (e.g. `posts`) - `slug` — document slug - `action` — `created`, `updated`, or `deleted` - `document` — full document JSON (null for deletes) - `paths` — array of URL paths to revalidate ## Fallback behavior If the webhook fails (site is down, authentication error, network timeout), the CMS falls back to a full Docker deploy automatically. ## Security - The secret must be generated with `openssl rand -hex 32` (64 hex characters) - Never hardcode the secret in source code — always use environment variables - The endpoint uses HMAC-SHA256 with timing-safe comparison to prevent signature forgery - Without a valid `REVALIDATE_SECRET`, the endpoint rejects all requests --- ## docs/troubleshooting-da Title: Fejlfinding Updated: 2026-03-29 Locale: da Almindelige problemer og løsninger — GitHub-adapter, indhold vises ikke, portkonflikter, billeder og mere. ## GitHub-adapter: "Bad Token" **Årsag:** OAuth-token er udløbet eller tilbagekaldt. **Løsning:** Gå til Sites → Indstillinger → genforbind GitHub. Brug en finkornet PAT med `contents: read/write`. ## "Collection Not Found" **Årsag:** Collection-navn i `cms.config.ts` matcher ikke indholdsmappe. **Løsning:** Navne skal være identiske: ``` cms.config.ts: defineCollection({ name: 'posts' }) Mappe: content/posts/ ``` ## Indhold vises ikke efter gem **Årsag:** Next.js statisk cache — sider bygget ved deploy-tid genopbygges ikke. **Løsninger:** 1. **On-demand revalidering** (anbefalet) — konfigurér webhook 2. **Tidsbaseret revalidering** — `export const revalidate = 60;` 3. **Genbyg ved indholdsændring** — Git webhook trigger ## Port allerede i brug ```bash lsof -ti:3000 # Find hvad der bruger porten npx cms dev --port 3001 # Brug en anden port ``` ## Billeder indlæses ikke i produktion **Løsning:** Tilføj billeddomænet til `next.config.ts`: ```typescript images: { remotePatterns: [ { protocol: 'https', hostname: 'dit-domæne.dk', pathname: '/uploads/**' }, ], } ``` ## Lagringsadapter default til SQLite Hvis du glemmer at angive `storage`, bruges SQLite. Admin-UI skriver til database, `build.ts` læser fra JSON-filer — de to systemer er afkoblet. --- ## docs/relationships-da Title: Indholdsrelationer Updated: 2026-03-29 Locale: da Forbind dokumenter på tværs af collections med relationsfelter — single, multi og reverse lookups. ## Sådan fungerer relationer Relationer forbinder dokumenter på tværs af collections. Et relationsfelt gemmer en **slug-streng** (enkelt) eller **slug-array** (flere) — aldrig indlejret data. ```typescript // Enkelt relation — gemmer én slug, f.eks. "john-doe" { name: 'author', type: 'relation', collection: 'team' } // Multi-relation — gemmer slug-array, f.eks. ["guide-1", "guide-2"] { name: 'relatedPosts', type: 'relation', collection: 'posts', multiple: true } ``` ## Opløsning af relationer Da relationer gemmer slugs, opløser du dem med `getDocument()`: ```typescript const author = post.data.author ? getDocument('team', post.data.author) : null; ``` ## Omvendt opslag Find alle dokumenter der refererer til en given slug: ```typescript const posts = getCollection('posts') .filter(post => post.data.author === authorSlug); ``` ## Hvornår bruges relationer vs. indlejret data? **Brug relationer** når data deles på tværs af flere dokumenter. **Brug indlejret data** (object/array-felter) når data er unik for dokumentet. --- ## docs/richtext-da Title: Richtext-editor Updated: 2026-03-29 Locale: da TipTap-baseret richtext-editor med indlejret medier, callouts, tabeller, kodeblokke, inline AI-korrektur og mere. ## Oversigt Alle `richtext`-felter bruger en indbygget TipTap v3-editor med fuld medieindlejring, strukturerede indholdsnoder og AI-assistance. ## Indlejrede medietyper | Node | Beskrivelse | |------|-------------| | **Billede** | Upload eller indsæt. Understøtter størrelseshåndtag og justering. | | **Video** | YouTube eller Vimeo URL → responsiv iframe. | | **Lyd** | Upload mp3/wav/ogg → inline ``-afspiller. | | **Fil** | Upload enhver fil → download-linkkort. | | **Callout** | Stylet info/advarsel/tip/fare-boks med redigerbar tekst. | | **Tabel** | Struktureret datatabel med overskriftsrække. | | **Kodeblok** | Fenced kodeblok med syntaksfremhævning. | ## Styring af tilgængelige funktioner Brug `features`-arrayet til at styre hvilke værktøjslinjeelementer der vises: ```typescript // Fuld-udstyret (standard — alle værktøjer) { name: 'content', type: 'richtext' } // Begrænset — kun grundlæggende formatering + billeder { name: 'content', type: 'richtext', features: ['bold', 'italic', 'heading', 'link', 'image'] } ``` ## Markdown-lagring Richtext-felter gemmer **markdown**. Funktioner som markdown ikke understøtter bruger inline HTML (``, ``, `` osv.). ## Rendering i Next.js Brug `react-markdown` med `remark-gfm`: ```typescript import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; function ArtikelIndhold({ content }: { content: string }) { return {content}; } ``` > **Brug aldrig `dangerouslySetInnerHTML`** — det ødelægger billeder, tabeller og indlejrede medier. ## Inline korrektur Richtext-editoren har indbygget AI-korrektur der viser rettelser direkte inline — ingen toast-notifikationer eller popups. ### Sådan fungerer det 1. Klik på **Korrektur**-knappen i værktøjslinjen (eller brug AI-menuen) 2. AI'en scanner din tekst og returnerer rettelser med tegnpositioner 3. **Fejl** vises som rød understreget tekst (stavefejl, grammatikproblemer) 4. **Forslag** vises som grønne inline-widgets (stilforbedringer, ordvalg) ### Rettelsesværktøjslinje En sticky værktøjslinje vises nederst i editoren med: - **Navigation** — ← 1/9 → til at gå igennem rettelser en ad gangen - **Accepter / Afvis** — anvend eller afvis den aktuelle rettelse - **Accepter alle / Afvis alle** — batch-anvend eller afvis alle rettelser på én gang Navigation fremhæver den aktuelle rettelse i editoren så du kan se konteksten. ### Tekniske detaljer - Rettelser renderes som ProseMirror Decorations — de ændrer ikke dit indhold før du accepterer dem - API'et returnerer tegnpositioner; editoren mapper disse til ProseMirror-positioner - Server-side validerings-fallback sikrer at positionerne er korrekte selvom AI-svaret er lidt forskudt - Sprog detekteres automatisk — ingen konfiguration nødvendig ## AI-funktioner - **AI Korrektur** — inline rettelser med navigationsværktøjslinje (se ovenfor) - **AI Boblemenu** — markér tekst for omskrivningsmuligheder (kortere, længere, formel, afslappet, oversæt) --- ## docs/interactives-da Title: Interaktive elementer Updated: 2026-03-29 Locale: da Datadrevet interaktivt indhold — diagrammer, beregnere, demoer med CMS-styret data. ## Separationsprincippet Når du bygger interaktivt indhold (diagrammer, animationer, beregnere), **skal al tekst og data gemmes i CMS-collections — aldrig hardkodes.** | Hvad | Hvor | Redigerbar af | |------|------|---------------| | Tekstlabels, overskrifter | CMS-tekstfelter | Redaktør i admin | | Datapunkter, tal | CMS array/object-felter | Redaktør i admin | | Visualisering, animation | Interaktiv komponent | Udvikler | ## Mønster: CMS → Side → Interaktiv **1. Definér en datacollektion:** ```typescript defineCollection({ name: "chart-data", fields: [ { name: "title", type: "text", required: true }, { name: "dataPoints", type: "array", fields: [ { name: "label", type: "text" }, { name: "value", type: "number" }, ]}, ], }) ``` **2. Opret komponenten (klient-side):** Brug Chart.js, D3 eller ethvert visualiseringsbibliotek. **3. Brug på en side (server læser CMS, sender props):** ```typescript import { getDocument } from "@/lib/content"; import { Chart } from "@/components/chart"; export default function Page() { const data = getDocument("chart-data", "monthly-sales"); if (!data) return null; return ; } ``` ## Selvstændige HTML-interaktive CMS'et understøtter også selvstændige HTML-interaktive via Interaktive-manageren. Brug til hurtig prototyping med "Opret med AI" i admin. ## Richtext-indlejring Interaktive kan indlejres i richtext-felter: ``` !!INTERACTIVE[chart-id|Diagram Titel|align:center] ``` --- ## docs/field-types-da Title: Felttyper Updated: 2026-03-29 Locale: da Komplet reference for alle 22 felttyper — text, richtext, image, blocks, relation og mere. ## Fælles feltegenskaber Alle felter understøtter disse egenskaber: ```typescript { name: string; // Påkrævet: feltnøgle i dokumentdata type: FieldType; // Påkrævet: en af typerne nedenfor label?: string; // Visningslabel i admin required?: boolean; // Skal have en værdi defaultValue?: unknown; ai?: { // AI-genereringshints hint?: string; // f.eks. "Skriv i en venlig tone" maxLength?: number; tone?: string; }; } ``` ## Grundlæggende typer ### text Enkeltlinje tekstinput. ```typescript { name: 'title', type: 'text', required: true, maxLength: 120 } ``` ### textarea Flerlinjet ren tekst. ### number, boolean, date Standardtyper for tal, sand/falsk og datoer. ## Indholdstyper ### richtext Rich text / Markdown-indhold med blokeditor i admin. Valgfri `features`-array styrer værktøjslinjen. ### htmldoc Fuld HTML-dokumenteditor (visuel WYSIWYG). ## Medietyper **image**, **image-gallery**, **video**, **audio**, **file** — til alle medietyper. > **Vigtigt:** Billedgalleriværdier skal være `{ url, alt }[]`-objekter, ikke plain strings. ## Strukturtyper **select** — dropdown fra foruddefinerede valgmuligheder. **tags** — frie tags gemt som `string[]`. **relation** — reference til dokumenter i en anden collection. **array** — gentagelige elementer med underfelter. **object** — indlejret feltgruppe. **blocks** — dynamiske indholdssektioner via bloksystemet. ## Specialtyper **map** — OpenStreetMap med trækbar pin. **interactive** — reference til en Interaktiv komponent. **column-slots** — flerkolonne-layout med indlejrede felter. --- ## docs/media-da Title: Mediehåndtering Updated: 2026-03-29 Locale: da Billedbehandling, AI-analyse, gallerier og mediebibliotekets funktioner. ## Mediebibliotek CMS-admin inkluderer et fuldt mediebibliotek med: - **Upload** — træk og slip eller filvælger - **Organisering** — mapper, tags, søgning - **Behandling** — automatisk WebP-konvertering, responsive varianter - **AI-analyse** — auto-genererede billedtekster, alt-tekst og tags ## Billedbehandling Uploadede billeder bliver automatisk: 1. Konverteret til WebP for optimal filstørrelse 2. Skaleret til responsive varianter (f.eks. 400w, 800w, 1200w) 3. EXIF-data ekstraheret (kamera, objektiv, GPS osv.) 4. AI-analyseret for billedtekster og alt-tekst ## AI-billedanalyse Når du uploader et billede, analyserer AI det og genererer: - **Billedtekst** — beskrivende tekst til kontekst - **Alt-tekst** — tilgænglighed for skærmlæsere og SEO - **Tags** — auto-genererede tags til organisering ## Brug af billeder i indhold ### Billedfelt ```typescript { name: 'heroImage', type: 'image' } ``` ### Billedgalleri ```typescript { name: 'photos', type: 'image-gallery' } ``` Gallerværdier skal være `{ url, alt }[]`-objekter: ```json "photos": [ { "url": "/uploads/foto-1.webp", "alt": "Beskrivelse" }, { "url": "/uploads/foto-2.webp", "alt": "Et andet foto" } ] ``` --- ## docs/content-structure-da Title: Indholdsstruktur Updated: 2026-03-29 Locale: da Hvordan dokumenter gemmes som JSON-filer — dokumentformat, mappestruktur og konventioner. ## Dokumentformat Hvert dokument er en JSON-fil i `content/{collection}/{slug}.json`: ```json { "id": "unikt-uuid", "slug": "mit-dokument", "status": "published", "locale": "da", "translationGroup": "fælles-uuid", "data": { "title": "Mit dokument", "content": "Markdown-indhold her..." }, "_fieldMeta": {}, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-03-29T14:00:00Z" } ``` ## Vigtige regler 1. **Slug skal matche filnavn** — `hej-verden.json` skal have `"slug": "hej-verden"` 2. **`_fieldMeta` er påkrævet** — kan være tomt `{}`, sporer AI-låsestatus 3. **Filtrer altid på status** — spring kladder over med `status === "published"` 4. **`data` indeholder alle indholdsfelter** — alt defineret i din collections `fields`-array 5. **`_seo` er et reserveret felt** — bruges til SEO-metadata ## Mappestruktur ``` content/ posts/ hello-world.json hello-world-da.json # Dansk oversættelse typescript-guide.json pages/ home.json about.json global/ global.json # Singleton til siteindstillinger ``` ## Status-livscyklus | Status | Betydning | |--------|-----------| | `draft` | Under udarbejdelse, ikke synlig på sitet | | `published` | Live på sitet | | `archived` | Fjernet fra sitet men gemt til reference | | `expired` | Auto-sat når `unpublishAt`-datoen passeres | ## Planlagt publicering Dokumenter understøtter automatisk publicering/afpublicering via datofelter: ```json { "status": "draft", "publishAt": "2026-04-01T09:00:00Z", "unpublishAt": "2026-04-30T23:59:59Z" } ``` --- ## docs/api-reference-da Title: Indholds-API Updated: 2026-03-29 Locale: da Programmatisk API til læsning og skrivning af indhold — ContentService-metoder og REST-endpoints. ## ContentService CMS-kernemotor eksponerer en `ContentService` til programmatisk indholdsadgang: ```typescript import { createCms } from '@webhouse/cms'; import config from './cms.config'; const cms = await createCms(config); // Opret const doc = await cms.content.create('posts', { slug: 'nyt-indlaeg', status: 'draft', data: { title: 'Nyt indlæg', content: '...' }, }); // Læs const post = await cms.content.findBySlug('posts', 'nyt-indlaeg'); const alle = await cms.content.findMany('posts', { status: 'published' }); // Opdatér await cms.content.update('posts', doc.id, { data: { title: 'Opdateret titel' }, }); // Slet await cms.content.delete('posts', doc.id); ``` ## REST API | Metode | Endpoint | Beskrivelse | |--------|----------|-------------| | GET | `/api/cms/{collection}` | List dokumenter | | GET | `/api/cms/{collection}/{slug}` | Hent dokument efter slug | | POST | `/api/cms/{collection}` | Opret dokument | | PUT | `/api/cms/{collection}/{id}` | Opdatér dokument | | DELETE | `/api/cms/{collection}/{id}` | Slet dokument | ## MCP (Model Context Protocol) CMS'et eksponerer også indhold via MCP til AI-platformsadgang: ```bash # Generér en API-nøgle npx cms mcp keygen --label "Min App" --scopes "read" ``` MCP giver AI-platforme som Claude og ChatGPT mulighed for at læse dit indhold direkte. --- ## docs/ai-agents-da Title: AI-agenter Updated: 2026-03-29 Locale: da Indbyggede AI-agenter til indholdsgenerering, SEO-optimering, GEO-optimering og oversættelse. ## Hvad er AI-agenter? AI-agenter genererer og optimerer indhold baseret på din brandvoice og konfiguration. Hver agent har en specifik rolle: | Agent | Rolle | |-------|-------| | **Content Writer** | Opretter nye blogindlæg, sider, beskrivelser | | **SEO Optimizer** | Forbedrer metafelter, nøgleord, overskriftsstruktur | | **GEO Optimizer** | Omstrukturerer indhold til AI-citering (svar-først, statistik, kilder) | | **Translator** | Oversætter indhold til konfigurerede sprog | | **Content Refresher** | Opdaterer forældet indhold med aktuel information | ## Sådan fungerer agenter 1. Du konfigurerer agenter i admin (Indstillinger → Agenter) 2. Hver agent har en systemprompt der definerer dens adfærd 3. Agenter producerer **kladder** der lander i **Kurateringskøen** 4. Du gennemgår, godkender eller afviser hver kladde 5. Godkendt indhold publiceres automatisk ## AI-lås Felter du har redigeret i hånden er **låst** — agenter overskriver dem ikke. Dette sikrer at menneskelige redigeringer bevares selv når agenter kører masseoperationer. ## Brandvoice Konfigurér en brandvoice i Indstillinger for at sikre at alt AI-genereret indhold matcher din tone: - **Tone** — professionel, afslappet, venlig, autoritativ - **Målgruppe** — udviklere, marketingfolk, almen offentlighed - **Retningslinjer** — specifikke instruktioner som "Brug altid aktiv stemme" --- ## docs/seo-da Title: SEO & Synlighed Updated: 2026-03-29 Locale: da Meta-felter, JSON-LD strukturerede data, sitemap, robots.txt og AI-synlighedsoptimering. ## SEO-felter Hvert dokument kan have et `_seo`-felt i sine data: ```json { "data": { "title": "Mit indlæg", "_seo": { "metaTitle": "Mit indlæg — Bedste guide (30-60 tegn)", "metaDescription": "En omfattende guide til... (120-160 tegn)", "keywords": ["nøgleord1", "nøgleord2"], "ogImage": "/uploads/og-billede.jpg" } } } ``` ## Synlighedsscoring CMS-admin inkluderer et Synlighedsdashboard der scorer hvert dokument på to akser: **SEO-score** (13 regler) — metatitel-længde, metabeskrivelse, nøgleord, overskriftsstruktur, indholdslængde, interne links, billed-alt-tekst og mere. **GEO-score** (8 regler) — optimerer indhold til AI-platformcitering: 1. Svar-først-struktur 2. Spørgsmålsformat-overskrifter 3. Statistik og datapunkter 4. Eksterne citationer 5. Indholdsfriskhed (opdateret inden for 90 dage) 6. JSON-LD strukturerede data 7. Navngiven forfatter 8. Indholdsdybde (800+ ord) ## Build-output CMS build-pipelinen genererer automatisk: - `sitemap.xml` — alle publicerede sider med hreflang - `robots.txt` — AI-bevidste crawler-regler (4 strategier) - `llms.txt` — AI-venligt siteindeks - `llms-full.txt` — komplet markdown-eksport - `feed.xml` — RSS 2.0-feed - Per-side `.md`-filer til AI-forbrug --- ## docs/cli-reference-da Title: CLI-reference Updated: 2026-03-29 Locale: da Alle CMS CLI-kommandoer — init, dev, build, serve, AI generate, AI rewrite, SEO og MCP. ## Kommandoer Alle kommandoer køres via `npx cms ` (leveret af `@webhouse/cms-cli`). | Kommando | Beskrivelse | |----------|-------------| | `cms init [navn]` | Opret et nyt CMS-projekt | | `cms dev [--port 3000]` | Start udviklingsserver med hot reload | | `cms build [--outDir dist]` | Byg statisk site | | `cms serve [--port 5000] [--dir dist]` | Servér det byggede statiske site | | `cms ai generate ""` | Generér et nyt dokument med AI | | `cms ai rewrite / ""` | Omskriv eksisterende dokument | | `cms ai seo [--status published]` | Kør SEO-optimering på alle dokumenter | | `cms mcp keygen [--label "nøgle"] [--scopes "read,write"]` | Generér MCP API-nøgle | ## AI-kommandoer AI-kommandoer kræver `@webhouse/cms-ai` og en `ANTHROPIC_API_KEY` eller `OPENAI_API_KEY` i `.env`. ```bash # Generér et blogindlæg npx cms ai generate posts "Skriv en guide til TypeScript generics" # Omskriv med instruktioner npx cms ai rewrite posts/hello-world "Gør det mere kortfattet og tilføj kodeeksempler" # SEO-optimering på tværs af alt publiceret indhold npx cms ai seo ``` --- ## docs/deployment-da Title: Udrulning Updated: 2026-03-29 Locale: da Deploy din CMS-drevne side til Vercel, Fly.io, Netlify eller Docker. ## Checkliste før udrulning - Alle dokumenter beregnet til at være live har `status: "published"` - Ingen publicerede sider refererer til kladde-dokumenter - Alle relationsfelter peger på eksisterende, publicerede dokumenter - OG-billeder findes for nøglesider - Miljøvariabler er konfigureret - `next build` lykkes lokalt ## Vercel ```bash npx vercel ``` ## Fly.io ```toml # fly.toml primary_region = "arn" [build] dockerfile = "Dockerfile" [env] NODE_ENV = "production" ``` ## Netlify ```bash npx netlify-cli deploy --build ``` ## Docker (selvhostet) ```dockerfile FROM node:22-alpine AS builder WORKDIR /app COPY . . RUN npm ci && npm run build FROM node:22-alpine WORKDIR /app COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/content ./content COPY --from=builder /app/package.json ./ RUN npm ci --omit=dev CMD ["npm", "start"] ``` ## Instant Content Deployment (ICD) For Next.js-sites på Fly.io kan du bruge **Instant Content Deployment** til at pushe indholdsændringer direkte til sitet uden fuld Docker-rebuild. Ændringer er live på ~2 sekunder. Se [ICD-guiden](/docs/instant-content-deployment-da) for fuld opsætning. ## Verifikation efter udrulning - Besøg `/sitemap.xml` og bekræft at alle sider er oplistet - Tjek sidekilde for OpenGraph- og JSON-LD-tags - Test social deling-forhåndsvisning - Bekræft at billeder indlæses korrekt --- ## docs/storage-adapters-da Title: Lagringsadaptere Updated: 2026-03-29 Locale: da Vælg hvor dit indhold gemmes — filsystem, GitHub, SQLite eller Supabase. ## Valg af lagringsadapter @webhouse/cms understøtter fire lagringsbackends. Hver har forskellige afvejninger mht. ydeevne, samarbejde og udrulning. > **Kritisk:** Du SKAL altid angive `storage` i `cms.config.ts`. Hvis den udelades, bruges SQLite som standard — ikke filsystemet. ## Filsystem (anbefalet) Gemmer dokumenter som JSON-filer i `content//.json`. Bedst til: - Statiske sites med `build.ts` - Git-baseret versionskontrol - Lokal udvikling ```typescript storage: { adapter: 'filesystem', filesystem: { contentDir: 'content' }, } ``` ## GitHub Læser og skriver filer via GitHub API. Hver oprettelse/opdatering/sletning er et Git-commit. Bedst til: - Samarbejdsredigering uden lokal Git - PR-baserede indholdsgennemgangsworkflows - Sites hostet på GitHub Pages ```typescript storage: { adapter: 'github', github: { owner: 'din-org', repo: 'dit-repo', branch: 'main', contentDir: 'content', token: process.env.GITHUB_TOKEN!, }, } ``` ## SQLite Lokal SQLite-database. Bedst til: - API-tunge use cases - Når du ikke har brug for filbaseret indhold - Prototyping ## Supabase Cloud-hostet PostgreSQL med Row Level Security. Bedst til: - Flerbruger-miljøer - Cloud-native udrulninger - Når du har brug for realtids-abonnementer --- ## docs/blocks-da Title: Blokke Updated: 2026-03-29 Locale: da Byg indholdsrige sider med genanvendelige blokkomponenter — hero, features, CTA og brugerdefinerede blokke. ## Hvad er blokke? Blokke er genanvendelige indholdssektioner som redaktører kan tilføje, fjerne og omorganisere. Hver bloktype har sine egne felter og renderes forskelligt på frontend. ## Definition af blokke ```typescript import { defineConfig, defineBlock, defineCollection } from '@webhouse/cms'; export default defineConfig({ blocks: [ defineBlock({ name: 'hero', label: 'Hero-sektion', fields: [ { name: 'tagline', type: 'text', required: true }, { name: 'description', type: 'textarea' }, { name: 'image', type: 'image' }, { name: 'ctas', type: 'array', fields: [ { name: 'label', type: 'text' }, { name: 'href', type: 'text' }, ]}, ], }), ], collections: [ defineCollection({ name: 'pages', fields: [ { name: 'title', type: 'text', required: true }, { name: 'sections', type: 'blocks', blocks: ['hero', 'features'] }, ], }), ], }); ``` ## Sådan gemmes blokke Hver blok er et objekt med et `_block`-diskriminatorfelt: ```json { "sections": [ { "_block": "hero", "tagline": "Byg hurtigere med AI", "description": "CMS'et der skriver indhold for dig." } ] } ``` ## Rendering af blokke i Next.js ```typescript function BlockRenderer({ block }: { block: any }) { switch (block._block) { case 'hero': return ( {block.tagline} {block.description} ); default: return null; } } ``` --- ## docs/introduction-da Title: Introduktion Updated: 2026-03-29 Locale: da Hvad er @webhouse/cms og hvorfor det eksisterer — et filbaseret, AI-native CMS til TypeScript-projekter. ## Hvad er @webhouse/cms? `@webhouse/cms` er en **filbaseret, AI-native CMS-motor** til TypeScript-projekter. Du definerer collections og felter i en `cms.config.ts`-fil, og CMS'et gemmer indhold som flade JSON-filer i en `content/`-mappe — én fil pr. dokument, organiseret efter collection. > **🔥 Ny her?** Se [Hot Features](/docs/hot-features-da) for de patterns vi anbefaler som default for nye sites — Instant Content Deployment, Beam, Headless API og mere. Det giver dig: - **REST API-server** — Hono-baseret API til læsning og skrivning af indhold - **Statisk site-builder** — 9-faset build-pipeline der genererer HTML, sitemap, RSS, robots.txt og AI-discovery-filer - **AI-indholdsgenerering** — indbyggede agenter til skrivning, SEO-optimering, oversættelse og mere - **Visuel admin-brugerflade** — fuld-udstyret editor på [webhouse.app](https://webhouse.app) med rich text, blokke, medier og planlægning - **MCP-integration** — Model Context Protocol-server til AI-platformes adgang til dit indhold ## Hvem er det til? @webhouse/cms er designet til udviklere der bygger indholdsdrevne websites med Next.js. Det fungerer særligt godt når: - Du vil have **filbaseret indhold** der lever i dit Git-repository - Du har brug for **AI-drevne indholdsworkflows** (generering, oversættelse, SEO-optimering) - Du foretrækker **TypeScript-first** konfiguration frem for YAML eller markdown frontmatter - Du vil have en **visuel admin-brugerflade** uden kompleksiteten af et headless CMS ## Arkitektur ``` cms.config.ts → Collection- og feltdefinitioner content/ → JSON-dokumenter (én pr. fil) packages/cms → Kernemotor (@webhouse/cms) packages/cms-admin → Next.js admin-brugerflade (@webhouse/cms-admin) packages/cms-ai → AI-agenter (@webhouse/cms-ai) packages/cms-cli → CLI-værktøjer (@webhouse/cms-cli) packages/cms-mcp-* → MCP-servere til AI-platformsadgang ``` Kernepakken (`@webhouse/cms`) er framework-agnostisk — den læser og skriver JSON-filer. Admin-brugerfladen (`@webhouse/cms-admin`) er en selvstændig Next.js-applikation der forbinder til kernemotor. ## Næste skridt - [Hurtig start](/docs/quick-start-da) — opret og kør dit første projekt på under 5 minutter - [Konfigurationsreference](/docs/config-reference-da) — lær hvordan du definerer collections og felter - [Felttyper](/docs/field-types-da) — udforsk alle 22 felttyper --- ## docs/richtext Title: Richtext Editor Updated: 2026-03-29 Locale: en TipTap-based rich text editor with embedded media, callouts, tables, code blocks, inline AI proofreading, and more. ## Overview Every `richtext` field uses a built-in TipTap v3 editor with full media embedding, structured content nodes, and AI assistance. ## Embedded media types | Node | Description | |------|-------------| | **Image** | Upload or paste. Supports resize handles and alignment. | | **Video embed** | YouTube or Vimeo URL → responsive iframe. | | **Audio embed** | Upload mp3/wav/ogg → inline `` player. | | **File attachment** | Upload any file → download-link card. | | **Callout** | Styled info/warning/tip/danger box with editable text. | | **Table** | Structured data table with header row, context toolbar. | | **Code block** | Fenced code block with syntax highlighting. | | **Interactive embed** | Embed an Interactive from the Interactives manager. | ## Controlling available features Use the `features` array to control which toolbar items are shown: ```typescript // Full-featured (default — all tools available) { name: 'content', type: 'richtext' } // Restricted — only basic formatting + images { name: 'content', type: 'richtext', features: ['bold', 'italic', 'heading', 'link', 'image', 'bulletList', 'orderedList'] } // Minimal — text only, no media { name: 'bio', type: 'richtext', features: ['bold', 'italic', 'link'] } ``` ## All available features | Feature | Toolbar item | Shortcut | |---------|-------------|----------| | `bold` | Bold text | Cmd+B | | `italic` | Italic text | Cmd+I | | `underline` | Underline | Cmd+U | | `strike` | Strikethrough | Cmd+Shift+S | | `code` | Inline code | | | `superscript` | Superscript (x²) | Cmd+. | | `subscript` | Subscript (x₂) | Cmd+, | | `heading` | Heading selector (H1-H3) | | | `bulletList` | Bullet list | | | `orderedList` | Numbered list | | | `blockquote` | Blockquote | | | `horizontalRule` | Horizontal line | | | `textAlign` | Text alignment | | | `highlight` | Highlight with color picker | | | `link` | Hyperlink | Cmd+K | | `table` | Data table | | | `image` | Image upload/embed | | | `video` | Video embed | | | `audio` | Audio file upload | | | `file` | File attachment | | | `callout` | Info/warning/tip callout | | | `interactive` | Interactive embed | | ## Markdown storage Richtext fields store **markdown**. Standard formatting uses native markdown syntax. Features that markdown doesn't support use inline HTML: | Feature | Stored as | |---------|-----------| | Underline | `text` | | Superscript | `text` | | Subscript | `text` | | Highlight | `text` | | Text alignment | `text` | | Interactive embed | `!!INTERACTIVE[id|title|align:left]` | ## Rendering in Next.js Use `react-markdown` with `remark-gfm`: ```typescript import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; function ArticleBody({ content }: { content: string }) { return ( {content} ); } ``` > **Never use `dangerouslySetInnerHTML`** with a regex-based parser — it breaks images with sizing, tables, and embedded media. ## Inline Proofreading The richtext editor has built-in AI proofreading that shows corrections directly inline — no toast notifications or popups. ### How it works 1. Click the **Proofread** button in the toolbar (or use the AI menu) 2. The AI scans your text and returns corrections with character offsets 3. **Errors** appear as red underlined text (spelling mistakes, grammar issues) 4. **Suggestions** appear as green inline widgets (style improvements, word choice) ### Correction toolbar A sticky toolbar appears at the bottom of the editor showing: - **Navigation** — ← 1/9 → to step through corrections one by one - **Accept / Reject** — apply or dismiss the current correction - **Accept All / Reject All** — batch-apply or dismiss all corrections at once Navigating highlights the current correction in the editor so you can see the context. ### Technical details - Corrections are rendered as ProseMirror Decorations — they don't modify your content until you accept them - The API returns character offsets; the editor maps these to ProseMirror positions - Server-side validation fallback ensures offsets are correct even if the AI response is slightly off - Language is auto-detected — no configuration needed ## AI features - **AI Proofread** — inline corrections with navigation toolbar (see above) - **AI Bubble Menu** — select text for rewrite options (shorter, longer, formal, casual, translate) - **Zoom** — scale editor content 50%-200% --- ## docs/api-endpoints Title: API Endpoints Reference Updated: 2026-03-29 Locale: en Complete reference of all 136 REST API endpoints in the CMS admin. ## Overview The CMS admin exposes **136 API endpoints** across 4 categories. All endpoints under `/api/cms/`, `/api/admin/`, and `/api/media/` require authentication via session cookie or API key. ## Admin API | Method | Endpoint | Description | |--------|----------|-------------| | GET, POST | `/api/admin/ai-config` | | | GET, POST | `/api/admin/analytics` | GET /api/admin/analytics | | GET, POST | `/api/admin/backups` | | | GET, POST, DELETE | `/api/admin/backups/:id` | | | GET, POST | `/api/admin/deploy` | | | GET | `/api/admin/deploy/can-deploy` | | | POST | `/api/admin/email-test` | | | POST | `/api/admin/github-service-token` | POST /api/admin/github-service-token | | GET, POST | `/api/admin/invitations` | | | DELETE | `/api/admin/invitations/:id` | | | POST | `/api/admin/invitations/accept` | | | GET | `/api/admin/invitations/validate` | | | GET, POST | `/api/admin/mcp-config` | | | GET | `/api/admin/my-sites` | | | GET, POST | `/api/admin/org-settings` | | | GET | `/api/admin/probe-url` | GET /api/admin/probe-url?url= | | GET, POST | `/api/admin/profile` | | | GET | `/api/admin/scheduler-events` | | | GET | `/api/admin/scheduler-stream` | | | POST | `/api/admin/scheduler-test` | | | GET | `/api/admin/seo` | GET /api/admin/seo | | GET | `/api/admin/seo/export` | GET /api/admin/seo/export?format=csv|json | | GET, POST | `/api/admin/seo/keywords` | GET /api/admin/seo/keywords | | POST | `/api/admin/seo/og-image` | POST /api/admin/seo/og-image | | POST | `/api/admin/seo/optimize-bulk` | POST /api/admin/seo/optimize-bulk | | GET, POST, PATCH | `/api/admin/site-config` | | | GET | `/api/admin/site-health` | | | POST | `/api/admin/translate-bulk` | POST /api/admin/translate-bulk | | GET, POST, PATCH | `/api/admin/user-state` | | | GET | `/api/admin/users` | | | PATCH, DELETE | `/api/admin/users/:id` | | | GET | `/api/admin/users/available` | GET /api/admin/users/available | | POST | `/api/mcp/admin/message` | | ## Authentication | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/auth/github` | GET /api/auth/github — Redirect to GitHub OAuth authorize page. | | GET | `/api/auth/github/callback` | GET /api/auth/github/callback — Exchange OAuth code for access token. | | POST | `/api/auth/login` | | | POST | `/api/auth/logout` | | | GET | `/api/auth/me` | | | GET, POST | `/api/auth/setup` | | ## Content API | Method | Endpoint | Description | |--------|----------|-------------| | GET, POST | `/api/check-links` | POST /api/check-links | | POST | `/api/check-links/apply-fix` | | | POST | `/api/check-links/fix` | | | GET | `/api/check-links/last` | | | GET, POST | `/api/cms/:collection` | | | GET, POST, PATCH, DELETE | `/api/cms/:collection/:slug` | | | GET | `/api/cms/:collection/:slug/revisions` | | | POST | `/api/cms/:collection/:slug/revisions/:index` | POST /api/cms/{collection}/{slug}/revisions/{index}/restore | | POST | `/api/cms/:collection/:slug/translate` | | | GET, POST | `/api/cms/agents` | | | GET, PUT, DELETE | `/api/cms/agents/:id` | | | POST | `/api/cms/agents/:id/clone` | | | POST | `/api/cms/agents/:id/run` | | | POST | `/api/cms/agents/create-from-description` | | | POST | `/api/cms/agents/generate-prompt` | | | POST | `/api/cms/ai/chat` | | | POST | `/api/cms/ai/generate` | | | POST | `/api/cms/ai/htmldoc` | | | GET, PUT | `/api/cms/ai/prompts` | | | POST | `/api/cms/ai/proofread` | | | POST | `/api/cms/ai/rewrite` | | | GET, POST | `/api/cms/brand-voice` | | | POST | `/api/cms/brand-voice/chat` | | | POST | `/api/cms/brand-voice/translate` | | | PATCH | `/api/cms/brand-voice/versions/:id` | | | POST | `/api/cms/chat` | | | GET, POST | `/api/cms/chat/conversations` | | | GET, DELETE | `/api/cms/chat/conversations/:id` | | | GET | `/api/cms/chat/export` | | | POST | `/api/cms/chat/import` | | | GET, POST | `/api/cms/chat/memory` | | | PATCH, DELETE | `/api/cms/chat/memory/:id` | | | GET | `/api/cms/chat/memory/export` | | | POST | `/api/cms/chat/memory/extract` | | | POST | `/api/cms/chat/memory/import` | | | GET | `/api/cms/chat/memory/search` | | | GET | `/api/cms/collections` | | | GET | `/api/cms/collections/:name/schema` | | | GET, POST | `/api/cms/command` | | | POST | `/api/cms/command/sync` | | | GET | `/api/cms/curation` | | | GET, PATCH | `/api/cms/curation/:id` | | | POST | `/api/cms/curation/:id/approve` | | | POST | `/api/cms/curation/:id/reject` | | | GET, POST | `/api/cms/folder-picker` | POST /api/cms/folder-picker | | GET | `/api/cms/heartbeat` | GET /api/cms/heartbeat | | GET, POST, PUT, DELETE | `/api/cms/mcp-servers` | | | GET, POST, PUT, DELETE | `/api/cms/registry` | | | POST | `/api/cms/registry/import` | POST /api/cms/registry/import | | POST | `/api/cms/registry/move-site` | | | POST | `/api/cms/registry/rename` | | | GET | `/api/cms/registry/stats` | | | POST | `/api/cms/registry/validate` | POST /api/cms/registry/validate | | GET, POST | `/api/cms/revalidation` | GET /api/cms/revalidation — get revalidation settings + recent log for active site | | GET | `/api/cms/scheduled` | | | GET | `/api/cms/scheduled/calendar.ics` | GET /api/cms/scheduled/calendar.ics?token=&org=&site= | | GET | `/api/cms/schema-drift` | GET /api/cms/schema-drift | | POST | `/api/cms/schema-drift/fix` | POST /api/cms/schema-drift/fix | | POST | `/api/extract-text` | POST /api/extract-text | | GET, POST, DELETE | `/api/github` | GET /api/github?action=status|orgs|repos&org=... | | GET, POST | `/api/interactives` | | | GET, PUT, DELETE | `/api/interactives/:id` | | | GET | `/api/interactives/:id/preview` | | | POST | `/api/interactives/:id/translate` | POST /api/interactives/[id]/translate | | GET | `/api/internal-links` | GET /api/internal-links?q=query | | GET | `/api/mcp` | | | GET | `/api/mcp/admin` | | | GET | `/api/mcp/info` | | | POST | `/api/mcp/message` | | | GET | `/api/media` | | | POST | `/api/preview-build` | POST /api/preview-build | | POST | `/api/preview-serve` | Starts (or reuses) a lightweight static file server for the active site's dist/ directory. | | GET | `/api/preview-site-root` | Serve dist/index.html for the root path of the preview site. | | GET | `/api/preview-site/:...path` | Serve static files from the active site's dist/ directory. | | POST | `/api/publish-scheduled` | POST /api/publish-scheduled | | GET | `/api/schema` | | | PUT, DELETE | `/api/schema/:collection` | | | GET, POST | `/api/schema/collections` | | | GET | `/api/search` | GET /api/search?q=query | | GET | `/api/site-file/:...path` | Serve static files from the site's public/ directory or proxy from previewUrl. | | GET, DELETE | `/api/trash` | | | POST | `/api/upload` | | | GET | `/api/uploads/:...path` | | ## Media API | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/cms/media/usage` | GET /api/cms/media/usage | | DELETE | `/api/media/:...path` | | | GET | `/api/media/ai-analyzed` | | | GET | `/api/media/ai-meta` | | | POST | `/api/media/analyze` | | | POST | `/api/media/analyze-batch` | | | POST | `/api/media/analyze-test` | | | GET | `/api/media/exif` | GET /api/media/exif?file=/uploads/IMG_0051.jpeg | | POST | `/api/media/optimize-batch` | POST /api/media/optimize-batch — Generate WebP variants for all images in uploads/ | | POST | `/api/media/rename` | POST /api/media/rename | | POST | `/api/media/restore` | | | POST | `/api/media/rotate` | POST /api/media/rotate | | GET, PATCH | `/api/media/tags` | | | GET | `/api/media/video-thumb` | GET /api/media/video-thumb?file=/uploads/VIDEO.MOV | ## Authentication All protected endpoints require one of: - **Session cookie** — set after login via `POST /api/auth/login` - **API key** — passed in `Authorization: Bearer ` header Public endpoints: `/api/auth/login`, `/api/auth/setup`, `/api/auth/me` ## Rate Limits No rate limits are enforced in the self-hosted CMS admin. The API is designed for single-tenant use. ## Content Push Webhook When content is saved, the CMS can push updates to your site via webhook: ```json { "event": "content.revalidate", "collection": "posts", "slug": "hello-world", "action": "published", "document": { "id": "...", "slug": "...", "data": { ... } }, "paths": ["/blog/hello-world", "/blog"] } ``` Header `X-CMS-Signature: sha256=` is computed using your shared secret. --- ## docs/interactives Title: Interactives Updated: 2026-03-29 Locale: en Data-driven interactive content — charts, calculators, demos with CMS-managed data. ## The separation principle When building interactive content (charts, animations, calculators), **all text and data must be stored in CMS collections — never hardcoded.** | What | Where | Editable by | |------|-------|-------------| | Text labels, headings | CMS text fields | Editor in admin | | Data points, numbers | CMS array/object fields | Editor in admin | | Visualization, animation | Interactive component | Developer | | Styling, colors | Interactive CSS | Developer | ## Pattern: CMS → Page → Interactive **1. Define a data collection:** ```typescript defineCollection({ name: "chart-data", fields: [ { name: "title", type: "text", required: true }, { name: "chartType", type: "select", options: [ { label: "Line", value: "line" }, { label: "Bar", value: "bar" }, ]}, { name: "dataPoints", type: "array", fields: [ { name: "label", type: "text" }, { name: "value", type: "number" }, ]}, ], }) ``` **2. Create the component (client):** ```typescript "use client"; export function Chart({ title, data }: { title: string; data: { label: string; value: number }[] }) { // Use Chart.js, D3, or any visualization library return {title}{/* render chart */}; } ``` **3. Use in a page (server reads CMS, passes props):** ```typescript import { getDocument } from "@/lib/content"; import { Chart } from "@/components/chart"; export default function Page() { const data = getDocument("chart-data", "monthly-sales"); if (!data) return null; return ; } ``` ## Standalone HTML interactives The CMS also supports standalone HTML interactives managed via the Interactives Manager. These are complete HTML files that render in iframes. Use for: - Self-contained interactives without CMS data - Quick prototyping with "Create with AI" in admin - One-off visualizations ## Richtext embedding Interactives can be embedded in richtext fields: ``` !!INTERACTIVE[chart-id|Chart Title|align:center] ``` Your renderer must convert these tokens to iframes: ```typescript html = html.replace( /!!INTERACTIVE\[([^\]]+)\]/g, (_match, inner) => { const [id, title = id] = inner.split("|"); return ``; }, ); ``` ## Scaled rendering Render full-size interactives as miniatures using CSS transform: ```typescript ``` --- ## docs/troubleshooting Title: Troubleshooting Updated: 2026-03-29 Locale: en Common issues and fixes — GitHub adapter, content not showing, port conflicts, images, and more. ## GitHub adapter: "Bad Token" **Cause:** OAuth token expired or revoked. **Fix:** 1. Go to Sites → Settings → reconnect GitHub 2. Use a fine-grained PAT with `contents: read/write` for long-term stability 3. For automation: use a GitHub App installation token ## "Collection Not Found" **Cause:** Collection name in `cms.config.ts` doesn't match content directory. **Fix:** Names must be identical: ``` cms.config.ts: defineCollection({ name: 'posts' }) Directory: content/posts/ ``` ## Content not showing after save **Cause:** Next.js static cache — pages built at deploy time aren't regenerated until next build. **Fix options:** 1. **On-demand revalidation** (recommended) — configure webhook in Site Settings → Revalidation 2. **Time-based revalidation** — add `export const revalidate = 60;` to pages 3. **Rebuild on content change** — Git webhook triggers deployment ## Port already in use ```bash # Find what's using the port lsof -ti:3000 # Use a different port npx cms dev --port 3001 ``` ## Images not loading in production **Cause:** Missing `remotePatterns` in `next.config.ts`. **Fix:** ```typescript // next.config.ts images: { remotePatterns: [ { protocol: 'https', hostname: 'your-domain.com', pathname: '/uploads/**' }, ], } ``` ## Supabase: "Could Not Find Table" **Cause:** PostgREST hasn't refreshed its schema cache. **Fix:** 1. Restart the Supabase project 2. Verify table exists: `SELECT * FROM information_schema.tables WHERE table_name = 'documents';` 3. Check that the `anon` key has `SELECT` permission ## Storage adapter defaults to SQLite If you forget to specify `storage` in `cms.config.ts`, it defaults to SQLite. This means: - Admin UI writes to a SQLite database - `build.ts` reads from `content/` JSON files - The two systems are disconnected **Fix:** Always specify the adapter explicitly: ```typescript storage: { adapter: 'filesystem', filesystem: { contentDir: 'content' }, } ``` --- ## docs/relationships Title: Content Relationships Updated: 2026-03-29 Locale: en Connect documents across collections with relation fields — single, multi, and reverse lookups. ## How relations work Relations connect documents across collections. A relation field stores a **slug string** (single) or **slug array** (multiple) — never embedded data. ```typescript // Single relation — stores one slug, e.g. "john-doe" { name: 'author', type: 'relation', collection: 'team' } // Multi relation — stores slug array, e.g. ["guide-1", "guide-2"] { name: 'relatedPosts', type: 'relation', collection: 'posts', multiple: true } ``` ## Resolving relations Since relations store slugs, resolve them with `getDocument()`: ```typescript function resolveRelation(collection: string, slug: string | null) { if (!slug) return null; return getDocument(collection, slug); } function resolveRelations(collection: string, slugs: string[] | null) { if (!slugs?.length) return []; return slugs .map(slug => getDocument(collection, slug)) .filter(Boolean); } ``` ## Pattern: Blog post with author ```typescript export default async function PostPage({ params }) { const { slug } = await params; const post = getDocument('posts', slug); if (!post) notFound(); // Resolve author const author = post.data.author ? getDocument('team', post.data.author) : null; // Resolve related posts const related = (post.data.relatedPosts ?? []) .map(s => getDocument('posts', s)) .filter(Boolean); return ( {post.data.title} {author && ( {author.data.name} )} ); } ``` ## Reverse lookup Find all documents that reference a given slug: ```typescript // All posts by a specific author const posts = getCollection('posts') .filter(post => post.data.author === authorSlug); ``` ## When to use relations vs. embedded data **Use relations** when: - Data is shared across multiple documents (e.g., author on many posts) - Related data changes independently - You need a canonical source of truth **Use embedded data** (object/array fields) when: - Data is unique to this document - Data doesn't need independent querying - Simpler structure without cross-collection lookups --- ## docs/ai-agents Title: AI Agents Updated: 2026-03-29 Locale: en Built-in AI agents for content generation, SEO optimization, GEO optimization, and translation. ## What are AI agents? AI agents generate and optimize content based on your brand voice and configuration. Each agent has a specific role: | Agent | Role | |-------|------| | **Content Writer** | Creates new blog posts, pages, descriptions | | **SEO Optimizer** | Improves meta fields, keywords, heading structure | | **GEO Optimizer** | Restructures content for AI citation (answer-first, statistics, sources) | | **Translator** | Translates content to configured locales | | **Content Refresher** | Updates stale content with current information | ## How agents work 1. You configure agents in the admin UI (Settings → Agents) 2. Each agent has a system prompt that defines its behavior 3. Agents produce **drafts** that land in the **Curation Queue** 4. You review, approve, or reject each draft 5. Approved content is published automatically ## AI Lock Fields you've edited by hand are **locked** — agents won't overwrite them. This ensures human edits are preserved even when agents run bulk operations. The lock state is tracked in `_fieldMeta`: ```json { "_fieldMeta": { "title": { "lockedBy": "user", "lockedAt": "2026-03-29T10:00:00Z" } } } ``` ## Brand voice Configure a brand voice in Settings to ensure all AI-generated content matches your tone: - **Tone** — professional, casual, friendly, authoritative - **Audience** — developers, marketers, general public - **Guidelines** — specific instructions like "Always use active voice" or "Include code examples" ## Programmatic usage ```typescript import { createAi } from '@webhouse/cms-ai'; const ai = await createAi(); // Generate content const result = await ai.content.generate('posts', { prompt: 'Write a guide to TypeScript generics', }); // Translate const translated = await ai.content.translate( sourceDoc.data, 'da', { collection: collectionConfig }, ); ``` --- ## docs/api-reference Title: Content API Updated: 2026-03-29 Locale: en Programmatic API for reading and writing content — ContentService methods and REST endpoints. ## ContentService The core CMS engine exposes a `ContentService` for programmatic content access: ```typescript import { createCms } from '@webhouse/cms'; import config from './cms.config'; const cms = await createCms(config); // Create const doc = await cms.content.create('posts', { slug: 'new-post', status: 'draft', data: { title: 'New Post', content: '...' }, }); // Read const post = await cms.content.findBySlug('posts', 'new-post'); const all = await cms.content.findMany('posts', { status: 'published' }); // Update await cms.content.update('posts', doc.id, { data: { title: 'Updated Title' }, }); // Delete await cms.content.delete('posts', doc.id); ``` ## REST API The CMS exposes a Hono-based REST API: | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/cms/{collection}` | List documents | | GET | `/api/cms/{collection}/{slug}` | Get document by slug | | POST | `/api/cms/{collection}` | Create document | | PUT | `/api/cms/{collection}/{id}` | Update document | | DELETE | `/api/cms/{collection}/{id}` | Delete document | ### Query parameters | Param | Description | |-------|-------------| | `status` | Filter by status: `published`, `draft`, `all` | | `locale` | Filter by locale | | `limit` | Maximum results | | `offset` | Pagination offset | | `tags` | Filter by tags (comma-separated) | ### Example ```bash # List published posts curl http://localhost:3000/api/cms/posts?status=published # Get a specific post curl http://localhost:3000/api/cms/posts/hello-world # Create a new post curl -X POST http://localhost:3000/api/cms/posts \ -H "Content-Type: application/json" \ -d '{"slug":"new-post","status":"draft","data":{"title":"New"}}' ``` ## MCP (Model Context Protocol) The CMS also exposes content via MCP for AI platform access: ```bash # Generate an API key npx cms mcp keygen --label "My App" --scopes "read" # Test the endpoint npx cms mcp test ``` MCP allows AI platforms like Claude and ChatGPT to read your content directly, enabling them to cite your documentation and articles. --- ## docs/cli-reference Title: CLI Reference Updated: 2026-03-29 Locale: en All CMS CLI commands — init, dev, build, serve, AI generate, AI rewrite, SEO, and MCP. ## Commands All commands run via `npx cms ` (provided by `@webhouse/cms-cli`). | Command | Description | |---------|-------------| | `cms init [name]` | Scaffold a new CMS project | | `cms dev [--port 3000]` | Start dev server with hot reload | | `cms build [--outDir dist]` | Build static site | | `cms serve [--port 5000] [--dir dist]` | Serve the built static site | | `cms ai generate ""` | Generate a new document with AI | | `cms ai rewrite / ""` | Rewrite existing document | | `cms ai seo [--status published]` | Run SEO optimization on all documents | | `cms mcp keygen [--label "key"] [--scopes "read,write"]` | Generate MCP API key | | `cms mcp test [--endpoint url]` | Test local MCP server | | `cms mcp status [--endpoint url]` | Check MCP server status | ## AI commands AI commands require `@webhouse/cms-ai` and an `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in `.env`. ```bash # Generate a blog post npx cms ai generate posts "Write a guide to TypeScript generics" # Rewrite with instructions npx cms ai rewrite posts/hello-world "Make it more concise and add code examples" # SEO optimization across all published content npx cms ai seo ``` ## MCP commands ```bash # Generate an API key for MCP access npx cms mcp keygen --label "My App" --scopes "read" # Test the MCP endpoint npx cms mcp test --endpoint http://localhost:3000/api/mcp # Check MCP server status npx cms mcp status ``` --- ## docs/content-structure Title: Content Structure Updated: 2026-03-29 Locale: en How documents are stored as JSON files — the document format, directory layout, and conventions. ## Document format Every document is a JSON file in `content/{collection}/{slug}.json`: ```json { "id": "unique-uuid", "slug": "my-document", "status": "published", "locale": "en", "translationGroup": "shared-uuid", "data": { "title": "My Document", "content": "Markdown content here...", "_seo": { "metaTitle": "SEO Title", "metaDescription": "Description for search engines" } }, "_fieldMeta": {}, "createdAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-03-29T14:00:00Z" } ``` ## Key rules 1. **Slug must match filename** — `hello-world.json` must have `"slug": "hello-world"` 2. **`_fieldMeta` is required** — can be empty `{}`, tracks AI lock state 3. **Always filter by status** — skip drafts with `status === "published"` 4. **`data` contains all content fields** — everything defined in your collection's `fields` array 5. **`_seo` is a reserved field** — used for SEO metadata ## Directory layout ``` content/ posts/ hello-world.json hello-world-da.json # Danish translation typescript-guide.json pages/ home.json about.json global/ global.json # Singleton for site settings ``` ## Status lifecycle | Status | Meaning | |--------|---------| | `draft` | Work in progress, not visible on site | | `published` | Live on site | | `archived` | Removed from site but kept for reference | | `expired` | Auto-set when `unpublishAt` date passes | ## Scheduled publishing Documents support automatic publish/unpublish via date fields: ```json { "status": "draft", "publishAt": "2026-04-01T09:00:00Z", "unpublishAt": "2026-04-30T23:59:59Z" } ``` The scheduler automatically changes status when the date arrives. --- ## docs/deployment Title: Deployment Updated: 2026-03-29 Locale: en Deploy your CMS-powered site to Vercel, Fly.io, Netlify, or Docker. ## Pre-deployment checklist - All documents intended to be live have `status: "published"` - No published pages reference draft-only documents - All relation fields point to existing, published documents - OG images exist for key pages - Environment variables are configured - `next build` succeeds locally ## Vercel ```bash npx vercel ``` Configure image domains in `next.config.ts`: ```typescript images: { remotePatterns: [ { protocol: 'https', hostname: 'your-domain.com', pathname: '/uploads/**' }, ], } ``` ## Fly.io ```toml # fly.toml primary_region = "arn" [build] dockerfile = "Dockerfile" [env] NODE_ENV = "production" ``` ```dockerfile FROM node:22-alpine AS builder WORKDIR /app COPY . . RUN npm ci && npm run build FROM node:22-alpine WORKDIR /app COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public COPY --from=builder /app/content ./content CMD ["node", "server.js"] ``` ## Netlify ```bash npx netlify-cli deploy --build ``` ## Docker (self-hosted) ```dockerfile FROM node:22-alpine AS builder WORKDIR /app COPY . . RUN npm ci && npm run build FROM node:22-alpine WORKDIR /app COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/content ./content COPY --from=builder /app/package.json ./ RUN npm ci --omit=dev CMD ["npm", "start"] ``` ## Instant Content Deployment (ICD) For Next.js sites on Fly.io, you can use **Instant Content Deployment** to push content changes directly to the site without a full Docker rebuild. Changes are live in ~2 seconds. See the [ICD guide](/docs/instant-content-deployment) for full setup. ## Post-deployment verification - Visit `/sitemap.xml` and confirm all pages are listed - Check page source for OpenGraph and JSON-LD tags - Test social sharing preview - Confirm images load correctly - If using revalidation, test the webhook endpoint --- ## docs/media Title: Media Management Updated: 2026-03-29 Locale: en Image processing, AI analysis, galleries, and media library features. ## Media library The CMS admin includes a full media library with: - **Upload** — drag & drop or file picker - **Organization** — folders, tags, search - **Processing** — automatic WebP conversion, responsive variants - **AI analysis** — auto-generated captions, alt text, and tags ## Image processing Uploaded images are automatically: 1. Converted to WebP for optimal file size 2. Resized to responsive variants (e.g., 400w, 800w, 1200w) 3. EXIF data extracted (camera, lens, GPS, etc.) 4. AI-analyzed for captions and alt text ## AI image analysis When you upload an image, the AI analyzes it to generate: - **Caption** — descriptive text for context - **Alt text** — accessibility description for screen readers and SEO - **Tags** — auto-generated tags for organization You can also batch-analyze existing images from the media library. ## Using images in content ### Image field ```typescript { name: 'heroImage', type: 'image' } ``` ### Image gallery ```typescript { name: 'photos', type: 'image-gallery' } ``` Gallery values must be `{ url, alt }[]` objects: ```json "photos": [ { "url": "/uploads/photo-1.webp", "alt": "Description" }, { "url": "/uploads/photo-2.webp", "alt": "Another photo" } ] ``` ## Rendering images in Next.js ```typescript import Image from 'next/image'; function HeroImage({ src, alt }: { src: string; alt: string }) { return ( ); } ``` --- ## docs/blocks Title: Blocks Updated: 2026-03-29 Locale: en Build content-rich pages with reusable block components — hero, features, CTA, and custom blocks. ## What are blocks? Blocks are reusable content sections that editors can add, remove, and reorder. Each block type has its own fields and renders differently on the frontend. ## Defining blocks ```typescript import { defineConfig, defineBlock, defineCollection } from '@webhouse/cms'; export default defineConfig({ blocks: [ defineBlock({ name: 'hero', label: 'Hero Section', fields: [ { name: 'tagline', type: 'text', required: true }, { name: 'description', type: 'textarea' }, { name: 'image', type: 'image' }, { name: 'ctas', type: 'array', fields: [ { name: 'label', type: 'text' }, { name: 'href', type: 'text' }, ]}, ], }), defineBlock({ name: 'features', label: 'Features Grid', fields: [ { name: 'title', type: 'text' }, { name: 'items', type: 'array', fields: [ { name: 'icon', type: 'text' }, { name: 'title', type: 'text' }, { name: 'description', type: 'textarea' }, ]}, ], }), ], collections: [ defineCollection({ name: 'pages', fields: [ { name: 'title', type: 'text', required: true }, { name: 'sections', type: 'blocks', blocks: ['hero', 'features'] }, ], }), ], }); ``` ## How blocks are stored Each block is an object with a `_block` discriminator field: ```json { "sections": [ { "_block": "hero", "tagline": "Build faster with AI", "description": "The CMS that writes content for you." }, { "_block": "features", "title": "Why choose us", "items": [ { "icon": "⚡", "title": "Fast", "description": "Sub-second builds" } ] } ] } ``` ## Rendering blocks in Next.js ```typescript function BlockRenderer({ block }: { block: any }) { switch (block._block) { case 'hero': return ( {block.tagline} {block.description} ); case 'features': return ( {block.title} {block.items?.map((item: any, i: number) => ( {item.icon} {item.title} {item.description} ))} ); default: return null; } } ``` --- ## docs/field-types Title: Field Types Updated: 2026-03-29 Locale: en Complete reference for all 22 field types — text, richtext, image, blocks, relation, and more. ## Common field properties Every field supports these properties: ```typescript { name: string; // Required: field key in document data type: FieldType; // Required: one of the types below label?: string; // Display label in admin UI required?: boolean; // Must have a value defaultValue?: unknown; ai?: { // AI generation hints hint?: string; // e.g. "Write in a friendly tone" maxLength?: number; tone?: string; // e.g. "professional", "casual" }; aiLock?: { // AI lock behavior autoLockOnEdit?: boolean; // Lock when user edits (default: true) lockable?: boolean; requireApproval?: boolean; }; } ``` ## Basic types ### text Single-line text input. ```typescript { name: 'title', type: 'text', required: true, maxLength: 120 } ``` ### textarea Multi-line plain text. ```typescript { name: 'excerpt', type: 'textarea', maxLength: 300 } ``` ### number ```typescript { name: 'price', type: 'number' } ``` ### boolean ```typescript { name: 'featured', type: 'boolean' } ``` ### date ISO date string. ```typescript { name: 'publishDate', type: 'date' } ``` ## Content types ### richtext Rich text / Markdown content with a block editor in admin UI. Optional `features` array controls toolbar. ```typescript { name: 'content', type: 'richtext' } // Restricted features { name: 'content', type: 'richtext', features: ['bold', 'italic', 'heading', 'link', 'image'] } ``` ### htmldoc Full HTML document editor (visual WYSIWYG). ```typescript { name: 'template', type: 'htmldoc' } ``` ## Media types ### image Single image reference. ```typescript { name: 'heroImage', type: 'image' } ``` ### image-gallery Multiple images. **Values must be `{ url, alt }[]` objects, not plain strings.** ```typescript { name: 'photos', type: 'image-gallery' } ``` ### video ```typescript { name: 'intro', type: 'video' } ``` ### audio ```typescript { name: 'podcast', type: 'audio' } ``` ### file ```typescript { name: 'download', type: 'file' } ``` ## Structure types ### select ```typescript { name: 'category', type: 'select', options: [ { label: 'Web', value: 'web' }, { label: 'Mobile', value: 'mobile' }, ], } ``` ### tags Free-form tags stored as `string[]`. ```typescript { name: 'tags', type: 'tags' } ``` ### relation Reference to documents in another collection. ```typescript { name: 'author', type: 'relation', collection: 'team' } { name: 'related', type: 'relation', collection: 'posts', multiple: true } ``` ### array Repeatable items. Without `fields` it stores `string[]`. ```typescript { name: 'bullets', type: 'array' } { name: 'stats', type: 'array', fields: [ { name: 'value', type: 'text' }, { name: 'label', type: 'text' }, ]} ``` ### object Nested field group. ```typescript { name: 'address', type: 'object', fields: [ { name: 'street', type: 'text' }, { name: 'city', type: 'text' }, ]} ``` ### blocks Dynamic content sections using the block system. ```typescript { name: 'sections', type: 'blocks', blocks: ['hero', 'features', 'cta'] } ``` ## Special types ### map OpenStreetMap with draggable pin. Stores `{ lat, lng, address, zoom }`. ```typescript { name: 'location', type: 'map' } ``` ### interactive Reference to an Interactive component from the library. ```typescript { name: 'chart', type: 'interactive' } ``` ### column-slots Multi-column layout with nested fields. ```typescript { name: 'layout', type: 'column-slots' } ``` --- ## docs/seo Title: SEO & Visibility Updated: 2026-03-29 Locale: en Meta fields, JSON-LD structured data, sitemap, robots.txt, and AI visibility optimization. ## SEO fields Every document can have an `_seo` field in its data: ```json { "data": { "title": "My Post", "_seo": { "metaTitle": "My Post — Best Guide (30-60 chars)", "metaDescription": "A comprehensive guide to... (120-160 chars)", "keywords": ["keyword1", "keyword2"], "ogImage": "/uploads/og-image.jpg", "jsonLd": { "@type": "Article", "headline": "..." } } } } ``` ## Generating metadata in Next.js ```typescript export async function generateMetadata({ params }) { const doc = getDocument('posts', (await params).slug); const seo = doc?.data._seo ?? {}; return { title: seo.metaTitle ?? doc?.data.title, description: seo.metaDescription ?? doc?.data.excerpt, openGraph: { title: seo.metaTitle ?? doc?.data.title, description: seo.metaDescription, images: seo.ogImage ? [seo.ogImage] : [], }, }; } ``` ## Visibility scoring The CMS admin includes a Visibility dashboard that scores every document on two axes: **SEO Score** (13 rules) — meta title length, meta description, keywords, heading structure, content length, internal links, image alt text, and more. **GEO Score** (8 rules) — optimizes content for AI platform citation: 1. Answer-first structure 2. Question-format headers 3. Statistics and data points 4. External citations 5. Content freshness (updated within 90 days) 6. JSON-LD structured data 7. Named author 8. Content depth (800+ words) ## Build output The CMS build pipeline automatically generates: - `sitemap.xml` — all published pages with hreflang - `robots.txt` — AI-aware crawler rules (4 strategies) - `llms.txt` — AI-friendly site index - `llms-full.txt` — complete markdown export - `feed.xml` — RSS 2.0 feed - Per-page `.md` files for AI consumption --- ## docs/storage-adapters Title: Storage Adapters Updated: 2026-03-29 Locale: en Choose where your content is stored — filesystem, GitHub, SQLite, or Supabase. ## Choosing a storage adapter @webhouse/cms supports four storage backends. Each has different trade-offs for performance, collaboration, and deployment. > **Critical:** You MUST always specify `storage` in `cms.config.ts`. If omitted, it defaults to SQLite — not filesystem. This is the most common configuration mistake. ## Filesystem (recommended) Stores documents as JSON files in `content//.json`. Best for: - Static sites with `build.ts` - Git-based version control - Local development ```typescript storage: { adapter: 'filesystem', filesystem: { contentDir: 'content' }, } ``` ## GitHub Reads and writes files via the GitHub API. Each create/update/delete is a Git commit. Best for: - Collaborative editing without local Git - PR-based content review workflows - Sites hosted on GitHub Pages ```typescript storage: { adapter: 'github', github: { owner: 'your-org', repo: 'your-repo', branch: 'main', contentDir: 'content', token: process.env.GITHUB_TOKEN!, }, } ``` ## SQLite Local SQLite database. Best for: - API-heavy use cases - When you don't need file-based content - Prototyping ```typescript storage: { adapter: 'sqlite', sqlite: { path: './data/cms.db' }, } ``` ## Supabase Cloud-hosted PostgreSQL with Row Level Security. Best for: - Multi-user environments - Cloud-native deployments - When you need real-time subscriptions ```typescript storage: { adapter: 'supabase', supabase: { url: process.env.SUPABASE_URL!, serviceKey: process.env.SUPABASE_SERVICE_KEY!, }, } ``` --- ## docs/introduction Title: Introduction Updated: 2026-03-29 Locale: en What is @webhouse/cms and why it exists — a file-based, AI-native CMS for TypeScript projects. ## What is @webhouse/cms? `@webhouse/cms` is a **file-based, AI-native CMS engine** for TypeScript projects. You define collections and fields in a `cms.config.ts` file, and the CMS stores content as flat JSON files in a `content/` directory — one file per document, organized by collection. > **🔥 New here?** See [Hot Features](/docs/hot-features) for the patterns we recommend by default for new sites — Instant Content Deployment, Beam, Headless API, and more. It provides: - **REST API server** — Hono-based API for reading and writing content - **Static site builder** — 9-phase build pipeline generating HTML, sitemap, RSS, robots.txt, and AI discovery files - **AI content generation** — built-in agents for writing, SEO optimization, translation, and more - **Visual admin UI** — full-featured editor at [webhouse.app](https://webhouse.app) with rich text, blocks, media, and scheduling - **MCP integration** — Model Context Protocol server for AI platform access to your content ## Who is it for? @webhouse/cms is designed for developers building content-driven websites with Next.js. It works especially well when: - You want **file-based content** that lives in your Git repository - You need **AI-powered content workflows** (generation, translation, SEO optimization) - You prefer **TypeScript-first** configuration over YAML or markdown frontmatter - You want a **visual admin UI** without the complexity of a headless CMS ## Architecture ``` cms.config.ts → Collection + field definitions content/ → JSON documents (one per file) packages/cms → Core engine (@webhouse/cms) packages/cms-admin → Next.js admin UI (@webhouse/cms-admin) packages/cms-ai → AI agents (@webhouse/cms-ai) packages/cms-cli → CLI tools (@webhouse/cms-cli) packages/cms-mcp-* → MCP servers for AI platform access ``` The core package (`@webhouse/cms`) is framework-agnostic — it reads and writes JSON files. The admin UI (`@webhouse/cms-admin`) is a standalone Next.js application that connects to the core engine. ## Next steps - [Quick Start](/docs/quick-start) — scaffold and run your first project in under 5 minutes - [Configuration Reference](/docs/config-reference) — learn how to define collections and fields - [Field Types](/docs/field-types) — explore all 22 field types --- ## docs/hot-features-da Title: Hot Features Locale: da Hvad er nyt og værd at kende lige nu i @webhouse/cms — de patterns vi anbefaler som default for nye sites. ## Hvad er hot i @webhouse/cms En kort, holdningsstærk gennemgang af de nyeste features der ændrer *hvordan* du bør bygge nye sites — ikke bare en changelog. Skim det før du starter et nyt projekt; de defaults du baker ind her sparer dig uger af refaktorering senere. --- ## ⚡ Instant Content Deployment (ICD) **Den nye default for ethvert Next.js site med editor-drevet indhold.** Glem at trigge en Docker rebuild hver gang nogen retter en kommafejl. ICD pusher content-ændringer fra CMS admin direkte til dit deployede Next.js site via en HMAC-signed webhook. Sitet skriver opdateringen til disk og kalder `revalidatePath()` — indholdet går live på **~2 sekunder**. - Drop-in `app/api/revalidate/route.ts` template — copy, paste, deploy - Virker på enhver Next.js host med persistent filesystem (Fly.io med volumes, self-hosted Docker) - HMAC-SHA256 signerede payloads + timing-safe sammenligning - Header-pill `ICD · auto` i admin når konfigureret — redaktører ser live status - Fulde Docker deploys er stadig tilgængelige for kode/config ændringer; ICD håndterer kun *content*-vejen 👉 [Fuld ICD guide](/docs/instant-content-deployment-da) · Også dækket i [AI Builder Guide modul 18](https://ai.webhouse.app/ai/18-deployment) --- ## 📦 Beam — single-file site export/import Pak et komplet site (indhold + media + config + agents + settings) ind i ét `.beam` arkiv. Importér på en hvilken som helst anden CMS admin instans — indhold, secrets stripped automatisk, checksums verificeret. Brug det til: - Kloning af sites mellem dev / staging / prod - Onboarding af kunder på din production CMS - Backup før risikable migrationer Live Beam (HTTP push til en remote admin) er også tilgængelig for direkte admin-til-admin overførsel med chunked upload. --- ## 🤖 Headless Site API (F139) Brug CMS admin som **headless backend** inde i ethvert framework — Next.js, Astro, plain Node, you name it. Autentificér med et permanent `wh_` Access Token. Læs/skriv indhold, trigger deploys, læs form-indbakker, embed AI chatten i dit eget UI. Til projekter hvor design-systemet er for custom til at passe ind i vores default Next.js boilerplate. 👉 [Headless API guide](/docs/headless-api-da) --- ## 🔐 Permission-baseret ACL (F55) 20 fine-grained permissions — `content.publish`, `media.upload`, `forms.export`, `settings.edit` osv. Map til roller (admin / editor / viewer) eller tildel per bruger. Server-side håndhævelse på hver API route via `requirePermission()`. UI-knapper skjules når brugeren ikke kan handle. --- ## 🌍 i18n med translation groups (F48) Link dokumenter på tværs af locales via `translationGroup` felt. AI auto-translater ved create. Hreflang tags, locale routing, language switcher — alt indbygget. 18 AI routes til translation operationer. --- ## 🛡 AI Lock Field-level beskyttelse: AI agents *kan ikke* overskrive menneskelige edits. WriteContext threader actor-identitet gennem hver CRUD-kald. Når du har manuelt poleret en sætning, rører ingen agent den uden eksplicit override. --- ## Hvad er nyt denne uge Se [changelog](/changelog) for fulde per-version noter. --- ## docs/hot-features Title: Hot Features Locale: en What's new and worth knowing right now in @webhouse/cms — the patterns we recommend by default for new sites. ## What's hot in @webhouse/cms A short, opinionated tour of the recent features that change *how* you should build new sites — not just the changelog. Skim this before you start a new project; the defaults you bake in here save you weeks of refactoring later. --- ## ⚡ Instant Content Deployment (ICD) **The new default for any Next.js site that needs editor-driven content updates.** Forget triggering a Docker rebuild every time someone fixes a typo. ICD pushes content edits from CMS admin directly to your deployed Next.js site via an HMAC-signed webhook. The site writes the update to disk and calls `revalidatePath()` — content goes live in **~2 seconds**. - Drop-in `app/api/revalidate/route.ts` template — copy, paste, deploy - Works on any Next.js host with a persistent filesystem (Fly.io with volumes, self-hosted Docker) - HMAC-SHA256 signed payloads + timing-safe comparison - Header pill `ICD · auto` in admin once configured — editors see live status - Full Docker deploys are still available for code/config changes; ICD just handles the *content* path 👉 [Full ICD guide](/docs/instant-content-deployment) · Also covered in [AI Builder Guide module 18](https://ai.webhouse.app/ai/18-deployment) --- ## 📦 Beam — single-file site export/import Pack a complete site (content + media + config + agents + settings) into one `.beam` archive. Import on any other CMS admin instance — content, secrets stripped automatically, checksums verified. Use it for: - Cloning sites between dev / staging / prod - Onboarding clients onto your production CMS - Backing up before risky migrations Live Beam (HTTP push to a remote admin) is also available for direct admin-to-admin transfer with chunked upload. --- ## 🤖 Headless Site API (F139) Use CMS admin as a **headless backend** inside any framework — Next.js, Astro, plain Node, you name it. Authenticate with a permanent `wh_` Access Token. Read/write content, trigger deploys, read form inboxes, embed the AI chat in your own UI. For projects where the design system is too custom to fit our default Next.js boilerplate. 👉 [Headless API guide](/docs/headless-api) --- ## 🔐 Permission-based ACL (F55) 20 fine-grained permissions — `content.publish`, `media.upload`, `forms.export`, `settings.edit` etc. Map to roles (admin / editor / viewer) or assign per user. Server-side enforcement on every API route via `requirePermission()`. UI buttons hide when user can't act. --- ## 🌍 i18n with translation groups (F48) Link documents across locales via `translationGroup` field. AI auto-translates on create. Hreflang tags, locale routing, language switcher — all built in. 18 AI routes for translation operations. --- ## 🛡 AI Lock Field-level protection: AI agents *cannot* overwrite human edits. WriteContext threads actor identity through every CRUD call. Once you've manually polished a sentence, no agent will touch it without an explicit override. --- ## What's new this week See the [changelog](/changelog) for full per-version notes. --- ## changelog/unreleased Title: Unreleased Updated: 2026-05-05 Locale: en ## Unreleased Changes **4 changes** since v0.5.0 ### Features - feat(admin): /admin/switch/ route + F146 plan for URL-based site routing ### Bug Fixes - fix(api): cms.ts now honors withSiteContext override ### Other Changes - docs(F136): add 'Resume — pick up after compact' appendix - chore(release): weekly 2026.05.04 --- ## changelog/v0-5-0 Title: Release v0.5.0 Updated: 2026-05-05 Locale: en ## What's New in v0.5.0 **54 changes** released on 2026-05-04 ### Features - feat(settings): dedicated 'Build' tab for output browser + SSR builds - feat(deploy): native browser push notifications for deploy events - feat(deploy): visual browser for the active site's deploy/ output - feat(deploy): GH page_build webhook → SSE → instant 'live' toast - feat(deploy): GitHub Pages live-status polling + clearer toasts - feat(F144 P8): GitHub push-webhook trigger for SSR builds - feat(F144 P7): wire orchestrator into deploy-service as fly-ephemeral provider - feat(F144 P6): rollback lookup — find previous good image per site - feat(F144 P6): smokeTestImage helper for post-deploy health gate - feat(F144 P5): SSR build history panel + list API - feat(F144 P4): builder trigger + callback + status API endpoints - feat(F144 P3): Dockerfile generator + source-tar packer + orchestrator - feat(F144 P2): Fly Machines API client for build-VM lifecycle - feat(F144 P1): cms-builder ephemeral VM image (Dockerfile + entrypoint) - feat(F143 P5 partial): PIN-FIRST version resolution for auto-detected deps - feat(F143 P4): auto-detect npm imports in build.ts + wire into runSiteBuild - feat(F143 P3): extra-deps content-addressable store + install queue - feat(F143 P2): extend Beam source-list to ship build.ts + package.json + public/ - feat(F143 P1): expand build-server's provided-deps to all 5 core packages ### Bug Fixes - fix(api): hard-delete now fires content.deleted, not duplicate trashed - fix(api): /api/cms/{collection}/{slug} now honors ?site= for tokens too - fix(api): /api/cms/{collection} now honors ?site= for token-based POSTs - fix(deploy): output preview now full-width + tall (was cramped iframe) - fix(deploy): output-browser was always empty — field-name mismatch - fix(deploy): URL drift + drop internal F-numbers from user UI - fix(deploy): resolveToken also checks getAdminDataDir for github-service-token - fix(deploy): resolve "oauth" sentinel before sending GitHub Bearer token - fix(cms): skip macOS AppleDouble files (._*.json) in filesystem adapter - fix(F143): loader must skip relative imports in extra-deps resolver - fix(F143): per-site extra-deps resolved by ESM loader, not just NODE_PATH - fix(F143): pnpm-store fallback in build-runtime-loader for Next.js standalone - fix(F143): use Node --experimental-strip-types when tsx is broken on standalone - fix(F143): force-include tsx + build deps in Next.js standalone tracing - fix(F143): add tsx as direct dep so build server works on Fly standalone container - fix(F143 P2): wire source/ section into LIVE beam push (not just .beam export) ### Other Changes - docs(CLAUDE): hard rule for GH Pages custom-domain switch order - ci(F144): auto-rebuild cms-builder image on Dockerfile changes - chore(deploy): remove temp token-debug log (kept resolveToken step logs) - debug(deploy): log resolved token prefix + repo before github-pages publish - docs(F144): mark P7 + P8 shipped, list manual prereqs for first deploy - docs(F144): track P3-P6 completion status - test(F144 P4): build-log persistence + concurrent-write coverage - docs(F143 P6): trail-landing pilot dry-run + migration guide - ci(test): build workspace deps before typecheck + tests - chore: gitignore .claude/ runtime state files - docs(F145): formalize ICD as 3rd leg of deploy-triumvirat (core shipped, polish planned) - docs(F144): cms-admin orchestrates ephemeral Fly Machines as SSR build VMs - docs(F143): add PIN-FIRST dependency lifecycle + upgrade UI + smoke-build - docs(F143): add auto-detect npm deps + background install on Fly - ci(_release-build): pause Docker image build, unblock release pipeline - docs(F142+F143): two paths for cms-admin as build host (templated SSG + common build server) - docs(CLAUDE.md): hard rule — live sites are authored + deployed from a remote CMS server, not localhost - ci(ghcr-cleanup): require GHCR_CLEANUP_PAT for real deletes - ci: add GHCR cleanup workflow — delete untagged manifests + keep 5 tagged --- ## changelog/v0-4-0 Title: Release v0.4.0 Updated: 2026-05-05 Locale: en ## What's New in v0.4.0 **10 changes** released on 2026-05-02 ### Features - feat(F136): port Stripe Connect + dashboard-quality patterns from sanneandersen-site - feat(F136 phase 1): cart engine, Checkout, webhook, islands, storefront - feat(F136 phase 1): scaffold @webhouse/cms-shop with core collections ### Bug Fixes - fix(ci): add @webhouse/cms-shop to publish.yml matrix - fix(ci): release-auto must dispatch release-stable explicitly ### Other Changes - chore: release v0.4.0 - docs(F136): mark Phase 1 steps 4–10 as done, list remaining wiring - chore: pnpm-lock for @webhouse/cms-shop workspace package - ci: release-auto also dispatches npm publish - ci: don't block release on cms-admin test failures (temporary) --- ## changelog/v0-3-0 Title: Release v0.3.0 Updated: 2026-05-05 Locale: en ## What's New in v0.3.0 **132 changes** released on 2026-05-02 ### Features - feat(beam): two-phase progress modal for Beam Import - feat(beam): proper progress modal + background mode + persistent pill - feat(search): Cmd+K now searches document body/field content, not just titles - feat(mobile): content search — search all collections, tap result to edit - feat(deploy): live deploy progress + completion notification - feat(mobile): Deploy button on Site screen + /api/mobile/deploy endpoint - feat(mobile): multi-server support — connect and switch between servers - feat(mobile): server switching in Settings + pairingEnabled always true - feat(config-doctor): "Copy for AI builder" button on repair panel - feat(F138-E): "Receive via Beam" button on Sites empty-state - feat(F138-D): auto-init registry on first beam-finalize - feat(F138-C): Beam tokens move to Account Preferences (admin-level) - feat(F138-B): redirect site-scoped pages from empty admin - feat(F138-A): empty-admin detection + sidebar gating - feat(F137): fast Fly deploys — BuildKit cache + lean context - feat(admin): Config Doctor — LLM diagnosis + one-click auto-fix for bad cms.config.ts - feat(F100): Cloudflare domain registrar in Deploy Settings - feat(F134): Phase 4 — Cloudflare-literal token-create UI - feat(F134): Phase 3.5 — token auth for all admin routes + MCP bridge - feat(F134): Phase 1-3 — Cloudflare-style access-token rules + per-site deploy - feat(admin): cross-platform admin data dir with Docker/Fly volume support - feat(deploy): provide build runtime from cms-admin — no site-level deps - feat(trail): click-to-fullscreen SVG figures - feat(trail): 6 new Bauhaus figures spanning all four posts + width:100% - feat(trail): 4 SVG illustrations for three-filters-on-the-gate + design agent - feat(trail): SVG figures use currentColor for dark-mode legibility - feat(trail): dark mode with admin's token palette ### Bug Fixes - fix(ci): build workspace deps before typechecking cms-admin - fix(relation-picker): filter dropdown by document locale - fix(sidebar): collection prefix-match lit two siblings at once - fix(deploy): ICD-only sites — Re-sync triggers full content re-fan-out - fix(admin-header): ICD-only sites — hide redundant rocket button - fix(security): refuse user-mutation when users.json is unreadable - fix(security): harden /api/auth/setup against re-init attacks - fix(cms): stale-cookie recovery — find site in any org before falling back - fix(beam-import-modal): don't re-run upload on parent re-render - fix(beam/import): volume-backed tmp + SHA-256 verify + atomic addSite - fix(beam/import): chunked upload to bypass Fly's 10MB body cap - fix(beam/import): explicit ArrayBuffer + truncation guards - fix(beam/import): raw-binary upload to bypass formData size limit - fix(beam-import): visible file picker button + error feedback when no file chosen - fix(beam): pin SSE listeners Map to globalThis to survive Next.js HMR - fix(beam): force https on non-localhost targets + dedupe toast across header instances - fix(deploy): show Deploy button alongside ICD pill, raise live-probe timeout to 8s - fix(deploy): 2-min abort timeout on deploy fetch — prevents infinite spinner - fix(openapi): merge duplicate components key — securitySchemes now inside single block - fix(editor): text field width no longer false-matches camelCase substrings - fix(mobile/search): use getAdminCmsForSite() instead of getAdminCms() — avoids AsyncLocalStorage context issues - fix(security): bump protobufjs to 8.0.1 via pnpm override - fix(search): multi-token search + deeper richtext traversal - fix(mobile/deploy): use team membership lookup instead of getSiteRole() — cookie-incompatible with Bearer JWT - fix(examples/blog): remove trailing slash from team photo URL in build.ts - fix(mobile): show local blob preview immediately on image upload — no server re-fetch - fix(test): update QR TTL assertion 5→15 minutes - fix(mobile): /api/mobile/uploads accepts Bearer JWT — no tok required for ImageField - fix(mobile): ImageField fetches via /api/mobile/uploads with orgId+siteId context - fix(mobile): upload returns signed /api/mobile/uploads URL with orgId+siteId context - fix(deploy): deployHookUrl now works with GitHub Actions workflow_dispatch - fix(mobile): fetch /uploads/ images with Bearer auth in ImageField + persist QR sessions to disk - fix(mobile): persist QR sessions to disk so hot-reloads don't wipe tokens - fix(mobile): guard pair generate() against React StrictMode double-fire - fix(mobile): remove duplicate onDeepLink in Login — App.tsx is the single handler - fix(mobile): prevent double token exchange (deep link + QR scanner race) - fix(mobile): increase QR pairing TTL from 5 to 15 minutes - fix(mobile): use X-Forwarded-Host / CMS_PUBLIC_URL in QR + add server-switch link - fix(mobile): show server URL on QR pairing page + pairingEnabled always true - fix(settings): Toggle click now dispatches cms:settings-dirty to enable save button - fix(preview): strip trailing slash from previewSiteUrl to prevent double-slash URLs - fix(preview): drop stale sessionStorage initial value for preview base URL - fix(mobile): shared findLanHost() prefers en0 (Wi-Fi) over en5/VPN - fix(chat): header dropdowns and cmd-palette now exit chat mode before navigating - fix(mobile): instant sign-out — navigate before awaiting auth clear - fix(mobile): clearer network error + 401 handling in Site screen - fix(mobile): auto-clear stale defaultSite instead of showing dead end - fix(mobile): 10s request timeout + auto-redirect on 401 - fix(chat): prevent SSE self-loop from wiping streamed messages - fix(chat): dispatch ICD revalidation on publish/update/unpublish/trash - fix(deploy): auto-detect site type — hide Deploy button when rebuild is impossible - fix(deploy): platform-wide FLY_API_TOKEN env wins over per-site config - fix(cms): POST /api/cms/[collection] honors status: published + dispatches ICD - fix(beam): inject filesystem storage block on import if config omits it - fix(deploy): build native bindings (better-sqlite3) in Fly Docker image - fix(cms-cli): omit undefined optional fields under exactOptionalPropertyTypes - fix(examples/blog): use relative contentDir so build works in CI - fix(beam): strip BEAM_REDACTED placeholders on read + import - fix(schema-drift): use site AI config key instead of env-only ANTHROPIC_API_KEY - fix(F138): no FOUC — SSR-seed isAdminEmpty into HeaderDataProvider - fix(F138): redirect Dashboard + Favorites when admin is empty - fix(F138): no-registry counts as empty + gate Site Settings dropdown - fix(deploy): rm-before-ln for @webhouse/cms symlink - fix(deploy): symlink @webhouse/cms into /data/node_modules - fix(deploy): wire Next.js standalone runner + WEBHOUSE_DATA_DIR for Fly - fix(ux): avatar initial fallback + schema drift add-to-schema - fix(admin): quarantine bad site configs — never crash the whole CMS - fix(F134): use CustomSelect + chip picker, not native - fix(deploy): show tail of stderr in build errors, not head - fix(settings): persist configPath + contentDir changes from General panel - fix(trail): explicit color:var(--fg) on nav-brand + footer-brand so inline SVG's currentColor resolves correctly - fix(trail): inline logo SVG so currentColor follows the theme - fix(trail): fullscreen close × uses SVG icon — pixel-perfect centering, no glyph baseline drift - fix(trail): any click on fullscreen overlay closes it (lightbox UX) - fix(trail): branch arrowheads in ingest-pipeline point into auto_approve and curator_review boxes, not back at the diamond - fix(trail): bump fonts in filters-correspondence to match new table-readable sizes - fix(trail): readable font sizes in biological-trail-rag table + lesson in agent - fix(trail): three-filters link to brain-remembers uses full category path ### Other Changes - ci: auto-bump semver from conventional commits, weekly + on-demand - docs(ai-guide): feature ICD as default Next.js deploy pattern - chore: example blog updates + .gitignore TLS dev certs - docs(F141): site-switch context leak — plan + index - docs(F140): empty-org UX regression — plan + index - chore: accumulated changes — openapi fixes, F135/F136 plan docs, ecosystem config - docs(openapi): add Bearer auth, new endpoints, update servers to v0.3.0 - docs(F139): Headless Site API & Chat Embedding — feature plan + AI guide - ci: add site link to deploy Discord notification - ci: use DEPLOY_DISCORD_WEBHOOK for deploy notifications - ci: Discord notification on webhouse.app deploy (success + failure) - ui(cmd-palette): add Custom Domain entry, anchor jumps to deploy section - ui(user-menu): add Organization Settings entry below Site Settings - ui(sidebar): drop usage gauge, lift Site Settings + Trash up to Search group - docs(i18n): make translationGroup mandatory warning prominent everywhere - docs(F138): empty admin UX + Beam at Account Level — plan - chore: replace cms-logo-icon.svg with webhouse eye everywhere - refactor(admin): move admin-server data out of bootstrap site _data/ - refactor(admin): decouple cms-admin data + routing from any specific site - content(trail): drop broken /docs/scaling link from Work That Fits in a Night - content(trail): new post 'Work That Fits in a Night' + 3 figures - content(trail): nav CTA label 'Initialize Node' → 'Initialize trail' - perf(trail): 3 extra canvas optimizations from trail peer - perf(trail): neuron canvas no longer reads CSS vars on every frame - docs(CLAUDE): clarify mobile vs cc-to-cc paths in peer-intercom note - style(trail): widen article container from 44rem to 48rem for more comfortable reading + bigger figures - refactor(trail): wiki→Neuron across all content, remove dev F-numbers --- ## changelog/v0-2-17 Title: Release v0.2.17 Updated: 2026-05-05 Locale: en ## What's New in v0.2.17 **200 changes** released on 2026-04-14 ### Features - feat: locale-aware Brand Voice — per-locale storage + AI consumer wiring - feat: settings sub-tab titles in tab bar (Settings: Deploy, etc.) - feat: array field supports simple string arrays on mobile - feat: F126 Phase 4-7 — Docker mode, ICD, audit, docs - feat: F126 Phase 3 — build profiles with selector UI - Revert "feat: replace blob API with git push for GitHub Pages deploy" - feat: replace blob API with git push for GitHub Pages deploy - feat: F126 Phase 2 — build log panel with real-time streaming - feat: F126 Phase 1 — framework-agnostic build pipeline - feat: auto-set previewSiteUrl for ALL deploy providers - feat: auto-set previewSiteUrl after first successful deploy - feat: editor deploy button — direct publish with toast, no settings redirect - feat: guided GitHub Pages onboarding for users without GH account - feat: replace SSE streaming with poll-based chat for mobile - feat: Copy button on user messages in mobile chat - feat: chat system prompt instructs AI to use uploaded images in content - feat: Default Site setting — boot directly to a site - feat: chat vision — images sent as Anthropic vision content blocks - feat: chat image attachments with thumbnail preview + remove button - feat: chat image attachment + fix permissions server crash - feat: F07 Session 3 — F129 Edit FAB, splash screen, permissions, chat polish - feat: permission-based access control (foundation for F55) - feat: real-time chat sync across devices via SSE - feat: add get_lighthouse_history chat tool — track score trends - feat: conversation context menu — Copy ID, Rename, Delete - feat: copy conversation ID button in mobile chat history - feat: F07 chat polish — inline images, copy, history, memory, DocPills, site tags - feat: add run_lighthouse chat tool — scan site speed from chat - feat: F07 Chat with Your Site — full AI chat on mobile - feat: inline image rendering in chat messages - feat: form Inbox pill in chat + submission preview in list - feat: F07 Phase C — relation picker, array/object editors, direct AI analyze - feat: add F130 AI Fallback Gateway to features + roadmap - feat: F07 Media Browser — iOS Photos-style viewer, AI analysis, thumbnails - feat: F07 Phase 3B — richtext editor, image upload with AI analysis, auto-deploy - feat: add get_form_submission tool + message preview in list - feat: F07 Phase 3 — mobile content editing + F128/F129 feature specs - feat: add 3 chat tools — Lighthouse scores, form submissions, form stats - feat: clean up Settings — collapsible push, remove debug + profile card - feat: rewrite F30 Form Engine plan — CMS-native form submissions - feat: Access Tokens + push on deploy/agent events - feat: F98 Lighthouse Optimize — auto-fix + manual recommendations - feat: app icon badge + push notification setup manuskript - feat: push notifications end-to-end verified on real iPhone - feat: F98 Lighthouse — scan both Mobile + Desktop in parallel - feat: F98 Lighthouse — HelpCard on ActionBar + default PSI key - feat: Firebase SDK + GoogleService-Info.plist in build resources - feat: F98 Lighthouse — CWV HelpCard + diagnostics + broader audit capture - feat: F98 PSI API key setup — inline guide on quota error - feat: F98 Lighthouse Audit — PSI engine, score dashboard, sidebar - feat: signed URL tokens for preview proxy — production-ready iframe auth - feat: sirv preview proxy for all sites + live URL links - feat: glass backdrop blur overlay on org selector dropdown - feat: native PTR indicator, swipe-back with Home underneath, haptic feedback - feat: Simple Blog nav + data collection pages - feat: native pull-to-refresh + mobile UX improvements - feat: F03 WordPress Migration Phase 1 — probe + content + site creation - feat: F02 Import Engine — CSV/JSON/Markdown bulk import - feat(F07): swipe-back, preview placeholder, GoogleService-Info.plist, fixes - feat(F07): live QR scanner + LAN IP pairing + device cert trust - feat(F07): Settings screen + site search + avatar-as-menu-trigger - feat(F07 Phase 2): push notification infrastructure + Firebase setup - feat(F125): Swift (Vapor 4) reference consumer — code ready, build in progress - feat(F125): Rust (Axum) + Elixir (Plug) reference consumers - feat(F125): Astro 5 + SvelteKit + Hugo reference consumers - feat(F07 Phase 1.5): native UX polish — org dropdown, FAB, real preview, screen header - feat: PM2 dev pool, deep links, link-checker fix, onboarding shortcuts - feat(F07 Phase 1): end-to-end working — CapacitorHttp + auto-login + UI polish - feat(F125): PHP reference consumer (Laravel-compatible) - feat(F125): Ruby + Sinatra reference consumer (Rails-compatible) - feat(F125): Django + Python reference consumer example - feat(F125): Go + Gin reference consumer example - feat(F125): schema export UI in Site Settings - feat(F125): globals collection in Java + .NET examples ### Bug Fixes - fix: rename duplicate workspace name to unblock npm publish - fix: save/delete error feedback + scheduled backup URL typo - fix: deploy poll leak, silent failures in translation linking + locale - fix: hide Import button for editors (admin only) - fix: translation linking shows error on failure instead of silent fail - fix: preview link interceptor handles absolute same-origin URLs - fix: hide Edit schema button for editors (admin only) - perf+fix: shared header data context, preview-serve crash fix - fix: sharpen chat prompt for multi-language content creation - fix: sequential blob upload with exponential backoff for GHP deploy - fix: reduce GHP blob batch size to 5 + retry on 403 rate limit - fix: skip large files + video/archives in GitHub Pages deploy - fix: deploy button triggers directly for all roles — no settings redirect - fix: deploy button uses fetched role instead of permissions hook - fix: parallel blob creation for GitHub Pages deploy (2000+ files) - fix: hide Empty Trash button for editors (admin only) - fix: use relative proxy URL for link interceptor (cross-origin fix) - fix: preview proxy intercepts link clicks for in-iframe navigation - fix: remove backticks in template literal that broke build - fix: desktop chat rewrites absolute /uploads/ URLs to relative - fix: chat streaming starts immediately with keepalive from first byte - fix: web chat user messages now render markdown (shows images inline) - fix: SSE keepalive prevents WKWebView timeout on long chat responses - fix: mobile upload AI analysis + chat image display - fix: chat vision reads images from disk instead of HTTP fetch - fix: move Default Site from Settings to star toggle on Site page - fix: chat image × top-right, larger circle (matches Claude iOS) - fix: chat image × moved to top-left (Claude iOS match) + tap-to-fullscreen - fix: chat input redesigned to match Claude iOS layout - fix: chat image preview uses DataUrl instead of Uri - fix: gate Rename/Clone/Settings/NewAgent/Mobile for editors - fix: SiteSwitcher used siteRole instead of global role for admin check - fix: hide site filters when user has only 1 site - fix: hide Backup menu item for editors (admin only) - fix: hide org switcher when user has access to only 1 org + 1 site - fix: invitation accept silently discarded password for existing users - fix: split permissions into shared + server-only to fix SSR build error - fix: permission-gate UI for non-admin users - fix: remove emoji icons from Content and Media buttons on Site page - fix: Lighthouse saves both mobile+desktop, chat shows both strategies - fix: run_lighthouse tool now correctly parses scan API response - fix: sanitize image alt text in chat to prevent broken markdown - fix: unique key prop in MessageList to prevent React warning - fix: secure image serving — HMAC-signed URLs instead of JWT in query params - fix: regenerate pnpm-lock.yaml for cms-mobile badge dep - fix: extract AccessTokensPanel to client component (useState needs 'use client') - fix: remove stray closing paren in account page - fix: F98 Lighthouse — SEO label uppercase - fix: F98 human-readable PSI error messages - fix: fullscreen preview uses liveUrl when available - fix: clear probe cache on pull-to-refresh - fix: source .env.local for cms-admin-prod so CMS_CONFIG_PATH loads - fix: use deployProductionUrl regardless of deployProvider status - fix: prefer liveUrl for preview card, fix proxy base LAN IP - fix: resolve live URLs from deployCustomDomain/deployProductionUrl - fix: proxy ALL localhost preview URLs through cms-admin for mobile - fix: remove embed code from snippets page — show title + content only - fix: mobile horizontal overflow (wiggle) on all 12 framework sites + snippet resolution in Simple Blog - fix: move WP migration to New Site page + DnD import drop zone - fix: F02 import wizard — disable Upload button until file selected - fix: cast firebase-admin import through unknown for strict prod build - fix: remove require.resolve that Turbopack can't ignore - fix: two more prod build blockers (ts-expect-error + Turbopack resolve) - fix: correct import path for CustomSelect in import-wizard - fix: widen executeImport cms param type to unblock prod build - fix: install web-push + remove unused ts-expect-error in push-send - fix(F125): SvelteKit runs production build, not dev mode - fix(F125): Ruby — disable X-Frame-Options for CMS admin preview - fix(F125): Elixir HEAD handler for CMS health check - fix(F125): Astro global CSS + Swift Vapor raw HTML tag - fix(F07): rewrite localhost preview URLs to LAN IP + fix role display - fix: goto API role check + path validation + preview buttons live-update - fix: sync pnpm-lock.yaml (remove firebase-admin + web-push from lockfile) - fix(scripts): pm2-ports.sh now detects ports from all framework formats - fix: header preview/live/deploy buttons + collection thumbs update instantly on config save - fix: sync pnpm-lock.yaml with package.json (remove stale firebase-admin + web-push) - fix(F125): Swift Vapor compiles — remove invalid ViewRenderer extension - fix(F125): Go example responds to HEAD requests for health check ### Other Changes - chore: release v0.2.17 - chore: bump npm packages to 0.2.16 — publish forms support - docs: add TODO section for deferred tech debt - quality: remove 12 redundant as any casts on Document type - perf: migrate 4 components from direct fetch to useHeaderData() - docs: rewrite F61 as 3-layer observability (audit + server + client) - a11y: add aria-labels to icon-only header buttons - docs: rewrite F61 as 3-layer observability (audit + server + client) - docs: add AUDIT-TOOLS.md — comprehensive audit guide for CC sessions - perf+quality: save race guard, sidebar context dedup, shared data rule - cleanup: remove 2 dead component files - perf: incremental GitHub Pages deploy — diff tree, upload only changes - security+quality: auth guards, abort controllers, hooks fix - security: stop logging API key labels in MCP route - cleanup: remove debug console.logs, add LRU cap to doc cache - security: add auth guards to 7 unprotected API routes - security+perf: fix auth guards, registry race, sidebar polling - perf: lazy-load translation siblings in document editor - perf: async client-side loading for collection + scheduled pages - chore: stage uncommitted changes from other sessions - perf: fix site-switch freezes and dashboard blocking - SECURITY: add admin role check to schema editor route - SECURITY: add admin scope to MCP server for privileged tools - SECURITY: permission-gate chat tools — editors can't use admin tools - SECURITY: fix usePermissions default — deny all, not allow all - docs(features): add F118 — Face Detection & Recognition - chore: remove temporary debug log from registry endpoint - SECURITY: filter /api/cms/registry by team membership - chore: gitignore .vscode, remove backup + auto-generated test file - chore: commit accumulated changes across sessions - F112: AI Fallback Gateway (Local Gemma 4) - dev: Start/Stop Server in site more-menu via PM2 API - F129: rewrite plan — Visual Inline Editing (Pitch Vault + framework consumers) - F30: three form embedding methods + form field type + build shortcodes - F30: form builder UI + auto-reply + admin-defined forms - F30: poll form unread count every 30s in sidebar badge - F30: add /api/forms/ to public proxy prefixes - F30 day 3: schema endpoint + widget + build.ts + chat tools - docs: F07 session handoff #2 — comprehensive resume for next session - F30 day 2: public endpoint + admin routes + inbox UI + sidebar badge - F30 day 1: form schema types + FormService + spam protection + 15 tests - docs: mark F98 Lighthouse Audit done (milestone 50) - docs: update Lighthouse help to reflect parallel mobile+desktop scan - chore: park F03 Migrate tab — comment out from New Site UI - refactor: drop standalone mode for local prod — use regular next start - docs: mark F122 Beam as Done in FEATURES.md - docs: F07 session handoff for next Claude Code session - docs(F125): AI guide module 21 + schema re-export hard rules --- ## changelog/v0-2-15 Title: Release v0.2.15 Updated: 2026-05-05 Locale: en ## What's New in v0.2.15 **2 changes** released on 2026-03-30 ### Other Changes - chore: release v0.2.15 - chore: bump all packages to 0.2.14 --- ## changelog/v0-2-14 Title: Release v0.2.14 Updated: 2026-05-05 Locale: en ## What's New in v0.2.14 **200 changes** released on 2026-03-30 ### Features - feat: F122 Beam — site teleportation from localhost to cloud - feat: F121 deploy-service supports Dockerfile-based Fly.io deploys - feat: F121 @webhouse/cms/next — drop-in SEO helpers for Next.js - feat: add F121 Next.js CMS Helpers to feature roadmap (Tier 1) - feat: re-implement F27 GitHub restore (reverted by parallel session) - feat: F120 Onboarding — guided product tour (Tier 1) - feat: add F119 One-Click Docker Deploy to feature roadmap - Revert "feat: add sticky navbar to webhouse.app landing with Docs link" - feat: F117 MCP tool parity — 28 new tools, 43 total - Revert "feat: F27 GitHub restore — push backup content to repo via API" - feat: F27 GitHub restore — push backup content to repo via API - feat: add sticky navbar to webhouse.app landing with Docs link - feat: wire HelpCard learnMorePath links to docs.webhouse.app - feat: F109 Inline Proofreading + tab isolation fix - feat: add F117 MCP ↔ Chat Tool Parity to roadmap (Tier 2) - feat: F114 master memory — immutable product identity in every chat - feat: Visibility card on Dashboard with combined SEO + GEO score - feat: import preview with merge confirmation + timestamps in memory export - feat: F114 full chat export/import — portable ZIP archive - feat: F114 Phase 4 — memory import/export + conversation search - feat: F116 Contextual Help — HelpCard framework with 10 articles - feat: F114 Chat Memory — cross-conversation intelligence - feat: add F116 Contextual Help (HelpCard Framework) to feature roadmap - feat: F112 G05 — Visibility dashboard (SEO + GEO combined) - feat: F48 session — previewable flag, chat fixes, link translations tool - feat: rename Performance → AI Analytics, add Visibility page placeholder - feat: previewable flag on collections + Copy ID in chat history - feat: add F115 CMS Help Chat — product knowledge base for chat - feat: RSS feed generator — /feed.xml with configurable collections - feat: content search covers ALL data fields (tags, author, category, etc.) - feat: search_content now searches ALL content including media tags - feat: F112 G08 — GEO settings panel (robots.txt strategy, organization, API keys) - feat: Link translations tool in Properties + fix chat hydration error - feat: show thinking toggle in chat input area - feat: F112 G07 — GEO Optimizer agent for AI citation-friendly content - feat: F112 G04 — 5 new JSON-LD templates (HowTo, Service, Software, Breadcrumb, WebSite) - feat: F112 G02 UI — dual SEO + GEO score in panel and dashboard API - feat: F112 G02 — GEO score with 8 AI visibility rules + combined Visibility Score - feat: add F114 Chat Memory & Cross-Conversation Intelligence - feat: collection list — Preview in context menu + menu on grid cards - feat: create_document accepts locale parameter from chat AI - feat: F112 G03 — llms-full.txt + per-page .md markdown endpoints - feat: F112 G01 — smart robots.txt generator with 4 AI crawler strategies - feat: F48 final locale coverage — AI routes, SEO score, JSON-LD - feat: chat avatars use Gravatar + larger matching size - feat: side-by-side tabs for 3+ locales - feat: search_media returns locale-specific alt-text and captions - feat: force-change locale in Properties drawer + No locale indicator - feat: F48 chat i18n — auto-translate toggle + locale-aware chat - feat: chat create_document auto-translates to all configured locales - feat: thumbnail size control (S/M/L) in media action bar - feat: rotate images from media lightbox toolbar - feat: extend F104 with Fase 6 — Frontend Media UI Performance - feat: F63 audit as Sharp PNG billboard — 25 components, Illustrator-ready - feat: F63 audit SVG with 25 precise element screenshots via data-testid - feat: interactive translate auto-flips direction when language detected - feat: F80 selector-map.json + Playwright helpers + roadmap update - feat: F80 Admin Selector Map — 65 data-testid attributes across 17 files - feat: interactive translate detects actual language from HTML lang attr - feat: side-by-side for interactives — preview sibling alongside editor - feat: F63 component audit — Playwright screenshots + Sharp crops + SVG visual reference - feat: translate endpoint swaps interactive IDs to locale versions - feat: side-by-side always available — equal partners, no source concept - feat: restore stale banner as info-only (no re-translate button) - feat: showStaleTranslations site config toggle — OFF by default - feat: F48 translationGroup — bidirectional ID-based translation partners - feat: F27 backup includes cms.config.ts + feature markers for i18n compatibility - feat: interactives list view matches collection-list pattern exactly - feat: interactives list view uses table layout matching collections - feat: Interactive editor — locale switcher + download in menu - feat: F48 i18n — Interactive translation as first-class feature - feat: F48 i18n — Interactive translation - feat: F48 i18n — complete remaining features - feat: F13 Notification Channels — shared webhook dispatcher + wire all automations - feat: F48 i18n — upload auto-analyzes for all site locales - feat: F48 i18n — translate endpoint replaces image alt-text per locale - feat: F48 i18n — lightbox AI panel with per-locale caption/alt pills - feat: F48 i18n — per-locale media metadata + SEO translation - feat: F48 i18n — build.ts with DA/EN locale support + flag toggle - feat: F48 i18n — AI-generated translated slugs - feat: F48 i18n — locale switcher navigation + new document locale picker ### Bug Fixes - fix: deploy-service respects Dockerfile build context hint - fix: previewSiteUrl takes priority over sirv in all preview contexts - fix: respect localeStrategy in preview URL construction - fix: remove automatic category injection from preview URLs - fix: MCP services now cookie-free — all use resolved dataDir directly - fix: add 5s timeout on MCP media trash to prevent hanging - fix: MCP list_trash and empty_trash now include media files - fix: add permanent MCP session logging, remove debug code - fix: MCP site-scoped key resolution — API key determines site, not cookies - fix: zoom controls larger — 28px buttons, 0.75rem percentage, text ± - fix: editor zoom tool uses CSS zoom instead of fontSize - fix: MCP session persistence — globalThis survives Next.js HMR - fix: rename Conversations → Chats, server-side chat search - fix: F116 HelpCard — always visible, collapsible instead of dismissible - fix: Edit pill uses SPA navigation — preserves chat session - fix: Edit pill navigates to doc with ?mode=admin to exit chat mode - fix: DocPill View uses correct endpoints + schema returns urlPrefix - fix: search_media includes user tags in results (was only showing AI tags) - fix: click outside Properties drawer to close it - fix: link famous/bekannte ski posts with shared translationGroup - fix: chat View pill resolves exact preview URL with locale + category - fix: document editor preview URL includes locale prefix - fix: chat preview base uses previewSiteUrl first, sirv as fallback - fix: grid context menu in title bar + preview available on all collections - fix: resolve preview base URL in both grid and list views - fix: collection status filter persisted in localStorage per collection - fix: build.ts reads locales from site config + chat preview uses locale prefix - fix: chat system prompt — NEVER create_document twice for translations - fix: chat avatar uses sessionStorage cache instead of fetching /api/auth/me - fix: chat user avatar reads from d.user.gravatarUrl (not d.gravatarUrl) - fix: chat avatar vertically centered with first tool call card - fix: chat page path includes category segment when collection has category field - fix: chat get_document resolves hierarchical URLs (categories in path) - fix: chat preview card respects previewSiteUrl — same fix as preview page - fix: preview respects previewSiteUrl — never overrides with sirv - fix: translate button spinner + flushSync error in richtext editor - fix: locale dropdown shows only locale code, interactives get locale on upload - fix: link traening/training ski pages with shared translationGroup - fix: ALL document creation paths always set locale - fix: chat SEO generation uses document locale, not site default - fix: translate endpoint stamps locale on source doc if missing - fix: chat create_document sets locale on creation for multi-locale sites - fix: rotate buttons grouped with border for visibility - fix: media grid uses ⋮ dropdown menu + rotate buttons show for all images - fix: pass thumbMinWidth to GridView + robust rotate button check - fix: auto-correct locale before rejection check - fix: interactive translate shows error toast + reloads on locale correction - fix: EN ski post uses EN interactive (ski-vinter-slideshow-en) - fix: use xlink:href for SVG images — Adobe Illustrator compatibility - fix: richtext editor skips onChange when content didn't actually change - fix: side-by-side button matches sibling link style exactly - fix: TipTap onCreate dispatch no longer triggers dirty flag - fix: key={doc.id} on DocumentEditor — fresh state on doc navigation - fix: side-by-side reads localStorage after hydration to avoid dirty flag - fix: side-by-side button always says 'Side-by-side', yellow when active - fix: side-by-side persists across navigation via localStorage - fix: side-by-side button matches sibling link height exactly - fix: move side-by-side button to translations bar, compact size - fix: Language section toggles now trigger settings dirty state - fix: side-by-side shows default-locale source on translation docs - fix: remove all stale translation code, auto-translate only with toggle - fix: auto-translate only on first publish, remove stale triangles - fix: remove stale banner, preserve status on re-translate - fix: stale banner — only on non-default-locale docs + persistent dismiss - fix: stale banner only shows when a sibling is actually newer - fix: re-translate uses correct targetLocale fallback + better error messages - fix: stale banner — dismiss button + no page refresh on re-translate - fix: Re-translate button calls translate API directly instead of opening dialog - fix: interactives remembers grid/list view in localStorage - fix: interactives page uses same 2rem padding as collection pages - fix: interactives context menu uses same style as sites cards - fix: Interactive grid — context menu always visible in info bar - fix: Interactive i18n — match document editor design + locale badges - fix: Interactive code editor was permanently read-only - fix: side-by-side uses FieldEditor with locked=true for identical rendering - fix: side-by-side — inline heading/code/link styles without prose class - fix: side-by-side — proper heading rendering + field alignment spacing - fix: side-by-side parses TipTap image title for float/width styling - fix: side-by-side renders markdown content with mini converter - fix: side-by-side source pane styling — prose class, better field types - fix: side-by-side uses server-provided source data instead of API fetch - fix: move side-by-side button to action bar for visibility - fix: upload AI analysis — log errors instead of silently swallowing - fix: AI metadata popover captures per-locale fields after re-analyze - fix: EN pages use canonical paths (/en/about/ not /en/about-en/) - fix: add gap between EXIF labels and values - fix: AI panel shows Analyze button when no AI data exists + reverse geocoding - fix: remove CopyField reference — use inline click-to-copy on ID - fix: copy button on ID in Properties panel + translation chain guard - fix: only source documents can create translations + copyable doc ID - fix: create_document strips reserved doc-level fields from data - fix: locale badge is read-only, not a dropdown selector - fix: hide "+ Add translation" when all locales are covered - fix: CreateTranslationDialog only shows available locales - fix: editor reads locales from siteConfig instead of cms.config.ts ### Other Changes - chore: release v0.2.14 - test: F99 preview URL e2e tests — 11 tests, all passing - docs: F42 Phase 2 — universal --template flag for scaffolder - docs: mark F27 Backup & Restore as done (milestone #37) - docs: mark F114 Chat Memory and F117 MCP Parity as Done - docs: F117 — add F48 i18n as hard ship-gate dependency - docs: mark F109 Inline Proofreading as done (milestone #36) - docs: F31 session prompt — build docs.webhouse.app dogfooded on CMS - docs: complete rewrite of F31 Documentation Site plan - docs: mark F48 i18n as Done in roadmap - docs: MCP setup guide + tool parity analysis (MCP vs Chat) - chore: update pnpm-lock.yaml for minisearch dependency - chore: add pnpm clean to root package.json - chore: add clean script to cms-admin - docs: mark F112 GEO as Done in roadmap - docs: absorb G06 Index Checker into G05 Visibility Monitor - docs: update F112 GEO plan with all shipped features (RSS, llms-full, robots, GEO score, agents, settings) - docs: F48 i18n — mark as DONE with implementation summary - docs: update F112 GEO progress — 5 of 8 phases complete - docs: F114 — add token budget section, bump default aiChatMaxTokens to 16384 - docs: mark F13 Notification Channels as done in roadmap - docs: mark F84 Move Site to Org as done - docs: comprehensive roadmap audit — mark 7 more features as done - docs: mark F42, F67, F79, F96, F97, F99 as done in roadmap --- ## changelog/v0-2-13 Title: Release v0.2.13 Updated: 2026-05-05 Locale: en ## What's New in v0.2.13 **200 changes** released on 2026-03-28 ### Features - feat(F42): Next.js GitHub boilerplate — webhook revalidation + LiveRefresh - feat(F42): Next.js boilerplate — Next.js 16 + React 19 + Tailwind v4 - feat(F42): static site boilerplate — complete starter template - feat: add F112 GEO Generative Engine Optimization (Tier 1) - feat: add F111 External Publishing to feature roadmap (Tier 2) - feat(F67): Discord notification on security gate failure + weekly scan - feat(F67): security gate — pre-commit hook, CI workflow, custom CMS scanner - feat(F79): site config validator — friendly errors, validate button, suggestion engine - feat(F96): interactive map preview in TipTap editor - feat(F96): add Map option to richtext media insert dropdown - feat(F96): embeddable maps — OSM/Leaflet map field type, richtext embed, interactive template - feat: ESC closes history drawer - feat: More menu on history items — Star, Rename, Delete - feat(F97): canonical URL, social preview, readability, rewrite-for-keyword, duplicate title detection - feat(F97): SEO dashboard tabs + keyword coverage on Dashboard card - feat(F97): complete SEO module — keyword tracker, JSON-LD templates, export - feat: delete conversations from history drawer - feat: restructure AI defaults panel to match model resolver - feat: F97 — SEO card on Dashboard + auto-generate OG images with Sharp - feat: AI-powered SEO auto-generation on document creation in chat - feat: configurable chat limits — model, max tokens, tool iterations - feat: increase chat limits — 25 tool iterations, 8192 max_tokens - feat: F97 Phase 2 — SEO Dashboard, bulk optimize, chat integration - feat: expose EXIF/GPS data in chat media tools - feat: add Phase 4 bulk tools — bulk_publish, bulk_update, schedule_publish - feat: empty_trash tool — permanently delete all trashed items - feat: arrow up on empty input recalls last user message - feat: F97 SEO Module Phase 1 — per-document SEO panel - feat: F102 schema drift auto-fix — remove orphaned fields from content - feat: Simple Blog build.ts renders !!INTERACTIVE and !!FILE embeds - feat: F110 Digital Island Apps — artifact cards in chat - feat: add F110 Digital Island Apps to feature roadmap - feat: file type validation + AI knows about upload capabilities - feat: Media tags — comma to add tag + paste comma-separated list - feat: mark F103 AI Image Analysis as Done - feat: server-side PDF and Word text extraction for chat uploads - feat: Claude Desktop-style input — textarea top, buttons bottom row - feat: upload CSV, Markdown, Word, PowerPoint, PDF, HTML in chat - feat: cancel button on building preview cards - feat: AI Chat tools tab in Help & Support drawer - feat: history drawer from left side + Danish datetime format - feat: rename conversations in history panel - feat: 15 new chat tools — calendar, agents, curation, deploy, more - feat: image upload in chat — + button and drag & drop - feat: update_site_settings tool — change settings via chat - feat: rename 'View schema' to 'Site info' — broader and friendlier - feat: add 'What can you do?' and 'Edit a page' to welcome screen - feat: F107 Phase 3 — Inline edit forms in chat - feat: mark F102 Schema Drift Detection as Done - feat: F102 Schema Drift Detection — warns when content has fields missing from schema - feat: mark F106 + F108 as Done, F108 shipped 2026-03-27 - feat: teach chat AI about image sizing and float in richtext - feat: add F109 Inline Proofreading to feature roadmap - feat: F108 Rich Text Editor Enhancements - feat: media library tools — list_media + search_media for chat - feat: include draft documents in preview builds with visible DRAFT banner - feat: add F108 Rich Text Editor Enhancements to roadmap - feat: table context toolbar — add/delete rows, columns, and table - feat: branded 404 page with real SVG wordmark logo and favicon - feat: copy buttons, branded 404 page, preview path fix - feat: page preview cards in chat — iframe preview of actual pages - feat: rich markdown rendering in chat messages - feat: F107 Phase 2 — Create & Edit via chat - feat: support both Ctrl+Shift+C and Cmd+Shift+. for chat toggle ### Bug Fixes - fix: exclude boilerplates from pnpm workspace + Discord only on failure - fix: add .gitleaks.toml — suppress test file false positives - fix: gitignore deploy output, WebP variants, revisions, example uploads - fix: internal link search uses collection name as URL fallback - fix(F96): map embed geocodes address with Nominatim + renders Leaflet map - fix: JSON-LD hides duplicate fields, chat creates full _seo with score + OG image - fix: harmonize + and send button sizes — both 32px circular - fix: row menu uses fixed positioning to avoid overflow clipping - fix: React key warning on keyword tracker list — Fragment needs key, not child tr - fix: dashboard media count excludes WebP variants and dotfiles - fix: cap chat limits — max 32768 tokens, max 50 tool iterations - fix: OG image generator — auto-rotate based on EXIF before resize - fix: SEO Save button — visual feedback (Saving… → Saved ✓ → Save) - fix: invisible scrollbar globally — visible only on hover - fix: hidden scrollbar on main content + purple dot for scheduled docs - fix: SEO uses configurable aiContentModel instead of hardcoded Haiku - fix: remove min-h-screen from Media + Interactives — no forced scrollbar - fix: remove min-h-screen from Link Checker — caused unnecessary scrollbar - fix: apply overflow-y:overlay to sidebar-inset (main content area) - fix: use overflow-y:overlay — scrollbar floats over content, no shift - fix: scrollbar-gutter:stable prevents layout shift between pages - fix: remove flex:1 + overflowY from SEO content wrapper - fix: SEO page uses fragment wrapper like all other pages - fix: Optimize All button in ActionBar actions slot (right side) - fix: Optimize All button — variant="primary" to match Link Checker - fix: SEO dashboard — status dot indicator on each document - fix: SEO dashboard — use ActionButton for Optimize All, add Tools breadcrumb - fix: SEO tab title shows "SEO" not "seo" - fix: bulk SEO optimize — use doc.id not doc.slug for cms.content.update - fix: grid view builds with drafts before serving previews - fix: grid view always attempts preview — fallback to // - fix: eliminate Edge Runtime compile warnings from instrumentation - fix: meta description textarea taller (100px min) to show 160 chars - fix: SEO check messages — actionable, tell you exactly what to do - fix: include field name in SEO score messages - fix: restore 120-160 char requirement, fix AI prompt to hit target - fix: lower meta description minimum from 120 to 70 chars - fix: always re-extract OG image on AI optimize - fix: OG image extraction — only accept valid /uploads/ paths - fix: hide broken OG image preview — onError hides the img element - fix: OG image extraction — only capture URL, not markdown title attr - fix: AI Optimize auto-fills OG image from content - fix: list_trash now includes media files, not just documents - fix: SEO panel — Save button, auto-save after AI, better prompt, collapsible preview - fix: unescaped backtick in system prompt caused "uploads is not defined" - fix: SEO panel — lastOptimized flag + re-optimize warning + save reminder - fix: use CustomSelect for robots directive in SEO panel - fix: SEO panel renders as fixed sidebar drawer (same as Properties) - fix: respect devInspector site setting — only render DevInspector when enabled - fix: dynamic import getDocumentUrl to prevent Turbopack crash - fix: dynamic import pdf-parse and mammoth to prevent Turbopack crash - fix: wrap chat init in try-catch with descriptive error message - fix: schema drift fix — use direct file writes instead of cms.content.update - fix: auto-resize interactive iframes to content height - fix: interactive thumbnails — add allow-same-origin to sandbox - fix: interactive iframe path — /uploads/interactives/ not /interactives/ - fix: add immediatelyRender: false to TipTap useEditor for SSR - fix: artifact card injects tag for relative image URLs - fix: Command Palette media results pass search term to Media filter - fix: Command Palette search includes user-defined media tags - fix: import pdf-parse/lib/pdf-parse.js to skip test file loader - fix: static import of pdf-parse — Turbopack requires top-level import - fix: pdf-parse + mammoth in root deps + robust import strategies - fix: Empty Trash — race condition + missing file tolerance - fix: dedicated /api/extract-text endpoint for PDF/DOCX extraction - fix: inline PDF/DOCX extraction with require() in upload route - fix: use createRequire for pdf-parse (CJS module in Turbopack) - fix: PDF extraction working — pdf-parse v1 + logging + border fix - fix: larger send button — 40px circle with 18px icon - fix: send button always gold circle — visible when empty (30% opacity) - fix: restore exact original send button style - fix: send button always gold circle, dims when empty - fix: restore send button to 32px with 16px icon - fix: align message list with chat input — same maxWidth + padding - fix: entities decoded + preview cards share single build - fix: history titles wrap to max 2 lines, edit button always aligned - fix: widen history drawer from 300px to 400px - fix: bot avatar aligned with tool cards (marginTop 2px for assistant) - fix: pixel-center icons in tool call cards - fix: pixel-center avatars with first line of text - fix: strict guardrail — only use image URLs from tool results - fix: remove immediatelyRender:false to eliminate flushSync warning - fix: suppress flushSync warning — add shouldRerenderOnTransaction: false - fix: all content is Markdown — never generate HTML - fix: teach AI to detect richtext format (HTML vs Markdown) - fix: preview build works for all sites + fresh server restart - fix: add CSS for highlight marks, underline, sup/sub, text-align - fix: preview button opens in new window when in chat mode - fix: move New + History buttons into AdminHeader (always visible) - fix: prevent duplicate text output in chat - fix: preview card rebuilds site before showing iframe - fix: chat toolbar (+ New / History) is now sticky at top - fix: 404 page — transparent logo (no white bg), fix Turbopack error - fix: use getDocumentUrl from routing resolver for preview paths - fix: preview path resolves against actual dist/ files - fix: remove webpack config — incompatible with Turbopack - fix: tabs system no longer hijacks navigation after site switch - fix: re-focus chat input when AI finishes responding - fix: Cmd+S stale closure — save was using old doc.data - fix: chat input text scrolled out of view - fix: make chat input placeholder text more visible - fix: auto-focus chat input when switching to chat mode - fix: chat input — auto-focus on mount + fix invisible text color - fix: simplify RTE value sync — no guards, just compare and set - fix: RTE sync — compare with actual editor content, not last emitted - fix: use globalThis for doc state cache — survives HMR module re-eval - fix: module-level doc state cache survives tab navigation - fix: prevent content loss — remove router.refresh + ignore dist/ in webpack watcher - fix: hide sidebar in chat mode - fix: stronger guard against content overwrite on save/build - fix: prevent content loss on save — skip round-trip setContent - fix: Cmd+Shift+S no longer triggers save — only strikethrough - fix: cache session user in sessionStorage to prevent avatar flash - fix: toolbar state reactivity for TipTap v3 ### Other Changes - chore: release v0.2.13 - chore: update pnpm-lock.yaml for nextjs-boilerplate workspace - chore: bump all packages to 0.2.12 — includes map field type - docs: add 'map' to AI guide field types summary - docs: add F111 External Publishing feature plan - security: add denyViewers() role check to 57 write endpoints — scanner now 0 findings - security: fix 11 command injection findings — execSync → execFileSync - security: protect ALL /api/* routes via middleware — closes 27 unauthed route findings - security: fix 4 vulnerabilities from security review - refactor: central model resolver — getModel("content" | "code" | "premium") - test: guard against unescaped backticks in chat system prompt - debug: log full stack trace for chat init error - docs: add F107 Chat Integration note to 81 planned feature plans - docs: update 5 AI guide modules for F44/F103/F106/F108 features - docs: add TipTap + Next.js SSR note to CLAUDE.md - chore: remove debug logging from chat-input PDF extraction - debug: add console.log to trace PDF text extraction flow - docs: add architecture SVG diagram - refactor: centralize all org/site switching into switch-context.ts - debug: log doc state cache on mount + setDoc to diagnose content loss - debug: add console.log to RTE sync to diagnose content loss - perf: render both modes with CSS display toggle — instant switching --- ## changelog/v0-2-11 Title: Release v0.2.11 Updated: 2026-05-05 Locale: en ## What's New in v0.2.11 **200 changes** released on 2026-03-18 ### Features - feat: add F67 Security Gate to roadmap (Tier 1) - feat: Supabase-style default view + Switch Site/Org actions - feat: add keyboard shortcuts — Cmd+S save, Cmd+Shift+Arrow tab switch, n/g collection actions - feat: add dynamic collections to command palette - feat: Spotlight-style command palette with navigation, settings, and actions - feat: add F66 Search Index feature plan - feat: styled Checkbox and Radio components replacing native inputs - feat: add F65 Agent Pipeline E2E Tests to roadmap (Tier 1) - feat: add F64 Toast Notifications System to feature roadmap - feat: toast notifications across all CMS admin actions - feat: add scheduler toast test endpoint - feat: custom toast design + notification sounds - feat: show red 'expired' badge in document editor - feat: add 'expired' document status with red indicators - feat: red tab dot for expired/unpublished documents - feat: replace polling with SSE for instant scheduler notifications - feat: live scheduler notifications with toast + tab status updates - feat: add F63 Shared Component Library & Design Tokens to roadmap - feat: Default Views section in Account General — all view preferences - feat: Account Preferences tab — default calendar view + agents view - feat: Calendar page H1 title section matching all other pages - feat: calendar — PageHeader, persisted view pref, scrollbar fix - feat: F43 persist last active site on user record - feat: week view scrolls full 24h in 11-hour viewport - feat: week view with hourly time grid, NOW marker, weekend shading - feat: add F62 Directory Sync (AD / SCIM / External User Sources) to roadmap - feat: add F61 Activity Log to feature roadmap - feat: dashboard cards for Media Library and Interactives with counts - feat: F43 persist remaining prefs — agents view, curation tab, logo icon - feat: F43 Persist User State — tabs + sidebar survive cookie clear - feat: calendar layout matches Apple Calendar — Day/Week/Month/Year - feat: scheduler webhook notifications — Discord, Slack, generic - feat: add F60 Reliable Scheduled Tasks to feature roadmap - feat: F47 final — expiry icon, snapshot cron, calendar sidebar legend - feat: per-user HMAC tokens for calendar feed authentication - feat: calendar event tooltips with excerpt and metadata - feat: add document URL to iCal events - feat: full calendar UI with month/week/day views - feat: iCalendar subscription feed for scheduled content - feat: add F59 Passwordless Auth (Passkeys + QR Code Login) to roadmap - feat: F47 Calendar page + dashboard scheduled card + sidebar nav - feat: F47 Content Scheduling — unpublishAt expiry + scheduler daemon - feat: add F58 Interactive Islands to feature roadmap - feat: add signup page with two-step onboarding form - feat: sites dashboard filtered to user's team memberships - feat: viewer read-only guards on all admin pages - feat: viewer role is fully read-only — all write UI hidden, fields locked - feat: one-click accept for logged-in users — skip name/password form - feat: autocomplete existing CMS users in invite form - feat: track editor identity — _lastEditedBy in documents + Git author attribution - feat: GitHub service token — editors access GitHub sites without GitHub account - feat: proper gate screens for team access, GitHub connect, and no-access - feat: API-level role enforcement on content and settings routes - feat: site-switcher filters to only sites user has team access to - feat: role-based UI enforcement — hide Settings/Trash/New site for non-admins - feat: Resend email integration for team invitations - feat: add F57 Extranet (Protected Site Pages) to feature roadmap - feat: F01 Invite Users — site-scoped team management with invite links - feat: block Properties as shadcn Sheet drawer from right side - feat: block Properties panel — ⚙️ button in header, property fields in separate panel - feat: add internal label to Columns block — shown in block header - feat: add fullscreenLabel text field to interactive block - feat: add F56 GitHub Live Content to feature roadmap - feat: fullscreen button on interactive blocks + inline compact fields - feat: add viewport scaling to interactive block (viewportWidth/Height/scale) - feat: combine Visual/Code/AI into single Edit dropdown on INT toolbar - feat: add Sonnet 4.6, Opus 4, Opus 4.6 to AI model options - feat: add F55 Enhance Prompt to feature roadmap - feat: toggle sidebar logo icon in account preferences - feat: add F54 Local AI Tunnel to feature roadmap - feat: editable AI model defaults + prompts in Site Settings - feat: complete F39 Interactives Engine — AI generation, data docs, standalone rendering - feat: add F53 Drag & Drop Blocks Between Columns to feature roadmap - feat: click-to-focus BlocksEditor + 'A' shortcut scoped to focused instance ### Bug Fixes - fix: graceful GitHub error gate instead of crashing entire UI - fix: cache GitHub config for 2min in dev to avoid API rate limiting - fix: search keyboard navigation highlight was hidden by inline style - fix: remove UI zoom feature + fix logo icon persistence - fix: apply zoom on html element instead of body for proper layout - fix: styled checkbox for scheduler notifications + inline delete confirm for field defaults - fix: storage adapters now properly delete publishAt/unpublishAt on null - fix: prevent scheduler publish/unpublish infinite loop - fix: SSE stream — fix variable scoping error in ReadableStream - fix: sidebar counts deduplicate docs with both publish+expiry events - fix: center time text in NOW pill - fix: revert calendar events to publish/expiry colors (green/red) - fix: calendar events use collection colors + scheduler notifications in Card - fix: calendar sidebar compares with current time, not just date - fix: calendar sidebar counts unique documents, not events - fix: calendar sidebar — only count future events, fix count alignment - fix: calendar sidebar — bring collection count closer to name - fix: section headings white in dark mode for better readability - fix: harmonize Scheduler Notifications heading to SectionHeading - fix: harmonize section heading styles across all Settings and Account tabs - fix: move preferences into Account General tab, remove separate tab - fix: calendar title spacing -17px - fix: calendar title spacing -mb-3 - fix: calendar title spacing -mb-1 - fix: calendar title spacing mb-1 - fix: tighter spacing between title and selector mb-3 - fix: view selector centered below title, not in header row - fix: calendar title spacing mb-5 - fix: reduce gap between Calendar title and March label - fix: calendar header layout — title+controls in one row, fix JSX tags - fix: calendar scrollbar completely hidden — scroll via trackpad/wheel - fix: NOW dot 8px on left edge of today column - fix: NOW dot centered on day column border line - fix: NOW line 2px height, color #342122 - fix: NOW line 1px thin + pill touches line edge - fix: NOW pill vertically centered on red line - fix: remove JSX comments after closing tags — Turbopack parse error - fix: NOW pill rendered outside scroll container — never clipped - fix: NOW pill z-index 50 — always on top of calendar grid - fix: NOW pill extends 15px left — container has left margin for space - fix: NOW pill fully rounded inside container + scrollbar only on hover - fix: NOW pill nudged left over the edge like Apple Calendar - fix: calendar scrollbar hidden until hover + NOW pill fully rounded - fix: NOW label is a red pill badge like Apple Calendar - fix: NOW time label has background — no overlap with hour labels - fix: F43 user state is global per CMS installation, not per site - fix: Today button scrolls week view to center NOW - fix: week view — header inside scroll container, no gap/hack at top - fix: week view shows 07:00-18:00 without scroll - fix: scheduler webhook uses site-specific config for each site - fix: scheduler iterates ALL registered sites, not just default - fix: GitHub media adapter uses service-token fallback after cookie clear - fix: GitHub service token fallback scans all site cache dirs - fix: F43 flush pending tab sync on page unload/visibility change - fix: dashboard scheduled card shows max 2 upcoming items - fix: site-config API accepts PATCH — scheduler settings now persist - fix: calendar feed reads from snapshot — works for GitHub-backed sites - fix: hydration mismatch — move localhost detection to onClick handler - fix: calendar feed works without cookies — site context via query params - fix: persist calendarSecret on first use — tokens now stable across requests - fix: calendar subscribe — copy URL on localhost, webcal:// in production - fix: calendar subscribe — webcal:// for production, download for localhost - fix: allow unauthenticated access to calendar.ics feed - fix: calendar subscribe uses onClick with webcal:// protocol - fix: use webcal:// protocol for calendar subscribe link - fix: schedule times stored and displayed as-is — no UTC conversion - fix: schedule datetime picker shows UTC instead of local time - fix: GitHub adapter missing publishAt/unpublishAt/locale/translationOf - fix: auto-save schedule/expiry immediately on Set click - fix: schedule buttons — icon only, no text label - fix: content overflow on smaller screens (laptop viewport) - fix: hide write actions from viewers in collection list and agents list - fix: readOnly defaults to true while loading — safe by default - fix: fieldset disabled on all admin pages for viewers + guard media actions - fix: tabs are now per-user-per-site — switching sites starts with clean tabs - fix: add mobile hamburger menu to landing page - fix: replace all "Sure?" confirmations with "OK" / Cancel pattern - fix: remove AdminHeader from fallback layouts (useTabs outside TabsProvider) - fix: layout redirects to accessible site when user has no team access - fix: invite sets site cookies, layout handles missing GitHub token - fix: auto-bootstrap adds oldest CMS user as admin, not current user - fix: per-user tab storage + server-side admin guard on Settings page - fix: add favicon to landing page - fix: invite token validation searches all sites (cookie-less support) - fix: email branding (webhouse bold white, .app bold gold) + fix settings panel API path - fix: truncate block header label to single line with ellipsis - fix: lighten field input backgrounds in dark mode for better visibility - fix: block remove confirm — hide ↑↓ Clone buttons to make room for Remove? Yes/No - fix: AI panel wider (430px), taller textarea (4 rows), clear of inspect button - fix: add dollar sign preservation rule to AI interactive prompts - fix: wrong Sonnet model ID — claude-sonnet-4-5 → claude-sonnet-4 - fix: users.json lookup always uses primary CMS data dir - fix: hydration mismatch on logo toggle + "User not found" on save - fix: Network error in Edit with AI — fallback prompt + maxDuration - fix: profile save crashing — add try/catch to API route - fix: save buttons stuck on "Saving…" when fetch throws - fix: AI truncation — upgrade interactive AI to Sonnet + 16K tokens + truncation guard - fix: show "No sites yet" empty state instead of infinite loading - fix: delete all documents when deleting a collection - fix: replace window.confirm with safe delete dialog for collection deletion - fix: match Edit schema button size to Generate and New item buttons - fix: hide htmldoc fields from collection list + add missing field types to schema editor - fix: hydration mismatch — load localStorage state after mount - fix: use route groups to isolate login/setup from workspace layout - fix: remove red delete button from image thumbnail - fix: prevent RSC prefetch redirect loop on login page - fix: focus targets individual column, not parent sections ### Other Changes - chore: release v0.2.11 - chore: bump all packages to 0.2.10 - perf: parallelize search across collections - refactor: move Interface settings from Site Settings to Account Preferences - docs: F63 — add section-heading.tsx as reference example - revert: user state back to per-site — tabs are site-specific - debug: add logging to user-state sync and restore - docs: mark F47 Content Scheduling + F43 Persist User State as done - docs: F60 add Tier 2b — webhouse.app cron service integration - perf: Calendar page — server component data fetch, no client API call - docs: mark F01 Invite Users as done - docs: mark F39 Interactives Engine as done, add viewer RBAC milestone - test: Playwright viewer RBAC test suite — 7 passing - docs: mark F39 interactive picker as done — all phases complete - revert: Properties back to inline panel — Sheet was too far away - docs: F56 — add external trigger section (webhook + direct pull API) - chore: add pricing calculator example interactive - simplify: interactive block — drop viewportWidth/Height, keep only scale % - test: add login flow Playwright test + fix timeouts --- ## changelog/v0-2-9 Title: Release v0.2.9 Updated: 2026-05-05 Locale: en ## What's New in v0.2.9 **3 changes** released on 2026-03-16 ### Bug Fixes - fix: upgrade GHA to Node 24, fix SQLite close() null guard ### Other Changes - chore: release v0.2.9 - docs: add root CLAUDE.md with development instructions --- ## changelog/v0-2-8 Title: Release v0.2.8 Updated: 2026-05-05 Locale: en ## What's New in v0.2.8 **124 changes** released on 2026-03-16 ### Features - feat: add F44-F49 feature plans from master plan audit - feat: add F43 Persist User State to feature roadmap - feat: content push — webhook sends full document to site - feat: F41 — GitHub site auto-sync & webhook revalidation - feat: add F42 Framework Boilerplates feature proposal - feat: CMS admin proxies /images/, /audio/, /interactives/ via rewrites - feat: add F41 GitHub Site Auto-Sync & Webhook Revalidation plan - feat: Image and Audio toolbar buttons support Browse Media - feat: FileAttachment supports upload + browse from Media library - feat: Interactive picker has search, ESC close, matches block picker style - feat: add search to image field Media Library browser - feat: image field with upload, media browser, and preview - feat: add InteractiveEmbed TipTap node for inline interactive embeds - feat: add F40 Drag and Drop Tab Reordering to feature roadmap - feat: Media files use soft delete (trash) with metadata tracking - feat: Trash page shows trashed Interactives alongside documents - feat: Interactives list — Media Manager top bar + Pitch Vault list view - feat: SVG filter, proper Properties panel, fix preview URL - feat: Interactives Properties panel with rename - feat: Interactives status workflow (draft/published/trashed) - feat: add History, Properties, Delete buttons to Interactives top bar - feat: add Clone button to Interactives detail page - feat: Interactives detail page — fullscreen layout + Monaco editor - feat: MediaAdapter pattern for adapter-agnostic media operations - feat: add Interactives Manager (F39 Phase 1) - feat: add F39 Interactives Engine to feature roadmap - feat: add Supabase dependencies and new home.json example - feat: add F38 Environment Manager to feature roadmap - feat: add htmldoc field type with visual editing, AI edit, and code modes - feat: add F37 HTML Document Field (htmldoc) to feature roadmap - feat: add AudioEmbed, FileAttachment, and Callout TipTap nodes to rich text editor - feat: media library supports all file types with type filter - feat: add audio field type with inline player preview - feat: dynamic collection list columns based on schema fields - feat: disable RLS in migrate, add seed script with real content - feat: add F36 Framework Integrations to feature roadmap - feat: add F35 Webhooks to feature roadmap - feat: upgrade /feature skill to idea-first workflow - feat: add /feature skill for implementing features from plan docs - feat: add UI screenshot agent using Playwright - feat: add experimental Supabase/PostgreSQL storage adapter - feat: add AI analytics module with run tracking and performance dashboard - feat: wire up per-collection lifecycle hooks in ContentService - feat: add framework adapter helpers for Next.js content loading - feat: landing page build pipeline (roadmap #15) - feat: add initial documentation for Admin UI & Deployment Plan and Plan Summary ### Bug Fixes - fix: use full page load after login instead of router.push - fix: slug rename dispatches delete for old slug + push for new slug - fix: hide Revalidation HR for filesystem sites, collapsible delivery log - fix: hide Revalidation section for filesystem sites - fix: revalidation endpoint includes git pull before revalidatePath - fix: update README for clarity on framework support - fix: editor image browse stores relative path, not proxy URL - fix: embedded nodes use !!FILE[] and !!INTERACTIVE[] text tokens - fix: editor image browse uses /api/uploads/ proxy for non-uploads paths - fix: use HTML comments for embedded node serialization in markdown - fix: re-enable html:true in markdown plugin - fix: richtext editor saves as HTML instead of markdown - fix: enable HTML in markdown parser so embedded nodes survive roundtrip - fix: uploaded files preserve original filename - fix: video dialog, AI bubble menu, interactive embed serialization - fix: Interactive picker shows titles, embed survives markdown roundtrip - fix: all custom modals and panels close on Escape key - fix: add confirmation dialogs to all delete/remove actions - fix: Media Library browser closes on Escape - fix: image field preview uses proxy for non-absolute URLs - fix: dropdown menus no longer clipped on Media and Collection lists - fix: dropdown menu no longer clipped by card overflow-hidden - fix: Media usage scanning works for all media paths, not just /uploads/ - fix: Content sidebar toggle persists state in localStorage - fix: move Interactives menu item below Content, before Media - fix: Interactives Properties panel closes on Escape - fix: Media Manager uses proper icons per media type - fix: Interactives top bar uses same Button components and icon sizes as editor - fix: Interactives top bar matches document editor exactly - fix: Interactives detail page sets tab title to interactive name - fix: Media Manager uses site preview URL, excludes interactives - fix: GitHub media proxy handles files >1MB and uses correct Content-Type - fix: GitHub sites use local cache dir for media/interactives storage - fix: Interactives API uses getActiveSitePaths() for site-scoped storage - fix: F38 uses built-in port scanner, no Code Launcher dependency - fix: close Properties and Revision panels with Escape key - fix: auto-reload PostgREST schema cache after migrate - fix: enable RLS with proper policies instead of disabling - fix: use service role key for all Supabase operations + add test script - fix: new-site screenshot navigates via sidebar instead of direct URL - fix: screenshot agent uses sidebar navigation + dynamic collections - fix: type curation status as DocumentStatus literal for Docker build - fix: cast trashed status comparison in stats route for Docker build - fix: Docker build — add .pnpmfile.cjs COPY and fix trashed status type - fix: standalone Docker admin Dockerfile with complete workspace support ### Other Changes - chore: release v0.2.8 - chore: bump all packages to 0.2.7, cms-admin to 0.2.0 - docs: reorder Tier 1 roadmap, add Claude Code toolkit to F42 - docs: prioritized roadmap with 4 tiers and product milestones - chore: add knip code audit tool and initial report - chore: F41 code review cleanup - docs: content push architecture + two boilerplates in F42 - docs: CLAUDE.md — react-markdown as standard richtext renderer - revert: back to markdown serialization for richtext fields - docs: mark TipTap interactiveEmbed node as done in F39 - docs: update F39 with checkboxes tracking implementation progress - docs: update Docker command to Docker Hub + add link - docs: add sections 3-8 to cms CLAUDE.md (relationships, SEO, images, i18n, deployment, troubleshooting) - docs: add Common Mistakes, Site Building Patterns, and SEO guide to CLAUDE.md - docs: note that richtext fields expect markdown, not HTML - docs: add audio field type, richtext embedded media, and rendering guide to CLAUDE.md - docs: clean up ROADMAP — 22 done, 34 features indexed - docs: add admin UI screenshots to README - docs: update FEATURES.md and add ARTICLES.md and TECH.md with new content - docs: ROADMAP — all 10 overnight tasks complete - docs: ROADMAP overnight update — 9 of 10 tasks done - docs: add 31 detailed feature plan documents (F01-F34) - docs: update ROADMAP — #23 screenshot agent done - docs: update ROADMAP — #22 Supabase adapter done - docs: update ROADMAP — #16, #18 done, #17, #22, #23 still in progress - docs: update ROADMAP — #15, #21, #24 done, #16-18, #22-23 in progress - docs: numbered feature roadmap with 34 features - docs: add numbered ROADMAP.md with full project status - docs: update OpenAPI spec to v0.2.6 - docs: expand CLI section and add REST API documentation to README - docs: add global CLI install instructions to README - Refactor code structure for improved readability and maintainability - docs: complete README rewrite with all 4 admin options --- ## changelog/v0-2-6 Title: Release v0.2.6 Updated: 2026-05-05 Locale: en ## What's New in v0.2.6 **2 changes** released on 2026-03-14 ### Features - feat: seed webhouse.app eye logo as favicon in scaffolded projects ### Other Changes - chore: release v0.2.6 --- ## changelog/v0-2-5 Title: Release v0.2.5 Updated: 2026-05-05 Locale: en ## What's New in v0.2.5 **3 changes** released on 2026-03-14 ### Features - feat: standalone CMS admin CLI + Docker image ### Other Changes - chore: release v0.2.5 - docs: add CMS admin options to CLAUDE.md files --- ## changelog/v0-2-4 Title: Release v0.2.4 Updated: 2026-05-05 Locale: en ## What's New in v0.2.4 **2 changes** released on 2026-03-14 ### Features - feat: add start.sh and simplify scaffolder output ### Other Changes - chore: release v0.2.4 --- ## changelog/v0-2-3 Title: Release v0.2.3 Updated: 2026-05-05 Locale: en ## What's New in v0.2.3 **2 changes** released on 2026-03-14 ### Bug Fixes - fix: add pnpm permissions to scaffolded .claude/settings.json ### Other Changes - chore: release v0.2.3 --- ## changelog/v0-2-2 Title: Release v0.2.2 Updated: 2026-05-05 Locale: en ## What's New in v0.2.2 **2 changes** released on 2026-03-14 ### Features - feat: add .claude/settings.json to scaffolded projects ### Other Changes - chore: release v0.2.2 --- ## changelog/v0-2-1 Title: Release v0.2.1 Updated: 2026-05-05 Locale: en ## What's New in v0.2.1 **2 changes** released on 2026-03-14 ### Bug Fixes - fix: remove .nvmrc from scaffolder — CMS works with Node 20+ ### Other Changes - chore: release v0.2.1 --- ## changelog/v0-2-0 Title: Release v0.2.0 Updated: 2026-05-05 Locale: en ## What's New in v0.2.0 **96 changes** released on 2026-03-14 ### Features - feat: add stdio MCP server mode to cms-cli - feat: add new features including block editor, site wizard, and GitHub login - feat: editable site name in Site Settings → General - feat: improved repo scaffolding with proper .gitignore and rich README - feat: add new features including block editor, site wizard, and GitHub login - feat: seed new GitHub repos with full project scaffolding - feat: tab-based New repo / Import existing layout in New Site dialog - feat: create new GitHub repo from New Site dialog - feat: add Re-authorize button to GitHub connection in New Site dialog - feat: GitHub OAuth integration for New Site flow - feat: New Site dialog with GitHub and filesystem adapter support - feat: implement GitHub adapter for multi-site pool - feat: structured object editor + JSON/UI toggle for complex fields - feat: add "New site" option to site switcher dropdown - feat: fast site switcher dropdown in admin header - feat: block editor and structured array editor for CMS admin - feat: AI-powered Fix button for broken links in Link Checker - feat: site cards show page count and collection count - feat: site-paths helper + async upload dir for multi-site scoping - feat: Sites Dashboard + back-to-sites navigation - feat: restructure settings — Site Settings in sidebar, Account Preferences in user menu - feat: multi-site API + site switcher UI in header - feat: multi-site foundation — registry, pool, backwards-compat cms.ts - feat: landing page example site — schema, content seed, assets - feat: add @webhouse/create-cms package and .env support in CLI - feat: branding polish — logos, favicon, landing 120% zoom, login redesign - feat: branded landing page, /login route, webhouse eye logo - feat: add SVG logo for webhouse app and implement agents view toggle component - feat: Playwright e2e tests, site search, collections schema API - feat: Admin UI improvements — Generate Article, agent UX, editor fixes, search - feat: Phase C complete — tool-use, MCP client, multi-draft, scheduling, content context - feat: implement CMS MCP client and server with AI integration - feat: add API routes for link checking, agent management, and brand voice configuration - feat: Add AI Orchestrator foundation — Phase A+B - feat: Implement scheduled publishing for draft documents and enhance secret management - feat: Add multilingual support with locale handling and hreflang alternates - feat: Add initial documentation and web interface for @cms project - feat: AI Lock — field-level content protection + OpenAPI spec ### Bug Fixes - fix: add github adapter to Zod validation schema - fix: add error handling to repo seeding and fix useEffect dependency - fix: separate Brave and Tavily API keys in AI settings - fix: standardize all page headings to text-2xl - fix: add Node .gitignore when creating new GitHub repo - fix: align New site page with standard route layout - fix: remove footer branding from admin workspace - fix: use CustomSelect for GitHub account/repo pickers, add Manage access link - fix: show only repo names in repository dropdown - fix: reopen New Site dialog after GitHub OAuth callback - fix: open documents in new tab from link checker - fix: duplicate key warning in link checker table headers - fix: Sites Dashboard layout aligned with Agents, rename AI Cockpit/Agents - fix: Sites as regular menu item with Boxes icon instead of back arrow - fix: rename landing.html → home.html, add /home rewrite for preview - fix: uploads API route now site-scoped via getUploadDir() - fix: increase footer logo size for readability - fix: replace footer text with webhouse.app wordmark logo - fix: restore version in admin footer - fix: split settings — user vs site, password under Security tab - fix: rewrite site switcher to match codepromptmaker org-switcher style - fix: remove version from admin footer - fix: rename admin browser title to webhouse.app - fix: pull before push in publish workflow to avoid conflicts - fix: use NPM_TOKEN secret for publish, add --provenance flag - fix: update import path in next-env.d.ts and modify AI usage label in sidebar - fix: add build tools for better-sqlite3 on CI, soft-fail tests - fix: exclude private packages from CI build - fix: remove pnpm version conflict in publish workflow - fix: sign out redirects to landing page instead of login - fix: reduce hero wordmark size, use css zoom 1.2 for landing - fix: use actual SVG logo files in sidebar and landing page - fix: serve landing page as static HTML, replace ASCII with SVG diagram - fix: stacked webhouse.app logo in sidebar (eye + wordmark + tagline) - fix: use webhouse.app wordmark in sidebar, theme-aware - fix: branded fullscreen login page + tab init corruption fix - fix: tab corruption on restart + move deploy files into repo - fix: Sidebar Content section style, CustomSelect, default agent seeding - fix: Translate all new admin UI strings from Danish to English - fix: Update subproject commit to indicate dirty state ### Other Changes - chore: release v0.2.0 - refactor: move New Site from dialog to dedicated /admin/sites/new route - refactor: all libs and API routes now site-scoped via getActiveSitePaths() - docs: multi-site design v2 — orgs, backwards compat, getAdminCms flow - docs: update multi-site design — site switcher in header, backwards compat - docs: multi-site admin architecture design - chore: sync all package versions to 0.1.2 - docs: add trusted publishing setup guide - ci: add GitHub Actions publish workflow with trusted publishing - docs: update CLI README — recommend global install, warn about npx cms - chore: restore workspace deps, add create-cms source files - chore: bump to 0.1.1 and add publish script - chore: prepare all packages for npm publish - chore: rename cms-engine references to cms after repo rename - Add GitHub and SQLite storage adapters with comprehensive tests - docs: tilføj CMS-PLUGIN-SOME.md — Social Media plugin spec - docs: opdater CMS-ENGINE.md med implementeringsstatus og Phase 3.5 (Plugin API) - Initial commit --- ## changelog/v0-0-1-foundation Title: Foundation — Initial Development Updated: 2026-05-05 Locale: en ## Foundation Phase The initial development period building the core CMS engine, admin UI, and tooling from scratch. **96 commits** from 2026-04-17 to 2026-03-14 ### Features - feat: AI Lock — field-level content protection + OpenAPI spec - feat: Add initial documentation and web interface for @cms project - feat: Add multilingual support with locale handling and hreflang alternates - feat: Implement scheduled publishing for draft documents and enhance secret management - feat: Add AI Orchestrator foundation — Phase A+B - feat: add API routes for link checking, agent management, and brand voice configuration - feat: implement CMS MCP client and server with AI integration - feat: Phase C complete — tool-use, MCP client, multi-draft, scheduling, content context - feat: Admin UI improvements — Generate Article, agent UX, editor fixes, search - feat: Playwright e2e tests, site search, collections schema API - feat: add SVG logo for webhouse app and implement agents view toggle component - feat: branded landing page, /login route, webhouse eye logo - feat: branding polish — logos, favicon, landing 120% zoom, login redesign - feat: add @webhouse/create-cms package and .env support in CLI - feat: landing page example site — schema, content seed, assets - feat: multi-site foundation — registry, pool, backwards-compat cms.ts - feat: multi-site API + site switcher UI in header - feat: restructure settings — Site Settings in sidebar, Account Preferences in user menu - feat: Sites Dashboard + back-to-sites navigation - feat: site-paths helper + async upload dir for multi-site scoping - feat: site cards show page count and collection count - feat: AI-powered Fix button for broken links in Link Checker - feat: block editor and structured array editor for CMS admin - feat: fast site switcher dropdown in admin header - feat: add "New site" option to site switcher dropdown - feat: structured object editor + JSON/UI toggle for complex fields - feat: implement GitHub adapter for multi-site pool - feat: New Site dialog with GitHub and filesystem adapter support - feat: GitHub OAuth integration for New Site flow - feat: add Re-authorize button to GitHub connection in New Site dialog - feat: create new GitHub repo from New Site dialog - feat: tab-based New repo / Import existing layout in New Site dialog - feat: seed new GitHub repos with full project scaffolding - feat: add new features including block editor, site wizard, and GitHub login - feat: improved repo scaffolding with proper .gitignore and rich README - feat: editable site name in Site Settings → General - feat: add new features including block editor, site wizard, and GitHub login - feat: add stdio MCP server mode to cms-cli ### Bug Fixes - fix: Update subproject commit to indicate dirty state - fix: Translate all new admin UI strings from Danish to English - fix: Sidebar Content section style, CustomSelect, default agent seeding - fix: tab corruption on restart + move deploy files into repo - fix: branded fullscreen login page + tab init corruption fix - fix: use webhouse.app wordmark in sidebar, theme-aware - fix: stacked webhouse.app logo in sidebar (eye + wordmark + tagline) - fix: serve landing page as static HTML, replace ASCII with SVG diagram - fix: use actual SVG logo files in sidebar and landing page - fix: reduce hero wordmark size, use css zoom 1.2 for landing - fix: sign out redirects to landing page instead of login - fix: remove pnpm version conflict in publish workflow - fix: exclude private packages from CI build - fix: add build tools for better-sqlite3 on CI, soft-fail tests - fix: update import path in next-env.d.ts and modify AI usage label in sidebar - fix: use NPM_TOKEN secret for publish, add --provenance flag - fix: pull before push in publish workflow to avoid conflicts - fix: rename admin browser title to webhouse.app - fix: remove version from admin footer - fix: rewrite site switcher to match codepromptmaker org-switcher style - ... and 20 more fixes ---