✨ feat: implement mock data support with API interceptor and update documentation
This commit is contained in:
10
AGENTS.md
10
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
76
frontend/mocking/content.json
Normal file
76
frontend/mocking/content.json
Normal file
@@ -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": "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
|
||||
}
|
||||
],
|
||||
"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": "<p>Wir sind ein Demo-Unternehmen.</p>"
|
||||
}
|
||||
],
|
||||
"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": "<p>Schreiben Sie uns eine Nachricht.</p>"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"title": "Kontakt",
|
||||
"description": "Kontaktieren Sie uns"
|
||||
}
|
||||
}
|
||||
]
|
||||
38
frontend/mocking/navigation.json
Normal file
38
frontend/mocking/navigation.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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 <T>(
|
||||
}
|
||||
incrementRequests()
|
||||
|
||||
// ── Mock interceptor (tree-shaken when __MOCK__ is false) ──
|
||||
if (__MOCK__) {
|
||||
const mockResult = mockApiRequest(endpoint, mergedOptions, body)
|
||||
if (mockResult) return mockResult as ApiResult<T>
|
||||
// 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)
|
||||
|
||||
252
frontend/src/lib/mock.ts
Normal file
252
frontend/src/lib/mock.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* API Mock Interceptor
|
||||
*
|
||||
* Serves mock data from `frontend/mocking/<endpoint>.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<string, Record<string, unknown>[]> = {
|
||||
content: contentData as Record<string, unknown>[],
|
||||
navigation: navigationData as Record<string, unknown>[],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<unknown> | 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<string, unknown>, 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<string, unknown>)) 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<string, unknown>): 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<string, unknown>, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((acc, part) => {
|
||||
if (acc !== null && typeof acc === "object") return (acc as Record<string, unknown>)[part]
|
||||
return undefined
|
||||
}, obj)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applySort(data: Record<string, unknown>[], sortSpec: string): Record<string, unknown>[] {
|
||||
// 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<string, unknown>[], projectionStr: string): Record<string, unknown>[] {
|
||||
// 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<string, unknown> = {}
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!exclude.has(key)) result[key] = entry[key]
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
const result: Record<string, unknown> = {}
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
3
types/global.d.ts
vendored
3
types/global.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user