5 Commits 3883882726 ... ece1655939

Autor SHA1 Mensagem Data
  Sanaei ece1655939 fix(docs): prevent theme switch hydration mismatch há 20 horas atrás
  Sanaei cb902314db fix(docs): restore theme switch without runtime warnings há 22 horas atrás
  n0ctal 7eacce6a46 chore(frontend): resolve the high-severity brace-expansion advisory (#6180) há 1 dia atrás
  dependabot[bot] 199ddaf485 chore(deps-dev): bump brace-expansion (#6172) há 1 dia atrás
  dependabot[bot] d142307366 chore(deps-dev): bump postcss (#6173) há 1 dia atrás

+ 4 - 16
docs/app/[lang]/layout.tsx

@@ -1,30 +1,18 @@
-import '../global.css';
 import { RootProvider } from 'fumadocs-ui/provider/next';
-import { Inter, Vazirmatn } from 'next/font/google';
-import { i18n, localeDirection } from '@/lib/i18n';
+import { i18n } from '@/lib/i18n';
 import { provider } from '@/lib/i18n-ui';
 import SearchDialog from '@/components/search-dialog';
 
-const inter = Inter({ subsets: ['latin'], display: 'swap' });
-// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
-const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
-
 export function generateStaticParams() {
   return i18n.languages.map((lang) => ({ lang }));
 }
 
 export default async function LangLayout({ params, children }: LayoutProps<'/[lang]'>) {
   const { lang } = await params;
-  const dir = localeDirection(lang);
-  const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
 
   return (
-    <html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
-      <body className="flex min-h-screen flex-col" suppressHydrationWarning>
-        <RootProvider i18n={provider(lang)} search={{ SearchDialog }}>
-          {children}
-        </RootProvider>
-      </body>
-    </html>
+    <RootProvider i18n={provider(lang)} search={{ SearchDialog }} theme={{ enabled: false }}>
+      {children}
+    </RootProvider>
   );
 }

+ 30 - 5
docs/app/layout.tsx

@@ -1,10 +1,16 @@
 import type { Metadata } from 'next';
 import type { ReactNode } from 'react';
+import { Inter, Vazirmatn } from 'next/font/google';
+import './global.css';
 import { appName, appTagline, siteUrl } from '@/lib/shared';
+import { i18n, localeDirection } from '@/lib/i18n';
 
-// Global SEO defaults. The real <html>/<body> live in `app/[lang]/layout.tsx`
-// so we can set `lang`/`dir` per locale (RTL for fa); this root layout is a
-// pass-through that only carries site-wide metadata.
+const inter = Inter({ subsets: ['latin'], display: 'swap' });
+// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
+const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
+
+// Global SEO defaults and document shell. Locale-aware html attributes are
+// computed from route params so RTL locales get a correct base direction.
 export const metadata: Metadata = {
   metadataBase: new URL(siteUrl),
   title: {
@@ -26,6 +32,25 @@ export const metadata: Metadata = {
   },
 };
 
-export default function RootLayout({ children }: { children: ReactNode }) {
-  return children;
+export default async function RootLayout({
+  children,
+  params,
+}: {
+  children: ReactNode;
+  params: Promise<{ lang?: string }>;
+}) {
+  const { lang: rawLang } = await params;
+  const lang = i18n.languages.includes(rawLang as (typeof i18n.languages)[number])
+    ? (rawLang as (typeof i18n.languages)[number])
+    : i18n.defaultLanguage;
+  const dir = localeDirection(lang);
+  const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
+
+  return (
+    <html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
+      <body className="flex min-h-screen flex-col" suppressHydrationWarning>
+        {children}
+      </body>
+    </html>
+  );
 }

+ 4 - 4
docs/components/search-dialog.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { create } from '@orama/orama';
+import { create } from 'zbsearch';
 import { useDocsSearch } from 'fumadocs-core/search/client';
 import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static';
 import {
@@ -25,8 +25,8 @@ interface SharedProps {
 // default static dialog feeds those codes to Orama as a tokenizer language, but
 // Orama only accepts full names ("english") and throws on "en" — which silently
 // breaks search entirely. All docs content is English (other locales fall back
-// to it), so re-create the dialog — the documented escape hatch for custom Orama
-// setups — with an initOrama that always builds an English index.
+// to it), so re-create the dialog — the documented escape hatch for custom search
+// setups — with an initDB that always builds an English index.
 export default function SearchDialogClient(props: SharedProps) {
   const { locale } = useI18n();
   const client = useMemo(
@@ -34,7 +34,7 @@ export default function SearchDialogClient(props: SharedProps) {
       oramaStaticClient({
         from: '/api/search',
         locale,
-        initOrama: () => create({ schema: { _: 'string' }, language: 'english' }),
+        initDB: () => create({ schema: { _: 'string' }, language: 'english' }),
       }),
     [locale],
   );

+ 104 - 0
docs/components/theme-switch.tsx

@@ -0,0 +1,104 @@
+'use client';
+
+import { Moon, Sun } from 'lucide-react';
+import { useEffect, useState, useSyncExternalStore } from 'react';
+import type { ComponentProps } from 'react';
+import { cn } from '@/lib/cn';
+
+type ThemeMode = 'light-dark' | 'light-dark-system';
+type ThemePref = 'light' | 'dark' | 'system';
+
+const STORAGE_KEY = 'docs-theme';
+
+// `useSyncExternalStore` supplies the same value for SSR and hydration, then
+// switches to the browser value after React has attached to the markup.
+const subscribeToHydration = () => () => {};
+const getHydrationClientSnapshot = () => true;
+const getHydrationServerSnapshot = () => false;
+
+function getStoredTheme(): ThemePref {
+  if (typeof window === 'undefined') return 'system';
+  const raw = window.localStorage.getItem(STORAGE_KEY);
+  return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : 'system';
+}
+
+function getResolvedTheme(theme: ThemePref): 'light' | 'dark' {
+  if (theme !== 'system') return theme;
+  if (typeof window === 'undefined') return 'light';
+  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+function applyTheme(theme: ThemePref): void {
+  if (typeof document === 'undefined') return;
+  const resolved = getResolvedTheme(theme);
+  const root = document.documentElement;
+  root.classList.toggle('dark', resolved === 'dark');
+  root.style.colorScheme = resolved;
+}
+
+export function DocsThemeSwitch({
+  className,
+  mode = 'light-dark-system',
+  ...props
+}: {
+  className?: string;
+  mode?: ThemeMode;
+} & Omit<ComponentProps<'div'>, 'children'>) {
+  // Keep the server and first client render identical. Reading localStorage or
+  // matchMedia here would make a persisted/system preference change the client
+  // markup before React has finished hydrating it.
+  const [selectedTheme, setSelectedTheme] = useState<ThemePref>('system');
+  const hydrated = useSyncExternalStore(
+    subscribeToHydration,
+    getHydrationClientSnapshot,
+    getHydrationServerSnapshot,
+  );
+  const theme = hydrated ? getStoredTheme() : selectedTheme;
+
+  useEffect(() => {
+    if (hydrated) applyTheme(theme);
+  }, [hydrated, theme]);
+
+  useEffect(() => {
+    if (!hydrated) return;
+    if (theme !== 'system') return;
+    const media = window.matchMedia('(prefers-color-scheme: dark)');
+    const update = () => applyTheme('system');
+    media.addEventListener('change', update);
+    return () => media.removeEventListener('change', update);
+  }, [hydrated, theme]);
+
+  const resolved = hydrated ? getResolvedTheme(theme) : 'light';
+
+  const setTheme = (nextTheme: ThemePref) => {
+    window.localStorage.setItem(STORAGE_KEY, nextTheme);
+    applyTheme(nextTheme);
+    setSelectedTheme(nextTheme);
+  };
+
+  const nextTheme = () => {
+    if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
+    if (theme === 'light') return 'dark';
+    if (theme === 'dark') return 'system';
+    return resolved === 'dark' ? 'light' : 'dark';
+  };
+
+  const label =
+    mode === 'light-dark-system'
+      ? `Switch theme (current: ${theme})`
+      : `Switch to ${resolved === 'dark' ? 'light' : 'dark'} mode`;
+
+  return (
+    <div className={cn('inline-flex', className)} {...props}>
+      <button
+        type="button"
+        aria-label={label}
+        title={label}
+        onClick={() => setTheme(nextTheme())}
+        className="inline-flex size-8 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
+      >
+        {resolved === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
+      </button>
+    </div>
+  );
+}

+ 4 - 0
docs/lib/layout.shared.tsx

@@ -2,6 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
 import { Heart } from 'lucide-react';
 import { Logo } from '@/components/logo';
 import { TelegramIcon } from '@/components/icons';
+import { DocsThemeSwitch } from '@/components/theme-switch';
 import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
 import { getSiteMessages } from './site-i18n';
 
@@ -12,6 +13,9 @@ export function baseOptions(lang: string): BaseLayoutProps {
   const m = getSiteMessages(lang);
 
   return {
+    slots: {
+      themeSwitch: DocsThemeSwitch,
+    },
     nav: {
       title: (
         <span className="inline-flex items-center gap-2 font-semibold">

+ 16 - 14
docs/package.json

@@ -19,34 +19,36 @@
   },
   "dependencies": {
     "@orama/orama": "^3.1.18",
-    "fumadocs-core": "^16.11.5",
+    "fumadocs-core": "^16.14.1",
     "fumadocs-docgen": "^3.1.0",
-    "fumadocs-mdx": "^15.2.0",
-    "fumadocs-openapi": "^11.2.2",
-    "fumadocs-ui": "^16.11.5",
-    "lucide-react": "^1.25.0",
-    "mermaid": "^11.16.0",
-    "next": "16.2.11",
+    "fumadocs-mdx": "^15.2.2",
+    "fumadocs-openapi": "^11.2.3",
+    "fumadocs-ui": "^16.14.1",
+    "lucide-react": "^1.29.0",
+    "mermaid": "^11.16.1",
+    "next": "16.3.0",
     "next-themes": "^0.4.6",
     "react": "^19.2.8",
     "react-dom": "^19.2.8",
     "react-qr-code": "^2.2.0",
     "tailwind-merge": "^3.6.0",
+    "zbsearch": "3.3.4",
     "zod": "^4.4.3"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4.3.3",
     "@types/mdx": "^2.0.14",
-    "@types/node": "^26.1.1",
-    "@types/react": "^19.2.17",
-    "@types/react-dom": "^19.2.3",
+    "@types/node": "^26.1.2",
+    "@types/react": "^19.2.18",
+    "@types/react-dom": "^19.2.4",
     "eslint": "^9.39.5",
-    "eslint-config-next": "16.2.11",
-    "postcss": "^8.5.21",
+    "eslint-config-next": "16.3.0",
+    "eslint-plugin-react": "^7.37.5",
+    "postcss": "^8.5.26",
     "prettier": "^3.9.6",
     "tailwindcss": "^4.3.3",
-    "typescript": "^6.0.3",
+    "typescript": "6.0.3",
     "vitest": "^4.1.10"
   },
-  "packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
+  "packageManager": "pnpm@11.20.0"
 }

Diff do ficheiro suprimidas por serem muito extensas
+ 254 - 190
docs/pnpm-lock.yaml


+ 4 - 0
docs/pnpm-workspace.yaml

@@ -10,3 +10,7 @@ overrides:
 minimumReleaseAgeExclude:
   - '@mermaid-js/[email protected]'
   - [email protected]
+  - [email protected]
+  - [email protected]
+  - [email protected]
+  - [email protected]

+ 16 - 3
frontend/package-lock.json

@@ -4567,6 +4567,18 @@
         "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.12.0"
       }
     },
+    "node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": {
+      "version": "5.0.9",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+      "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
     "node_modules/@swagger-api/apidom-reference/node_modules/minimatch": {
       "version": "10.2.6",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -5928,6 +5940,7 @@
       "version": "5.0.8",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
       "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "balanced-match": "^4.0.2"
@@ -7034,9 +7047,9 @@
       "license": "MIT"
     },
     "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
-      "version": "1.1.16",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
-      "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+      "version": "1.1.18",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+      "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {

+ 2 - 1
frontend/package.json

@@ -89,7 +89,8 @@
       "react": "^19.0.0"
     },
     "swagger-ui-react": {
-      "js-yaml": "^4.2.0"
+      "js-yaml": "^4.2.0",
+      "brace-expansion": "^5.0.9"
     },
     "@typeschema/valibot": {
       "valibot": "^1.1.0"

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff