@mandujs/core 0.39.2 → 0.40.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.
@@ -618,6 +618,45 @@ export interface ManduConfig {
618
618
  /** Domain → locale map; required when strategy === "domain". */
619
619
  domains?: Record<string, LocaleCode>;
620
620
  };
621
+ /**
622
+ * Issue #235 — Brain LLM adapter selection.
623
+ *
624
+ * Mandu is a CONNECTOR for third-party LLMs, not an LLM owner. Cloud
625
+ * adapters forward requests using the user's own OAuth credentials,
626
+ * obtained via `mandu brain login --provider=<name>` and stored in
627
+ * the OS keychain. No API keys ever live in Mandu's process memory;
628
+ * Mandu-controlled billing is not a thing.
629
+ *
630
+ * Fields:
631
+ * - `adapter` — Which connector to use. Default `"auto"`.
632
+ * Auto resolves in priority order:
633
+ * openai → anthropic → ollama → template.
634
+ * Explicit values pin the choice but still
635
+ * degrade to template when the dependency is
636
+ * unreachable (no hard failures).
637
+ * - `openai.model` — Override the OpenAI model (default
638
+ * `"gpt-4o-mini"`).
639
+ * - `anthropic.model` — Override the Anthropic model (default
640
+ * `"claude-haiku-4-5-20251001"`).
641
+ * - `ollama.model` — Override the local Ollama model (default
642
+ * `"ministral-3:3b"`).
643
+ * - `telemetryOptOut` — When `true`, cloud adapters are disabled
644
+ * entirely regardless of stored tokens. The
645
+ * resolver falls to ollama/template. Use for
646
+ * privacy-strict environments.
647
+ *
648
+ * Omitting this block is equivalent to `{ adapter: "auto" }`.
649
+ *
650
+ * @see `@mandujs/core/brain/adapters` for the resolver implementation.
651
+ * @see `docs/brain/oauth-adapters.md` (when authored).
652
+ */
653
+ brain?: {
654
+ adapter?: "auto" | "openai" | "anthropic" | "ollama" | "template";
655
+ openai?: { model?: string };
656
+ anthropic?: { model?: string };
657
+ ollama?: { model?: string; baseUrl?: string };
658
+ telemetryOptOut?: boolean;
659
+ };
621
660
  }
622
661
 
623
662
  export const CONFIG_FILES = [
@@ -587,6 +587,48 @@ const I18nConfigSchema = z
587
587
  }
588
588
  });
589
589
 
590
+ /**
591
+ * Issue #235 — Brain adapter block (strict).
592
+ *
593
+ * All fields optional; omitting the block is equivalent to
594
+ * `{ adapter: "auto" }`. `telemetryOptOut: true` forces the resolver
595
+ * to skip every cloud tier regardless of stored tokens.
596
+ *
597
+ * Per-provider sub-blocks are strict so stale `apiKey` / `endpoint` /
598
+ * etc. keys from ad-hoc configs fail fast — Mandu's connector model
599
+ * does NOT accept inline API keys.
600
+ */
601
+ const BrainOpenAIConfigSchema = z
602
+ .object({
603
+ model: z.string().min(1).optional(),
604
+ })
605
+ .strict();
606
+
607
+ const BrainAnthropicConfigSchema = z
608
+ .object({
609
+ model: z.string().min(1).optional(),
610
+ })
611
+ .strict();
612
+
613
+ const BrainOllamaConfigSchema = z
614
+ .object({
615
+ model: z.string().min(1).optional(),
616
+ baseUrl: z.string().url().optional(),
617
+ })
618
+ .strict();
619
+
620
+ const BrainConfigSchema = z
621
+ .object({
622
+ adapter: z
623
+ .enum(["auto", "openai", "anthropic", "ollama", "template"])
624
+ .default("auto"),
625
+ openai: BrainOpenAIConfigSchema.optional(),
626
+ anthropic: BrainAnthropicConfigSchema.optional(),
627
+ ollama: BrainOllamaConfigSchema.optional(),
628
+ telemetryOptOut: z.boolean().optional(),
629
+ })
630
+ .strict();
631
+
590
632
  export const ManduConfigSchema = z
591
633
  .object({
592
634
  adapter: AdapterConfigSchema.optional(),
@@ -643,6 +685,13 @@ export const ManduConfigSchema = z
643
685
  * Optional; omission leaves i18n disabled with zero runtime overhead.
644
686
  */
645
687
  i18n: I18nConfigSchema.optional(),
688
+ /**
689
+ * Issue #235 — Brain adapter selection. See {@link BrainConfigSchema}.
690
+ * Optional; omission is equivalent to `{ adapter: "auto" }`, which
691
+ * resolves in priority order: openai-oauth → anthropic-oauth →
692
+ * ollama → template.
693
+ */
694
+ brain: BrainConfigSchema.optional(),
646
695
  })
647
696
  .strict();
648
697
 
