@mandujs/core 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,239 +1,243 @@
1
- /**
2
- * Mandu useFetch Composable
3
- * SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시
4
- */
5
-
6
- import { useState, useEffect, useCallback, useRef } from "react";
7
-
8
- // ========== Types ==========
9
-
10
- export interface UseFetchOptions<T = unknown> {
11
- query?: Record<string, string | number>;
12
- headers?: Record<string, string>;
13
- method?: string;
14
- body?: unknown;
15
- /** SSR 데이터 있으면 클라이언트 fetch 생략 (기본: true) */
16
- dedupe?: boolean;
17
- /** SSR에서 전달된 초기 데이터 */
18
- initialData?: T;
19
- /** 캐시 유지 시간 (ms, 0이면 캐시 안 함) */
20
- cacheTime?: number;
21
- /** 자동 실행 여부 (기본: true) */
22
- immediate?: boolean;
23
- /** 응답 변환 함수 */
24
- transform?: (data: unknown) => T;
25
- }
26
-
27
- export interface UseFetchReturn<T> {
28
- data: T | null;
29
- error: Error | null;
30
- loading: boolean;
31
- refresh: () => Promise<void>;
32
- mutate: (updater: T | ((prev: T | null) => T)) => void;
33
- }
34
-
35
- // ========== Cache (LRU, 최대 200 엔트리) ==========
36
-
37
- const MAX_CACHE_SIZE = 200;
38
-
39
- interface CacheEntry { data: unknown; timestamp: number; }
40
- const fetchCache = new Map<string, CacheEntry>();
41
-
42
- function getCached(key: string, maxAge: number): unknown | undefined {
43
- const entry = fetchCache.get(key);
44
- if (!entry) return undefined;
45
- if (Date.now() - entry.timestamp > maxAge) {
46
- fetchCache.delete(key);
47
- return undefined;
48
- }
49
- return entry.data;
50
- }
51
-
52
- function setCache(key: string, data: unknown): void {
53
- // LRU: 오래된 것부터 제거
54
- if (fetchCache.size >= MAX_CACHE_SIZE) {
55
- const oldest = fetchCache.keys().next().value;
56
- if (oldest !== undefined) fetchCache.delete(oldest);
57
- }
58
- fetchCache.set(key, { data, timestamp: Date.now() });
59
- }
60
-
61
- function stableStringify(value: unknown): string {
62
- if (value === undefined || value === null) return "";
63
- if (typeof value !== "object") return String(value);
64
- // 키를 정렬하여 삽입 순서에 무관한 안정적 직렬화
65
- const obj = value as Record<string, unknown>;
66
- const sorted: Record<string, unknown> = {};
67
- for (const key of Object.keys(obj).sort()) {
68
- sorted[key] = obj[key];
69
- }
70
- return JSON.stringify(sorted);
71
- }
72
-
73
- export function buildFetchCacheKey(
74
- url: string,
75
- options: {
76
- queryKey?: string;
77
- method?: string;
78
- headersKey?: string;
79
- bodyKey?: string;
80
- } = {}
81
- ): string {
82
- return [
83
- (options.method ?? "GET").toUpperCase(),
84
- url,
85
- options.queryKey ?? "",
86
- options.headersKey ?? "",
87
- options.bodyKey ?? "",
88
- ].join("::");
89
- }
90
-
91
- function buildUrl(url: string, query?: Record<string, string | number>): string {
92
- if (!query || Object.keys(query).length === 0) return url;
93
- const params = new URLSearchParams();
94
- for (const [key, value] of Object.entries(query)) {
95
- if (value !== undefined && value !== null) {
96
- params.set(key, String(value));
97
- }
98
- }
99
- return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
100
- }
101
-
102
- // ========== Hook ==========
103
-
104
- /**
105
- * 데이터 페칭 훅 — SSR 중복 방지, 캐싱, pending/error 상태 관리
106
- *
107
- * @example
108
- * ```tsx
109
- * const { data, loading, error, refresh } = useFetch<Post[]>("/api/posts", {
110
- * query: { page: 1 },
111
- * cacheTime: 300_000,
112
- * });
113
- *
114
- * const { data, mutate } = useFetch<Todo[]>("/api/todos");
115
- * const addTodo = (todo: Todo) => mutate(prev => [...(prev ?? []), todo]);
116
- * ```
117
- */
118
- export function useFetch<T = unknown>(
119
- url: string,
120
- options?: UseFetchOptions<T>
121
- ): UseFetchReturn<T> {
122
- const {
123
- query,
124
- headers,
125
- method = "GET",
126
- body,
127
- dedupe = true,
128
- initialData,
129
- cacheTime = 0,
130
- immediate = true,
131
- transform,
132
- } = options ?? {};
133
-
134
- // 안정적 직렬화 — useRef로 이전 값과 비교하여 변경 시에만 갱신
135
- const queryStr = stableStringify(query);
136
- const headersStr = stableStringify(headers);
137
- const bodyStr = stableStringify(body);
138
-
139
- const prevQueryRef = useRef(queryStr);
140
- const prevHeadersRef = useRef(headersStr);
141
- const prevBodyRef = useRef(bodyStr);
142
-
143
- const stableQuery = prevQueryRef.current === queryStr ? prevQueryRef.current : (prevQueryRef.current = queryStr);
144
- const stableHeaders = prevHeadersRef.current === headersStr ? prevHeadersRef.current : (prevHeadersRef.current = headersStr);
145
- const stableBody = prevBodyRef.current === bodyStr ? prevBodyRef.current : (prevBodyRef.current = bodyStr);
146
-
147
- const cacheKey = buildFetchCacheKey(url, {
148
- queryKey: stableQuery,
149
- method,
150
- headersKey: stableHeaders,
151
- bodyKey: stableBody,
152
- });
153
-
154
- // URL/query 변경 감지
155
- const prevCacheKeyRef = useRef(cacheKey);
156
-
157
- const [data, setData] = useState<T | null>(() => {
158
- if (initialData !== undefined) return initialData;
159
- if (cacheTime > 0) {
160
- const cached = getCached(cacheKey, cacheTime);
161
- if (cached !== undefined) return (transform ? transform(cached) : cached) as T;
162
- }
163
- return null;
164
- });
165
- const [error, setError] = useState<Error | null>(null);
166
- const [loading, setLoading] = useState(!data && immediate);
167
- const abortRef = useRef<AbortController | null>(null);
168
-
169
- // URL/query 변경 data 초기화 (useEffect로 React 규칙 준수)
170
- useEffect(() => {
171
- if (prevCacheKeyRef.current === cacheKey) return;
172
- prevCacheKeyRef.current = cacheKey;
173
-
174
- if (cacheTime > 0) {
175
- const cached = getCached(cacheKey, cacheTime);
176
- if (cached !== undefined) {
177
- setData((transform ? transform(cached) : cached) as T);
178
- return;
179
- }
180
- }
181
- setData(null);
182
- }, [cacheKey, cacheTime, transform]);
183
-
184
- const fetchData = useCallback(async () => {
185
- abortRef.current?.abort();
186
- const controller = new AbortController();
187
- abortRef.current = controller;
188
-
189
- setLoading(true);
190
- setError(null);
191
-
192
- try {
193
- const fetchUrl = buildUrl(url, query);
194
- const response = await fetch(fetchUrl, {
195
- method,
196
- headers: { "Accept": "application/json", ...headers },
197
- body: body ? JSON.stringify(body) : undefined,
198
- signal: controller.signal,
199
- });
200
-
201
- if (controller.signal.aborted) return;
202
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
203
-
204
- // Content-Type 확인 후 파싱
205
- const contentType = response.headers.get("content-type") ?? "";
206
- let result: unknown;
207
- if (contentType.includes("application/json")) {
208
- result = await response.json();
209
- } else if (response.status === 204) {
210
- result = null;
211
- } else {
212
- result = await response.text();
213
- }
214
-
215
- if (transform) result = transform(result);
216
- setData(result as T);
217
-
218
- if (cacheTime > 0) setCache(cacheKey, result);
219
- } catch (e) {
220
- if (e instanceof DOMException && e.name === "AbortError") return;
221
- setError(e instanceof Error ? e : new Error(String(e)));
222
- } finally {
223
- if (!controller.signal.aborted) setLoading(false);
224
- }
225
- }, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]);
226
-
227
- useEffect(() => {
228
- if (!immediate) return;
229
- if (data && dedupe) return;
230
- fetchData();
231
- return () => { abortRef.current?.abort(); };
232
- }, [fetchData, immediate, dedupe]);
233
-
234
- const mutate = useCallback((updater: T | ((prev: T | null) => T)) => {
235
- setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater);
236
- }, []);
237
-
238
- return { data, error, loading, refresh: fetchData, mutate };
239
- }
1
+ /**
2
+ * Mandu useFetch Composable
3
+ * SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시
4
+ */
5
+
6
+ import { useState, useEffect, useCallback, useRef } from "react";
7
+ import { LRUCache } from "../utils/lru-cache";
8
+ import { registerCacheSize } from "../observability/metrics";
9
+
10
+ // ========== Types ==========
11
+
12
+ export interface UseFetchOptions<T = unknown> {
13
+ query?: Record<string, string | number>;
14
+ headers?: Record<string, string>;
15
+ method?: string;
16
+ body?: unknown;
17
+ /** SSR 데이터 있으면 클라이언트 fetch 생략 (기본: true) */
18
+ dedupe?: boolean;
19
+ /** SSR에서 전달된 초기 데이터 */
20
+ initialData?: T;
21
+ /** 캐시 유지 시간 (ms, 0이면 캐시 안 함) */
22
+ cacheTime?: number;
23
+ /** 자동 실행 여부 (기본: true) */
24
+ immediate?: boolean;
25
+ /** 응답 변환 함수 */
26
+ transform?: (data: unknown) => T;
27
+ }
28
+
29
+ export interface UseFetchReturn<T> {
30
+ data: T | null;
31
+ error: Error | null;
32
+ loading: boolean;
33
+ refresh: () => Promise<void>;
34
+ mutate: (updater: T | ((prev: T | null) => T)) => void;
35
+ }
36
+
37
+ // ========== Cache (LRU, 최대 200 엔트리) ==========
38
+ //
39
+ // Phase 17 swapped hand-rolled Map for `LRUCache` so `get()` actually
40
+ // promotes the entry to most-recently-used (was FIFO before). Size is
41
+ // registered with the observability module for `/_mandu/heap` + the
42
+ // Prometheus `mandu_cache_entries{cache="fetchCache"}` series.
43
+
44
+ const MAX_CACHE_SIZE = 200;
45
+
46
+ interface CacheEntry { data: unknown; timestamp: number; }
47
+ const fetchCache = new LRUCache<string, CacheEntry>({ maxSize: MAX_CACHE_SIZE });
48
+ registerCacheSize("fetchCache", () => fetchCache.size);
49
+
50
+ function getCached(key: string, maxAge: number): unknown | undefined {
51
+ const entry = fetchCache.get(key);
52
+ if (!entry) return undefined;
53
+ if (Date.now() - entry.timestamp > maxAge) {
54
+ fetchCache.delete(key);
55
+ return undefined;
56
+ }
57
+ return entry.data;
58
+ }
59
+
60
+ function setCache(key: string, data: unknown): void {
61
+ // LRUCache handles eviction — we just set.
62
+ fetchCache.set(key, { data, timestamp: Date.now() });
63
+ }
64
+
65
+ function stableStringify(value: unknown): string {
66
+ if (value === undefined || value === null) return "";
67
+ if (typeof value !== "object") return String(value);
68
+ // 키를 정렬하여 삽입 순서에 무관한 안정적 직렬화
69
+ const obj = value as Record<string, unknown>;
70
+ const sorted: Record<string, unknown> = {};
71
+ for (const key of Object.keys(obj).sort()) {
72
+ sorted[key] = obj[key];
73
+ }
74
+ return JSON.stringify(sorted);
75
+ }
76
+
77
+ export function buildFetchCacheKey(
78
+ url: string,
79
+ options: {
80
+ queryKey?: string;
81
+ method?: string;
82
+ headersKey?: string;
83
+ bodyKey?: string;
84
+ } = {}
85
+ ): string {
86
+ return [
87
+ (options.method ?? "GET").toUpperCase(),
88
+ url,
89
+ options.queryKey ?? "",
90
+ options.headersKey ?? "",
91
+ options.bodyKey ?? "",
92
+ ].join("::");
93
+ }
94
+
95
+ function buildUrl(url: string, query?: Record<string, string | number>): string {
96
+ if (!query || Object.keys(query).length === 0) return url;
97
+ const params = new URLSearchParams();
98
+ for (const [key, value] of Object.entries(query)) {
99
+ if (value !== undefined && value !== null) {
100
+ params.set(key, String(value));
101
+ }
102
+ }
103
+ return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
104
+ }
105
+
106
+ // ========== Hook ==========
107
+
108
+ /**
109
+ * 데이터 페칭 SSR 중복 방지, 캐싱, pending/error 상태 관리
110
+ *
111
+ * @example
112
+ * ```tsx
113
+ * const { data, loading, error, refresh } = useFetch<Post[]>("/api/posts", {
114
+ * query: { page: 1 },
115
+ * cacheTime: 300_000,
116
+ * });
117
+ *
118
+ * const { data, mutate } = useFetch<Todo[]>("/api/todos");
119
+ * const addTodo = (todo: Todo) => mutate(prev => [...(prev ?? []), todo]);
120
+ * ```
121
+ */
122
+ export function useFetch<T = unknown>(
123
+ url: string,
124
+ options?: UseFetchOptions<T>
125
+ ): UseFetchReturn<T> {
126
+ const {
127
+ query,
128
+ headers,
129
+ method = "GET",
130
+ body,
131
+ dedupe = true,
132
+ initialData,
133
+ cacheTime = 0,
134
+ immediate = true,
135
+ transform,
136
+ } = options ?? {};
137
+
138
+ // 안정적 직렬화 — useRef로 이전 값과 비교하여 변경 시에만 갱신
139
+ const queryStr = stableStringify(query);
140
+ const headersStr = stableStringify(headers);
141
+ const bodyStr = stableStringify(body);
142
+
143
+ const prevQueryRef = useRef(queryStr);
144
+ const prevHeadersRef = useRef(headersStr);
145
+ const prevBodyRef = useRef(bodyStr);
146
+
147
+ const stableQuery = prevQueryRef.current === queryStr ? prevQueryRef.current : (prevQueryRef.current = queryStr);
148
+ const stableHeaders = prevHeadersRef.current === headersStr ? prevHeadersRef.current : (prevHeadersRef.current = headersStr);
149
+ const stableBody = prevBodyRef.current === bodyStr ? prevBodyRef.current : (prevBodyRef.current = bodyStr);
150
+
151
+ const cacheKey = buildFetchCacheKey(url, {
152
+ queryKey: stableQuery,
153
+ method,
154
+ headersKey: stableHeaders,
155
+ bodyKey: stableBody,
156
+ });
157
+
158
+ // URL/query 변경 감지
159
+ const prevCacheKeyRef = useRef(cacheKey);
160
+
161
+ const [data, setData] = useState<T | null>(() => {
162
+ if (initialData !== undefined) return initialData;
163
+ if (cacheTime > 0) {
164
+ const cached = getCached(cacheKey, cacheTime);
165
+ if (cached !== undefined) return (transform ? transform(cached) : cached) as T;
166
+ }
167
+ return null;
168
+ });
169
+ const [error, setError] = useState<Error | null>(null);
170
+ const [loading, setLoading] = useState(!data && immediate);
171
+ const abortRef = useRef<AbortController | null>(null);
172
+
173
+ // URL/query 변경 시 data 초기화 (useEffect로 React 규칙 준수)
174
+ useEffect(() => {
175
+ if (prevCacheKeyRef.current === cacheKey) return;
176
+ prevCacheKeyRef.current = cacheKey;
177
+
178
+ if (cacheTime > 0) {
179
+ const cached = getCached(cacheKey, cacheTime);
180
+ if (cached !== undefined) {
181
+ setData((transform ? transform(cached) : cached) as T);
182
+ return;
183
+ }
184
+ }
185
+ setData(null);
186
+ }, [cacheKey, cacheTime, transform]);
187
+
188
+ const fetchData = useCallback(async () => {
189
+ abortRef.current?.abort();
190
+ const controller = new AbortController();
191
+ abortRef.current = controller;
192
+
193
+ setLoading(true);
194
+ setError(null);
195
+
196
+ try {
197
+ const fetchUrl = buildUrl(url, query);
198
+ const response = await fetch(fetchUrl, {
199
+ method,
200
+ headers: { "Accept": "application/json", ...headers },
201
+ body: body ? JSON.stringify(body) : undefined,
202
+ signal: controller.signal,
203
+ });
204
+
205
+ if (controller.signal.aborted) return;
206
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
207
+
208
+ // Content-Type 확인 후 파싱
209
+ const contentType = response.headers.get("content-type") ?? "";
210
+ let result: unknown;
211
+ if (contentType.includes("application/json")) {
212
+ result = await response.json();
213
+ } else if (response.status === 204) {
214
+ result = null;
215
+ } else {
216
+ result = await response.text();
217
+ }
218
+
219
+ if (transform) result = transform(result);
220
+ setData(result as T);
221
+
222
+ if (cacheTime > 0) setCache(cacheKey, result);
223
+ } catch (e) {
224
+ if (e instanceof DOMException && e.name === "AbortError") return;
225
+ setError(e instanceof Error ? e : new Error(String(e)));
226
+ } finally {
227
+ if (!controller.signal.aborted) setLoading(false);
228
+ }
229
+ }, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]);
230
+
231
+ useEffect(() => {
232
+ if (!immediate) return;
233
+ if (data && dedupe) return;
234
+ fetchData();
235
+ return () => { abortRef.current?.abort(); };
236
+ }, [fetchData, immediate, dedupe]);
237
+
238
+ const mutate = useCallback((updater: T | ((prev: T | null) => T)) => {
239
+ setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater);
240
+ }, []);
241
+
242
+ return { data, error, loading, refresh: fetchData, mutate };
243
+ }
@@ -96,6 +96,29 @@ export interface ManduConfig {
96
96
  * Default: `true`.
97
97
  */
98
98
  prefetch?: boolean;
99
+ /**
100
+ * Issue #193 — Enable opt-out SPA navigation. When `true` (default)
101
+ * Mandu intercepts every internal same-origin `<a href="/...">` click
102
+ * and routes it through the client-side router, using the View
103
+ * Transitions API where available for a zero-flash experience. Plain
104
+ * `<a href="/about">` tags "just work" without a component wrapper.
105
+ *
106
+ * Escape hatches (the anchor always falls through to the browser):
107
+ * - Per-link opt-out: `data-no-spa` on the `<a>` tag.
108
+ * - External / cross-origin `href`.
109
+ * - `mailto:` / `tel:` / `javascript:` / etc. (non-http schemes).
110
+ * - `target="_blank"` (any `target` other than `_self`).
111
+ * - `download` attribute.
112
+ * - Modifier keys (Ctrl / Cmd / Shift / Alt) or middle/right-click.
113
+ *
114
+ * Global opt-out: set this field to `false` to revert to the legacy
115
+ * opt-in behavior, where only `<a data-mandu-link href="/...">` is
116
+ * intercepted. This is a breaking-change escape hatch for projects
117
+ * that relied on the pre-v0.22 default.
118
+ *
119
+ * Default: `true`.
120
+ */
121
+ spa?: boolean;
99
122
  server?: {
100
123
  port?: number;
101
124
  hostname?: string;
@@ -151,6 +174,30 @@ export interface ManduConfig {
151
174
  * a no-op in prod regardless of value.
152
175
  */
153
176
  devtools?: boolean;
177
+ /**
178
+ * Issue #196 — Auto-run `scripts/prebuild-*.ts` before `mandu dev`
179
+ * boots, and re-run them when files under `contentDir` change in
180
+ * watch mode.
181
+ *
182
+ * - `true` → always run discovered prebuild scripts, regardless
183
+ * of whether `content/` exists (useful for projects
184
+ * that ship generators that write outside `content/`).
185
+ * - `false` → never auto-run. User stays responsible for the
186
+ * chain (`bun scripts/prebuild-*.ts && mandu dev`).
187
+ * - `undefined` → default. Auto-enabled iff the project has a
188
+ * `content/` directory OR at least one
189
+ * `scripts/prebuild-*.ts`. Silent no-op otherwise.
190
+ *
191
+ * See `@mandujs/core/content/prebuild` for the discovery + execution
192
+ * contract.
193
+ */
194
+ autoPrebuild?: boolean;
195
+ /**
196
+ * Issue #196 — Directory whose changes trigger a watch-mode
197
+ * prebuild re-run. Defaults to `"content"`. Ignored when
198
+ * `autoPrebuild === false`. Relative to project root.
199
+ */
200
+ contentDir?: string;
154
201
  };
155
202
  fsRoutes?: {
156
203
  routesDir?: string;
@@ -165,6 +212,23 @@ export interface ManduConfig {
165
212
  };
166
213
  /** Phase 12.1 — `mandu test` configuration block. */
167
214
  test?: TestConfig;
215
+ /**
216
+ * Phase 17 — observability endpoint toggles.
217
+ *
218
+ * Both fields default to `undefined`, which means "use mode default":
219
+ * - dev mode → endpoint exposed
220
+ * - prod mode → endpoint hidden unless `MANDU_DEBUG_HEAP=1`
221
+ *
222
+ * Explicit `true` / `false` overrides the mode default, so operators
223
+ * can opt-in to exposing metrics in prod (for a trusted internal
224
+ * network) or opt-out in dev (for a clean test harness).
225
+ */
226
+ observability?: {
227
+ /** `/_mandu/heap` JSON exposure toggle. */
228
+ heapEndpoint?: boolean;
229
+ /** `/_mandu/metrics` Prometheus text exposure toggle. */
230
+ metricsEndpoint?: boolean;
231
+ };
168
232
  plugins?: ManduPlugin[];
169
233
  hooks?: Partial<ManduHooks>;
170
234
  }
@@ -110,6 +110,18 @@ const DevConfigSchema = z
110
110
  * force on / off. Only applies in dev mode.
111
111
  */
112
112
  devtools: z.boolean().optional(),
113
+ /**
114
+ * Issue #196 — auto-run `scripts/prebuild-*.ts` before `mandu dev`.
115
+ * `undefined` = default (auto-enabled when content/ OR prebuild
116
+ * scripts exist); explicit `true` / `false` forces on / off.
117
+ */
118
+ autoPrebuild: z.boolean().optional(),
119
+ /**
120
+ * Issue #196 — directory whose changes trigger a watch-mode
121
+ * prebuild re-run. Non-empty to keep chokidar from trying to watch
122
+ * an empty pattern. Default `"content"`.
123
+ */
124
+ contentDir: z.string().min(1).default("content"),
113
125
  })
