@mandujs/core 0.24.0 → 0.25.1
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.
- package/package.json +3 -1
- package/src/bundler/build.test.ts +18 -8
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +18 -4
- package/src/bundler/safe-build.test.ts +22 -4
- package/src/client/globals.ts +61 -44
- package/src/client/router.ts +93 -15
- package/src/client/use-fetch.ts +243 -239
- package/src/config/mandu.ts +64 -0
- package/src/config/validate.ts +35 -0
- package/src/content/collection.ts +506 -0
- package/src/content/frontmatter.ts +189 -0
- package/src/content/generate-types.ts +168 -0
- package/src/content/index.ts +206 -168
- package/src/content/llms-txt.ts +196 -0
- package/src/content/prebuild.test.ts +249 -0
- package/src/content/prebuild.ts +400 -0
- package/src/content/schema.ts +20 -0
- package/src/content/sidebar.ts +212 -0
- package/src/content/slug.ts +110 -0
- package/src/guard/check.ts +5 -2
- package/src/observability/index.ts +37 -18
- package/src/observability/metrics.ts +334 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +171 -0
- package/src/runtime/server.ts +153 -6
- package/src/runtime/ssr.ts +118 -1
- package/src/runtime/streaming-ssr.ts +11 -1
- package/src/utils/__tests__/lru-cache.test.ts +186 -0
- package/src/utils/lru-cache.ts +172 -75
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
"./auth/reset": "./src/auth/reset.ts",
|
|
14
14
|
"./auth/verification": "./src/auth/verification.ts",
|
|
15
15
|
"./client": "./src/client/index.ts",
|
|
16
|
+
"./content": "./src/content/index.ts",
|
|
16
17
|
"./db": "./src/db/index.ts",
|
|
17
18
|
"./desktop": "./src/desktop/index.ts",
|
|
18
19
|
"./desktop/worker": "./src/desktop/worker.ts",
|
|
19
20
|
"./email": "./src/email/index.ts",
|
|
20
21
|
"./filling/session-sqlite": "./src/filling/session-sqlite.ts",
|
|
22
|
+
"./kitchen": "./src/kitchen/index.ts",
|
|
21
23
|
"./middleware": "./src/middleware/index.ts",
|
|
22
24
|
"./middleware/oauth": "./src/middleware/oauth/index.ts",
|
|
23
25
|
"./middleware/secure": "./src/middleware/secure/index.ts",
|
|
@@ -62,14 +62,24 @@ afterAll(async () => {
|
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
65
|
+
// Historical note — `MANDU_SKIP_BUNDLER_TESTS` gate REMOVED.
|
|
66
|
+
//
|
|
67
|
+
// A previous revision gated this describe block behind
|
|
68
|
+
// `describe.skipIf(MANDU_SKIP_BUNDLER_TESTS === "1")` because running
|
|
69
|
+
// `bun test src/bundler/` without the gate hung indefinitely on Windows
|
|
70
|
+
// (see Phase 0.6 and `docs/qa/wave-R2-integration-report.md`). Root cause
|
|
71
|
+
// was NOT actually in THIS file — it was a deadlock in `safe-build.test.ts`'s
|
|
72
|
+
// "slot handoff" regression test, which drove Bun's microtask queue with a
|
|
73
|
+
// `while (!stop) { await Promise.resolve() }` sampler. That starved libuv
|
|
74
|
+
// I/O callbacks, so the 7 parallel `safeBuild()` calls never completed, the
|
|
75
|
+
// whole test process hung, and downstream test files (including this one
|
|
76
|
+
// when run in the same invocation) looked flaky when they were simply
|
|
77
|
+
// never reached. The handoff sampler now yields via `setImmediate`, which
|
|
78
|
+
// unblocks Bun.build completion and makes `bun test src/bundler/` finish
|
|
79
|
+
// deterministically in ~35s on Windows. Confirmed green 3/3 runs without
|
|
80
|
+
// the gate on 2026-04-20. If you are tempted to re-introduce the skip here,
|
|
81
|
+
// first check whether a sibling test is starving the event loop.
|
|
82
|
+
describe("buildClientBundles vendor shims", () => {
|
|
73
83
|
test("build succeeds", () => {
|
|
74
84
|
if (!result.success) {
|
|
75
85
|
console.error("[build.test] errors:", result.errors);
|
package/src/bundler/build.ts
CHANGED
|
@@ -859,11 +859,34 @@ function getListeners() {
|
|
|
859
859
|
return window.__MANDU_ROUTER_LISTENERS__;
|
|
860
860
|
}
|
|
861
861
|
|
|
862
|
-
// 패턴 매칭 캐시
|
|
862
|
+
// 패턴 매칭 캐시 (Phase 17 — bounded LRU, inlined to keep the runtime
|
|
863
|
+
// bundle self-contained. Same semantics as packages/core/src/utils/lru-cache.ts
|
|
864
|
+
// but hand-written here so the client-side shim has no server imports.)
|
|
865
|
+
var PATTERN_CACHE_MAX = 200;
|
|
863
866
|
var patternCache = new Map();
|
|
864
867
|
|
|
868
|
+
function patternCacheGet(key) {
|
|
869
|
+
if (!patternCache.has(key)) return undefined;
|
|
870
|
+
var value = patternCache.get(key);
|
|
871
|
+
// Promote to MRU.
|
|
872
|
+
patternCache.delete(key);
|
|
873
|
+
patternCache.set(key, value);
|
|
874
|
+
return value;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function patternCacheSet(key, value) {
|
|
878
|
+
if (patternCache.has(key)) {
|
|
879
|
+
patternCache.delete(key);
|
|
880
|
+
} else if (patternCache.size >= PATTERN_CACHE_MAX) {
|
|
881
|
+
var oldest = patternCache.keys().next().value;
|
|
882
|
+
if (oldest !== undefined) patternCache.delete(oldest);
|
|
883
|
+
}
|
|
884
|
+
patternCache.set(key, value);
|
|
885
|
+
}
|
|
886
|
+
|
|
865
887
|
function compilePattern(pattern) {
|
|
866
|
-
|
|
888
|
+
var cached = patternCacheGet(pattern);
|
|
889
|
+
if (cached) return cached;
|
|
867
890
|
|
|
868
891
|
const paramNames = [];
|
|
869
892
|
let paramIndex = 0;
|
|
@@ -881,7 +904,7 @@ function compilePattern(pattern) {
|
|
|
881
904
|
});
|
|
882
905
|
|
|
883
906
|
const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
|
|
884
|
-
|
|
907
|
+
patternCacheSet(pattern, compiled);
|
|
885
908
|
return compiled;
|
|
886
909
|
}
|
|
887
910
|
|
package/src/bundler/dev.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
} from "./reverse-import-graph";
|
|
23
23
|
import path from "path";
|
|
24
24
|
import fs from "fs";
|
|
25
|
+
import { LRUCache } from "../utils/lru-cache";
|
|
26
|
+
import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* #184: 공통 디렉토리 변경 시 사용하는 sentinel.
|
|
@@ -685,8 +687,19 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
685
687
|
* Lifecycle: timers are created by `scheduleFileChange`, cleared on flush or
|
|
686
688
|
* on `close()`. We call `.delete(key)` on flush to keep the Map bounded —
|
|
687
689
|
* no leak from editing a single file repeatedly.
|
|
690
|
+
*
|
|
691
|
+
* Phase 17 — upgraded to `LRUCache` with an `onEvict` hook so
|
|
692
|
+
* (a) a pathological watcher (millions of distinct files) cannot leak
|
|
693
|
+
* (b) evicted entries still get `clearTimeout` called (no dangling refs)
|
|
694
|
+
* (c) the size is registered with `/_mandu/metrics`
|
|
695
|
+
* Max 2000 entries is generous for even large monorepos (each entry is a
|
|
696
|
+
* single in-flight debounce token; flush cycle is 100 ms).
|
|
688
697
|
*/
|
|
689
|
-
const perFileTimers = new
|
|
698
|
+
const perFileTimers = new LRUCache<string, ReturnType<typeof setTimeout>>({
|
|
699
|
+
maxSize: 2000,
|
|
700
|
+
onEvict: (_key, timer) => clearTimeout(timer),
|
|
701
|
+
});
|
|
702
|
+
registerCacheSize("perFileTimers", () => perFileTimers.size);
|
|
690
703
|
|
|
691
704
|
/**
|
|
692
705
|
* B2 fix — multi-file pending build queue (Phase 7.0 R1 Agent A).
|
|
@@ -1515,10 +1528,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1515
1528
|
initialBuild,
|
|
1516
1529
|
close: () => {
|
|
1517
1530
|
// B6: clear all per-file timers to release event-loop refs.
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1531
|
+
// Phase 17 — `LRUCache.clear()` fires the registered `onEvict`
|
|
1532
|
+
// (`clearTimeout(timer)`) for every entry before dropping them,
|
|
1533
|
+
// so we no longer need an explicit loop.
|
|
1521
1534
|
perFileTimers.clear();
|
|
1535
|
+
unregisterCacheSize("perFileTimers");
|
|
1522
1536
|
for (const watcher of watchers) {
|
|
1523
1537
|
watcher.close();
|
|
1524
1538
|
}
|
|
@@ -141,16 +141,34 @@ describe("safeBuild", () => {
|
|
|
141
141
|
|
|
142
142
|
let peak = 0;
|
|
143
143
|
let samples = 0;
|
|
144
|
-
// Sample
|
|
145
|
-
//
|
|
146
|
-
//
|
|
144
|
+
// Sample active-slot count while the build burst is in flight. An earlier
|
|
145
|
+
// revision of this test used a `while (!stop) { await Promise.resolve() }`
|
|
146
|
+
// microtask busy-loop to push sampling granularity below setInterval's
|
|
147
|
+
// Windows 4ms-ish clamp. That deadlocks under Bun 1.3.x on Windows:
|
|
148
|
+
// `await Promise.resolve()` stays on the microtask queue, which runs
|
|
149
|
+
// to exhaustion before Bun's libuv I/O phase — so Bun.build completion
|
|
150
|
+
// callbacks never fire, `releaseSlot()` never runs, and the promises
|
|
151
|
+
// returned by the 7 parallel `safeBuild()` calls hang indefinitely.
|
|
152
|
+
// Reproduction: `bun test src/bundler/safe-build.test.ts` times out with
|
|
153
|
+
// only the banner printed (confirmed with a standalone repro of the
|
|
154
|
+
// sampler + 7 safeBuild calls — hung at "start" past 60s).
|
|
155
|
+
//
|
|
156
|
+
// Fix: yield to the macrotask queue via `setImmediate`. This lets
|
|
157
|
+
// libuv I/O callbacks run between samples, so Bun.build completes and
|
|
158
|
+
// `releaseSlot()` advances the queue. Per-tick granularity on Node/Bun
|
|
159
|
+
// is still sub-millisecond and fires ~hundreds of times during a 7-
|
|
160
|
+
// build burst — more than enough to statistically catch the cap+1
|
|
161
|
+
// regression window if it ever returned (the window is microtask-sized,
|
|
162
|
+
// but any cross-tick sampling with high fan-out has a realistic chance
|
|
163
|
+
// of landing inside it). The strict assertion is still `peak <= max`.
|
|
147
164
|
let stop = false;
|
|
148
165
|
const sample = async () => {
|
|
149
166
|
while (!stop) {
|
|
150
167
|
const { active } = _getConcurrencyState();
|
|
151
168
|
if (active > peak) peak = active;
|
|
152
169
|
samples++;
|
|
153
|
-
|
|
170
|
+
// Yield to libuv I/O phase so Bun.build callbacks can fire.
|
|
171
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
154
172
|
}
|
|
155
173
|
};
|
|
156
174
|
const sampler = sample();
|
package/src/client/globals.ts
CHANGED
|
@@ -1,44 +1,61 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu 전역 타입 선언
|
|
3
|
-
* 클라이언트 측 전역 상태의 타입 정의
|
|
4
|
-
*/
|
|
5
|
-
import type { Root } from "react-dom/client";
|
|
6
|
-
import type { RouterState } from "./router";
|
|
7
|
-
|
|
8
|
-
interface ManduRouteInfo {
|
|
9
|
-
id: string;
|
|
10
|
-
pattern: string;
|
|
11
|
-
params: Record<string, string>;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface ManduDataEntry {
|
|
15
|
-
serverData: unknown;
|
|
16
|
-
timestamp?: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
declare global {
|
|
20
|
-
interface Window {
|
|
21
|
-
/** 서버에서 전달된 데이터 (routeId → data) */
|
|
22
|
-
__MANDU_DATA__?: Record<string, ManduDataEntry>;
|
|
23
|
-
|
|
24
|
-
/** 직렬화된 서버 데이터 (raw JSON) */
|
|
25
|
-
__MANDU_DATA_RAW__?: string;
|
|
26
|
-
|
|
27
|
-
/** 현재 라우트 정보 */
|
|
28
|
-
__MANDU_ROUTE__?: ManduRouteInfo;
|
|
29
|
-
|
|
30
|
-
/** 클라이언트 라우터 상태 */
|
|
31
|
-
__MANDU_ROUTER_STATE__?: RouterState;
|
|
32
|
-
|
|
33
|
-
/** 라우터 상태 변경 리스너 */
|
|
34
|
-
__MANDU_ROUTER_LISTENERS__?: Set<(state: RouterState) => void>;
|
|
35
|
-
|
|
36
|
-
/** Hydrated roots 추적 (unmount용) */
|
|
37
|
-
__MANDU_ROOTS__?: Map<string, Root>;
|
|
38
|
-
|
|
39
|
-
/** React 인스턴스 공유 */
|
|
40
|
-
__MANDU_REACT__?: typeof import("react");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Mandu 전역 타입 선언
|
|
3
|
+
* 클라이언트 측 전역 상태의 타입 정의
|
|
4
|
+
*/
|
|
5
|
+
import type { Root } from "react-dom/client";
|
|
6
|
+
import type { RouterState } from "./router";
|
|
7
|
+
|
|
8
|
+
interface ManduRouteInfo {
|
|
9
|
+
id: string;
|
|
10
|
+
pattern: string;
|
|
11
|
+
params: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ManduDataEntry {
|
|
15
|
+
serverData: unknown;
|
|
16
|
+
timestamp?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare global {
|
|
20
|
+
interface Window {
|
|
21
|
+
/** 서버에서 전달된 데이터 (routeId → data) */
|
|
22
|
+
__MANDU_DATA__?: Record<string, ManduDataEntry>;
|
|
23
|
+
|
|
24
|
+
/** 직렬화된 서버 데이터 (raw JSON) */
|
|
25
|
+
__MANDU_DATA_RAW__?: string;
|
|
26
|
+
|
|
27
|
+
/** 현재 라우트 정보 */
|
|
28
|
+
__MANDU_ROUTE__?: ManduRouteInfo;
|
|
29
|
+
|
|
30
|
+
/** 클라이언트 라우터 상태 */
|
|
31
|
+
__MANDU_ROUTER_STATE__?: RouterState;
|
|
32
|
+
|
|
33
|
+
/** 라우터 상태 변경 리스너 */
|
|
34
|
+
__MANDU_ROUTER_LISTENERS__?: Set<(state: RouterState) => void>;
|
|
35
|
+
|
|
36
|
+
/** Hydrated roots 추적 (unmount용) */
|
|
37
|
+
__MANDU_ROOTS__?: Map<string, Root>;
|
|
38
|
+
|
|
39
|
+
/** React 인스턴스 공유 */
|
|
40
|
+
__MANDU_REACT__?: typeof import("react");
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Issue #193 — global SPA navigation toggle.
|
|
44
|
+
*
|
|
45
|
+
* - `undefined` (not set) → default. Plain `<a href="/about">`
|
|
46
|
+
* is intercepted and routed through the client-side router.
|
|
47
|
+
* - `false` → legacy opt-in behavior. Only `<a>`
|
|
48
|
+
* tags with `data-mandu-link` are intercepted; all other
|
|
49
|
+
* internal links perform a full browser navigation.
|
|
50
|
+
* - `true` → same as undefined; present only for
|
|
51
|
+
* symmetry and forward compat.
|
|
52
|
+
*
|
|
53
|
+
* SSR injects this global only when `mandu.config.ts` sets
|
|
54
|
+
* `spa: false` — the default case emits nothing to keep the
|
|
55
|
+
* typical response payload unchanged.
|
|
56
|
+
*/
|
|
57
|
+
__MANDU_SPA__?: boolean;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export {};
|
package/src/client/router.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./window-state";
|
|
16
16
|
import { LRUCache } from "../utils/lru-cache";
|
|
17
17
|
import { LIMITS } from "../constants";
|
|
18
|
+
import { registerCacheSize } from "../observability/metrics";
|
|
18
19
|
|
|
19
20
|
// ========== Types ==========
|
|
20
21
|
|
|
@@ -158,6 +159,12 @@ interface CompiledPattern {
|
|
|
158
159
|
|
|
159
160
|
const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN_CACHE);
|
|
160
161
|
|
|
162
|
+
// Phase 17 — expose the cache size to the /_mandu/heap + /_mandu/metrics
|
|
163
|
+
// endpoints so long-running processes can detect runaway growth.
|
|
164
|
+
// The registration happens at module init — safe to call once per process
|
|
165
|
+
// because `registerCacheSize` replaces any prior reporter under the same key.
|
|
166
|
+
registerCacheSize("patternCache", () => patternCache.size);
|
|
167
|
+
|
|
161
168
|
/**
|
|
162
169
|
* 패턴을 정규식으로 컴파일
|
|
163
170
|
*/
|
|
@@ -435,40 +442,105 @@ export function getNavigationState(): NavigationState {
|
|
|
435
442
|
// ========== Link Click Handler ==========
|
|
436
443
|
|
|
437
444
|
/**
|
|
438
|
-
*
|
|
445
|
+
* Issue #193 — Link click handler (event delegation).
|
|
446
|
+
*
|
|
447
|
+
* Mandu v0.22+ reversed the default from opt-in to opt-out SPA navigation.
|
|
448
|
+
* Every internal same-origin `<a href="/...">` click is intercepted and
|
|
449
|
+
* routed through the client-side router unless one of the explicit escape
|
|
450
|
+
* hatches fires:
|
|
451
|
+
*
|
|
452
|
+
* - `data-no-spa` → per-link opt-out (always skip).
|
|
453
|
+
* - `<a>` without `href` → degenerate anchor, let the browser decide.
|
|
454
|
+
* - `href="#fragment"` → same-page anchor, browser handles scroll.
|
|
455
|
+
* - `href="mailto:"` / `tel:` / → non-http schemes the browser owns.
|
|
456
|
+
* `javascript:` / `data:` / …
|
|
457
|
+
* - `target="_blank" / "_top" / → any target other than `_self` means the
|
|
458
|
+
* "_parent" / "framename" user wants a new browsing context.
|
|
459
|
+
* - `download` attribute present → file download, never a navigation.
|
|
460
|
+
* - Modifier keys (Ctrl / Cmd / → browser shortcut for new tab, bookmark,
|
|
461
|
+
* Shift / Alt) save, or save-as.
|
|
462
|
+
* - Non-left click → middle-click opens a new tab, right-click
|
|
463
|
+
* opens context menu.
|
|
464
|
+
* - `event.defaultPrevented` → another listener already handled it.
|
|
465
|
+
* - Cross-origin href → full document navigation required.
|
|
466
|
+
*
|
|
467
|
+
* The legacy opt-in attribute `data-mandu-link` still works for
|
|
468
|
+
* backward compatibility — it is simply a no-op under the new default
|
|
469
|
+
* because we already intercept by default. Teams that want the old
|
|
470
|
+
* opt-in behavior back can set `spa: false` in `mandu.config.ts`, which
|
|
471
|
+
* surfaces as `window.__MANDU_SPA__ === false` and re-introduces the
|
|
472
|
+
* requirement that `<a>` tags carry `data-mandu-link`.
|
|
439
473
|
*/
|
|
440
474
|
function handleLinkClick(event: MouseEvent): void {
|
|
441
|
-
//
|
|
475
|
+
// Pre-filter: obvious browser-owned events.
|
|
442
476
|
if (
|
|
443
477
|
event.defaultPrevented ||
|
|
444
|
-
event.button !== 0 ||
|
|
445
|
-
event.metaKey ||
|
|
446
|
-
event.altKey ||
|
|
447
|
-
event.ctrlKey ||
|
|
448
|
-
event.shiftKey
|
|
478
|
+
event.button !== 0 || // middle-click / right-click — browser decides.
|
|
479
|
+
event.metaKey || // Cmd (macOS) — new tab.
|
|
480
|
+
event.altKey || // Alt — "save-as" in most browsers.
|
|
481
|
+
event.ctrlKey || // Ctrl (Windows/Linux) — new tab.
|
|
482
|
+
event.shiftKey // Shift — new window / bookmark.
|
|
449
483
|
) {
|
|
450
484
|
return;
|
|
451
485
|
}
|
|
452
486
|
|
|
453
|
-
//
|
|
454
|
-
|
|
487
|
+
// Find the closest anchor ancestor — users commonly nest `<span>` /
|
|
488
|
+
// `<img>` inside `<a>` and the event target is the inner element.
|
|
489
|
+
const anchor = (event.target as HTMLElement | null)?.closest("a");
|
|
455
490
|
if (!anchor) return;
|
|
456
491
|
|
|
457
|
-
//
|
|
458
|
-
if (
|
|
492
|
+
// Escape hatch 1: explicit per-link opt-out always wins.
|
|
493
|
+
if (anchor.hasAttribute("data-no-spa")) return;
|
|
494
|
+
|
|
495
|
+
// Escape hatch 2: global config `spa: false` — reverts to the legacy
|
|
496
|
+
// opt-in behavior (only `data-mandu-link` intercepts). SSR injects
|
|
497
|
+
// `window.__MANDU_SPA__ = false` when the user sets `spa: false`.
|
|
498
|
+
const spaGlobal = (globalThis as { window?: { __MANDU_SPA__?: boolean } }).window?.__MANDU_SPA__;
|
|
499
|
+
if (spaGlobal === false && !anchor.hasAttribute("data-mandu-link")) return;
|
|
459
500
|
|
|
501
|
+
// `<a>` without `href` is a degenerate anchor — the browser will not
|
|
502
|
+
// navigate, but a listener somewhere might. Don't intercept.
|
|
460
503
|
const href = anchor.getAttribute("href");
|
|
461
504
|
if (!href) return;
|
|
462
505
|
|
|
463
|
-
//
|
|
506
|
+
// Same-page fragment link — let the browser handle scroll / focus.
|
|
507
|
+
if (href.startsWith("#")) return;
|
|
508
|
+
|
|
509
|
+
// `target` other than `_self` (or absent) signals the user wants a
|
|
510
|
+
// new browsing context. `target="_blank"` is the common case but we
|
|
511
|
+
// also pass through `_top`, `_parent`, and framed targets.
|
|
512
|
+
const target = anchor.getAttribute("target");
|
|
513
|
+
if (target && target !== "_self") return;
|
|
514
|
+
|
|
515
|
+
// `download` attribute means the user wants to save the resource,
|
|
516
|
+
// never navigate to it.
|
|
517
|
+
if (anchor.hasAttribute("download")) return;
|
|
518
|
+
|
|
519
|
+
// URL parsing — catches both cross-origin and non-http schemes.
|
|
520
|
+
let url: URL;
|
|
464
521
|
try {
|
|
465
|
-
|
|
466
|
-
if (url.origin !== window.location.origin) return;
|
|
522
|
+
url = new URL(href, window.location.origin);
|
|
467
523
|
} catch {
|
|
524
|
+
// Malformed href — let the browser produce its own error.
|
|
468
525
|
return;
|
|
469
526
|
}
|
|
470
527
|
|
|
471
|
-
//
|
|
528
|
+
// Only same-origin http(s) navigations are eligible for SPA handling.
|
|
529
|
+
// `mailto:`, `tel:`, `javascript:`, `data:`, `blob:`, chrome-extension,
|
|
530
|
+
// etc. all fail this check because `new URL("mailto:foo@bar").origin`
|
|
531
|
+
// is the string `"null"` (spec-defined), never equal to
|
|
532
|
+
// `window.location.origin`.
|
|
533
|
+
if (url.origin !== window.location.origin) return;
|
|
534
|
+
|
|
535
|
+
// Only intercept http / https schemes. Defense-in-depth against any
|
|
536
|
+
// exotic same-origin scheme we haven't considered (e.g. a custom
|
|
537
|
+
// protocol handler installed by a browser extension).
|
|
538
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return;
|
|
539
|
+
|
|
540
|
+
// All clear — prevent the default full-page navigation and hand off
|
|
541
|
+
// to the client-side router. `href` preserves the user's original
|
|
542
|
+
// string (relative paths, fragments, query strings) so the router
|
|
543
|
+
// can normalize as needed.
|
|
472
544
|
event.preventDefault();
|
|
473
545
|
navigate(href);
|
|
474
546
|
}
|
|
@@ -828,3 +900,9 @@ if (typeof window !== "undefined") {
|
|
|
828
900
|
// can exercise the schema-check path directly without round-tripping
|
|
829
901
|
// through `window.__MANDU_ROUTER_REVALIDATE__`.
|
|
830
902
|
export { applyHDRUpdate as _testOnly_applyHDRUpdate };
|
|
903
|
+
|
|
904
|
+
// Issue #193: export the link-click handler for unit tests so we can
|
|
905
|
+
// drive every exclusion case without installing a real DOM click
|
|
906
|
+
// listener. Keeping this under a `_testOnly_` prefix to signal it is
|
|
907
|
+
// not part of the public API.
|
|
908
|
+
export { handleLinkClick as _testOnly_handleLinkClick };
|