@@ -2848,6 +2848,9 @@ async function renderPageSSR(
2848
2848
  hmrPort: settings.hmrPort,
2849
2849
  routeId: route.id,
2850
2850
  routePattern: route.pattern,
2851
+ // Issue #233 — emit data-mandu-layout so SPA nav can detect
2852
+ // cross-layout transitions and hard-nav instead of half-swapping.
2853
+ layoutChain: route.layoutChain,
2851
2854
  hydration: route.hydration,
2852
2855
  bundleManifest: settings.bundleManifest,
2853
2856
  criticalData: loaderData as Record<string, unknown> | undefined,
@@ -2896,6 +2899,9 @@ async function renderPageSSR(
2896
2899
  prefetch: settings.prefetch,
2897
2900
  spa: settings.spa,
2898
2901
  devtools: settings.devtools,
2902
+ // Issue #233 — SPA nav uses this to detect cross-layout transitions
2903
+ // and fall back to a hard navigation.
2904
+ layoutChain: route.layoutChain,
2899
2905
  });
2900
2906
  return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
2901
2907
  } catch (error) {
@@ -53,6 +53,16 @@ export interface SSROptions {
53
53
  bundleManifest?: BundleManifest;
54
54
  /** 라우트 ID (island 식별용) */
55
55
  routeId?: string;
56
+ /**
57
+ * Issue #233 — layout chain applied to this render, ordered outer → inner.
58
+ * Serialized into `data-mandu-layout` on `<div id="root">` so the SPA
59
+ * navigation helper can detect cross-layout transitions and fall back
60
+ * to a hard navigation instead of a soft `<main>` swap (which would
61
+ * leave stale layout chrome like a sidebar from the source layout).
62
+ * When omitted or empty the attribute is dropped — same-layout nav
63
+ * remains cheap and visually crossfaded.
64
+ */
65
+ layoutChain?: string[];
56
66
  /** 추가 head 태그 */
57
67
  headTags?: string;
58
68
  /** 추가 body 끝 태그 */
@@ -623,8 +633,25 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
623
633
  spa,
624
634
  devtools,
625
635
  devErrorOverlay,
636
+ layoutChain,
626
637
  } = options;
627
638
 
639
+ // Issue #233 — derive a stable short key for the layout chain so the
640
+ // SPA nav helper can detect cross-layout transitions. Deterministic
641
+ // per server, stable across the same route/layout combo. Missing /
642
+ // empty chain → omit the attribute entirely (attribute absence acts
643
+ // like a wildcard match).
644
+ let layoutKey = "";
645
+ if (layoutChain && layoutChain.length > 0) {
646
+ let hash = 0;
647
+ const joined = layoutChain.join("|");
648
+ for (let i = 0; i < joined.length; i++) {
649
+ hash = ((hash << 5) - hash + joined.charCodeAt(i)) | 0;
650
+ }
651
+ layoutKey = (hash >>> 0).toString(16).padStart(8, "0");
652
+ }
653
+ const rootAttrs = layoutKey ? ` data-mandu-layout="${layoutKey}"` : "";
654
+
628
655
  // CSS 링크 태그 생성
629
656
  // - cssPath가 string이면 해당 경로 사용
630
657
  // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
@@ -804,7 +831,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
804
831
  ${devErrorOverlayTag}
805
832
  </head>
806
833
  <body>
807
- <div id="root">${bodyContent}</div>
834
+ <div id="root"${rootAttrs}>${bodyContent}</div>
808
835
  ${dataScript}
809
836
  ${routeScript}
810
837
  ${hydrationScripts}
@@ -99,6 +99,12 @@ export interface StreamingSSROptions {
99
99
  routeId?: string;
100
100
  /** 라우트 패턴 */
101
101
  routePattern?: string;
102
+ /**
103
+ * Issue #233 — layout chain (outer → inner). Emitted as
104
+ * `data-mandu-layout="<hash>"` on `<div id="root">` so the SPA
105
+ * navigation helper can detect cross-layout transitions.
106
+ */
107
+ layoutChain?: string[];
102
108
  /** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */
103
109
  criticalData?: Record<string, unknown>;
104
110
  // Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
@@ -543,8 +549,24 @@ function generateHTMLShell(options: StreamingSSROptions): string {
543
549
  transitions = true,
544
550
  prefetch = true,
545
551
  spa = true,
552
+ layoutChain,
546
553
  } = options;
547
554
 
555
+ // Issue #233 — layout-key for SPA cross-layout detection. Mirror of the
556
+ // block in `ssr.ts::renderToHTML`; see that call-site for the full
557
+ // rationale. Streaming path must stamp the same attribute so the SPA
558
+ // helper's layout-mismatch heuristic is consistent across modes.
559
+ let layoutKey = "";
560
+ if (layoutChain && layoutChain.length > 0) {
561
+ let hash = 0;
562
+ const joined = layoutChain.join("|");
563
+ for (let i = 0; i < joined.length; i++) {
564
+ hash = ((hash << 5) - hash + joined.charCodeAt(i)) | 0;
565
+ }
566
+ layoutKey = (hash >>> 0).toString(16).padStart(8, "0");
567
+ }
568
+ const rootAttrs = layoutKey ? ` data-mandu-layout="${layoutKey}"` : "";
569
+
548
570
  // CSS 링크 태그 생성
549
571
  // - cssPath가 string이면 해당 경로 사용
550
572
  // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
@@ -652,7 +674,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
652
674
  ${fastRefreshPreamble}
653
675
  </head>
654
676
  <body>
655
- <div id="root">${islandOpenTag}`;
677
+ <div id="root"${rootAttrs}>${islandOpenTag}`;
656
678
  }
657
679
 
658
680
  /**