diff --git a/AGENTS.md b/AGENTS.md
index 93344dc..cd9129f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,16 +13,18 @@ Tibi CMS starter template — Svelte 5 SPA with esbuild, SSR via goja, and Playw
## Setup commands
- Install deps: `yarn install`
-- Start dev (Docker): `make docker-up && make docker-start`
-- Start dev (local): `yarn dev`
+- Start dev: `make docker-up && make docker-start`
+- Start with mock data: set `MOCK=1` in `.env`, then `make docker-up && make docker-start`
- Build frontend: `yarn build`
- Build SSR bundle: `yarn build:server`
- Validate types: `yarn validate`
## Development workflow
-- Default dev flow is Docker/Makefile: `make docker-up`, `make docker-start`, `make docker-logs`, `make docker-restart-frontend`.
-- Local dev is secondary: `yarn dev` for watch, `yarn build` and `yarn build:server` for production builds.
+- **Dev servers always run in Docker** — never use `yarn dev` or `yarn start` locally; web access only works through the Docker reverse proxy.
+- Docker/Makefile commands: `make docker-up`, `make docker-start`, `make docker-logs`, `make docker-restart-frontend`.
+- Local `yarn` is only for standalone tasks: `yarn build`, `yarn build:server`, `yarn validate`.
+- **Mock mode**: Set `MOCK=1` to run the frontend without a tibi-server. API calls are served from JSON files in `frontend/mocking/`. Enable in Docker via `MOCK=1` in `.env`, then `make docker-up && make docker-start`. Missing mock endpoints return 404.
- Frontend code is automatically built by watcher and BrowserSync; backend hooks are automatically reloaded on change.
- Read `.env` for environment URLs and secrets.
- `webserver/` is for staging/ops only; use BrowserSync/esbuild for day-to-day dev.
diff --git a/docker-compose-local.yml b/docker-compose-local.yml
index 5ef9331..5e3070c 100644
--- a/docker-compose-local.yml
+++ b/docker-compose-local.yml
@@ -14,6 +14,8 @@ services:
- ./tmp/.yarn:/.yarn
working_dir: /data
command: sh -c "yarn install && API_BASE=http://tibiserver:8080/api/v1/_/${TIBI_NAMESPACE} yarn start${START_SCRIPT}"
+ environment:
+ MOCK: ${MOCK:-0}
expose:
- 3000
labels:
diff --git a/esbuild.config.js b/esbuild.config.js
index 7ec063f..accb2d5 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -44,6 +44,25 @@ const resolvePlugin = {
},
}
+// When MOCK is disabled, replace the mock module with a no-op stub so
+// esbuild can tree-shake all mock data out of the production bundle.
+const mockPlugin = {
+ name: "mockPlugin",
+ setup(build) {
+ if (process.env.MOCK !== "1") {
+ build.onResolve({ filter: /\/mock$/ }, (args) => {
+ if (args.importer.includes("api.ts") || args.importer.includes("api.js")) {
+ return { path: "mock-noop", namespace: "mock-noop" }
+ }
+ })
+ build.onLoad({ filter: /.*/, namespace: "mock-noop" }, () => ({
+ contents: "export function mockApiRequest() { return null }",
+ loader: "ts",
+ }))
+ }
+ },
+}
+
////////////////////////// esbuild-svelte
const sveltePlugin = require("esbuild-svelte")
@@ -84,7 +103,10 @@ const options = {
minify: process.argv[2] == "build",
bundle: true,
splitting: false,
- plugins: [esbuildSvelte, postcssPlugin(), resolvePlugin],
+ define: {
+ __MOCK__: process.env.MOCK === "1" ? "true" : "false",
+ },
+ plugins: [esbuildSvelte, postcssPlugin(), resolvePlugin, mockPlugin],
loader: {
".woff2": "file",
".woff": "file",
diff --git a/frontend/mocking/content.json b/frontend/mocking/content.json
new file mode 100644
index 0000000..813386f
--- /dev/null
+++ b/frontend/mocking/content.json
@@ -0,0 +1,76 @@
+[
+ {
+ "id": "home",
+ "_id": "home",
+ "active": true,
+ "type": "page",
+ "lang": "de",
+ "translationKey": "home",
+ "name": "Startseite",
+ "path": "/",
+ "blocks": [
+ {
+ "type": "hero",
+ "headline": "Willkommen",
+ "headlineH1": true,
+ "subline": "Demo-Startseite des Starter-Templates",
+ "heroImage": {
+ "image": "demo-hero"
+ }
+ },
+ {
+ "type": "richtext",
+ "headline": "Über uns",
+ "text": "
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
"
+ }
+ ],
+ "meta": {
+ "title": "Startseite",
+ "description": "Demo-Startseite"
+ }
+ },
+ {
+ "id": "about",
+ "_id": "about",
+ "active": true,
+ "type": "page",
+ "lang": "de",
+ "translationKey": "about",
+ "name": "Über uns",
+ "path": "/ueber-uns",
+ "blocks": [
+ {
+ "type": "richtext",
+ "headline": "Über uns",
+ "headlineH1": true,
+ "text": "Wir sind ein Demo-Unternehmen.
"
+ }
+ ],
+ "meta": {
+ "title": "Über uns",
+ "description": "Erfahren Sie mehr über uns"
+ }
+ },
+ {
+ "id": "contact",
+ "_id": "contact",
+ "active": true,
+ "type": "page",
+ "lang": "de",
+ "translationKey": "contact",
+ "name": "Kontakt",
+ "path": "/kontakt",
+ "blocks": [
+ {
+ "type": "richtext",
+ "headline": "Kontakt",
+ "headlineH1": true,
+ "text": "Schreiben Sie uns eine Nachricht.
"
+ }
+ ],
+ "meta": {
+ "title": "Kontakt",
+ "description": "Kontaktieren Sie uns"
+ }
+ }
+]
\ No newline at end of file
diff --git a/frontend/mocking/navigation.json b/frontend/mocking/navigation.json
new file mode 100644
index 0000000..a06c97b
--- /dev/null
+++ b/frontend/mocking/navigation.json
@@ -0,0 +1,38 @@
+[
+ {
+ "id": "header-de",
+ "_id": "header-de",
+ "language": "de",
+ "type": "header",
+ "elements": [
+ {
+ "name": "Startseite",
+ "page": "home"
+ },
+ {
+ "name": "Über uns",
+ "page": "about"
+ },
+ {
+ "name": "Kontakt",
+ "page": "contact"
+ }
+ ]
+ },
+ {
+ "id": "footer-de",
+ "_id": "footer-de",
+ "language": "de",
+ "type": "footer",
+ "elements": [
+ {
+ "name": "Impressum",
+ "page": "imprint"
+ },
+ {
+ "name": "Datenschutz",
+ "page": "privacy"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index d2c9bcb..2a289ec 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -16,6 +16,7 @@ import { apiBaseOverride } from "./store"
import { incrementRequests, decrementRequests } from "./requestsStore"
import { setServerBuildTime } from "./serverBuildInfo"
import { checkBuildVersion } from "./versionCheck"
+import { mockApiRequest } from "./mock"
// ---------------------------------------------------------------------------
// Request deduplication
@@ -84,6 +85,14 @@ export const api = async (
}
incrementRequests()
+ // ── Mock interceptor (tree-shaken when __MOCK__ is false) ──
+ if (__MOCK__) {
+ const mockResult = mockApiRequest(endpoint, mergedOptions, body)
+ if (mockResult) return mockResult as ApiResult
+ // No mock data for this endpoint → 404
+ throw { response: { status: 404 }, data: { error: `[mock] No mock data for "${endpoint}"` } }
+ }
+
const data = await apiRequest(_apiBaseOverride + endpoint, mergedOptions, body, sentry)
// Build-version check only on GETs (don't reload mid-write)
diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts
new file mode 100644
index 0000000..38534cd
--- /dev/null
+++ b/frontend/src/lib/mock.ts
@@ -0,0 +1,252 @@
+/**
+ * API Mock Interceptor
+ *
+ * Serves mock data from `frontend/mocking/.json` when `__MOCK__` is enabled.
+ * Supports tibi-server-style filtering, single-item retrieval by ID, sorting,
+ * limit/offset pagination, and projection.
+ *
+ * Activate via build flag: `MOCK=1 yarn start`
+ * For production builds, the entire module is eliminated by dead-code removal.
+ */
+
+// ---------------------------------------------------------------------------
+// Mock data registry — static imports for tree-shaking compatibility.
+// Add new collections here as needed.
+// ---------------------------------------------------------------------------
+import contentData from "../../mocking/content.json"
+import navigationData from "../../mocking/navigation.json"
+
+const mockRegistry: Record[]> = {
+ content: contentData as Record[],
+ navigation: navigationData as Record[],
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Attempt to serve the request from mock data.
+ * Returns an `ApiResult`-shaped object or `null` if no mock data is registered.
+ *
+ * @param endpoint Raw endpoint string, e.g. `"content"` or `"content/home"`
+ * @param options ApiOptions (filter, sort, limit, offset, projection)
+ */
+export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: unknown): ApiResult | null {
+ // Strip leading `/api/` or `/` if present (callers may include it)
+ const cleaned = endpoint.replace(/^\/?(api\/)?/, "")
+
+ // Split into collection name and optional ID: "content/home" → ["content", "home"]
+ const segments = cleaned.split("/").filter(Boolean)
+ const collection = segments[0]
+ const itemId = segments[1] // undefined for list requests
+
+ const sourceData = mockRegistry[collection]
+ if (!sourceData) return null // no mock registered → interceptor should 404
+
+ console.log(`[mock] ${itemId ? "GET" : "LIST"} /${collection}${itemId ? "/" + itemId : ""}`, options?.filter ?? "")
+
+ // --- Single-item retrieval ---
+ if (itemId) {
+ const item = sourceData.find((e) => e.id === itemId || e._id === itemId)
+ return {
+ data: item ?? null,
+ count: item ? 1 : 0,
+ buildTime: null,
+ }
+ }
+
+ // --- List retrieval with filter / sort / pagination ---
+ let results = [...sourceData]
+
+ // Filter
+ if (options?.filter) {
+ results = results.filter((entry) => matchesFilter(entry, options.filter!))
+ }
+
+ const totalCount = results.length
+
+ // Sort
+ if (options?.sort) {
+ results = applySort(results, options.sort)
+ }
+
+ // Pagination
+ if (options?.offset) {
+ results = results.slice(options.offset)
+ }
+ if (options?.limit) {
+ results = results.slice(0, options.limit)
+ }
+
+ // Projection
+ if (options?.projection) {
+ results = applyProjection(results, options.projection)
+ }
+
+ return {
+ data: results,
+ count: totalCount,
+ buildTime: null,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Filter engine — simple MongoDB-style matching
+// ---------------------------------------------------------------------------
+
+function matchesFilter(entry: Record, filter: MongoFilter): boolean {
+ for (const key of Object.keys(filter)) {
+ const condition = filter[key]
+
+ // Logical operators
+ if (key === "$and") {
+ if (!Array.isArray(condition)) return false
+ return condition.every((sub: MongoFilter) => matchesFilter(entry, sub))
+ }
+ if (key === "$or") {
+ if (!Array.isArray(condition)) return false
+ return condition.some((sub: MongoFilter) => matchesFilter(entry, sub))
+ }
+ if (key === "$nor") {
+ if (!Array.isArray(condition)) return false
+ return !condition.some((sub: MongoFilter) => matchesFilter(entry, sub))
+ }
+
+ const value = getNestedValue(entry, key)
+
+ // Operator object: { field: { $in: [...], $ne: "x", ... } }
+ if (condition !== null && typeof condition === "object" && !Array.isArray(condition)) {
+ if (!matchesOperators(value, condition as Record)) return false
+ continue
+ }
+
+ // Direct equality (or array-contains)
+ if (Array.isArray(value)) {
+ if (!value.includes(condition)) return false
+ } else {
+ if (value !== condition) return false
+ }
+ }
+ return true
+}
+
+function matchesOperators(value: unknown, operators: Record): boolean {
+ for (const op of Object.keys(operators)) {
+ const expected = operators[op]
+ switch (op) {
+ case "$eq":
+ if (value !== expected) return false
+ break
+ case "$ne":
+ if (value === expected) return false
+ break
+ case "$in":
+ if (!Array.isArray(expected) || !expected.includes(value)) return false
+ break
+ case "$nin":
+ if (Array.isArray(expected) && expected.includes(value)) return false
+ break
+ case "$exists":
+ if (expected ? value === undefined : value !== undefined) return false
+ break
+ case "$gt":
+ if (typeof value !== "number" || typeof expected !== "number" || value <= expected) return false
+ break
+ case "$gte":
+ if (typeof value !== "number" || typeof expected !== "number" || value < expected) return false
+ break
+ case "$lt":
+ if (typeof value !== "number" || typeof expected !== "number" || value >= expected) return false
+ break
+ case "$lte":
+ if (typeof value !== "number" || typeof expected !== "number" || value > expected) return false
+ break
+ case "$regex": {
+ const flags = (operators.$options as string) || ""
+ const re = new RegExp(expected as string, flags)
+ if (typeof value !== "string" || !re.test(value)) return false
+ break
+ }
+ default:
+ // Unknown operator — skip gracefully
+ break
+ }
+ }
+ return true
+}
+
+/** Resolve dot-notation paths: `"meta.title"` → `entry.meta.title` */
+function getNestedValue(obj: Record, path: string): unknown {
+ return path.split(".").reduce((acc, part) => {
+ if (acc !== null && typeof acc === "object") return (acc as Record)[part]
+ return undefined
+ }, obj)
+}
+
+// ---------------------------------------------------------------------------
+// Sort
+// ---------------------------------------------------------------------------
+
+function applySort(data: Record[], sortSpec: string): Record[] {
+ // tibi-server sort format: "field" (asc) or "-field" (desc), comma-separated
+ const fields = sortSpec
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+
+ return [...data].sort((a, b) => {
+ for (const field of fields) {
+ const desc = field.startsWith("-")
+ const key = desc ? field.slice(1) : field
+ const aVal = getNestedValue(a, key)
+ const bVal = getNestedValue(b, key)
+ const cmp = compareValues(aVal, bVal)
+ if (cmp !== 0) return desc ? -cmp : cmp
+ }
+ return 0
+ })
+}
+
+function compareValues(a: unknown, b: unknown): number {
+ if (a === b) return 0
+ if (a === undefined || a === null) return -1
+ if (b === undefined || b === null) return 1
+ if (typeof a === "number" && typeof b === "number") return a - b
+ return String(a).localeCompare(String(b))
+}
+
+// ---------------------------------------------------------------------------
+// Projection
+// ---------------------------------------------------------------------------
+
+function applyProjection(data: Record[], projectionStr: string): Record[] {
+ // projection format: "field1,field2" (include) or "-field1,-field2" (exclude)
+ const fields = projectionStr
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ if (fields.length === 0) return data
+
+ const isExclude = fields[0].startsWith("-")
+
+ return data.map((entry) => {
+ if (isExclude) {
+ const exclude = new Set(fields.map((f) => f.replace(/^-/, "")))
+ const result: Record = {}
+ for (const key of Object.keys(entry)) {
+ if (!exclude.has(key)) result[key] = entry[key]
+ }
+ return result
+ } else {
+ const result: Record = {}
+ for (const field of fields) {
+ if (field in entry) result[field] = entry[field]
+ }
+ // Always include id/_id
+ if (entry.id !== undefined) result.id = entry.id
+ if (entry._id !== undefined) result._id = entry._id
+ return result
+ }
+ })
+}
diff --git a/package.json b/package.json
index e521a21..cb9ed8f 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"validate": "svelte-check && tsc --noEmit",
"dev": "node scripts/esbuild-wrapper.js watch",
"start": "node scripts/esbuild-wrapper.js start",
+ "start:mock": "MOCK=1 node scripts/esbuild-wrapper.js start",
"start:ssr": "SSR=1 node scripts/esbuild-wrapper.js start",
"build": "node scripts/esbuild-wrapper.js build",
"build:admin": "node scripts/esbuild-wrapper.js build esbuild.config.admin.js",
diff --git a/types/global.d.ts b/types/global.d.ts
index 6cc0945..4340a64 100644
--- a/types/global.d.ts
+++ b/types/global.d.ts
@@ -155,3 +155,6 @@ interface ProductEntry {
id: string
// ...
}
+
+/** Build-time flag: `true` when built with `MOCK=1`, enables API mock interceptor */
+declare const __MOCK__: boolean