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