114
126
  .strict();
115
127
 
@@ -189,6 +201,20 @@ const TestConfigSchema = z
189
201
  })
190
202
  .strict();
191
203
 
204
+ /**
205
+ * Phase 17 — observability endpoint config (strict).
206
+ *
207
+ * Shape mirrors `ServerOptions.observability`; both fields omit a
208
+ * default so the runtime can distinguish "not set" (use mode default)
209
+ * from "explicit false" (force off even in dev).
210
+ */
211
+ const ObservabilityConfigSchema = z
212
+ .object({
213
+ heapEndpoint: z.boolean().optional(),
214
+ metricsEndpoint: z.boolean().optional(),
215
+ })
216
+ .strict();
217
+
192
218
  const AdapterConfigSchema = z.custom<ManduAdapter | undefined>(
193
219
  (value) =>
194
220
  value === undefined ||
@@ -237,6 +263,14 @@ export const ManduConfigSchema = z
237
263
  * Set `false` to suppress the ~500-byte prefetch IIFE.
238
264
  */
239
265
  prefetch: z.boolean().default(true),
266
+ /**
267
+ * Issue #193 — Opt-out SPA navigation. Default `true`.
268
+ * When `true`, plain `<a href="/...">` clicks are intercepted by
269
+ * the client-side router (per-link escape hatch: `data-no-spa`).
270
+ * Set `false` to revert to the legacy opt-in behavior, where only
271
+ * `<a data-mandu-link>` is intercepted.
272
+ */
273
+ spa: z.boolean().default(true),
240
274
  server: ServerConfigSchema.default({}),
241
275
  guard: GuardConfigSchema.default({}),
242
276
  build: BuildConfigSchema.default({}),
@@ -244,6 +278,7 @@ export const ManduConfigSchema = z
244
278
  fsRoutes: FsRoutesConfigSchema.default({}),
245
279
  seo: SeoConfigSchema.default({}),
246
280
  test: TestConfigSchema.default({}),
281
+ observability: ObservabilityConfigSchema.default({}),
247
282
  plugins: z.array(ManduPluginSchema).optional(),
248
283
  hooks: ManduHooksSchema.optional(),
249
284
  })