@djangocfg/layouts 2.1.277 → 2.1.280

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/README.md CHANGED
@@ -87,7 +87,7 @@ Wraps `BaseApp` and picks **admin → private → public** layout by path (`matc
87
87
 
88
88
  | Component | Use |
89
89
  |---|---|
90
- | **`PublicLayout`** | Marketing / docs. Slots for navbar + footer. **[See PublicLayout README](./src/layouts/PublicLayout/README.md)** for full props, navbar variants (`FloatingNavbar` / `FlushNavbar` / `MinimalNavbar`), `DefaultFooter`, `NavAction`, and hooks. |
90
+ | **`PublicLayout`** | Marketing / docs. Slots for navbar + footer. Supports `LinkComponentProvider` to inject an i18n-aware `Link` (e.g. `next-intl`) so every anchor picks up the locale prefix. **[See PublicLayout README](./src/layouts/PublicLayout/README.md)** for full props, navbar variants (`FloatingNavbar` / `FlushNavbar` / `MinimalNavbar`), `DefaultFooter`, `NavAction`, and hooks. |
91
91
  | **`PrivateLayout`** | App shell — sidebar + header. |
92
92
  | **`AuthLayout`** | Sign-in flows. |
93
93
  | **`AdminLayout`** | Admin console. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.277",
3
+ "version": "2.1.280",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -74,19 +74,19 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.277",
78
- "@djangocfg/centrifugo": "^2.1.277",
79
- "@djangocfg/debuger": "^2.1.277",
80
- "@djangocfg/i18n": "^2.1.277",
81
- "@djangocfg/monitor": "^2.1.277",
82
- "@djangocfg/ui-core": "^2.1.277",
83
- "@djangocfg/ui-nextjs": "^2.1.277",
84
- "@djangocfg/ui-tools": "^2.1.277",
77
+ "@djangocfg/api": "^2.1.280",
78
+ "@djangocfg/centrifugo": "^2.1.280",
79
+ "@djangocfg/debuger": "^2.1.280",
80
+ "@djangocfg/i18n": "^2.1.280",
81
+ "@djangocfg/monitor": "^2.1.280",
82
+ "@djangocfg/ui-core": "^2.1.280",
83
+ "@djangocfg/ui-nextjs": "^2.1.280",
84
+ "@djangocfg/ui-tools": "^2.1.280",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
88
88
  "moment": "^2.30.1",
89
- "next": "^16.0.10",
89
+ "next": "^16.2.2",
90
90
  "p-retry": "^7.0.0",
91
91
  "react": "^19.1.0",
92
92
  "react-dom": "^19.1.0",
@@ -110,15 +110,15 @@
110
110
  "uuid": "^11.1.0"
111
111
  },
112
112
  "devDependencies": {
113
- "@djangocfg/api": "^2.1.277",
114
- "@djangocfg/centrifugo": "^2.1.277",
115
- "@djangocfg/debuger": "^2.1.277",
116
- "@djangocfg/i18n": "^2.1.277",
117
- "@djangocfg/monitor": "^2.1.277",
118
- "@djangocfg/typescript-config": "^2.1.277",
119
- "@djangocfg/ui-core": "^2.1.277",
120
- "@djangocfg/ui-nextjs": "^2.1.277",
121
- "@djangocfg/ui-tools": "^2.1.277",
113
+ "@djangocfg/api": "^2.1.280",
114
+ "@djangocfg/centrifugo": "^2.1.280",
115
+ "@djangocfg/debuger": "^2.1.280",
116
+ "@djangocfg/i18n": "^2.1.280",
117
+ "@djangocfg/monitor": "^2.1.280",
118
+ "@djangocfg/typescript-config": "^2.1.280",
119
+ "@djangocfg/ui-core": "^2.1.280",
120
+ "@djangocfg/ui-nextjs": "^2.1.280",
121
+ "@djangocfg/ui-tools": "^2.1.280",
122
122
  "@types/node": "^24.7.2",
123
123
  "@types/react": "^19.1.0",
124
124
  "@types/react-dom": "^19.1.0",
@@ -23,6 +23,30 @@ import { PublicLayout, FloatingNavbar, DefaultFooter } from '@djangocfg/layouts'
23
23
  | `contentTopSpacing` | `'auto' \| 'none'` | `'auto'` | Auto pads `<main>` based on the navbar variant. |
24
24
  | `contentBottomSpacing` | `'auto' \| 'none' \| 'compact'` | `'auto'` | Bottom breathing room before footer. |
25
25
 
26
+ ## `LinkComponentProvider` — inject a custom Link
27
+
28
+ All internal anchors (navbar, footer, mobile drawer) render through a single
29
+ injectable `Link` component. Default is plain `next/link`. Wrap the layout in
30
+ `LinkComponentProvider` to swap it — for example to pipe in a locale-aware
31
+ `Link` from `next-intl`:
32
+
33
+ ```tsx
34
+ import { LinkComponentProvider, PublicLayout, FloatingNavbar } from '@djangocfg/layouts';
35
+ import { Link } from '@/i18n/navigation'; // next-intl createNavigation().Link
36
+
37
+ <LinkComponentProvider value={Link}>
38
+ <PublicLayout navbar={<FloatingNavbar config={…} />} footer={…}>
39
+ {children}
40
+ </PublicLayout>
41
+ </LinkComponentProvider>
42
+ ```
43
+
44
+ Why: with `next-intl`'s `localePrefix: 'as-needed'`, plain `next/link` does
45
+ not prepend the active locale, so every navbar item drops the `/ru/` prefix
46
+ on click. Injecting the i18n-aware `Link` fixes that once for the whole tree.
47
+
48
+ Monolingual apps don't need the provider — the default `next/link` is fine.
49
+
26
50
  ## Navbar variants
27
51
 
28
52
  Three variants. All share the same core props (below); only the chrome differs.
@@ -131,7 +155,7 @@ Three variants: `full` (default) with brand column + menus + controls; `compact`
131
155
 
132
156
  ## Primitives
133
157
 
134
- `NavBrand`, `NavActions`, `NavActionItem`, `NavDesktopItems`, `ThemeBrandMark`, `ThemeBrandMarkImg`, `PublicLayoutProvider`, `usePublicLayout`.
158
+ `NavBrand`, `NavActions`, `NavActionItem`, `NavDesktopItems`, `ThemeBrandMark`, `ThemeBrandMarkImg`, `LinkComponentProvider`, `useLinkComponent`, `PublicLayoutProvider`, `usePublicLayout`.
135
159
 
136
160
  ## Context
137
161
 
@@ -13,7 +13,7 @@ import { Button } from '@djangocfg/ui-core/components';
13
13
  import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
14
14
 
15
15
  import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
16
- import { SmartNavLink } from '../../primitives/SmartNavLink';
16
+ import { useLinkComponent } from '../../primitives/LinkComponentContext';
17
17
  import { FooterMenuSections } from './FooterMenuSections';
18
18
  import { FooterProjectInfo } from './FooterProjectInfo';
19
19
 
@@ -69,6 +69,7 @@ function ThemeModeControl() {
69
69
  }
70
70
 
71
71
  export function DefaultFooter({ config }: DefaultFooterProps) {
72
+ const Link = useLinkComponent();
72
73
  const variant = config.variant ?? 'full';
73
74
  const shellClass = config.shell?.className;
74
75
  const brandSlot = config.brand?.slot;
@@ -132,13 +133,13 @@ export function DefaultFooter({ config }: DefaultFooterProps) {
132
133
  {link.label}
133
134
  </a>
134
135
  ) : (
135
- <SmartNavLink
136
+ <Link
136
137
  key={link.path}
137
138
  href={link.path}
138
139
  className="text-sm text-muted-foreground hover:text-foreground transition-colors"
139
140
  >
140
141
  {link.label}
141
- </SmartNavLink>
142
+ </Link>
142
143
  )
143
144
  )}
144
145
  </div>
@@ -195,13 +196,13 @@ export function DefaultFooter({ config }: DefaultFooterProps) {
195
196
  {link.label}
196
197
  </a>
197
198
  ) : (
198
- <SmartNavLink
199
+ <Link
199
200
  key={link.path}
200
201
  href={link.path}
201
202
  className="text-xs text-muted-foreground hover:text-primary transition-colors"
202
203
  >
203
204
  {link.label}
204
- </SmartNavLink>
205
+ </Link>
205
206
  )
206
207
  )}
207
208
  </div>
@@ -302,9 +303,9 @@ export function DefaultFooter({ config }: DefaultFooterProps) {
302
303
  {link.label}
303
304
  </a>
304
305
  ) : (
305
- <SmartNavLink key={link.path} href={link.path} className="hover:text-foreground transition-colors whitespace-nowrap">
306
+ <Link key={link.path} href={link.path} className="hover:text-foreground transition-colors whitespace-nowrap">
306
307
  {link.label}
307
- </SmartNavLink>
308
+ </Link>
308
309
  )
309
310
  )}
310
311
  </div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React from 'react';
4
4
 
5
- import { SmartNavLink } from '../../primitives/SmartNavLink';
5
+ import { useLinkComponent } from '../../primitives/LinkComponentContext';
6
6
  import { DjangoCFGLogo } from './DjangoCFGLogo';
7
7
 
8
8
  import type { FooterLink } from './types';
@@ -23,6 +23,7 @@ export function FooterBottom({
23
23
  links = [],
24
24
  variant = 'desktop',
25
25
  }: FooterBottomProps) {
26
+ const Link = useLinkComponent();
26
27
  const isMobile = variant === 'mobile';
27
28
 
28
29
  if (isMobile) {
@@ -95,13 +96,13 @@ export function FooterBottom({
95
96
  {link.label}
96
97
  </a>
97
98
  ) : (
98
- <SmartNavLink
99
+ <Link
99
100
  key={link.path}
100
101
  href={link.path}
101
102
  className="text-xs text-muted-foreground hover:text-primary transition-colors"
102
103
  >
103
104
  {link.label}
104
- </SmartNavLink>
105
+ </Link>
105
106
  )
106
107
  )}
107
108
  </div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React from 'react';
4
4
 
5
- import { SmartNavLink } from '../../primitives/SmartNavLink';
5
+ import { useLinkComponent } from '../../primitives/LinkComponentContext';
6
6
 
7
7
  import type { FooterMenuSection } from './types';
8
8
 
@@ -17,6 +17,7 @@ export function FooterMenuSections({
17
17
  minColumnWidth = 180,
18
18
  maxColumns = 5,
19
19
  }: FooterMenuSectionsProps) {
20
+ const Link = useLinkComponent();
20
21
  if (menuSections.length === 0) return null;
21
22
 
22
23
  const effectiveColumns = Math.max(1, Math.min(maxColumns, menuSections.length));
@@ -34,12 +35,12 @@ export function FooterMenuSections({
34
35
  <ul className="space-y-2">
35
36
  {section.items.map((item) => (
36
37
  <li key={item.path}>
37
- <SmartNavLink
38
+ <Link
38
39
  href={item.path}
39
40
  className="text-sm text-foreground/90 hover:text-foreground transition-colors"
40
41
  >
41
42
  {item.label}
42
- </SmartNavLink>
43
+ </Link>
43
44
  </li>
44
45
  ))}
45
46
  </ul>
@@ -36,18 +36,16 @@ export {
36
36
  NavDesktopItems,
37
37
  ThemeBrandMark,
38
38
  ThemeBrandMarkImg,
39
- ExternalPrefixesProvider,
40
- useExternalPrefixes,
41
- isExternalPrefixHref,
42
- SmartNavLink,
39
+ LinkComponentProvider,
40
+ useLinkComponent,
43
41
  } from './primitives';
44
42
  export type {
45
43
  NavAction,
46
44
  ThemeBrandMarkProps,
47
45
  ThemeBrandMarkImgProps,
48
- ExternalPrefixes,
49
- ExternalPrefixesProviderProps,
50
- SmartNavLinkProps,
46
+ LinkComponent,
47
+ LinkComponentProps,
48
+ LinkComponentProviderProps,
51
49
  } from './primitives';
52
50
 
53
51
  // Navbar variants
@@ -11,7 +11,6 @@ import React from 'react';
11
11
  import { cn } from '@djangocfg/ui-core/lib';
12
12
 
13
13
  import { publicFloatingChromeClassName } from '../../publicShellShadow';
14
- import { ExternalPrefixesProvider } from '../../primitives/ExternalPrefixesContext';
15
14
  import type { NavAction } from '../../primitives/NavActionItem';
16
15
  import { NavbarShell } from '../../shared';
17
16
  import type {
@@ -52,14 +51,6 @@ export interface FloatingNavbarConfig {
52
51
  actionsLeadingSlot?: React.ReactNode;
53
52
  /** Arbitrary ReactNode after the mobile toggle. */
54
53
  actionsTrailingSlot?: React.ReactNode;
55
- /**
56
- * Path prefixes that should be navigated with a plain `<a>` (full page load)
57
- * instead of `next/link`. Use this for routes served by a catch-all handler
58
- * outside the Next.js App Router (e.g. Nextra at `/docs/*`) — those routes
59
- * cannot return an RSC payload, so `next/link` client navigation fails.
60
- * @default []
61
- */
62
- externalPrefixes?: readonly string[];
63
54
  }
