@supatent/supatent-docs 0.1.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.
Files changed (46) hide show
  1. package/dist/chunk-QVZFIUSH.js +13777 -0
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.js +75 -0
  4. package/dist/next-config.d.ts +20 -0
  5. package/dist/next-config.js +305 -0
  6. package/dist/types-Cf4pRODK.d.ts +60 -0
  7. package/package.json +49 -0
  8. package/runtime/middleware.ts +52 -0
  9. package/runtime/src/app/[locale]/docs-internal/[[...slug]]/page.tsx +91 -0
  10. package/runtime/src/app/[locale]/docs-internal/ai/all/route.ts +52 -0
  11. package/runtime/src/app/[locale]/docs-internal/ai/index/route.ts +52 -0
  12. package/runtime/src/app/[locale]/docs-internal/markdown/[...slug]/route.ts +50 -0
  13. package/runtime/src/app/globals.css +1082 -0
  14. package/runtime/src/app/layout.tsx +37 -0
  15. package/runtime/src/app/page.tsx +26 -0
  16. package/runtime/src/components/code-group-enhancer.tsx +128 -0
  17. package/runtime/src/components/docs-shell.tsx +140 -0
  18. package/runtime/src/components/header-dropdown.tsx +138 -0
  19. package/runtime/src/components/icons.tsx +58 -0
  20. package/runtime/src/components/locale-switcher.tsx +97 -0
  21. package/runtime/src/components/mobile-docs-menu.tsx +208 -0
  22. package/runtime/src/components/site-header.tsx +44 -0
  23. package/runtime/src/components/site-search.tsx +358 -0
  24. package/runtime/src/components/theme-toggle.tsx +74 -0
  25. package/runtime/src/docs.config.ts +91 -0
  26. package/runtime/src/framework/config.ts +76 -0
  27. package/runtime/src/framework/errors.ts +34 -0
  28. package/runtime/src/framework/locales.ts +45 -0
  29. package/runtime/src/framework/markdown-search-text.ts +11 -0
  30. package/runtime/src/framework/navigation.ts +58 -0
  31. package/runtime/src/framework/next-config.ts +160 -0
  32. package/runtime/src/framework/rendering.ts +445 -0
  33. package/runtime/src/framework/repository.ts +255 -0
  34. package/runtime/src/framework/runtime-locales.ts +85 -0
  35. package/runtime/src/framework/search-index-types.ts +34 -0
  36. package/runtime/src/framework/search-index.ts +271 -0
  37. package/runtime/src/framework/service.ts +302 -0
  38. package/runtime/src/framework/settings.ts +43 -0
  39. package/runtime/src/framework/site-title.ts +54 -0
  40. package/runtime/src/framework/theme.ts +17 -0
  41. package/runtime/src/framework/types.ts +66 -0
  42. package/runtime/src/framework/url.ts +78 -0
  43. package/runtime/src/supatent/client.ts +2 -0
  44. package/src/index.ts +11 -0
  45. package/src/next-config.ts +5 -0
  46. package/src/next.ts +10 -0
