@mandujs/core 0.41.2 → 0.43.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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/bundler/build.ts
CHANGED
|
@@ -58,6 +58,26 @@ function manduDefaultPlugins(options: BundlerOptions): BunPlugin[] {
|
|
|
58
58
|
return defaults;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Client-path plugin composition — defaults + (optionally) React
|
|
63
|
+
* Compiler. Only the islands / `"use client"` / partial builds call
|
|
64
|
+
* this; SSR builds use `manduDefaultPlugins()` directly because React
|
|
65
|
+
* Compiler offers zero benefit for one-shot HTML rendering.
|
|
66
|
+
*/
|
|
67
|
+
function manduClientPlugins(options: BundlerOptions): BunPlugin[] {
|
|
68
|
+
const base = manduDefaultPlugins(options);
|
|
69
|
+
if (options.reactCompiler?.enabled !== true) return base;
|
|
70
|
+
// Lazy import to avoid pulling the react-compiler module into every
|
|
71
|
+
// build graph — SSR / non-client paths never touch this branch.
|
|
72
|
+
const { reactCompiler } = require("./plugins/react-compiler") as typeof import("./plugins/react-compiler");
|
|
73
|
+
return [
|
|
74
|
+
...base,
|
|
75
|
+
reactCompiler({
|
|
76
|
+
reactCompilerConfig: options.reactCompiler.compilerConfig,
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
/**
|
|
62
82
|
* Scan for *.island.tsx / *.island.ts files across hydrated route directories.
|
|
63
83
|
*
|
|
@@ -83,30 +103,44 @@ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<Is
|
|
|
83
103
|
const entries: IslandFileEntry[] = [];
|
|
84
104
|
const seenDirs = new Set<string>();
|
|
85
105
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
// Sub-folders under a route directory that the scanner also descends into,
|
|
107
|
+
// one level only. Convention-driven (not configurable) so the scan cost
|
|
108
|
+
// stays predictable. `_components` is the Mandu equivalent of the widely
|
|
109
|
+
// used Next.js "private folder" convention — leading underscore signals
|
|
110
|
+
// "not a route", which is exactly the place users drop page-local island
|
|
111
|
+
// sources. Keep this list short; every name adds one `readdir` per route.
|
|
112
|
+
const ISLAND_SUBDIRS = ["_components", "_islands"] as const;
|
|
91
113
|
|
|
92
|
-
|
|
93
|
-
if (seenDirs.has(dir))
|
|
114
|
+
const collectIslandsInDir = async (dir: string, routeId: string, priority: IslandFileEntry["priority"]) => {
|
|
115
|
+
if (seenDirs.has(dir)) return;
|
|
94
116
|
seenDirs.add(dir);
|
|
95
|
-
|
|
96
117
|
let files: string[];
|
|
97
|
-
try { files = await fs.readdir(dir); } catch {
|
|
98
|
-
|
|
99
|
-
const priority = getRouteHydration(route)?.priority || HYDRATION.DEFAULT_PRIORITY;
|
|
118
|
+
try { files = await fs.readdir(dir); } catch { return; }
|
|
100
119
|
for (const file of files) {
|
|
101
120
|
if (/\.island\.tsx?$/.test(file)) {
|
|
102
121
|
entries.push({
|
|
103
122
|
name: file.replace(/\.island\.tsx?$/, ""),
|
|
104
123
|
filePath: path.join(dir, file),
|
|
105
|
-
routeId
|
|
124
|
+
routeId,
|
|
106
125
|
priority,
|
|
107
126
|
});
|
|
108
127
|
}
|
|
109
128
|
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const route of routes) {
|
|
132
|
+
// Defensive guard — see the block comment above for rationale. Without
|
|
133
|
+
// this, a hypothetical caller passing `manifest.routes` directly would
|
|
134
|
+
// readdir every page route, including pure-SSR ones.
|
|
135
|
+
if (!needsHydration(route)) continue;
|
|
136
|
+
|
|
137
|
+
const dir = path.dirname(path.join(rootDir, route.componentModule ?? route.module));
|
|
138
|
+
const priority = getRouteHydration(route)?.priority || HYDRATION.DEFAULT_PRIORITY;
|
|
139
|
+
|
|
140
|
+
await collectIslandsInDir(dir, route.id, priority);
|
|
141
|
+
for (const sub of ISLAND_SUBDIRS) {
|
|
142
|
+
await collectIslandsInDir(path.join(dir, sub), route.id, priority);
|
|
143
|
+
}
|
|
110
144
|
}
|
|
111
145
|
return entries;
|
|
112
146
|
}
|
|
@@ -130,6 +164,62 @@ export const _testOnly_scanIslandFiles = scanIslandFiles;
|
|
|
130
164
|
*/
|
|
131
165
|
export const _testOnly_getHydratedRoutes = getHydratedRoutes;
|
|
132
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Issue #240 Phase 2 — collect every file the React Compiler plugin
|
|
169
|
+
* would touch during a client build. Used by `mandu check` to feed
|
|
170
|
+
* `runReactCompilerLint` so developers see which components are
|
|
171
|
+
* silently bailing out.
|
|
172
|
+
*
|
|
173
|
+
* Included:
|
|
174
|
+
* - `*.island.tsx` / `*.island.ts` under each hydrated route dir
|
|
175
|
+
* (+ `_components/` / `_islands/` siblings — matches
|
|
176
|
+
* `scanIslandFiles` exactly).
|
|
177
|
+
* - `route.clientModule` for every hydrated route (`"use client"`
|
|
178
|
+
* page entry).
|
|
179
|
+
* - `*.partial.ts` / `*.partial.tsx` anywhere under `rootDir`.
|
|
180
|
+
*
|
|
181
|
+
* Paths are absolute and deduplicated. Returns `[]` when the manifest
|
|
182
|
+
* has no hydrated routes.
|
|
183
|
+
*/
|
|
184
|
+
export async function collectCompilerLintTargets(
|
|
185
|
+
manifest: RoutesManifest,
|
|
186
|
+
rootDir: string,
|
|
187
|
+
): Promise<string[]> {
|
|
188
|
+
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
189
|
+
if (hydratedRoutes.length === 0) return [];
|
|
190
|
+
|
|
191
|
+
const out = new Set<string>();
|
|
192
|
+
|
|
193
|
+
// 1) Islands (reuses the bundler's canonical scanner).
|
|
194
|
+
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
195
|
+
for (const entry of islandFiles) {
|
|
196
|
+
out.add(path.resolve(entry.filePath));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 2) `"use client"` page modules — the hydrated-route filter
|
|
200
|
+
// already asserted `clientModule` exists.
|
|
201
|
+
for (const route of hydratedRoutes) {
|
|
202
|
+
const rel = route.clientModule ?? route.module;
|
|
203
|
+
if (rel) out.add(path.resolve(rootDir, rel));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 3) Partials — glob the whole project once. Partials live anywhere
|
|
207
|
+
// the user chooses, they're not sibling-scoped like islands.
|
|
208
|
+
try {
|
|
209
|
+
const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
|
|
210
|
+
for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
211
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
|
|
212
|
+
if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
|
|
213
|
+
out.add(path.resolve(rel));
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Bun.Glob unavailable or scan failed — skip partials rather than
|
|
217
|
+
// aborting the whole diagnostic run.
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return Array.from(out).sort();
|
|
221
|
+
}
|
|
222
|
+
|
|
133
223
|
/** Build a single per-island bundle. */
|
|
134
224
|
async function buildPerIslandBundle(
|
|
135
225
|
entry: IslandFileEntry, outDir: string, options: BundlerOptions
|
|
@@ -150,7 +240,7 @@ async function buildPerIslandBundle(
|
|
|
150
240
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
151
241
|
target: "browser",
|
|
152
242
|
...(isDev ? { reactFastRefresh: true } : {}),
|
|
153
|
-
plugins: [...
|
|
243
|
+
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
154
244
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
155
245
|
define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
|
|
156
246
|
});
|
|
@@ -1623,7 +1713,7 @@ async function buildIsland(
|
|
|
1623
1713
|
target: "browser",
|
|
1624
1714
|
splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
|
|
1625
1715
|
...(isDev ? { reactFastRefresh: true } : {}),
|
|
1626
|
-
plugins: [...
|
|
1716
|
+
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
1627
1717
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
1628
1718
|
define: {
|
|
1629
1719
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
package/src/bundler/dev.ts
CHANGED
|
@@ -80,6 +80,31 @@ export interface DevBundlerOptions {
|
|
|
80
80
|
* `"resource-regen"` exactly once.
|
|
81
81
|
*/
|
|
82
82
|
onResourceChange?: (filePath: string) => void | Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Issue #242 — content collection change callback.
|
|
85
|
+
*
|
|
86
|
+
* Fires when an MDX/MD/YAML/JSON file under `content/` is added,
|
|
87
|
+
* edited, or removed. Before invoking the callback the bundler has
|
|
88
|
+
* already called `invalidateAllCollections()` so the Collection API's
|
|
89
|
+
* in-memory cache is clear; the next `.all()` / `.get()` call will
|
|
90
|
+
* rescan disk.
|
|
91
|
+
*
|
|
92
|
+
* The CLI typically wires this to the HMR server's `broadcast` to
|
|
93
|
+
* trigger a full browser reload so sidebars / route trees pick up the
|
|
94
|
+
* added file. Without a callback the invalidation still happens —
|
|
95
|
+
* a manual refresh will surface the change.
|
|
96
|
+
*/
|
|
97
|
+
onContentChange?: (filePath: string) => void | Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Issue #240 — React Compiler opt-in. Forwarded verbatim to each
|
|
100
|
+
* `buildClientBundles()` invocation (initial build + every rebuild
|
|
101
|
+
* triggered by `handleFileChange`). Off by default. SSR paths are
|
|
102
|
+
* never affected regardless of this flag.
|
|
103
|
+
*/
|
|
104
|
+
reactCompiler?: {
|
|
105
|
+
enabled?: boolean;
|
|
106
|
+
compilerConfig?: Record<string, unknown>;
|
|
107
|
+
};
|
|
83
108
|
/**
|
|
84
109
|
* Phase 7.0 R2 Agent D — package.json change notification.
|
|
85
110
|
*
|
|
@@ -375,7 +400,9 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
375
400
|
onAPIChange,
|
|
376
401
|
onConfigReload,
|
|
377
402
|
onResourceChange,
|
|
403
|
+
onContentChange,
|
|
378
404
|
onPackageJsonChange,
|
|
405
|
+
reactCompiler,
|
|
379
406
|
watchDirs: customWatchDirs = [],
|
|
380
407
|
disableDefaultWatchDirs = false,
|
|
381
408
|
} = options;
|
|
@@ -385,6 +412,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
385
412
|
const initialBuild = await buildClientBundles(manifest, rootDir, {
|
|
386
413
|
minify: false,
|
|
387
414
|
sourcemap: true,
|
|
415
|
+
reactCompiler,
|
|
388
416
|
});
|
|
389
417
|
|
|
390
418
|
if (initialBuild.success) {
|
|
@@ -566,6 +594,21 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
566
594
|
// layer simply never hit this path.
|
|
567
595
|
}
|
|
568
596
|
|
|
597
|
+
// Issue #242 — content collections (MDX/MD/YAML/JSON) directory. The
|
|
598
|
+
// Collection API caches scan results in-process, so adding a new
|
|
599
|
+
// `content/docs/**/foo.mdx` at runtime is invisible until we invalidate
|
|
600
|
+
// the cache. The `content/` directory is the conventional root used by
|
|
601
|
+
// `defineCollection({ path: "content/..." })`; projects that put
|
|
602
|
+
// collection roots elsewhere simply won't match and fall back to a full
|
|
603
|
+
// manual restart, which is the prior behaviour.
|
|
604
|
+
const contentDir = path.join(rootDir, "content");
|
|
605
|
+
try {
|
|
606
|
+
await fs.promises.access(contentDir);
|
|
607
|
+
watchDirs.add(contentDir);
|
|
608
|
+
} catch {
|
|
609
|
+
// No content/ directory — most projects without docs / blogs.
|
|
610
|
+
}
|
|
611
|
+
|
|
569
612
|
// 공통 컴포넌트 디렉토리 추가 (기본 + 커스텀)
|
|
570
613
|
const commonDirsToCheck = disableDefaultWatchDirs
|
|
571
614
|
? customWatchDirs
|
|
@@ -761,6 +804,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
761
804
|
// anyway).
|
|
762
805
|
let hasConfigReload = false;
|
|
763
806
|
let hasResourceRegen = false;
|
|
807
|
+
// Issue #242 — content collection edits (MDX/MD/YAML/JSON under
|
|
808
|
+
// `content/`). Caught here so a new file triggers `invalidateAll` +
|
|
809
|
+
// full-reload instead of falling through to "unknown" and never
|
|
810
|
+
// reaching the Collection cache.
|
|
811
|
+
let hasContent = false;
|
|
764
812
|
|
|
765
813
|
for (const file of files) {
|
|
766
814
|
const normalized = normalizeFsPath(file);
|
|
@@ -781,6 +829,18 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
781
829
|
continue;
|
|
782
830
|
}
|
|
783
831
|
|
|
832
|
+
// Issue #242 — content-collection sources. Anything under the
|
|
833
|
+
// project's `content/` directory that's plausibly an entry file.
|
|
834
|
+
// The Collection API accepts `.md`/`.mdx` for MDX entries and
|
|
835
|
+
// `.json`/`.yaml`/`.yml` for data collections — cover all four.
|
|
836
|
+
if (
|
|
837
|
+
normalized.includes("/content/") &&
|
|
838
|
+
/\.(mdx?|ya?ml|json)$/.test(normalized)
|
|
839
|
+
) {
|
|
840
|
+
hasContent = true;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
|
|
784
844
|
if (normalized.endsWith(".css")) {
|
|
785
845
|
hasCss = true;
|
|
786
846
|
continue;
|
|
@@ -831,6 +891,12 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
831
891
|
// would cause a stale rebuild.
|
|
832
892
|
if (hasConfigReload) return "config-reload";
|
|
833
893
|
if (hasResourceRegen) return "resource-regen";
|
|
894
|
+
// Issue #242 — content changes are a leaf category: they never
|
|
895
|
+
// produce code changes, only cache invalidation + a browser refresh.
|
|
896
|
+
// Rank them above common-dir so a mixed batch that touches both
|
|
897
|
+
// still reaches the content path (otherwise common-dir would
|
|
898
|
+
// rebuild but the collection cache would stay stale).
|
|
899
|
+
if (hasContent) return "content-change";
|
|
834
900
|
|
|
835
901
|
// Common-dir dominates — it already fans out to every island + SSR
|
|
836
902
|
// registry. No point in double-classifying "mixed" when a fan-out fix
|
|
@@ -885,6 +951,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
885
951
|
return;
|
|
886
952
|
}
|
|
887
953
|
|
|
954
|
+
// Issue #242 — content collection changes. Invalidate the shared
|
|
955
|
+
// Collection cache and notify the optional callback; no rebuild
|
|
956
|
+
// required. One invocation per debounce window.
|
|
957
|
+
if (kind === "content-change") {
|
|
958
|
+
const contentFiles = files.filter((f) =>
|
|
959
|
+
/\.(mdx?|ya?ml|json)$/.test(normalizeFsPath(f)),
|
|
960
|
+
);
|
|
961
|
+
await handleContentChange(contentFiles);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
888
965
|
// Common-dir dominates: one full-reload-adjacent rebuild covers everyone.
|
|
889
966
|
if (kind === "common-dir") {
|
|
890
967
|
await handleFileChange(files.find((f) => isInCommonDir(f)) ?? files[0]);
|
|
@@ -996,6 +1073,44 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
996
1073
|
}
|
|
997
1074
|
};
|
|
998
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* Issue #242 — dispatch a batch of content collection changes.
|
|
1078
|
+
*
|
|
1079
|
+
* Clears every registered `Collection`'s in-memory cache via
|
|
1080
|
+
* `invalidateAllCollections()` and then invokes `onContentChange` once
|
|
1081
|
+
* per file so the CLI can broadcast a full browser reload. Dynamic
|
|
1082
|
+
* import keeps the bundler module tree free of a hard content-layer
|
|
1083
|
+
* dependency (projects without `@mandujs/core/content` in the import
|
|
1084
|
+
* closure simply get a no-op).
|
|
1085
|
+
*/
|
|
1086
|
+
const handleContentChange = async (files: readonly string[]): Promise<void> => {
|
|
1087
|
+
if (files.length === 0) return;
|
|
1088
|
+
try {
|
|
1089
|
+
const mod: typeof import("../content/collection") = await import("../content/collection");
|
|
1090
|
+
if (typeof mod.invalidateAllCollections === "function") {
|
|
1091
|
+
mod.invalidateAllCollections();
|
|
1092
|
+
}
|
|
1093
|
+
} catch {
|
|
1094
|
+
// Content layer not in the closure — harmless.
|
|
1095
|
+
}
|
|
1096
|
+
if (onContentChange) {
|
|
1097
|
+
for (const file of files) {
|
|
1098
|
+
try {
|
|
1099
|
+
await Promise.resolve(onContentChange(file));
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
console.error(
|
|
1102
|
+
`[Mandu HMR] content-change callback threw for ${path.basename(file)}:`,
|
|
1103
|
+
err instanceof Error ? err.message : String(err),
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} else {
|
|
1108
|
+
console.log(
|
|
1109
|
+
`[Mandu HMR] ${files.length} content file(s) changed — collection cache invalidated`,
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
|
|
999
1114
|
const handleFileChange = async (changedFile: string): Promise<void> => {
|
|
1000
1115
|
// 동시 빌드 방지 (#121) — B2 강화: 빌드 중이면 Set에 추가 (drop 방지).
|
|
1001
1116
|
if (isBuilding) {
|
|
@@ -1020,6 +1135,13 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1020
1135
|
await handleResourceRegenBatch([changedFile]);
|
|
1021
1136
|
return;
|
|
1022
1137
|
}
|
|
1138
|
+
if (
|
|
1139
|
+
normalized.includes("/content/") &&
|
|
1140
|
+
/\.(mdx?|ya?ml|json)$/.test(normalized)
|
|
1141
|
+
) {
|
|
1142
|
+
await handleContentChange([changedFile]);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1023
1145
|
if (isPackageJsonFile(normalized)) {
|
|
1024
1146
|
// Advisory notification only — a `package.json` save on npm install
|
|
1025
1147
|
// fires multiple times in <100 ms, so auto-restart would loop. The
|
|
@@ -1202,6 +1324,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1202
1324
|
minify: false,
|
|
1203
1325
|
sourcemap: true,
|
|
1204
1326
|
targetRouteIds: targetIds,
|
|
1327
|
+
reactCompiler,
|
|
1205
1328
|
});
|
|
1206
1329
|
const buildTime = performance.now() - startTime;
|
|
1207
1330
|
if (result.success) {
|
|
@@ -1259,6 +1382,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1259
1382
|
minify: false,
|
|
1260
1383
|
sourcemap: true,
|
|
1261
1384
|
skipFrameworkBundles: true,
|
|
1385
|
+
reactCompiler,
|
|
1262
1386
|
});
|
|
1263
1387
|
|
|
1264
1388
|
const buildTime = performance.now() - startTime;
|
|
@@ -1383,6 +1507,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
1383
1507
|
minify: false,
|
|
1384
1508
|
sourcemap: true,
|
|
1385
1509
|
targetRouteIds: [routeId],
|
|
1510
|
+
reactCompiler,
|
|
1386
1511
|
});
|
|
1387
1512
|
|
|
1388
1513
|
const buildTime = performance.now() - startTime;
|
package/src/bundler/hmr-types.ts
CHANGED
|
@@ -237,6 +237,7 @@ export interface CoalescedChange {
|
|
|
237
237
|
| "api-only" // route.ts (API)
|
|
238
238
|
| "config-reload" // mandu.config.ts / .env — server restart
|
|
239
239
|
| "resource-regen" // *.resource.ts / *.contract.ts — code-gen
|
|
240
|
+
| "content-change" // content/**/*.{mdx,md,json,yaml} — collection invalidate + full-reload
|
|
240
241
|
| "mixed"; // more than one category
|
|
241
242
|
}
|
|
242
243
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
runReactCompilerLint,
|
|
5
|
+
formatCompilerReport,
|
|
6
|
+
type ReactCompilerDiagnostic,
|
|
7
|
+
} from "../react-compiler-lint";
|
|
8
|
+
|
|
9
|
+
describe("runReactCompilerLint", () => {
|
|
10
|
+
test("returns empty result with no targets without invoking ESLint", async () => {
|
|
11
|
+
const result = await runReactCompilerLint({
|
|
12
|
+
projectRoot: process.cwd(),
|
|
13
|
+
targetFiles: [],
|
|
14
|
+
});
|
|
15
|
+
expect(result.diagnostics).toEqual([]);
|
|
16
|
+
expect(result.skipped).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("degrades gracefully when ESLint peer dep is missing", async () => {
|
|
20
|
+
// In a clean Mandu checkout `eslint` and
|
|
21
|
+
// `eslint-plugin-react-compiler` are not installed. The runner
|
|
22
|
+
// must report `skipped: true` + empty diagnostics + a skipReason.
|
|
23
|
+
const result = await runReactCompilerLint({
|
|
24
|
+
projectRoot: process.cwd(),
|
|
25
|
+
targetFiles: [path.resolve(__filename)],
|
|
26
|
+
});
|
|
27
|
+
// If ESLint happens to be installed in this env, diagnostics is
|
|
28
|
+
// either empty (this file has no bailouts) or the runner ran
|
|
29
|
+
// successfully. Either shape is valid — the contract we care
|
|
30
|
+
// about is "no throw + typed result."
|
|
31
|
+
expect(Array.isArray(result.diagnostics)).toBe(true);
|
|
32
|
+
expect(typeof result.skipped).toBe("boolean");
|
|
33
|
+
if (result.skipped) {
|
|
34
|
+
expect(typeof result.skipReason).toBe("string");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("formatCompilerReport", () => {
|
|
40
|
+
test("returns the no-bailouts sentinel for empty input", () => {
|
|
41
|
+
expect(formatCompilerReport([])).toContain("no bailouts detected");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("summarises bailouts with file counts + bullet list", () => {
|
|
45
|
+
const diagnostics: ReactCompilerDiagnostic[] = [
|
|
46
|
+
{
|
|
47
|
+
file: "/app/todos/todo.island.tsx",
|
|
48
|
+
line: 42,
|
|
49
|
+
column: 5,
|
|
50
|
+
message: "Cannot memoize: ref escape in closure.",
|
|
51
|
+
ruleId: "react-compiler/react-compiler",
|
|
52
|
+
severity: "warning",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
file: "/app/todos/todo.island.tsx",
|
|
56
|
+
line: 87,
|
|
57
|
+
column: 3,
|
|
58
|
+
message: "Cannot memoize: conditional hook call.",
|
|
59
|
+
ruleId: "react-compiler/react-compiler",
|
|
60
|
+
severity: "warning",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
file: "/app/dashboard/chart.client.tsx",
|
|
64
|
+
line: 14,
|
|
65
|
+
column: 9,
|
|
66
|
+
message: "Cannot memoize: mutation of a shared value.",
|
|
67
|
+
ruleId: "react-compiler/react-compiler",
|
|
68
|
+
severity: "warning",
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
const out = formatCompilerReport(diagnostics);
|
|
72
|
+
expect(out).toContain("3 bailout(s) in 2 file(s)");
|
|
73
|
+
expect(out).toContain("todo.island.tsx:42:5");
|
|
74
|
+
expect(out).toContain("chart.client.tsx:14:9");
|
|
75
|
+
expect(out).toContain("https://react.dev");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("truncates past the limit with a '... N more' tail", () => {
|
|
79
|
+
const diagnostics: ReactCompilerDiagnostic[] = Array.from(
|
|
80
|
+
{ length: 30 },
|
|
81
|
+
(_, i) => ({
|
|
82
|
+
file: `/app/x/c${i}.island.tsx`,
|
|
83
|
+
line: i + 1,
|
|
84
|
+
column: 1,
|
|
85
|
+
message: "bailout",
|
|
86
|
+
ruleId: "react-compiler/react-compiler",
|
|
87
|
+
severity: "warning" as const,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
const out = formatCompilerReport(diagnostics, { limit: 5 });
|
|
91
|
+
expect(out).toContain("30 bailout(s)");
|
|
92
|
+
expect(out).toContain("… and 25 more");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("uses relative paths when projectRoot is provided", () => {
|
|
96
|
+
const diagnostic: ReactCompilerDiagnostic = {
|
|
97
|
+
file: path.resolve("/project/app/todo.island.tsx"),
|
|
98
|
+
line: 1,
|
|
99
|
+
column: 1,
|
|
100
|
+
message: "bailout",
|
|
101
|
+
ruleId: "react-compiler/react-compiler",
|
|
102
|
+
severity: "warning",
|
|
103
|
+
};
|
|
104
|
+
const out = formatCompilerReport([diagnostic], {
|
|
105
|
+
projectRoot: path.resolve("/project"),
|
|
106
|
+
});
|
|
107
|
+
expect(out).toContain(path.join("app", "todo.island.tsx"));
|
|
108
|
+
expect(out).not.toContain(path.resolve("/project/app"));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -22,6 +22,20 @@ export {
|
|
|
22
22
|
type BlockGeneratedImportsOptions,
|
|
23
23
|
} from "./block-generated-imports";
|
|
24
24
|
|
|
25
|
+
export {
|
|
26
|
+
reactCompiler,
|
|
27
|
+
type ReactCompilerPluginOptions,
|
|
28
|
+
} from "./react-compiler";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
runReactCompilerLint,
|
|
32
|
+
formatCompilerReport,
|
|
33
|
+
type ReactCompilerDiagnostic,
|
|
34
|
+
type RunReactCompilerLintOptions,
|
|
35
|
+
type RunReactCompilerLintResult,
|
|
36
|
+
type FormatCompilerReportOptions,
|
|
37
|
+
} from "./react-compiler-lint";
|
|
38
|
+
|
|
25
39
|
/**
|
|
26
40
|
* Subset of `ManduConfig.guard` consumed by `defaultBundlerPlugins()`.
|
|
27
41
|
* We deliberately don't import the full `ManduConfig` type to keep the
|