64
55
 
65
56
  export interface FloatingNavbarProps {
@@ -71,7 +62,6 @@ export function FloatingNavbar({ config }: FloatingNavbarProps) {
71
62
  const rounding = config.shell?.rounding;
72
63
  const containerClassName = config.shell?.className;
73
64
  const position = config.navbarPosition ?? 'sticky';
74
- const externalPrefixes = config.externalPrefixes;
75
65
 
76
66
  const outerClassName = cn(
77
67
  position === 'fixed' ? 'fixed' : position === 'static' ? 'static' : 'sticky',
@@ -87,7 +77,7 @@ export function FloatingNavbar({ config }: FloatingNavbarProps) {
87
77
  );
88
78
 
89
79
  return (
90
- <ExternalPrefixesProvider value={externalPrefixes}>
80
+ <>
91
81
  <NavbarShell
92
82
  variant="floating"
93
83
  position={position}
@@ -122,6 +112,6 @@ export function FloatingNavbar({ config }: FloatingNavbarProps) {
122
112
  containerClassName={containerClassName}
123
113
  rounding={rounding}
124
114
  />
125
- </ExternalPrefixesProvider>
115
+ </>
126
116
  );
127
117
  }
@@ -10,7 +10,6 @@ import React from 'react';
10
10
 
11
11
  import { cn } from '@djangocfg/ui-core/lib';
12
12
 
13
- import { ExternalPrefixesProvider } from '../../primitives/ExternalPrefixesContext';
14
13
  import type { NavAction } from '../../primitives/NavActionItem';
15
14
  import { NavbarShell } from '../../shared';
16
15
  import type {
@@ -51,14 +50,6 @@ export interface FlushNavbarConfig {
51
50
  actionsLeadingSlot?: React.ReactNode;
52
51
  /** Arbitrary ReactNode after the mobile toggle. */
53
52
  actionsTrailingSlot?: React.ReactNode;
54
- /**
55
- * Path prefixes that should be navigated with a plain `<a>` (full page load)
56
- * instead of `next/link`. Use this for routes served by a catch-all handler
57
- * outside the Next.js App Router (e.g. Nextra at `/docs/*`) — those routes
58
- * cannot return an RSC payload, so `next/link` client navigation fails.
59
- * @default []
60
- */
61
- externalPrefixes?: readonly string[];
62
53
  }
63
54
 
64
55
  export interface FlushNavbarProps {
@@ -69,7 +60,6 @@ export function FlushNavbar({ config }: FlushNavbarProps) {
69
60
  const navigation = config.navigation ?? [];
70
61
  const containerClassName = config.shell?.className;
71
62
  const position = config.navbarPosition ?? 'sticky';
72
- const externalPrefixes = config.externalPrefixes;
73
63
 
74
64
  const outerClassName = cn(
75
65
  position === 'fixed' ? 'fixed' : position === 'static' ? 'static' : 'sticky',
@@ -83,7 +73,7 @@ export function FlushNavbar({ config }: FlushNavbarProps) {
83
73
  );
84
74
 
85
75
  return (
86
- <ExternalPrefixesProvider value={externalPrefixes}>
76
+ <>
87
77
  <NavbarShell
88
78
  variant="flush"
89
79
  position={position}
@@ -117,6 +107,6 @@ export function FlushNavbar({ config }: FlushNavbarProps) {
117
107
  userMenu={config.userMenu}
118
108
  containerClassName={containerClassName}
119
109
  />
120
- </ExternalPrefixesProvider>
110
+ </>
121
111
  );
122
112
  }
@@ -17,7 +17,6 @@ import { Button } from '@djangocfg/ui-core/components';
17
17
  import { cn } from '@djangocfg/ui-core/lib';
18
18
 
19
19
  import { UserMenu } from '../../../_components/UserMenu';
20
- import { ExternalPrefixesProvider } from '../../primitives/ExternalPrefixesContext';
21
20
  import { NavActionItem, type NavAction } from '../../primitives/NavActionItem';
22
21
  import { NavbarShell, type NavbarActionsContext } from '../../shared';
23
22
  import type {
@@ -68,15 +67,6 @@ export interface MinimalNavbarConfig {
68
67
  * @default 'mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-10'
69
68
  */
70
69
  containerClassName?: string;
71
-
72
- /**
73
- * Path prefixes that should be navigated with a plain `<a>` (full page load)
74
- * instead of `next/link`. Use this for routes served by a catch-all handler
75
- * outside the Next.js App Router (e.g. Nextra at `/docs/*`) — those routes
76
- * cannot return an RSC payload, so `next/link` client navigation fails.
77
- * @default []
78
- */
79
- externalPrefixes?: readonly string[];
80
70
  }
81
71
 
82
72
  export interface MinimalNavbarProps {
@@ -131,7 +121,6 @@ export function MinimalNavbar({ config }: MinimalNavbarProps) {
131
121
  const position = config.navbarPosition ?? 'sticky';
132
122
  const transparent = config.transparent ?? true;
133
123
  const containerClassName = config.containerClassName ?? 'mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-10';
134
- const externalPrefixes = config.externalPrefixes;
135
124
 
136
125
  const outerClassName = cn(
137
126
  position === 'fixed' ? 'fixed' : position === 'static' ? 'static' : 'sticky',
@@ -142,7 +131,7 @@ export function MinimalNavbar({ config }: MinimalNavbarProps) {
142
131
  const shapeClassName = 'w-full rounded-none border-0 shadow-none';
143
132
 
144
133
  return (
145
- <ExternalPrefixesProvider value={externalPrefixes}>
134
+ <>
146
135
  <NavbarShell
147
136
  variant="minimal"
148
137
  position={position}
@@ -175,6 +164,6 @@ export function MinimalNavbar({ config }: MinimalNavbarProps) {
175
164
  userMenu={config.userMenu}
176
165
  containerClassName={containerClassName}
177
166
  />
178
- </ExternalPrefixesProvider>
167
+ </>
179
168
  );
180
169
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * LinkComponentContext
3
+ *
4
+ * Dependency-injects the Link component used by navbars, footers, and mobile
5
+ * drawers. Apps wire in their own i18n-aware Link (e.g. next-intl's
6
+ * `createNavigation().Link`) so internal navigation picks up the locale
7
+ * prefix. Default falls back to plain `next/link`.
8
+ */
9
+
10
+ 'use client';
11
+
12
+ import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
13
+ import React, {
14
+ createContext,
15
+ useContext,
16
+ type AnchorHTMLAttributes,
17
+ type ComponentType,
18
+ type ReactNode,
19
+ } from 'react';
20
+
21
+ type AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
22
+
23
+ export type LinkComponentProps = Omit<NextLinkProps, 'passHref' | 'legacyBehavior'> &
24
+ AnchorProps & {
25
+ children?: ReactNode;
26
+ };
27
+
28
+ export type LinkComponent = ComponentType<LinkComponentProps>;
29
+
30
+ const LinkComponentContext = createContext<LinkComponent>(NextLink as LinkComponent);
31
+
32
+ export interface LinkComponentProviderProps {
33
+ value: LinkComponent;
34
+ children: ReactNode;
35
+ }
36
+
37
+ export function LinkComponentProvider({
38
+ value,
39
+ children,
40
+ }: LinkComponentProviderProps) {
41
+ return (
42
+ <LinkComponentContext.Provider value={value}>
43
+ {children}
44
+ </LinkComponentContext.Provider>
45
+ );
46
+ }
47
+
48
+ export function useLinkComponent(): LinkComponent {
49
+ return useContext(LinkComponentContext);
50
+ }
@@ -11,7 +11,7 @@ import React, { type ReactNode } from 'react';
11
11
 
12
12
  import { cn } from '@djangocfg/ui-core/lib';
13
13
 
14
- import { SmartNavLink } from './SmartNavLink';
14
+ import { useLinkComponent } from './LinkComponentContext';
15
15
 
16
16
  export interface NavAction {
17
17
  label: string;
@@ -55,6 +55,7 @@ interface NavActionItemProps {
55
55
  }
56
56
 
57
57
  export function NavActionItem({ action, className }: NavActionItemProps) {
58
+ const Link = useLinkComponent();
58
59
  const variant = action.variant ?? 'ghost';
59
60
  const cls = cn(
60
61
  baseCls,
@@ -88,8 +89,8 @@ export function NavActionItem({ action, className }: NavActionItemProps) {
88
89
  );
89
90
  }
90
91
  return (
91
- <SmartNavLink href={action.href} className={cls} onClick={action.onClick}>
92
+ <Link href={action.href} className={cls} onClick={action.onClick}>
92
93
  {content}
93
- </SmartNavLink>
94
+ </Link>
94
95
  );
95
96
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { type ReactNode } from 'react';
4
4
 
5
- import { SmartNavLink } from './SmartNavLink';
5
+ import { useLinkComponent } from './LinkComponentContext';
6
6
 
7
7
  interface NavBrandProps {
8
8
  brand?: ReactNode;
@@ -10,16 +10,18 @@ interface NavBrandProps {
10
10
  }
11
11
 
12
12
  export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
13
+ const Link = useLinkComponent();
14
+
13
15
  if (brand == null || brand === '' || brand === false) return null;
14
16
 
15
17
  if (typeof brand === 'string') {
16
18
  return (
17
- <SmartNavLink
19
+ <Link
18
20
  href={brandHref}
19
21
  className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
20
22
  >
21
23
  {brand}
22
- </SmartNavLink>
24
+ </Link>
23
25
  );
24
26
  }
25
27
 
@@ -10,7 +10,7 @@ import type { NavigationItem } from '../../types';
10
10
  import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
11
11
  import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
12
12
  import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
13
- import { SmartNavLink } from './SmartNavLink';
13
+ import { useLinkComponent } from './LinkComponentContext';
14
14
 
15
15
  interface NavDesktopItemsProps {
16
16
  /** Full ordered item list; overflow is computed responsively. */
@@ -57,6 +57,7 @@ export function NavDesktopItems({
57
57
  dropdown,
58
58
  renderDesktopDropdown,
59
59
  }: NavDesktopItemsProps) {
60
+ const Link = useLinkComponent();
60
61
  const { openDropdownKey, scheduleOpen, scheduleClose, closeDropdown } = dropdown;
61
62
 
62
63
  const { visibleCount, containerRef, itemRef, measured } = useResponsiveOverflow({
@@ -91,9 +92,9 @@ export function NavDesktopItems({
91
92
  <span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
92
93
  </a>
93
94
  ) : (
94
- <SmartNavLink href={sub.href} className={subMenuLinkCls(subActive)}>
95
+ <Link href={sub.href} className={subMenuLinkCls(subActive)}>
95
96
  <span className="min-w-0 truncate" title={sub.label}>{sub.label}</span>
96
- </SmartNavLink>
97
+ </Link>
97
98
  )}
98
99
  </div>
99
100
  );
@@ -149,13 +150,13 @@ export function NavDesktopItems({
149
150
 
150
151
  const active = isActivePath(item.href);
151
152
  return (
152
- <SmartNavLink
153
+ <Link
153
154
  key={item.href}
154
155
  href={item.href}
155
156
  className={cn(navItemCls, active && navItemActiveCls)}
156
157
  >
157
158
  <span className={labelCls} title={item.label}>{item.label}</span>
158
- </SmartNavLink>
159
+ </Link>
159
160
  );
160
161
  };
161
162
 
@@ -235,9 +236,9 @@ export function NavDesktopItems({
235
236
  const active = isGroupActive(item);
236
237
  return (
237
238
  <div key={`overflow-${item.href}`} className="rounded-full">
238
- <SmartNavLink href={item.href} className={subMenuLinkCls(active)}>
239
+ <Link href={item.href} className={subMenuLinkCls(active)}>
239
240
  <span className="min-w-0 truncate" title={item.label}>{item.label}</span>
240
- </SmartNavLink>
241
+ </Link>
241
242
  </div>
242
243
  );
243
244
  })}
@@ -6,13 +6,11 @@ export { NavDesktopItems } from './NavDesktopItems';
6
6
  export { ThemeBrandMark, ThemeBrandMarkImg } from './ThemeBrandMark';
7
7
  export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './ThemeBrandMark';
8
8
  export {
9
- ExternalPrefixesProvider,
10
- useExternalPrefixes,
11
- isExternalPrefixHref,
12
- } from './ExternalPrefixesContext';
9
+ LinkComponentProvider,
10
+ useLinkComponent,
11
+ } from './LinkComponentContext';
13
12
  export type {
14
- ExternalPrefixes,
15
- ExternalPrefixesProviderProps,
16
- } from './ExternalPrefixesContext';
17
- export { SmartNavLink } from './SmartNavLink';
18
- export type { SmartNavLinkProps } from './SmartNavLink';
13
+ LinkComponent,
14
+ LinkComponentProps,
15
+ LinkComponentProviderProps,
16
+ } from './LinkComponentContext';
@@ -19,7 +19,7 @@ import { usePathnameWithoutLocale } from '../../../hooks';
19
19
  import { UserMenu } from '../../_components/UserMenu';
20
20
  import { usePublicLayoutOptional } from '../context';
21
21
  import { useMobileNavPanel } from '../hooks';
22
- import { SmartNavLink } from '../primitives/SmartNavLink';
22
+ import { useLinkComponent } from '../primitives/LinkComponentContext';
23
23
 
24
24
  import type { NavigationItem, UserMenuConfig } from '../../types';
25
25
 
@@ -35,6 +35,7 @@ export interface MobileDrawerShellProps {
35
35
  }
36
36
 
37
37
  export function MobileDrawerShell(props: MobileDrawerShellProps) {
38
+ const Link = useLinkComponent();
38
39
  const context = usePublicLayoutOptional();
39
40
  const mobileMenuOpen = props.isOpen ?? context?.mobileMenuOpen ?? false;
40
41
  const closeMobileMenu = props.onClose ?? context?.closeMobileMenu ?? (() => {});
@@ -133,7 +134,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
133
134
  const parentOnlySectionOpen = hasChildNav && anyChildActive;
134
135
  return (
135
136
  <div key={item.href}>
136
- <SmartNavLink
137
+ <Link
137
138
  href={item.href}
138
139
  onClick={closeMobileMenu}
139
140
  title={item.label}
@@ -148,13 +149,13 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
148
149
  )}
149
150
  >
150
151
  {item.label}
151
- </SmartNavLink>
152
+ </Link>
152
153
  {hasChildNav && (
153
154
  <div className="ml-3 mt-1.5 space-y-1 border-l border-border/40 pl-3">
154
155
  {childItems.map((subItem) => {
155
156
  const subActive = isActivePath(subItem.href);
156
157
  return (
157
- <SmartNavLink
158
+ <Link
158
159
  key={`${item.href}-${subItem.href}`}
159
160
  href={subItem.href}
160
161
  onClick={closeMobileMenu}
@@ -168,7 +169,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
168
169
  )}
169
170
  >
170
171
  {subItem.label}
171
- </SmartNavLink>
172
+ </Link>
172
173
  );
173
174
  })}
174
175
  </div>
@@ -182,7 +183,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
182
183
 
183
184
  {showSignInFooter && (
184
185
  <div className="shrink-0 border-t border-border/50 p-4">
185
- <SmartNavLink
186
+ <Link
186
187
  href={userMenu?.authPath || '/auth'}
187
188
  onClick={closeMobileMenu}
188
189
  className="block"
@@ -194,7 +195,7 @@ export function MobileDrawerShell(props: MobileDrawerShellProps) {
194
195
  aria-hidden
195
196
  />
196
197
  </Button>
197
- </SmartNavLink>
198
+ </Link>
198
199
  </div>
199
200
  )}
200
201
  </div>
@@ -1,69 +0,0 @@
1
- /**
2
- * ExternalPrefixesContext
3
- *
4
- * Provides a list of path prefixes that should be treated as "outside" the
5
- * Next.js App Router (e.g. a catch-all served by Nextra). Consumers inside the
6
- * navbar (`SmartNavLink`) check whether the `href` starts with any configured
7
- * prefix; if so, they render a native `<a>` (full page load) instead of a
8
- * `next/link` that would fetch an RSC payload the route cannot produce.
9
- *
10
- * Default: `[]` — so behaviour is unchanged for consumers who do not opt in.
11
- */
12
-
13
- 'use client';
14
-
15
- import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
16
-
17
- export type ExternalPrefixes = readonly string[];
18
-
19
- const ExternalPrefixesContext = createContext<ExternalPrefixes>([]);
20
-
21
- export interface ExternalPrefixesProviderProps {
22
- /** List of path prefixes treated as external to the App Router. */
23
- value?: ExternalPrefixes;
24
- children: ReactNode;
25
- }
26
-
27
- export function ExternalPrefixesProvider({
28
- value,
29
- children,
30
- }: ExternalPrefixesProviderProps) {
31
- const resolved = useMemo<ExternalPrefixes>(() => value ?? [], [value]);
32
- return (
33
- <ExternalPrefixesContext.Provider value={resolved}>
34
- {children}
35
- </ExternalPrefixesContext.Provider>
36
- );
37
- }
38
-
39
- export function useExternalPrefixes(): ExternalPrefixes {
40
- return useContext(ExternalPrefixesContext);
41
- }
42
-
43
- /**
44
- * Returns true if `href` is a string that starts with any of the provided
45
- * external prefixes. Non-string hrefs (objects, `next/link` URL shape) always
46
- * return false — those are App Router internal.
47
- *
48
- * Matches the prefix exactly or at a path-boundary (`/`, `?`, `#`) so that
49
- * `/docs` in the prefix list matches `/docs`, `/docs/`, `/docs/foo`, `/docs?x=1`,
50
- * `/docs#bar` — but NOT `/docsearch` (which should stay an App Router link).
51
- */
52
- export function isExternalPrefixHref(
53
- href: unknown,
54
- prefixes: ExternalPrefixes,
55
- ): href is string {
56
- if (typeof href !== 'string' || prefixes.length === 0) return false;
57
- for (const prefix of prefixes) {
58
- if (!prefix) continue;
59
- if (href === prefix) return true;
60
- if (
61
- href.startsWith(`${prefix}/`) ||
62
- href.startsWith(`${prefix}?`) ||
63
- href.startsWith(`${prefix}#`)
64
- ) {
65
- return true;
66
- }
67
- }
68
- return false;
69
- }
@@ -1,81 +0,0 @@
1
- /**
2
- * SmartNavLink
3
- *
4
- * Drop-in replacement for `next/link` used inside PublicLayout navbars/footers.
5
- *
6
- * If the `href` is a string that starts with one of the prefixes configured
7
- * via `ExternalPrefixesProvider`, renders a plain `<a>` (full page navigation).
8
- * This is required for routes owned by a catch-all handler outside App Router
9
- * (e.g. Nextra `/docs/*`), where `next/link` client navigation asks for an
10
- * RSC payload the route cannot produce — resulting in a hard error banner.
11
- *
12
- * Otherwise falls back to `next/link` so existing behaviour is preserved.
13
- */
14
-
15
- 'use client';
16
-
17
- import Link, { type LinkProps } from 'next/link';
18
- import React, { forwardRef, type AnchorHTMLAttributes, type ReactNode } from 'react';
19
-
20
- import {
21
- isExternalPrefixHref,
22
- useExternalPrefixes,
23
- type ExternalPrefixes,
24
- } from './ExternalPrefixesContext';
25
-
26
- type AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
27
-
28
- export interface SmartNavLinkProps
29
- extends Omit<LinkProps, 'href' | 'passHref' | 'legacyBehavior'>,
30
- AnchorProps {
31
- href: LinkProps['href'];
32
- children?: ReactNode;
33
- /** Optional override — falls back to context value. */
34
- externalPrefixes?: ExternalPrefixes;
35
- }
36
-
37
- export const SmartNavLink = forwardRef<HTMLAnchorElement, SmartNavLinkProps>(
38
- function SmartNavLink(props, ref) {
39
- const {
40
- href,
41
- children,
42
- externalPrefixes,
43
- // LinkProps-specific fields we should NOT forward to a plain <a>.
44
- prefetch,
45
- replace,
46
- scroll,
47
- shallow,
48
- locale,
49
- // Rest are anchor-compatible attributes.
50
- ...anchorProps
51
- } = props;
52
-
53
- const contextPrefixes = useExternalPrefixes();
54
- const prefixes = externalPrefixes ?? contextPrefixes;
55
-
56
- const shouldUseAnchor = isExternalPrefixHref(href, prefixes);
57
-
58
- if (shouldUseAnchor) {
59
- return (
60
- <a ref={ref} href={href} {...anchorProps}>
61
- {children}
62
- </a>
63
- );
64
- }
65
-
66
- return (
67
- <Link
68
- ref={ref}
69
- href={href}
70
- prefetch={prefetch}
71
- replace={replace}
72
- scroll={scroll}
73
- shallow={shallow}
74
- locale={locale}
75
- {...anchorProps}
76
- >
77
- {children}
78
- </Link>
79
- );
80
- },
81
- );