diff --git a/apps/api/package.json b/apps/api/package.json index 1589cb6fe..3e58cb7dd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -29,7 +29,7 @@ "drizzle-kit": "1.0.0-rc.4", "drizzle-orm": "1.0.0-rc.4", "hono": "^4.12.31", - "next-intl": "^4.13.3", + "next-intl": "^4.13.7", "react": "^19.2.8", "react-dom": "^19.2.8", "ws": "^8.21.1", diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 2e6f4a5fe..559bbe765 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -1,5 +1,6 @@ import { google } from "@ai-sdk/google"; import { blogApiPlugin } from "@vitnode/blog/config.api"; +import { coreRelations } from "@vitnode/core/database/relations"; // import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local"; import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { exampleApiPlugin } from "@vitnode/example/config.api"; @@ -8,7 +9,6 @@ import { NodemailerEmailAdapter } from "@vitnode/nodemailer"; // import { S3StorageAdapter } from "@vitnode/s3"; import { SupabaseStorageAdapter } from "@vitnode/supabase-storage"; import { config } from "dotenv"; -import { coreRelations } from "@vitnode/core/database/relations"; import { drizzle } from "drizzle-orm/postgres-js"; config({ diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index 7cbc1bd40..e16d6c775 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -3,16 +3,36 @@ import { vitNodeNextConfig } from "@vitnode/core/config/next.config"; import { createMDX } from "fumadocs-mdx/next"; import type { NextConfig } from "next"; +import { i18n } from "./src/i18n"; + const withMDX = createMDX(); const withBundleAnalyzer = nextAnalyzer({ enabled: process.env.ANALYZE === "true", }); +/** + * `/docs` has no section of its own, so it forwards to the first one. + * + * Handled here rather than in the page: the page reads its slug inside a + * `` boundary now, so a redirect decided there would stream after a + * 200 instead of being a real HTTP redirect. `localePrefix` is `as-needed`, so + * the default locale is unprefixed and every other locale gets its own entry. + */ +const docsIndexRedirects = [ + "", + ...i18n.locales + .filter(locale => locale.code !== i18n.defaultLocale) + .map(locale => `/${locale.code}`), +].map(prefix => ({ + source: `${prefix}/docs`, + destination: `${prefix}/docs/dev`, + permanent: false, +})); + const nextConfig: NextConfig = { reactCompiler: true, - // cacheComponents: true, - // partialPrefetching: true, + redirects: async () => docsIndexRedirects, }; export default withBundleAnalyzer(withMDX(vitNodeNextConfig(nextConfig))); diff --git a/apps/docs/package.json b/apps/docs/package.json index 91e2c6dc5..fbddada73 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -40,8 +40,8 @@ "hono": "^4.12.31", "lucide-react": "^1.25.0", "motion": "^12.42.2", - "next": "16.3.0-preview.9", - "next-intl": "^4.13.3", + "next": "16.3.1", + "next-intl": "^4.13.7", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.82.0", @@ -50,7 +50,7 @@ "sonner": "^2.0.7" }, "devDependencies": { - "@next/bundle-analyzer": "^16.2.11", + "@next/bundle-analyzer": "^16.3.1", "@playwright/test": "^1.61.1", "@react-email/ui": "^6.9.0", "@tailwindcss/postcss": "^4.3.3", diff --git a/apps/docs/src/app/[locale]/(docs)/docs/[[...slug]]/page.tsx b/apps/docs/src/app/[locale]/(docs)/docs/[[...slug]]/page.tsx index 542e0d724..c91d08121 100644 --- a/apps/docs/src/app/[locale]/(docs)/docs/[[...slug]]/page.tsx +++ b/apps/docs/src/app/[locale]/(docs)/docs/[[...slug]]/page.tsx @@ -1,25 +1,31 @@ import type { Metadata } from "next"; -import { redirect } from "@vitnode/core/lib/navigation"; +import { Skeleton } from "@vitnode/core/components/ui/skeleton"; import { getBreadcrumbItems } from "fumadocs-core/breadcrumb"; import { Step, Steps } from "fumadocs-ui/components/steps"; import defaultMdxComponents from "fumadocs-ui/mdx"; import { DocsBody, DocsPage } from "fumadocs-ui/page"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; import { Preview } from "@/components/fumadocs/preview"; import { source } from "@/lib/source"; import { ViewOptions } from "./page.client"; -export default async function Page( - props: PageProps<"/[locale]/docs/[[...slug]]">, -) { - const params = await props.params; - if (!params.slug) { - await redirect("/docs/dev"); - } - const page = source.getPage(params.slug); +/** + * Everything on a docs page comes from the slug - title, description, table of + * contents and body - so this is where `params` is read. + * + * Reading it here instead of in the page body is what keeps the fumadocs + * sidebar and nav in the App Shell: they are identical for every docs URL, so + * one prefetch is shared across every link and only this region streams. + */ +async function DocsArticle({ + params, +}: Pick, "params">) { + const { slug } = await params; + const page = source.getPage(slug); if (!page) notFound(); const MDX = page.data.body; @@ -53,9 +59,44 @@ export default async function Page( ); } -// export function generateStaticParams() { -// return source.generateParams(); -// } +/** + * Mirrors the article's own layout - heading, description, then body copy - so + * the swap to real content doesn't shift the page. + */ +const DocsArticleSkeleton = () => ( + +
+
+ + +
+ +
+ + +
+ + + + + + +
+
+
+); + +export default function Page(props: PageProps<"/[locale]/docs/[[...slug]]">) { + return ( + }> + + + ); +} + +export function generateStaticParams() { + return source.generateParams(); +} export async function generateMetadata(props: { params: Promise<{ slug?: string[] }>; diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx index 9c8e2a64e..f0bdf1474 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/files/page.tsx @@ -14,6 +14,8 @@ const MyFilesTableView = dynamic(async () => })), ); +export const instant = false; + export const generateMetadata = async () => { const t = await getTranslations("core.files"); @@ -41,7 +43,7 @@ export default async function Page(
- }> + }>
diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx index 7c076125f..d334b05f7 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/page.tsx @@ -4,13 +4,8 @@ import { getTranslations } from "next-intl/server"; import { SignInView } from "@vitnode/core/views/auth/sign-in/sign-in-view"; -export const generateMetadata = async ({ - params, -}: { - params: Promise<{ locale: string }>; -}): Promise => { - const { locale } = await params; - const t = await getTranslations({ locale, namespace: "core.global" }); +export const generateMetadata = async (): Promise => { + const t = await getTranslations("core.global"); return { title: t("login"), diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx index ebb16b44e..02f57b3b6 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx @@ -4,16 +4,8 @@ import { getTranslations } from "next-intl/server"; import { PasswordResetView } from "@vitnode/core/views/auth/password-reset/password-reset-view"; -export const generateMetadata = async ({ - params, -}: { - params: Promise<{ locale: string }>; -}): Promise => { - const { locale } = await params; - const t = await getTranslations({ - locale, - namespace: "core.auth.reset_password", - }); +export const generateMetadata = async (): Promise => { + const t = await getTranslations("core.auth.reset_password"); return { title: t("title"), diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/page.tsx index e52b56cbb..167fe488e 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/sso/[providerId]/page.tsx @@ -1,5 +1,7 @@ import { CallbackSSOView } from "@vitnode/core/views/auth/sso/callback/callback-sso-view"; +export const instant = false; + export default async function Page({ params, searchParams, diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx index 999d87e2f..7878f8a7b 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/register/page.tsx @@ -4,13 +4,8 @@ import { getTranslations } from "next-intl/server"; import { SignUpView } from "@vitnode/core/views/auth/sign-up/sign-up-view"; -export const generateMetadata = async ({ - params, -}: { - params: Promise<{ locale: string }>; -}): Promise => { - const { locale } = await params; - const t = await getTranslations({ locale, namespace: "core.global" }); +export const generateMetadata = async (): Promise => { + const t = await getTranslations("core.global"); return { title: t("register"), diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx index 175467cc1..0e3b8c17b 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx @@ -9,6 +9,13 @@ export const metadata: Metadata = { }, }; +// instant = false: kept on purpose. Every route under this layout is gated on a +// signed-in user in `LayoutSettings`, which calls `notFound()` when there isn't +// one. Moving that read inside `` would turn a real 404 into a 200 +// shell that later swaps to a not-found body, so the gate stays where it is and +// the segment is allowed to block. +export const instant = false; + export default function Layout( props: React.ComponentProps, ) { diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx index e66aa3f96..88eab8cc1 100644 --- a/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx @@ -1,3 +1,5 @@ +export const instant = false; + export default function BreadcrumbSlot() { return null; } diff --git a/apps/docs/src/app/[locale]/(main)/[...rest]/page.tsx b/apps/docs/src/app/[locale]/(main)/[...rest]/page.tsx index 64011c694..e1670efef 100644 --- a/apps/docs/src/app/[locale]/(main)/[...rest]/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/[...rest]/page.tsx @@ -1,5 +1,7 @@ import { notFound } from "next/navigation"; +export const instant = false; + export default function RestPage() { notFound(); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx index c30d6692d..2c7b84233 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/content/[...slug]/page.tsx @@ -1,8 +1,11 @@ import type { Metadata } from "next/dist/types"; +import React from "react"; + import { ContentAdminView, type ContentAdminViewProps, + ContentAdminViewSkeleton, getContentLabels, resolveContentType, } from "@vitnode/core/views/admin/views/content/content-admin-view"; @@ -19,5 +22,9 @@ export const generateMetadata = async ({ }; export default function ContentAdminPage(props: ContentAdminViewProps) { - return ; + return ( + }> + + + ); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/queue/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/queue/page.tsx index c9bc0b9d4..ca28b8415 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/queue/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/queue/page.tsx @@ -42,7 +42,7 @@ export default async function Page(
- }> + }>
diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/files/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/files/page.tsx index d89bc8204..51c4b4c22 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/files/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/system/files/page.tsx @@ -42,7 +42,7 @@ export default async function Page(
- }> + }>
diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx index 3dd4691f9..6d16ba707 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx @@ -46,7 +46,7 @@ export default async function Page( {canCreate && } - }> + }> diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx index c486e462a..d393da3f8 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx @@ -48,7 +48,7 @@ export default async function Page( {canCreate && } - }> + }> diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx index 830a2d0ea..49c2334d3 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/content/[...slug]/page.tsx @@ -1,71 +1,16 @@ -import { getTranslations } from "next-intl/server"; +import React from "react"; -import { contentTypeName } from "@vitnode/core/content/admin/labels"; -import { - CONTENT_ADMIN_CREATE_SEGMENT, - CONTENT_ADMIN_EDIT_SEGMENT, -} from "@vitnode/core/content/const"; -import { contentAdminHref } from "@vitnode/core/content/registry"; -import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; -import { - getContentLabels, - resolveContentRoute, -} from "@vitnode/core/views/admin/views/content/content-admin-view"; +import { BreadcrumbContentAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-content-admin"; +import { BreadcrumbSkeleton } from "@vitnode/core/views/breadcrumb/breadcrumb-render"; -/** - * The breadcrumb of every generated Content Engine screen. - * - * The list keeps the trail it always had. A create or an edit **page** appends - * one more crumb, labelled from `core.content` with the content type's own - * singular - so it reads "Blog / Articles / Create article" in whatever language - * the AdminCP is in, and "Articles" becomes a link back to the list. - * - * The record id is deliberately **not** a crumb of its own: `/42/` would render - * as a dead "42" between two words, and the page it would point at is the one - * being read. - */ -export default async function BreadcrumbSlot({ +export default function BreadcrumbSlot({ params, }: { params: Promise<{ slug: string[] }>; }) { - const { slug } = await params; - const route = await resolveContentRoute(params); - const labels = route ? await getContentLabels(route.entry) : undefined; - - if (!route || route.action === "list") { - return ( - - ); - } - - const t = await getTranslations("core.content"); - const { definition } = route.entry; - return ( - + }> + + ); } diff --git a/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx b/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx index 83191e110..a6e0ca312 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx @@ -5,6 +5,8 @@ import { import { vitNodeConfig } from "@/vitnode.config"; +export const instant = false; + export default function Layout(props: AdminLayoutProps) { return ; } diff --git a/apps/docs/src/app/[locale]/layout.client.tsx b/apps/docs/src/app/[locale]/layout.client.tsx index 4dbd83965..8ae133f18 100644 --- a/apps/docs/src/app/[locale]/layout.client.tsx +++ b/apps/docs/src/app/[locale]/layout.client.tsx @@ -1,22 +1,31 @@ "use client"; -import { cn } from "@vitnode/core/lib/utils"; import { useParams } from "next/navigation"; +import { useLayoutEffect } from "react"; -export function useMode(): string | undefined { +/** + * Keeps the docs section class (`dev`, `guides`, `plugins`, `ui`) on `` in + * sync with the route. The class sets `--color-fd-primary`, so it has to live on + * `` - an element that cannot be wrapped in ``. + * + * First paint is handled by the inline script in the root layout, which derives + * the same class from `location.pathname` before anything renders. This takes + * over from there, so client navigations between sections repaint correctly. + * Renders nothing. + */ +export function DocsModeSync(): null { const { slug } = useParams(); + const mode = Array.isArray(slug) && slug.length > 0 ? slug[0] : undefined; - return Array.isArray(slug) && slug.length > 0 ? slug[0] : undefined; -} + useLayoutEffect(() => { + if (typeof mode !== "string" || mode.length === 0) return; + + document.body.classList.add(mode); -export function Body({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}): React.ReactElement { - const mode = useMode(); + return () => { + document.body.classList.remove(mode); + }; + }, [mode]); - return {children}; + return null; } diff --git a/apps/docs/src/app/[locale]/layout.tsx b/apps/docs/src/app/[locale]/layout.tsx index 8d52d99de..99391552e 100644 --- a/apps/docs/src/app/[locale]/layout.tsx +++ b/apps/docs/src/app/[locale]/layout.tsx @@ -8,12 +8,14 @@ import { type RootLayoutProps, } from "@vitnode/core/views/layouts/root-layout"; import { RootProvider } from "fumadocs-ui/provider/next"; +import { getLocale } from "next-intl/server"; import { Geist } from "next/font/google"; +import { Suspense } from "react"; import { vitNodeConfig } from "@/vitnode.config"; import SearchDialogFumadocs from "../../components/fumadocs/search-dialog"; -import { Body } from "./layout.client"; +import { DocsModeSync } from "./layout.client"; export const generateMetadata = (): Metadata => generateMetadataRootLayout(vitNodeConfig); @@ -25,12 +27,31 @@ const geist = Geist({ subsets: ["latin"], }); +/** + * Paints the docs section class on `` before first paint. + * + * `` cannot be wrapped in ``, so the class cannot come from a + * `useParams()` read during prerendering without making every route block. + * Deriving it from `location.pathname` here keeps the shell static; the + * `DocsModeSync` component below takes over for client navigations. + */ +const docsModeScript = `(function(){var p=location.pathname.split("/"),i=p.indexOf("docs"),m=i<0?"":p[i+1];if(m)document.body.classList.add(m)})()`; + export default async function Layout(props: RootLayoutProps) { - const { locale } = await props.params; + const locale = await getLocale(); return ( - + + {/* eslint-disable-next-line @eslint-react/dom-no-dangerously-set-innerhtml */} +