Koppla en Next.js-sajt till sitecms
Deklarera vilka fält sajten har, hämta innehållet med en typad klient utan beroenden och låt kunden redigera själv. Allt går att sätta upp utan att prata med oss.
Innehåll
Snabbstart
Kom igång på 10 minuter
- 1
Skapa konto
Registrera dig på /signup och bekräfta e-postadressen. Inget kreditkort, ingen väntan. - 2
Skapa en sajt
Välj Ny sajt i panelen och ange namn och domän. Sajten får en slug (t.ex.jjbygg) som ingår i alla API-adresser. Du blir sajtens ägare. - 3
Hämta nycklar
Öppna Integration för sajten. Skapa en API-nyckel och generera en revalidate-hemlighet och kopiera env-blocket till.env.localoch till Vercel. - 4
Installera sitecms-next
npm install sitecms-nexti kundsajten. Lägg till revalidate-routen (en rad) och angehttps://din-domän.se/api/sitecms/revalidatesom revalidate-URL i panelen. - 5
Deklarera fälten
Beskriv sidor och fält isitecms.config.tsoch lägg"prebuild": "sitecms-next sync"i package.json. Fälten dyker upp i panelen vid nästa build. Tryck sedan på Testa anslutningen. - 6
Bjud in kunden
Under Medlemmar bjuder du in kunden som redaktör. Kunden väljer lösenord via mejlet och kan börja redigera — ändringar syns på sajten inom några sekunder.
Miljövariablerna som kundsajten behöver (värdena finns under Integration i panelen):
CMS_API_URL=https://sitecms.se
CMS_SITE=din-sajt
CMS_API_KEY=<klistra-in-api-nyckeln>
CMS_REVALIDATE_SECRET=<klistra-in-revalidate-hemligheten>Kontrollera att nycklarna fungerar innan du skriver någon kod:
npx sitecms-next checkÖversikt
Hur det hänger ihop
- Kundsajten äger strukturen.
sitecms.config.tsbeskriver vilka sidor och fält som finns.sitecms-next syncskickar den till sitecms före varje build, och panelen visar fälten med dina etiketter och hjälptexter. - Kunden äger innehållet. En synk skriver aldrig över det kunden har skrivit. Nya fält får sitt
default-värde; tomma fält fylls meddefault. - Sajten hämtar med cache. Klienten hämtar med
next: { tags: ["sitecms"] }, så sidorna kan vara statiska. - sitecms säger till när något ändrats. Vid varje sparning POST:ar sitecms
{ "secret": "…" }till din revalidate-URL, som körrevalidateTag("sitecms"). Ändringen syns inom några sekunder.
Rekommenderat
Koppla sajten med npm
sitecms-next har inga beroenden och innehåller klienten, revalidate-routen och CLI:t för schema-sync.1. Installera
npm install sitecms-nextLägg miljövariablerna från Kom igång i .env.local och i Vercel (Production och Preview). Kör npx sitecms-next check för att se att de stämmer.
2. app/api/sitecms/revalidate/route.ts
En rad. Routen jämför secret med CMS_REVALIDATE_SECRET i konstant tid och tömmer cachetaggen sitecms. Ange https://din-domän.se/api/sitecms/revalidate som revalidate-URL under Integration i panelen.
export { POST } from "sitecms-next/revalidate";3. sitecms.config.ts
Vilka sidor och fält kunden ska kunna redigera, med etiketter och hjälptexter som syns i panelen. type är text om det utelämnas. Formatet beskrivs under Schema-format.
import { defineConfig } from "sitecms-next";
// Vilka sidor och fält kunden ska kunna redigera. Synkas till sitecms med
// "sitecms-next sync" (prebuild). Kundens innehåll skrivs aldrig över;
// nya fält får sitt default-värde.
export default defineConfig({
pages: [
{
slug: "home",
title: "Startsida",
description: "Det besökaren ser först.",
blocks: [
{ key: "hero.title", label: "Rubrik", help: "Max 60 tecken", default: "Välkommen" },
{
key: "hero.image",
type: "image",
label: "Hero-bild",
default: "https://exempel.se/hero.jpg",
defaultAlt: "Byggarbetare på ett tak",
},
{ key: "intro.body", type: "richtext", label: "Ingress" },
],
},
],
});
4. Synka i prebuild
sitecms-next sync skickar configen till sitecms före varje build. Saknade sidor och fält skapas, kundens innehåll skrivs aldrig över, och bygget stoppas om configen är ogiltig. Saknas miljövariablerna hoppas steget över (lägg till --strict om bygget ska faila då).
{
"scripts": {
"prebuild": "sitecms-next sync"
}
}Vill du synka direkt utan att bygga:
npx sitecms-next sync5. Använd innehållet
getContent(), getPosts() och getPost(slug) läser CMS_API_URL, CMS_SITE och CMS_API_KEY från miljön och cachar med taggen sitecms, så sidorna kan vara statiska. block() returnerar alltid strängar och är säker att rendera även om fältet saknas.
// app/page.tsx
import Image from "next/image";
import { block, getContent, image, text } from "sitecms-next";
export default async function Home() {
const content = await getContent();
const hero = image(content, "home", "hero.image");
return (
<main>
<h1>{text(content, "home", "hero.title", "Välkommen")}</h1>
{hero ? <Image src={hero.src} alt={hero.alt} width={1200} height={630} /> : null}
<div dangerouslySetInnerHTML={{ __html: block(content, "home", "intro.body").html }} />
</main>
);
}Inlägg:
// app/nyheter/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost } from "sitecms-next";
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const post = await getPost((await params).slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
</article>
);
}Flera sajter i samma projekt, eller egna värden:
import { createSitecms } from "sitecms-next";
export const cms = createSitecms({
apiUrl: process.env.CMS_API_URL!,
site: "annan-sajt",
apiKey: process.env.CMS_API_KEY_ANNAN!,
});
const content = await cms.getContent();Verktyg
CLI: sitecms-next
| Kommando | Gör |
|---|---|
sitecms-next sync | Skickar sitecms.config.ts (eller .mjs/.js/.json) till sitecms och skriver ut vad som skapades. Exit 1 vid fel. |
--prune | Tar också bort fält och sidor som inte längre finns i configen (inklusive innehållet). |
--strict | Låter bygget faila om miljövariablerna saknas (annars hoppas synken över). |
--config <fil> | Använd en annan config-fil. |
sitecms-next check | Verifierar nycklarna och visar sajtens namn och senaste sync. |
sitecms-next pull | Skriver ut strukturen som finns i sitecms som JSON. |
Struktur
Schema-format
{
"version": 1,
"pages": [
{
"slug": "home",
"title": "Startsida",
"description": "Det besökaren ser först.",
"blocks": [
{ "key": "hero.title", "type": "text", "label": "Rubrik", "help": "Max 60 tecken", "default": "Välkommen" },
{ "key": "hero.image", "type": "image", "label": "Hero-bild", "default": "https://…/hero.jpg", "defaultAlt": "Byggarbetare" },
{ "key": "intro.body", "type": "richtext", "label": "Ingress" }
]
}
],
"prune": false
}| Fält | Typ | Beskrivning |
|---|---|---|
version | 1 | Formatversion. Alltid 1. |
pages[] | array | Max 100 sidor. Ordningen blir sidornas ordning i panelen. |
pages[].slug | string | Unik per sajt. Matchar ^[a-z0-9][a-z0-9._-]*$. |
pages[].title | string | Sidans namn i panelen. |
pages[].description | string? | En rad under sidans namn i editorn. |
pages[].blocks[] | array | Max 200 block per sida. Ordningen blir fältens ordning. |
blocks[].key | string | Unik per sida, t.ex. hero.title. Samma mönster som slug. |
blocks[].type | text | richtext | image | Enrads/flerradstext, formaterad text (markdown → html) eller bild (URL + alt). |
blocks[].label | string? | Etiketten kunden ser. |
blocks[].help | string? | Hjälptext under fältet. |
blocks[].default | string? | Startvärde när blocket skapas eller är tomt. |
blocks[].defaultAlt | string? | Startvärde för alt-texten (image). |
prune | boolean | Default false. true tar bort block och tomma sidor som inte finns i deklarationen. |
Regler vid synk
- Sidor och block som saknas skapas. Nya block får
value = defaultochalt = defaultAlt. - Befintliga block får ny
type,label,helpoch ordning — menvalue/altskrivs aldrig över. Kundens innehåll vinner. Ett tomtvaluefylls dock meddefault. - Sidors
title,descriptionoch ordning uppdateras från deklarationen. - Med
"prune": false(default) tas inget bort; block som bara finns i CMS:et rapporteras somorphans. - Fel i deklarationen ger
400med en lista över problemen — inget sparas.
Referens
API-referens
https://sitecms.se/api/v1/sites/{slug}. Alla svar är JSON och cachas inte av sitecms (Cache-Control: private, no-store) — cacha på din sida med next.tags.Autentisering
Varje anrop kräver headern x-api-key med en nyckel som hör till sajten. Nycklar skapas och tas bort under Integration. Använd dem bara på serversidan — en nyckel ger läsåtkomst till allt publicerat innehåll och kan synka struktur.
curl -H "x-api-key: $CMS_API_KEY" https://sitecms.se/api/v1/sites/din-sajt/contentHämta allt innehåll
https://sitecms.se/api/v1/sites/{slug}/contentSajtens namn, alla sidor med block i ordning, synliga tjänster och SEO per sida. För richtext-block finns färdig html; för bilder alt. label och description kommer från schemat.
{
"site": { "name": "JJ Bygg", "domain": "jjbygg.se" },
"pages": [
{
"slug": "home",
"title": "Startsida",
"description": "Det besökaren ser först.",
"blocks": [
{ "key": "hero.title", "type": "text", "label": "Rubrik", "value": "Välkommen", "alt": null, "html": null },
{ "key": "hero.image", "type": "image", "label": "Hero-bild", "value": "https://…/hero.jpg", "alt": "Byggarbetare", "html": null },
{ "key": "intro.body", "type": "richtext", "label": "Ingress", "value": "Vi bygger **tryggt**.", "alt": null, "html": "<p>Vi bygger <strong>tryggt</strong>.</p>" }
]
}
],
"services": [{ "title": "Takbyte", "description": "Komplett takbyte.", "price": "Från 1 200 kr/m²" }],
"seo": [{ "pageSlug": "home", "title": "JJ Bygg — bygg i Mellerud", "description": "…", "ogImage": null }]
}Lista inlägg
https://sitecms.se/api/v1/sites/{slug}/postsPublicerade inlägg, nyast först, utan brödtext — för listsidor.
{
"posts": [
{
"slug": "nytt-tak-pa-skolan",
"title": "Nytt tak på skolan",
"excerpt": "I veckan blev vi klara med…",
"coverImage": "https://…/tak.jpg",
"coverImageAlt": "Nylagt plåttak",
"publishedAt": "2026-10-12T08:30:00.000Z"
}
]
}Hämta ett inlägg
https://sitecms.se/api/v1/sites/{slug}/posts/{postSlug}Ett publicerat inlägg med body (markdown) och bodyHtml. Utkast och okända slugs ger 404.
{
"post": {
"slug": "nytt-tak-pa-skolan",
"title": "Nytt tak på skolan",
"excerpt": "I veckan blev vi klara med…",
"body": "## Bakgrund\nSkolan behövde…",
"bodyHtml": "<h2>Bakgrund</h2><p>Skolan behövde…</p>",
"coverImage": "https://…/tak.jpg",
"coverImageAlt": "Nylagt plåttak",
"publishedAt": "2026-10-12T08:30:00.000Z"
}
}Läs nuvarande struktur
https://sitecms.se/api/v1/sites/{slug}/schemaStrukturen i CMS:et i samma format som du skickar, men utan default och med hasValue per block — så att du kan diffa mot sitecms.config.ts.
{
"version": 1,
"pages": [
{
"slug": "home",
"title": "Startsida",
"description": "Det besökaren ser först.",
"blocks": [
{ "key": "hero.title", "type": "text", "label": "Rubrik", "help": "Max 60 tecken", "hasValue": true },
{ "key": "hero.image", "type": "image", "label": "Hero-bild", "hasValue": true },
{ "key": "intro.body", "type": "richtext", "label": "Ingress", "hasValue": false }
]
}
]
}Synka struktur
https://sitecms.se/api/v1/sites/{slug}/schemaBody: ett schema enligt Schema-format med Content-Type: application/json. Svaret berättar vad som skapades, uppdaterades och (med prune) togs bort. Uppdaterar sajtens "Senaste schema-sync" i panelen.
curl -X POST https://sitecms.se/api/v1/sites/din-sajt/schema \
-H "x-api-key: $CMS_API_KEY" \
-H "Content-Type: application/json" \
--data @schema.json{
"ok": true,
"created": { "pages": 1, "blocks": 3 },
"updated": { "pages": 0, "blocks": 0 },
"orphans": [{ "pageSlug": "home", "key": "gammal.rubrik" }],
"pruned": { "pages": 0, "blocks": 0 }
}{
"error": "Ogiltigt schema.",
"issues": [
"pages[0].blocks[1].type: måste vara text, richtext eller image",
"pages[0].blocks[2].key: \"Hero Title\" matchar inte ^[a-z0-9][a-z0-9._-]*$"
]
}Kontrollera nyckeln
https://sitecms.se/api/v1/sites/{slug}/healthSnabb kontroll av att slug och nyckel stämmer, utan att hämta innehåll. Bra i CI och för övervakning.
curl -s -H "x-api-key: $CMS_API_KEY" \
https://sitecms.se/api/v1/sites/din-sajt/health{
"ok": true,
"site": { "name": "JJ Bygg", "slug": "jjbygg" },
"pages": 4,
"lastSchemaSyncAt": "2026-10-18T09:14:03.000Z"
}Revalidate-webhook (sitecms → din sajt)
https://din-domän.se/api/sitecms/revalidateVarje gång innehåll sparas i panelen skickar sitecms en POST med Content-Type: application/json och body { "secret": "<CMS_REVALIDATE_SECRET>" } till sajtens revalidate-URL (timeout 5 s, inga omförsök). Svara 2xx när hemligheten stämmer och 401 annars.
Testa anslutningen på integrationssidan skickar exakt samma anrop (timeout 8 s) och visar statuskoden. Följer inte omdirigeringar — ange den slutliga adressen.
curl -s -X POST https://exempel.se/api/sitecms/revalidate \
-H "Content-Type: application/json" \
-d '{"secret":"'"$CMS_REVALIDATE_SECRET"'"}'{ "revalidated": true }Felkoder
Fel returneras som { "error": "…" } med en svensk beskrivning (schema-fel har även issues).
| Status | Betyder | Vanlig orsak |
|---|---|---|
400 | Ogiltig förfrågan | Schemat följer inte formatet (fel slug/key, okänd typ, fler än 100 sidor eller 200 block per sida), eller body är inte giltig JSON. Se issues. |
401 | Nyckel saknas eller är fel | Headern x-api-key saknas, nyckeln är borttagen eller hör till en annan sajt. |
404 | Hittades inte | Okänd sajt-slug, eller inlägget finns inte / är inte publicerat. |
413 | För stort | Request-bodyn är för stor för att tas emot. Kontrollera att schemat inte innehåller stora default-värden (t.ex. base64-bilder i stället för URL:er). |
{ "error": "Ogiltig API-nyckel för denna sajt." }Alternativ
Utan npm (copy-paste)
sitecms-next, som filer i ditt eget repo. Inga beroenden utöver tsx för synkskriptet. På integrationssidan i panelen är sajtens slug och CMS-adress redan ifyllda.lib/sitecms.ts
Typad klient: getContent(), getPosts(), getPost(slug) och hjälparna block(content, sida, nyckel) → { value, html, alt }, text(), image() och seo(). block() returnerar alltid strängar, så den är säker att rendera även om fältet saknas.
// lib/sitecms.ts — sitecms-klient för Next.js App Router. Inga beroenden.
// Allt hämtas med cache-taggen "sitecms" och töms av
// app/api/sitecms/revalidate/route.ts när innehåll sparas i panelen.
const API_URL = (process.env.CMS_API_URL ?? "https://sitecms.se").replace(/\/+$/, "");
const SITE = process.env.CMS_SITE ?? "din-sajt";
export type BlockType = "text" | "richtext" | "image";
export type Block = {
key: string;
type: BlockType;
/** Text, markdown (richtext) eller bild-URL (image). */
value: string | null;
/** Bildbeskrivning för image-block, annars null. */
alt: string | null;
/** Färdig HTML för richtext-block, annars null. */
html: string | null;
label?: string | null;
};
export type Page = {
slug: string;
title: string;
description?: string | null;
blocks: Block[];
};
export type Service = {
title: string;
description: string | null;
price: string | null;
};
export type Seo = {
pageSlug: string;
title: string | null;
description: string | null;
ogImage: string | null;
};
export type Content = {
site: { name: string; domain: string | null };
pages: Page[];
services: Service[];
seo: Seo[];
};
export type PostSummary = {
slug: string;
title: string;
excerpt: string | null;
coverImage: string | null;
coverImageAlt: string | null;
publishedAt: string | null;
};
export type Post = PostSummary & {
/** Markdown. */
body: string | null;
/** body renderad till HTML. */
bodyHtml: string;
};
export class SitecmsError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "SitecmsError";
}
}
async function request<T>(path: string): Promise<T | null> {
const res = await fetch(`${API_URL}/api/v1/sites/${encodeURIComponent(SITE)}${path}`, {
headers: { "x-api-key": process.env.CMS_API_KEY! },
next: { tags: ["sitecms"] },
});
if (res.status === 404 && path.startsWith("/posts/")) return null;
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new SitecmsError(`sitecms: ${body?.error ?? res.statusText} (HTTP ${res.status}, ${path})`, res.status);
}
return (await res.json()) as T;
}
/** Hela sajtens innehåll: sidor med block, tjänster och SEO. */
export async function getContent(): Promise<Content> {
return (await request<Content>("/content"))!;
}
/** Publicerade inlägg, nyast först (utan brödtext). */
export async function getPosts(): Promise<PostSummary[]> {
const data = await request<{ posts: PostSummary[] }>("/posts");
return data?.posts ?? [];
}
/** Ett publicerat inlägg, eller null om det inte finns. */
export async function getPost(slug: string): Promise<Post | null> {
const data = await request<{ post: Post }>(`/posts/${encodeURIComponent(slug)}`);
return data?.post ?? null;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
/**
* Ett block som alltid går att rendera: tomma strängar i stället för null.
* `html` är färdig HTML för richtext och HTML-escapad text för övriga typer.
*/
export function block(content: Content, pageSlug: string, key: string): { value: string; html: string; alt: string } {
const found = content.pages.find((p) => p.slug === pageSlug)?.blocks.find((b) => b.key === key);
const value = found?.value ?? "";
return {
value,
html: found?.html ?? escapeHtml(value),
alt: found?.alt ?? "",
};
}
/** Textvärdet för ett block, eller `fallback` om det är tomt. */
export function text(content: Content, pageSlug: string, key: string, fallback = ""): string {
return block(content, pageSlug, key).value || fallback;
}
/** Bild-URL + alt-text för ett image-block, eller null om ingen bild är vald. */
export function image(content: Content, pageSlug: string, key: string): { src: string; alt: string } | null {
const { value, alt } = block(content, pageSlug, key);
return value ? { src: value, alt } : null;
}
/** SEO-uppgifter för en sida (passar i generateMetadata). */
export function seo(content: Content, pageSlug: string): Seo | null {
return content.seo.find((s) => s.pageSlug === pageSlug) ?? null;
}
Användning i en sida:
// app/page.tsx — exempel
import Image from "next/image";
import { block, getContent, image, text } from "@/lib/sitecms";
export default async function Home() {
const content = await getContent();
const hero = image(content, "home", "hero.image");
return (
<main>
<h1>{text(content, "home", "hero.title", "Välkommen")}</h1>
{hero ? <Image src={hero.src} alt={hero.alt} width={1200} height={630} /> : null}
<div dangerouslySetInnerHTML={{ __html: block(content, "home", "intro.body").html }} />
</main>
);
}app/api/sitecms/revalidate/route.ts
Tar emot webhooken från sitecms, jämför hemligheten i konstant tid och tömmer cachetaggen. Ange https://din-domän.se/api/sitecms/revalidate som revalidate-URL i panelen.
// app/api/sitecms/revalidate/route.ts — sitecms anropar den här när innehåll sparas.
import { createHash, timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
export const dynamic = "force-dynamic";
/** Jämför i konstant tid (hashar först så längden inte läcker). */
function safeEqual(a: string, b: string): boolean {
const ha = createHash("sha256").update(a).digest();
const hb = createHash("sha256").update(b).digest();
return timingSafeEqual(ha, hb);
}
export async function POST(request: Request) {
const expected = process.env.CMS_REVALIDATE_SECRET;
if (!expected) {
return Response.json({ revalidated: false, error: "CMS_REVALIDATE_SECRET saknas" }, { status: 500 });
}
const body = (await request.json().catch(() => null)) as { secret?: unknown } | null;
const secret = typeof body?.secret === "string" ? body.secret : "";
if (!secret || !safeEqual(secret, expected)) {
return Response.json({ revalidated: false, error: "Fel hemlighet" }, { status: 401 });
}
// Next.js 16: { expire: 0 } ger färskt innehåll direkt (formen med bara taggen är utfasad).
// Next.js 14–15 tar bara taggen och ignorerar det andra argumentet.
(revalidateTag as (tag: string, profile?: { expire?: number }) => void)("sitecms", { expire: 0 });
return Response.json({ revalidated: true });
}
sitecms.config.ts
Typad deklaration av sidor och fält. Formatet beskrivs under Schema-format.
// sitecms.config.ts — vilka sidor och fält sajten har.
// Synkas till sitecms med scripts/sitecms-sync.ts (körs före varje build).
// Befintligt innehåll skrivs aldrig över; nya fält får sitt default-värde.
export type SitecmsBlock = {
/** Unik per sida. a–z, 0–9, punkt, bindestreck, understreck. T.ex. "hero.title". */
key: string;
type: "text" | "richtext" | "image";
/** Etiketten kunden ser i panelen. */
label?: string;
/** Hjälptext under fältet. */
help?: string;
/** Startvärde (text, markdown eller bild-URL). */
default?: string;
/** Startvärde för bildens alt-text (bara image). */
defaultAlt?: string;
};
export type SitecmsPage = {
slug: string;
title: string;
description?: string;
blocks: SitecmsBlock[];
};
export type SitecmsConfig = {
version: 1;
pages: SitecmsPage[];
/** true tar bort block och tomma sidor som inte längre finns här. */
prune?: boolean;
};
const config: SitecmsConfig = {
version: 1,
pages: [
{
slug: "home",
title: "Startsida",
description: "Det besökaren ser först.",
blocks: [
{ key: "hero.title", type: "text", label: "Rubrik", help: "Max 60 tecken", default: "Välkommen" },
{
key: "hero.image",
type: "image",
label: "Hero-bild",
default: "https://exempel.se/hero.jpg",
defaultAlt: "Byggarbetare",
},
{ key: "intro.body", type: "richtext", label: "Ingress" },
],
},
],
prune: false,
};
export default config;
scripts/sitecms-sync.ts
Läser sitecms.config.ts, POST:ar den till …/schema och skriver ut vad som skapades, uppdaterades och vilka fält som bara finns i CMS:et. Avslutar med felkod 1 vid fel, så att bygget stoppas innan en trasig struktur går ut. Läser env från miljön eller .env.local/.env.
// scripts/sitecms-sync.ts — skickar sitecms.config.ts till sitecms.
// Kör: npx tsx scripts/sitecms-sync.ts
// (eller node --experimental-strip-types scripts/sitecms-sync.ts)
// Läser CMS_API_URL, CMS_SITE och CMS_API_KEY från miljön eller .env.local / .env.
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { SitecmsConfig } from "../sitecms.config";
type SyncReport = {
ok: true;
created: { pages: number; blocks: number };
updated: { pages: number; blocks: number };
orphans: { pageSlug: string; key: string }[];
pruned?: { pages?: number; blocks?: number };
};
type SyncError = { error?: string; issues?: unknown[] };
function loadEnv(): void {
for (const file of [".env.local", ".env"]) {
if (existsSync(file)) {
try {
process.loadEnvFile(file);
} catch {
// Ogiltig rad i filen — variablerna kan fortfarande komma från miljön.
}
}
}
}
async function loadConfig(): Promise<SitecmsConfig> {
const file = resolve(process.cwd(), "sitecms.config.ts");
if (!existsSync(file)) throw new Error(`hittar inte ${file}`);
let mod: unknown = await import(pathToFileURL(file).href);
// tsx (CommonJS) kan slå in default-exporten en extra gång.
while (mod && typeof mod === "object" && "default" in mod && !("pages" in mod)) {
mod = (mod as { default: unknown }).default;
}
return mod as SitecmsConfig;
}
function fail(message: string): never {
console.error(`✖ sitecms-sync: ${message}`);
process.exit(1);
}
async function main(): Promise<void> {
loadEnv();
const apiUrl = process.env.CMS_API_URL?.replace(/\/+$/, "");
const site = process.env.CMS_SITE;
const apiKey = process.env.CMS_API_KEY;
if (!apiUrl || !site || !apiKey) {
fail("CMS_API_URL, CMS_SITE och CMS_API_KEY måste vara satta (t.ex. i .env.local).");
}
const config = await loadConfig();
const url = `${apiUrl}/api/v1/sites/${encodeURIComponent(site)}/schema`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
body: JSON.stringify(config),
signal: AbortSignal.timeout(30_000),
});
const data = (await res.json().catch(() => null)) as (SyncReport & SyncError) | null;
if (!res.ok || !data?.ok) {
console.error(`✖ sitecms-sync: HTTP ${res.status} från ${url}`);
if (data?.error) console.error(` ${data.error}`);
for (const issue of data?.issues ?? []) {
const i = issue as { path?: unknown; message?: unknown };
const line =
typeof issue === "string"
? issue
: typeof i?.message === "string"
? `${typeof i.path === "string" ? `${i.path}: ` : ""}${i.message}`
: JSON.stringify(issue);
console.error(` - ${line}`);
}
process.exit(1);
}
console.log(`✔ sitecms-sync: ${config.pages.length} sidor synkade till ${site}`);
console.log(` skapade: ${data.created.pages} sidor, ${data.created.blocks} block`);
console.log(` uppdaterade: ${data.updated.pages} sidor, ${data.updated.blocks} block`);
if (data.pruned && (data.pruned.pages || data.pruned.blocks)) {
console.log(` borttagna: ${data.pruned.pages ?? 0} sidor, ${data.pruned.blocks ?? 0} block`);
}
if (data.orphans.length > 0) {
console.log(` finns i CMS:et men inte i configen (${data.orphans.length}):`);
for (const o of data.orphans) console.log(` - ${o.pageSlug} / ${o.key}`);
console.log(" Sätt prune: true i sitecms.config.ts för att ta bort dem.");
}
}
main().catch((err: unknown) => fail(err instanceof Error ? err.message : String(err)));
Installera tsx och kör synken före varje build. Utan tsx (Node 22.6 eller senare) fungerar node --experimental-strip-types scripts/sitecms-sync.ts.
npm install --save-dev tsx{
"scripts": {
"sitecms:sync": "tsx scripts/sitecms-sync.ts",
"prebuild": "tsx scripts/sitecms-sync.ts"
}
}