@@ -0,0 +1,37 @@
1
+ import type { Metadata } from "next";
2
+ import "./globals.css";
3
+ import { SiteHeader } from "../components/site-header";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Supatent Docs Framework",
7
+ description: "SSR documentation framework powered by Supatent CMS",
8
+ };
9
+
10
+ const themeScript = `
11
+ (() => {
12
+ try {
13
+ const stored = localStorage.getItem("docs-theme");
14
+ const system = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
15
+ const theme = stored === "dark" || stored === "light" ? stored : system;
16
+ document.documentElement.dataset.theme = theme;
17
+ } catch {
18
+ document.documentElement.dataset.theme = "light";
19
+ }
20
+ })();
21
+ `;
22
+
23
+ export default function RootLayout({
24
+ children,
25
+ }: Readonly<{ children: React.ReactNode }>) {
26
+ return (
27
+ <html lang="en" suppressHydrationWarning>
28
+ <head>
29
+ <script dangerouslySetInnerHTML={{ __html: themeScript }} />
30
+ </head>
31
+ <body>
32
+ <SiteHeader />
33
+ {children}
34
+ </body>
35
+ </html>
36
+ );
37
+ }
@@ -0,0 +1,26 @@
1
+ import { docsConfig } from "../docs.config";
2
+ import { toDocsPagePath } from "../framework/url";
3
+
4
+ export default function HomePage() {
5
+ const firstLocale = docsConfig.routing.defaultLocale;
6
+ const docsPath = toDocsPagePath(docsConfig, firstLocale);
7
+
8
+ return (
9
+ <main className="home-wrap">
10
+ <section className="home-card">
11
+ <h1>Supatent Docs Framework</h1>
12
+ <p>
13
+ Framework is configured. Start with{" "}
14
+ <a href={docsPath}>
15
+ <code>{docsPath}</code>
16
+ </a>
17
+ .
18
+ </p>
19
+ <p>
20
+ Required env var: <code>SUPATENT_PROJECT_SLUG</code>.{" "}
21
+ <code>SUPATENT_API_KEY</code> is required only in draft mode.
22
+ </p>
23
+ </section>
24
+ </main>
25
+ );
26
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ type CleanupFn = () => void;
6
+
7
+ function parseIndex(value: string | null): number {
8
+ if (!value) {
9
+ return 0;
10
+ }
11
+
12
+ const parsed = Number.parseInt(value, 10);
13
+ return Number.isFinite(parsed) ? parsed : 0;
14
+ }
15
+
16
+ export function CodeGroupEnhancer() {
17
+ useEffect(() => {
18
+ const cleanups: CleanupFn[] = [];
19
+ const groups = Array.from(
20
+ document.querySelectorAll<HTMLElement>(".docs-code-group"),
21
+ );
22
+
23
+ for (const group of groups) {
24
+ const tabs = Array.from(
25
+ group.querySelectorAll<HTMLButtonElement>("[data-code-tab]"),
26
+ );
27
+ const panels = Array.from(
28
+ group.querySelectorAll<HTMLElement>("[data-code-panel]"),
29
+ );
30
+
31
+ if (tabs.length === 0 || panels.length === 0) {
32
+ continue;
33
+ }
34
+
35
+ const activateTab = (nextIndex: number, focus = false) => {
36
+ const clamped = Math.max(0, Math.min(nextIndex, tabs.length - 1));
37
+
38
+ tabs.forEach((tab, index) => {
39
+ const active = index === clamped;
40
+ tab.classList.toggle("docs-code-tab-active", active);
41
+ tab.setAttribute("aria-selected", active ? "true" : "false");
42
+ tab.tabIndex = active ? 0 : -1;
43
+ if (active && focus) {
44
+ tab.focus();
45
+ }
46
+ });
47
+
48
+ panels.forEach((panel, index) => {
49
+ panel.hidden = index !== clamped;
50
+ });
51
+ };
52
+
53
+ tabs.forEach((tab, index) => {
54
+ const onClick = () => activateTab(index);
55
+ tab.addEventListener("click", onClick);
56
+ cleanups.push(() => tab.removeEventListener("click", onClick));
57
+
58
+ const onKeyDown = (event: KeyboardEvent) => {
59
+ if (event.key === "ArrowRight") {
60
+ event.preventDefault();
61
+ activateTab((index + 1) % tabs.length, true);
62
+ } else if (event.key === "ArrowLeft") {
63
+ event.preventDefault();
64
+ activateTab((index - 1 + tabs.length) % tabs.length, true);
65
+ } else if (event.key === "Home") {
66
+ event.preventDefault();
67
+ activateTab(0, true);
68
+ } else if (event.key === "End") {
69
+ event.preventDefault();
70
+ activateTab(tabs.length - 1, true);
71
+ }
72
+ };
73
+
74
+ tab.addEventListener("keydown", onKeyDown);
75
+ cleanups.push(() => tab.removeEventListener("keydown", onKeyDown));
76
+ });
77
+
78
+ const copyButtons = Array.from(
79
+ group.querySelectorAll<HTMLButtonElement>("[data-code-copy]"),
80
+ );
81
+
82
+ copyButtons.forEach((button) => {
83
+ const onClick = async () => {
84
+ const panel = button.closest<HTMLElement>("[data-code-panel]");
85
+ if (!panel) {
86
+ return;
87
+ }
88
+
89
+ const code = panel.querySelector<HTMLElement>("pre code");
90
+ const text = code?.textContent?.replace(/\n$/, "") ?? "";
91
+ if (!text) {
92
+ return;
93
+ }
94
+
95
+ try {
96
+ await navigator.clipboard.writeText(text);
97
+ } catch {
98
+ return;
99
+ }
100
+
101
+ const copiedLabel = button.dataset.codeCopiedLabel ?? "Copied";
102
+ const copyLabel = button.dataset.codeCopyLabel ?? "Copy";
103
+ button.textContent = copiedLabel;
104
+
105
+ const timeoutId = window.setTimeout(() => {
106
+ button.textContent = copyLabel;
107
+ }, 1200);
108
+
109
+ cleanups.push(() => window.clearTimeout(timeoutId));
110
+ };
111
+
112
+ button.addEventListener("click", onClick);
113
+ cleanups.push(() => button.removeEventListener("click", onClick));
114
+ });
115
+
116
+ const selectedTab = tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true");
117
+ activateTab(selectedTab >= 0 ? selectedTab : parseIndex(tabs[0].dataset.codeIndex ?? "0"));
118
+ }
119
+
120
+ return () => {
121
+ for (const cleanup of cleanups) {
122
+ cleanup();
123
+ }
124
+ };
125
+ }, []);
126
+
127
+ return null;
128
+ }
@@ -0,0 +1,140 @@
1
+ import type { DocsNavNode } from "../framework/types";
2
+ import { MobileDocsMenu } from "./mobile-docs-menu";
3
+ import { CodeGroupEnhancer } from "./code-group-enhancer";
4
+
5
+ type DocsShellProps = {
6
+ pageHtml: string;
7
+ navigation: DocsNavNode[];
8
+ activeSlug: string;
9
+ locales: string[];
10
+ defaultLocale: string;
11
+ basePath: string;
12
+ tocItems: Array<{
13
+ id: string;
14
+ label: string;
15
+ depth: number;
16
+ }>;
17
+ };
18
+
19
+ function NavTree({ nodes, activeSlug }: { nodes: DocsNavNode[]; activeSlug: string }) {
20
+ return (
21
+ <ul className="docs-category-list">
22
+ {nodes.map((node) => {
23
+ const isCategory = node.path === null && node.children.length > 0;
24
+
25
+ if (isCategory) {
26
+ return (
27
+ <li key={node.id} className="docs-category">
28
+ <details open className="docs-category-details">
29
+ <summary className="docs-category-summary">
30
+ <span className="docs-category-title">{node.title}</span>
31
+ <span className="docs-category-chevron" aria-hidden="true">
32
+
33
+ </span>
34
+ </summary>
35
+ <ul className="docs-nav-tree">
36
+ {node.children.map((child) => {
37
+ const isChildActive = child.slug === activeSlug;
38
+ return (
39
+ <li key={child.id}>
40
+ {child.path ? (
41
+ <a
42
+ href={child.path}
43
+ className={
44
+ isChildActive ? "docs-nav-link docs-nav-link-active" : "docs-nav-link"
45
+ }
46
+ aria-current={isChildActive ? "page" : undefined}
47
+ >
48
+ {child.title}
49
+ </a>
50
+ ) : null}
51
+ </li>
52
+ );
53
+ })}
54
+ </ul>
55
+ </details>
56
+ </li>
57
+ );
58
+ }
59
+
60
+ const isActive = node.slug === activeSlug;
61
+ return (
62
+ <li key={node.id}>
63
+ {node.path ? (
64
+ <a
65
+ href={node.path}
66
+ className={isActive ? "docs-nav-link docs-nav-link-active" : "docs-nav-link"}
67
+ aria-current={isActive ? "page" : undefined}
68
+ >
69
+ {node.title}
70
+ </a>
71
+ ) : (
72
+ <span className="docs-nav-group-label">{node.title}</span>
73
+ )}
74
+ </li>
75
+ );
76
+ })}
77
+ </ul>
78
+ );
79
+ }
80
+
81
+ export function DocsShell({
82
+ pageHtml,
83
+ navigation,
84
+ activeSlug,
85
+ locales,
86
+ defaultLocale,
87
+ basePath,
88
+ tocItems,
89
+ }: DocsShellProps) {
90
+ return (
91
+ <div className="docs-grid">
92
+ <aside className="docs-sidebar">
93
+ <NavTree nodes={navigation} activeSlug={activeSlug} />
94
+ </aside>
95
+
96
+ <main className="docs-main">
97
+ <CodeGroupEnhancer />
98
+
99
+ <header className="docs-main-header">
100
+ <MobileDocsMenu
101
+ navigation={navigation}
102
+ activeSlug={activeSlug}
103
+ tocItems={tocItems}
104
+ locales={locales}
105
+ defaultLocale={defaultLocale}
106
+ basePath={basePath}
107
+ />
108
+ </header>
109
+
110
+ {tocItems.length > 1 ? (
111
+ <nav className="docs-inline-toc">
112
+ <ul>
113
+ {tocItems.map((item) => (
114
+ <li key={item.id} className={item.depth > 2 ? "toc-nested" : undefined}>
115
+ <a href={`#${item.id}`}>{item.label}</a>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </nav>
120
+ ) : null}
121
+
122
+ <article
123
+ id="overview"
124
+ className="docs-prose"
125
+ dangerouslySetInnerHTML={{ __html: pageHtml }}
126
+ />
127
+ </main>
128
+
129
+ <aside className="docs-toc">
130
+ <ul>
131
+ {tocItems.map((item) => (
132
+ <li key={item.id} className={item.depth > 2 ? "toc-nested" : undefined}>
133
+ <a href={`#${item.id}`}>{item.label}</a>
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ </aside>
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
+
5
+ type HeaderDropdownOption = {
6
+ value: string;
7
+ label: string;
8
+ };
9
+
10
+ type HeaderDropdownProps = {
11
+ ariaLabel: string;
12
+ value: string;
13
+ options: HeaderDropdownOption[];
14
+ leadingIcon: ReactNode;
15
+ onChange: (value: string) => void;
16
+ className?: string;
17
+ };
18
+
19
+ function ChevronDownIcon() {
20
+ return (
21
+ <svg viewBox="0 0 20 20" aria-hidden="true">
22
+ <path
23
+ d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.12l3.71-3.9a.75.75 0 0 1 1.08 1.04l-4.25 4.47a.75.75 0 0 1-1.08 0L5.21 8.27a.75.75 0 0 1 .02-1.06Z"
24
+ fill="currentColor"
25
+ />
26
+ </svg>
27
+ );
28
+ }
29
+
30
+ export function HeaderDropdown({
31
+ ariaLabel,
32
+ value,
33
+ options,
34
+ leadingIcon,
35
+ onChange,
36
+ className,
37
+ }: HeaderDropdownProps) {
38
+ const [open, setOpen] = useState(false);
39
+ const rootRef = useRef<HTMLDivElement | null>(null);
40
+
41
+ const selectedLabel = useMemo(
42
+ () => options.find((option) => option.value === value)?.label ?? value,
43
+ [options, value],
44
+ );
45
+
46
+ const listboxId = useMemo(
47
+ () => `header-dropdown-${ariaLabel.toLowerCase().replace(/\s+/g, "-")}`,
48
+ [ariaLabel],
49
+ );
50
+
51
+ useEffect(() => {
52
+ const onPointerDown = (event: MouseEvent) => {
53
+ if (!rootRef.current?.contains(event.target as Node)) {
54
+ setOpen(false);
55
+ }
56
+ };
57
+
58
+ const onKeyDown = (event: KeyboardEvent) => {
59
+ if (event.key === "Escape") {
60
+ setOpen(false);
61
+ }
62
+ };
63
+
64
+ document.addEventListener("mousedown", onPointerDown);
65
+ document.addEventListener("keydown", onKeyDown);
66
+
67
+ return () => {
68
+ document.removeEventListener("mousedown", onPointerDown);
69
+ document.removeEventListener("keydown", onKeyDown);
70
+ };
71
+ }, []);
72
+
73
+ return (
74
+ <div
75
+ className={
76
+ className
77
+ ? `select-wrap header-dropdown ${className}`
78
+ : "select-wrap header-dropdown"
79
+ }
80
+ ref={rootRef}
81
+ >
82
+ <button
83
+ type="button"
84
+ className="header-select-button"
85
+ aria-label={ariaLabel}
86
+ aria-haspopup="listbox"
87
+ aria-expanded={open}
88
+ aria-controls={listboxId}
89
+ onClick={() => setOpen((previous) => !previous)}
90
+ >
91
+ <span className="select-leading-icon" aria-hidden="true">
92
+ {leadingIcon}
93
+ </span>
94
+ <span className="header-select-value">{selectedLabel}</span>
95
+ <span
96
+ className={
97
+ open ? "select-trailing-icon select-trailing-icon-open" : "select-trailing-icon"
98
+ }
99
+ aria-hidden="true"
100
+ >
101
+ <ChevronDownIcon />
102
+ </span>
103
+ </button>
104
+
105
+ {open ? (
106
+ <div className="header-dropdown-panel">
107
+ <ul
108
+ id={listboxId}
109
+ role="listbox"
110
+ aria-label={ariaLabel}
111
+ className="header-dropdown-list"
112
+ >
113
+ {options.map((option) => (
114
+ <li key={option.value} role="presentation">
115
+ <button
116
+ type="button"
117
+ role="option"
118
+ aria-selected={option.value === value}
119
+ className={
120
+ option.value === value
121
+ ? "header-dropdown-item header-dropdown-item-active"
122
+ : "header-dropdown-item"
123
+ }
124
+ onClick={() => {
125
+ onChange(option.value);
126
+ setOpen(false);
127
+ }}
128
+ >
129
+ {option.label}
130
+ </button>
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ </div>
135
+ ) : null}
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,58 @@
1
+ import type { SVGProps } from "react";
2
+
3
+ type IconProps = SVGProps<SVGSVGElement>;
4
+
5
+ export function MoonIcon(props: IconProps) {
6
+ return (
7
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
8
+ <path
9
+ fill="currentColor"
10
+ d="M12.058 20q-3.333 0-5.667-2.334T4.058 12q0-2.47 1.413-4.535q1.414-2.067 4.01-2.973q.306-.107.536-.056t.381.199t.192.38q.04.233-.063.489q-.194.477-.282.971T10.158 7.5q0 2.673 1.863 4.537q1.864 1.863 4.537 1.863q.698 0 1.277-.148q.58-.148.988-.24q.218-.04.399.01t.292.176q.115.125.156.308t-.047.417q-.715 2.45-2.803 4.013T12.058 20"
11
+ />
12
+ </svg>
13
+ );
14
+ }
15
+
16
+ export function SunIcon(props: IconProps) {
17
+ return (
18
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
19
+ <path
20
+ fill="currentColor"
21
+ d="M11.643 5.28q-.143-.145-.143-.357V2.539q0-.213.144-.357t.357-.144t.356.144t.143.356v2.385q0 .213-.144.356t-.357.144t-.356-.144m5 2.079q-.141-.14-.131-.342q.01-.2.15-.366l1.65-1.694q.15-.166.359-.166t.378.17q.155.156.155.35t-.16.354L17.35 7.358q-.16.16-.354.16t-.354-.16m2.435 5.142q-.213 0-.356-.144t-.144-.357t.144-.356t.356-.143h2.385q.212 0 .356.144t.143.357t-.143.356t-.357.143zm-7.434 9.318q-.143-.144-.143-.356v-2.366q0-.212.144-.356t.357-.144t.356.144t.143.356v2.366q0 .212-.144.356t-.357.144t-.356-.144M6.67 7.358l-1.713-1.67q-.165-.149-.165-.36t.165-.372q.16-.14.367-.14t.34.14l1.714 1.713q.14.134.14.341t-.136.348q-.15.14-.348.14t-.364-.14m11.662 11.686l-1.67-1.694q-.14-.165-.15-.366t.128-.342t.34-.14t.371.16l1.694 1.669q.146.14.143.344t-.149.37q-.16.164-.366.164t-.341-.165M2.539 12.5q-.213 0-.357-.144t-.143-.357t.143-.356t.357-.143h2.384q.213 0 .356.144t.144.357t-.144.356t-.356.143zm2.436 6.544q-.14-.16-.15-.363t.13-.344l1.676-1.675q.16-.16.35-.16t.358.155q.165.17.165.371t-.165.366l-1.65 1.65q-.166.166-.373.166t-.341-.166m3.487-3.501Q7 14.086 7 12.005T8.457 8.46T11.995 7t3.544 1.457T17 11.995t-1.457 3.544T12.005 17T8.46 15.543"
22
+ />
23
+ </svg>
24
+ );
25
+ }
26
+
27
+ export function LanguageIcon(props: IconProps) {
28
+ return (
29
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
30
+ <path
31
+ fill="currentColor"
32
+ d="M8.125 21.213q-1.825-.788-3.187-2.15t-2.15-3.188T2 11.988t.788-3.875t2.15-3.175t3.187-2.15T12.013 2t3.875.788t3.175 2.15t2.15 3.175t.787 3.875t-.787 3.887t-2.15 3.188t-3.175 2.15t-3.875.787t-3.888-.787M12 19.95q.65-.9 1.125-1.875T13.9 16h-3.8q.3 1.1.775 2.075T12 19.95m-2.6-.4q-.45-.825-.787-1.713T8.05 16H5.1q.725 1.25 1.813 2.175T9.4 19.55m5.2 0q1.4-.45 2.488-1.375T18.9 16h-2.95q-.225.95-.562 1.838T14.6 19.55M4.25 14h3.4q-.075-.5-.112-.987T7.5 12t.038-1.012T7.65 10h-3.4q-.125.5-.187.988T4 12t.063 1.013t.187.987m5.4 0h4.7q.075-.5.113-.987T14.5 12t-.038-1.012T14.35 10h-4.7q-.075.5-.112.988T9.5 12t.038 1.013t.112.987m6.7 0h3.4q.125-.5.188-.987T20 12t-.062-1.012T19.75 10h-3.4q.075.5.113.988T16.5 12t-.038 1.013t-.112.987m-.4-6h2.95q-.725-1.25-1.812-2.175T14.6 4.45q.45.825.788 1.713T15.95 8M10.1 8h3.8q-.3-1.1-.775-2.075T12 4.05q-.65.9-1.125 1.875T10.1 8m-5 0h2.95q.225-.95.563-1.838T9.4 4.45Q8 4.9 6.912 5.825T5.1 8"
33
+ />
34
+ </svg>
35
+ );
36
+ }
37
+
38
+ export function MenuIcon(props: IconProps) {
39
+ return (
40
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
41
+ <path
42
+ fill="currentColor"
43
+ d="M4.75 6.5a.75.75 0 0 1 .75-.75h13a.75.75 0 1 1 0 1.5h-13a.75.75 0 0 1-.75-.75m0 5.5a.75.75 0 0 1 .75-.75h13a.75.75 0 1 1 0 1.5h-13a.75.75 0 0 1-.75-.75m.75 4.75a.75.75 0 1 0 0 1.5h13a.75.75 0 0 0 0-1.5z"
44
+ />
45
+ </svg>
46
+ );
47
+ }
48
+
49
+ export function CloseIcon(props: IconProps) {
50
+ return (
51
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
52
+ <path
53
+ fill="currentColor"
54
+ d="M6.47 6.47a.75.75 0 0 1 1.06 0L12 10.94l4.47-4.47a.75.75 0 0 1 1.06 1.06L13.06 12l4.47 4.47a.75.75 0 1 1-1.06 1.06L12 13.06l-4.47 4.47a.75.75 0 0 1-1.06-1.06L10.94 12L6.47 7.53a.75.75 0 0 1 0-1.06"
55
+ />
56
+ </svg>
57
+ );
58
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { usePathname } from "next/navigation";
5
+ import { HeaderDropdown } from "./header-dropdown";
6
+ import { LanguageIcon } from "./icons";
7
+
8
+ type LocaleSwitcherProps = {
9
+ locales: string[];
10
+ defaultLocale: string;
11
+ basePath: string;
12
+ className?: string;
13
+ };
14
+
15
+ function isSupportedLocale(value: string, locales: string[]): boolean {
16
+ return locales.includes(value);
17
+ }
18
+
19
+ function normalizePath(pathname: string): string {
20
+ if (!pathname.startsWith("/")) {
21
+ return `/${pathname}`;
22
+ }
23
+
24
+ return pathname;
25
+ }
26
+
27
+ function toLocalePath(
28
+ pathname: string,
29
+ targetLocale: string,
30
+ locales: string[],
31
+ basePath: string,
32
+ ): string {
33
+ const normalized = normalizePath(pathname);
34
+ const segments = normalized.split("/").filter(Boolean);
35
+
36
+ if (segments.length > 0 && isSupportedLocale(segments[0], locales)) {
37
+ segments[0] = targetLocale;
38
+ return `/${segments.join("/")}`;
39
+ }
40
+
41
+ if (normalized === "/") {
42
+ return `/${targetLocale}${basePath}`;
43
+ }
44
+
45
+ return `/${targetLocale}${normalized}`;
46
+ }
47
+
48
+ function localeLabel(locale: string): string {
49
+ try {
50
+ const displayNames = new Intl.DisplayNames([locale], { type: "language" });
51
+ const languageCode = locale.split("-")[0];
52
+ return displayNames.of(languageCode) ?? locale;
53
+ } catch {
54
+ return locale;
55
+ }
56
+ }
57
+
58
+ export function LocaleSwitcher({
59
+ locales,
60
+ defaultLocale,
61
+ basePath,
62
+ className,
63
+ }: LocaleSwitcherProps) {
64
+ const pathname = usePathname();
65
+
66
+ const currentLocale = useMemo(() => {
67
+ const first = pathname.split("/").filter(Boolean)[0];
68
+ if (isSupportedLocale(first, locales)) {
69
+ return first;
70
+ }
71
+
72
+ return defaultLocale;
73
+ }, [defaultLocale, locales, pathname]);
74
+
75
+ const options = useMemo(
76
+ () =>
77
+ locales.map((locale) => ({
78
+ value: locale,
79
+ label: localeLabel(locale),
80
+ })),
81
+ [locales],
82
+ );
83
+
84
+ return (
85
+ <HeaderDropdown
86
+ ariaLabel="Language"
87
+ leadingIcon={<LanguageIcon />}
88
+ value={currentLocale}
89
+ options={options}
90
+ className={className}
91
+ onChange={(nextLocale) => {
92
+ const nextPath = toLocalePath(pathname, nextLocale, locales, basePath);
93
+ window.location.assign(nextPath);
94
+ }}
95
+ />
96
+ );
97
+ }