@umami/shiso 0.55.0 → 1.0.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 (104) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -49
  3. package/bin/shiso.mjs +132 -0
  4. package/docs.schema.json +896 -0
  5. package/mdx.config.ts +143 -0
  6. package/package.json +74 -83
  7. package/scripts/check-package.mjs +54 -0
  8. package/scripts/generate-icon-registry.mjs +196 -0
  9. package/scripts/generate-last-modified.mjs +128 -0
  10. package/scripts/generate-search-index.mjs +298 -0
  11. package/scripts/lib/mdast.mjs +23 -0
  12. package/scripts/lib/slug.mjs +21 -0
  13. package/scripts/load-docs-config.mjs +244 -0
  14. package/scripts/prerender.mjs +194 -0
  15. package/scripts/validate-config.mjs +104 -0
  16. package/scripts/vite-docs-config.mjs +60 -0
  17. package/src/App.tsx +38 -0
  18. package/src/components/Banner.tsx +69 -0
  19. package/src/components/CodeBlock.tsx +46 -0
  20. package/src/components/ConfiguredIcon.tsx +15 -0
  21. package/src/components/ContextualMenu.tsx +93 -0
  22. package/src/components/DocContent.tsx +105 -0
  23. package/src/components/Docs.tsx +134 -0
  24. package/src/components/Footer.tsx +81 -0
  25. package/src/components/Header.tsx +82 -0
  26. package/src/components/LanguageSwitcher.tsx +59 -0
  27. package/src/components/Layout.tsx +24 -0
  28. package/src/components/PageLinks.tsx +71 -0
  29. package/src/components/Search.tsx +217 -0
  30. package/src/components/SideNav.tsx +347 -0
  31. package/src/components/SocialIcon.tsx +88 -0
  32. package/src/components/ThemeToggle.tsx +34 -0
  33. package/src/components/TopNav.tsx +127 -0
  34. package/src/components/VersionSwitcher.tsx +60 -0
  35. package/src/components/docs/Accordion.tsx +68 -0
  36. package/src/components/docs/Badge.tsx +171 -0
  37. package/src/components/docs/Callout.tsx +73 -0
  38. package/src/components/docs/Card.tsx +158 -0
  39. package/src/components/docs/CodeGroup.tsx +73 -0
  40. package/src/components/docs/Columns.tsx +20 -0
  41. package/src/components/docs/Expandable.tsx +28 -0
  42. package/src/components/docs/Frame.tsx +56 -0
  43. package/src/components/docs/Icon.tsx +30 -0
  44. package/src/components/docs/ParamField.tsx +45 -0
  45. package/src/components/docs/PropertiesTable.tsx +84 -0
  46. package/src/components/docs/ResponseField.tsx +36 -0
  47. package/src/components/docs/Steps.tsx +47 -0
  48. package/src/components/docs/Tabs.tsx +116 -0
  49. package/src/components/docs/Tooltip.tsx +21 -0
  50. package/src/components/docs/index.ts +15 -0
  51. package/src/components/docs/styles.ts +82 -0
  52. package/src/components/docs/utils.ts +118 -0
  53. package/src/components/icons/index.ts +17 -0
  54. package/src/components/ui/accordion.tsx +69 -0
  55. package/src/components/ui/alert.tsx +69 -0
  56. package/src/components/ui/badge.tsx +49 -0
  57. package/src/components/ui/button.tsx +58 -0
  58. package/src/components/ui/card.tsx +88 -0
  59. package/src/components/ui/collapsible.tsx +15 -0
  60. package/src/components/ui/command.tsx +173 -0
  61. package/src/components/ui/dialog.tsx +137 -0
  62. package/src/components/ui/dropdown-menu.tsx +257 -0
  63. package/src/components/ui/scroll-area.tsx +71 -0
  64. package/src/components/ui/sheet.tsx +124 -0
  65. package/src/components/ui/tabs.tsx +73 -0
  66. package/src/components/ui/tooltip.tsx +52 -0
  67. package/src/declarations.d.ts +9 -0
  68. package/src/entry-client.tsx +17 -0
  69. package/src/entry-server.tsx +89 -0
  70. package/src/generated/last-modified.ts +2 -0
  71. package/src/lib/content.ts +44 -0
  72. package/src/lib/docs-config.ts +986 -0
  73. package/src/lib/head.ts +231 -0
  74. package/src/lib/icon-registry.generated.ts +4 -0
  75. package/src/lib/icons.ts +29 -0
  76. package/src/lib/inline-markdown.tsx +86 -0
  77. package/src/lib/locale.ts +39 -0
  78. package/src/lib/mdast.ts +56 -0
  79. package/src/lib/paths.ts +86 -0
  80. package/src/lib/remark-toc.ts +71 -0
  81. package/src/lib/search/config.ts +43 -0
  82. package/src/lib/search/provider.ts +85 -0
  83. package/src/lib/search/providers/local.ts +15 -0
  84. package/src/lib/search-index.generated.ts +4 -0
  85. package/src/lib/search.ts +128 -0
  86. package/src/lib/site-config.ts +117 -0
  87. package/src/lib/site-model.ts +221 -0
  88. package/src/lib/slug.ts +38 -0
  89. package/src/lib/types.ts +515 -0
  90. package/src/lib/utils.ts +6 -0
  91. package/src/pages/DocPage.tsx +33 -0
  92. package/src/styles/global.css +268 -0
  93. package/src/styles/tokens.css +114 -0
  94. package/types/client.d.ts +3 -0
  95. package/types/search.d.ts +27 -0
  96. package/vite.config.ts +342 -0
  97. package/LICENSE +0 -21
  98. package/dist/index.css +0 -189
  99. package/dist/index.d.ts +0 -57
  100. package/dist/index.js +0 -464
  101. package/dist/index.mjs +0 -437
  102. package/server/index.d.ts +0 -30
  103. package/server/index.js +0 -189
  104. package/styles.css +0 -4766
@@ -0,0 +1,105 @@
1
+ import { Link } from 'react-router';
2
+ import { ContextualMenu } from '@/components/ContextualMenu';
3
+ import { ChevronRight } from '@/components/icons';
4
+ import { getLastModified } from '@/lib/content';
5
+ import { getScopeForPage } from '@/lib/docs-config';
6
+ import { resolveLocale } from '@/lib/locale';
7
+ import { docsSite } from '@/lib/site-config';
8
+ import { resolveContextualOptions } from '@/lib/site-model';
9
+ import type { DocModule, NormalizedDocsPage, SiteModel } from '@/lib/types';
10
+
11
+ export interface DocContentProps {
12
+ page: NormalizedDocsPage;
13
+ doc: DocModule;
14
+ site: SiteModel;
15
+ }
16
+
17
+ export function DocContent({ page, doc, site }: DocContentProps) {
18
+ // Prev/next paging never crosses a version or language boundary.
19
+ const pagerPages = getScopeForPage(docsSite, page).docs.pages.filter(item => !item.hidden);
20
+ const pageIndex = pagerPages.findIndex(item => item.slug === page.slug);
21
+ const prev = pageIndex > 0 ? pagerPages[pageIndex - 1] : undefined;
22
+ const next = pageIndex >= 0 ? pagerPages[pageIndex + 1] : undefined;
23
+ const title = doc.frontmatter?.title || page.label;
24
+ const description = doc.frontmatter?.description;
25
+ const Content = doc.default;
26
+ const shouldShowTimestamp =
27
+ typeof doc.frontmatter?.timestamp === 'boolean'
28
+ ? doc.frontmatter.timestamp
29
+ : site.showTimestamp;
30
+ const lastModified = shouldShowTimestamp ? getLastModified(page.filePath) : undefined;
31
+ // `styling.eyebrows`: the section name alone, or the full navigation path.
32
+ const eyebrow =
33
+ site.styling.eyebrows === 'breadcrumbs'
34
+ ? [...new Set([page.tabLabel, page.section])].filter(Boolean).join(' / ')
35
+ : page.section;
36
+ const contextualOptions = resolveContextualOptions(site.contextualOptions, page, site.labels);
37
+ // Dates follow the page's language when it is a valid locale code.
38
+ const dateFormat = new Intl.DateTimeFormat(resolveLocale(page.language, site.locale), {
39
+ dateStyle: 'medium',
40
+ timeZone: 'UTC',
41
+ });
42
+
43
+ return (
44
+ <article className="min-w-0 grow">
45
+ {eyebrow && <div className="text-sm font-bold text-primary">{eyebrow}</div>}
46
+ <div className="flex items-start justify-between gap-4">
47
+ {title && (
48
+ <h1 className="mt-2 text-4xl text-foreground leading-[1.2] tracking-[-0.03em] [font-family:var(--font-heading)] [font-weight:var(--font-heading-weight,800)]">
49
+ {title}
50
+ </h1>
51
+ )}
52
+ <ContextualMenu options={contextualOptions} labels={site.labels} />
53
+ </div>
54
+ {description && <p className="mt-1 mb-6 text-base text-muted-foreground">{description}</p>}
55
+ <div className="docs-markdown">
56
+ <Content />
57
+ </div>
58
+ {lastModified && (
59
+ <div className="mt-8 text-sm text-muted-foreground">
60
+ {site.labels.lastUpdated}{' '}
61
+ <time dateTime={lastModified}>{dateFormat.format(new Date(lastModified))}</time>
62
+ </div>
63
+ )}
64
+ <div className="mt-8 flex items-center justify-between">
65
+ <NavigationButton {...prev} isPrev />
66
+ <NavigationButton {...next} />
67
+ </div>
68
+ </article>
69
+ );
70
+ }
71
+
72
+ const NavigationButton = ({
73
+ label,
74
+ url,
75
+ isPrev,
76
+ }: {
77
+ label?: string;
78
+ url?: string;
79
+ isPrev?: boolean;
80
+ }) => {
81
+ if (!url || !label) {
82
+ return <div />;
83
+ }
84
+
85
+ return (
86
+ <Link
87
+ to={url}
88
+ className="group my-3 inline-flex items-center gap-3 text-base font-bold text-foreground"
89
+ >
90
+ {isPrev && (
91
+ <ChevronRight
92
+ size={14}
93
+ className="rotate-180 text-muted-foreground transition-colors group-hover:text-foreground"
94
+ />
95
+ )}
96
+ {label}
97
+ {!isPrev && (
98
+ <ChevronRight
99
+ size={14}
100
+ className="text-muted-foreground transition-colors group-hover:text-foreground"
101
+ />
102
+ )}
103
+ </Link>
104
+ );
105
+ };
@@ -0,0 +1,134 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useLocation, useNavigate } from 'react-router';
3
+ import { DocContent } from '@/components/DocContent';
4
+ import { Footer } from '@/components/Footer';
5
+ import { Menu } from '@/components/icons';
6
+ import { LanguageSwitcher } from '@/components/LanguageSwitcher';
7
+ import { PageLinks } from '@/components/PageLinks';
8
+ import { SideNav } from '@/components/SideNav';
9
+ import { Button } from '@/components/ui/button';
10
+ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
11
+ import { VersionSwitcher } from '@/components/VersionSwitcher';
12
+ import { renderInlineMarkdown } from '@/lib/inline-markdown';
13
+ import { docsHomeUrl, getScopeByPathname } from '@/lib/site-config';
14
+ import type { DocModule, NormalizedDocsPage, SiteModel } from '@/lib/types';
15
+
16
+ /**
17
+ * 404 view driven by the `errors.404` config key. The standard defaults to
18
+ * redirecting home; that happens after hydration rather than during render,
19
+ * because the prerendered 404.html must stay a static page for hosts that
20
+ * serve it for every unknown path.
21
+ */
22
+ function NotFound({ site }: { site: SiteModel }) {
23
+ const navigate = useNavigate();
24
+ const { redirect, title, description } = site.error404;
25
+
26
+ useEffect(() => {
27
+ if (redirect) {
28
+ navigate(docsHomeUrl, { replace: true });
29
+ }
30
+ }, [redirect, navigate]);
31
+
32
+ return (
33
+ <div className="py-16 text-center">
34
+ <h1>{title || site.labels.notFound}</h1>
35
+ {description && <p>{renderInlineMarkdown(description)}</p>}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export interface DocsProps {
41
+ page: NormalizedDocsPage | null;
42
+ doc: DocModule | null;
43
+ site: SiteModel;
44
+ }
45
+
46
+ export function Docs({ page, doc, site }: DocsProps) {
47
+ const { pathname } = useLocation();
48
+ const [menuOpen, setMenuOpen] = useState(false);
49
+ // Navigation follows the scope (version/language) that owns the current page.
50
+ const scopeDocs = getScopeByPathname(pathname).docs;
51
+ const { tabs, navigation } = scopeDocs;
52
+
53
+ // Close the mobile menu and start each newly loaded page at the top. Hash
54
+ // links keep their native section-scrolling behavior.
55
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger
56
+ useEffect(() => {
57
+ setMenuOpen(false);
58
+ if (!window.location.hash) {
59
+ window.scrollTo({ top: 0, left: 0 });
60
+ }
61
+ }, [pathname]);
62
+
63
+ if (!page || !doc) {
64
+ return (
65
+ <div className="flex min-h-full flex-col">
66
+ <div className="grow">
67
+ <NotFound site={site} />
68
+ </div>
69
+ <Footer footer={site.footer} />
70
+ </div>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div className="flex min-h-full flex-col gap-6 lg:gap-0">
76
+ <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
77
+ <div className="flex justify-end lg:hidden">
78
+ <SheetTrigger render={<Button variant="outline" className="bg-card" />}>
79
+ <Menu className="size-3.5" />
80
+ {site.labels.menu}
81
+ </SheetTrigger>
82
+ </div>
83
+ <SheetContent
84
+ side="right"
85
+ className="w-[min(320px,85vw)] gap-0 overflow-y-auto bg-background p-4 sm:max-w-80"
86
+ >
87
+ <SheetTitle className="sr-only">{site.labels.documentationNavigation}</SheetTitle>
88
+ <div className="pt-8">
89
+ <div className="flex items-center gap-2 pb-4 lg:hidden">
90
+ <VersionSwitcher />
91
+ <LanguageSwitcher />
92
+ </div>
93
+ <SideNav
94
+ tabs={tabs}
95
+ navigation={navigation}
96
+ anchors={scopeDocs.anchors}
97
+ drilldown={site.drilldown}
98
+ navigationLabel={site.labels.documentationNavigation}
99
+ expandLabel={site.labels.expand}
100
+ collapseLabel={site.labels.collapse}
101
+ />
102
+ </div>
103
+ </SheetContent>
104
+ </Sheet>
105
+ <div className="flex items-start gap-12 lg:min-h-[calc(100dvh-var(--header-height))] lg:pt-6">
106
+ <div className="hidden min-w-0 max-w-60 basis-60 self-start lg:sticky lg:top-[calc(var(--header-height)+1.5rem)] lg:block lg:h-[calc(100dvh-var(--header-height)-3rem)] lg:shrink-0">
107
+ <SideNav
108
+ tabs={tabs}
109
+ navigation={navigation}
110
+ anchors={scopeDocs.anchors}
111
+ isSticky
112
+ drilldown={site.drilldown}
113
+ navigationLabel={site.labels.documentationNavigation}
114
+ expandLabel={site.labels.expand}
115
+ collapseLabel={site.labels.collapse}
116
+ />
117
+ </div>
118
+ <div className="flex min-w-0 grow self-stretch flex-col">
119
+ <div className="flex grow items-start gap-12">
120
+ <DocContent page={page} doc={doc} site={site} />
121
+ <div className="hidden min-w-0 max-w-60 basis-60 self-start lg:sticky lg:top-[calc(var(--header-height)+1.5rem)] lg:block lg:shrink-0">
122
+ <PageLinks
123
+ items={doc.toc}
124
+ title={site.labels.tableOfContents}
125
+ navigationLabel={site.labels.tableOfContentsNavigation}
126
+ />
127
+ </div>
128
+ </div>
129
+ <Footer footer={site.footer} className="lg:mr-72" />
130
+ </div>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,81 @@
1
+ import { ConfiguredIcon } from '@/components/ConfiguredIcon';
2
+ import type { NormalizedFooter } from '@/lib/types';
3
+
4
+ export function Footer({
5
+ footer,
6
+ className = '',
7
+ }: {
8
+ footer: NormalizedFooter | null;
9
+ className?: string;
10
+ }) {
11
+ if (!footer) {
12
+ return null;
13
+ }
14
+
15
+ const { socials, links: columns, attribution } = footer;
16
+
17
+ return (
18
+ <footer className={`mt-8 border-border border-t py-8 text-muted-foreground ${className}`}>
19
+ {columns.length > 0 && (
20
+ <div className="mb-8 grid grid-cols-[repeat(auto-fit,minmax(10rem,max-content))] gap-x-16 gap-y-8">
21
+ {columns.map((column, index) => (
22
+ <div key={column.header || index}>
23
+ {column.header && (
24
+ <div className="mb-3 text-sm font-semibold text-foreground">{column.header}</div>
25
+ )}
26
+ <ul className="m-0 flex list-none flex-col gap-2 p-0">
27
+ {column.items.map(item => (
28
+ <li key={item.href}>
29
+ <a
30
+ href={item.href}
31
+ target={item.target}
32
+ rel={item.target === '_blank' ? 'noreferrer' : undefined}
33
+ className="text-sm text-muted-foreground hover:text-foreground"
34
+ >
35
+ {item.label}
36
+ </a>
37
+ </li>
38
+ ))}
39
+ </ul>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ )}
44
+ {attribution || socials.length > 0 ? (
45
+ <div className="flex items-center justify-between gap-4">
46
+ {attribution ? (
47
+ <a
48
+ href="https://shiso.umami.is?ref=docs-footer"
49
+ className="text-sm hover:text-foreground"
50
+ >
51
+ Powered by <span className="font-bold">shiso</span>
52
+ </a>
53
+ ) : (
54
+ <span />
55
+ )}
56
+ {socials.length > 0 && (
57
+ <div className="flex items-center gap-1">
58
+ {socials.map(link => {
59
+ const iconOnly = !link.label;
60
+
61
+ return (
62
+ <a
63
+ key={link.href}
64
+ href={link.href}
65
+ target={link.target}
66
+ rel={link.target === '_blank' ? 'noreferrer' : undefined}
67
+ className={`inline-flex min-h-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground ${iconOnly ? 'size-8' : 'gap-1.5 px-2'}`}
68
+ aria-label={iconOnly ? link.ariaLabel || link.icon || link.href : undefined}
69
+ >
70
+ <ConfiguredIcon icon={link.icon} />
71
+ {link.label}
72
+ </a>
73
+ );
74
+ })}
75
+ </div>
76
+ )}
77
+ </div>
78
+ ) : null}
79
+ </footer>
80
+ );
81
+ }
@@ -0,0 +1,82 @@
1
+ import { useLocation } from 'react-router';
2
+ import { ConfiguredIcon } from '@/components/ConfiguredIcon';
3
+ import { LanguageSwitcher } from '@/components/LanguageSwitcher';
4
+ import { Search } from '@/components/Search';
5
+ import { ThemeToggle } from '@/components/ThemeToggle';
6
+ import { TopNav } from '@/components/TopNav';
7
+ import { VersionSwitcher } from '@/components/VersionSwitcher';
8
+ import { docsHomeUrl, getScopeByPathname } from '@/lib/site-config';
9
+ import type { NormalizedLink, SiteModel } from '@/lib/types';
10
+
11
+ function NavbarLinkItem({ link, primary = false }: { link: NormalizedLink; primary?: boolean }) {
12
+ const iconOnly = !link.label;
13
+ const accessibleLabel = link.ariaLabel || link.icon || link.href;
14
+
15
+ return (
16
+ <a
17
+ href={link.href}
18
+ className={
19
+ primary
20
+ ? `ml-1 inline-flex items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground hover:opacity-90 ${iconOnly ? 'size-8' : 'gap-1.5 px-3.5 py-1.5'}`
21
+ : `inline-flex items-center rounded-md text-sm font-medium text-foreground hover:bg-accent hover:text-foreground ${iconOnly ? 'size-8 justify-center' : 'gap-1.5 px-2.5 py-1.5'}`
22
+ }
23
+ target={link.target}
24
+ rel={link.target === '_blank' ? 'noreferrer' : undefined}
25
+ aria-label={iconOnly ? accessibleLabel : undefined}
26
+ >
27
+ <ConfiguredIcon icon={link.icon} />
28
+ {link.label}
29
+ </a>
30
+ );
31
+ }
32
+
33
+ export function Header({ site }: { site: SiteModel }) {
34
+ const { logo, navbar, name, appearance, labels, search } = site;
35
+ const { pathname } = useLocation();
36
+ // The header renders the navigation of whichever scope owns the current page.
37
+ const docs = getScopeByPathname(pathname).docs;
38
+ const brandHref = logo?.href || docsHomeUrl;
39
+ const hasBrand = !!name || !!logo?.light || !!logo?.dark;
40
+
41
+ return (
42
+ <header className="sticky top-0 z-50 h-[var(--header-height)] shrink-0 border-border border-b bg-[color-mix(in_srgb,var(--background)_92%,transparent)] backdrop-blur-md">
43
+ <div className="mx-auto grid h-full max-w-[1600px] grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-5">
44
+ <div className="flex min-w-0 items-center gap-5 justify-self-start">
45
+ {hasBrand ? (
46
+ <a
47
+ href={brandHref}
48
+ target={logo?.target}
49
+ rel={logo?.target === '_blank' ? 'noreferrer' : undefined}
50
+ className="inline-flex items-center gap-2 text-xl font-bold text-foreground tracking-[-0.03em]"
51
+ >
52
+ {logo?.light ? (
53
+ <img src={logo.light} alt="" className="h-6 w-auto dark:hidden" />
54
+ ) : null}
55
+ {logo?.dark ? (
56
+ <img
57
+ src={logo.dark}
58
+ alt=""
59
+ className={`hidden h-6 w-auto dark:block ${logo.light === logo.dark ? 'dark:invert' : ''}`}
60
+ />
61
+ ) : null}
62
+ {name ? <span>{name}</span> : null}
63
+ </a>
64
+ ) : null}
65
+ <div className="hidden items-center gap-2 lg:flex">
66
+ <VersionSwitcher />
67
+ <LanguageSwitcher />
68
+ </div>
69
+ </div>
70
+ {docs.showTabs ? <TopNav docs={docs} label={labels.sections} /> : null}
71
+ <div className="flex min-w-0 items-center gap-2 justify-self-end">
72
+ <Search config={search} labels={labels} />
73
+ {navbar?.links.map(link => (
74
+ <NavbarLinkItem key={link.href} link={link} />
75
+ ))}
76
+ {!appearance.strict && <ThemeToggle label={labels.toggleTheme} />}
77
+ {navbar?.primary ? <NavbarLinkItem link={navbar.primary} primary /> : null}
78
+ </div>
79
+ </div>
80
+ </header>
81
+ );
82
+ }
@@ -0,0 +1,59 @@
1
+ import { useLocation, useNavigate } from 'react-router';
2
+ import { Check, ChevronRight } from '@/components/icons';
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from '@/components/ui/dropdown-menu';
10
+ import { getLanguageScopes } from '@/lib/docs-config';
11
+ import { docsSite, getScopeByPathname } from '@/lib/site-config';
12
+
13
+ /**
14
+ * Language selector for multi-language sites. Each option is a language's
15
+ * landing scope — its default version — so switching languages always lands
16
+ * on that language's default-version first page. Hidden languages never
17
+ * appear as options.
18
+ */
19
+ export function LanguageSwitcher() {
20
+ const { pathname } = useLocation();
21
+ const navigate = useNavigate();
22
+ const current = getScopeByPathname(pathname);
23
+ const options = getLanguageScopes(docsSite);
24
+
25
+ if (!current.language || (options.length < 2 && !current.hidden)) {
26
+ return null;
27
+ }
28
+
29
+ return (
30
+ <DropdownMenu>
31
+ <DropdownMenuTrigger
32
+ render={
33
+ <Button
34
+ variant="outline"
35
+ className="h-auto gap-1.5 rounded-md bg-card px-2.5 py-1.5 text-sm font-medium text-foreground"
36
+ />
37
+ }
38
+ >
39
+ {current.language}
40
+ <ChevronRight className="size-3.5 rotate-90 text-muted-foreground" />
41
+ </DropdownMenuTrigger>
42
+ <DropdownMenuContent align="start" className="min-w-32">
43
+ {options.map(scope => (
44
+ <DropdownMenuItem
45
+ key={scope.id}
46
+ onClick={() => {
47
+ if (scope.language !== current.language) {
48
+ navigate(scope.firstPageUrl);
49
+ }
50
+ }}
51
+ >
52
+ <span className="grow">{scope.language}</span>
53
+ {scope.language === current.language ? <Check className="size-3.5" /> : null}
54
+ </DropdownMenuItem>
55
+ ))}
56
+ </DropdownMenuContent>
57
+ </DropdownMenu>
58
+ );
59
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+ import { useLocation } from 'react-router';
3
+ import { Banner } from '@/components/Banner';
4
+ import { Header } from '@/components/Header';
5
+ import { useHead } from '@/lib/head';
6
+ import type { SiteModel } from '@/lib/types';
7
+
8
+ export function Layout({ children, site }: { children: ReactNode; site: SiteModel }) {
9
+ const { pathname } = useLocation();
10
+
11
+ // Keep metadata, document language, and text direction synchronized after
12
+ // client-side navigation. The prerendered values are retained on hydration.
13
+ useHead(pathname);
14
+
15
+ return (
16
+ <div className="flex min-h-dvh flex-col">
17
+ <Banner banner={site.banner} dismissLabel={site.labels.dismissBanner} />
18
+ <Header site={site} />
19
+ <div className="mx-auto flex min-h-0 w-full max-w-[1600px] grow flex-col px-5">
20
+ <main className="min-h-0 grow">{children}</main>
21
+ </div>
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,71 @@
1
+ import classNames from 'classnames';
2
+ import { useEffect, useState } from 'react';
3
+ import type { TocEntry } from '@/lib/types';
4
+
5
+ export interface PageLinksProps {
6
+ items?: TocEntry[];
7
+ title: string;
8
+ navigationLabel: string;
9
+ }
10
+
11
+ export function PageLinks({ items = [], title, navigationLabel }: PageLinksProps) {
12
+ const [hash, setHash] = useState(items?.[0]?.id);
13
+
14
+ useEffect(() => {
15
+ setHash(items?.[0]?.id);
16
+
17
+ const callback = () => {
18
+ const found = [...items].reverse().find(({ id }) => {
19
+ const rect = document.getElementById(id)?.getBoundingClientRect();
20
+ return rect && rect.top <= 0;
21
+ });
22
+
23
+ if (found) {
24
+ setHash(found.id);
25
+ }
26
+ };
27
+
28
+ window.addEventListener('scroll', callback, false);
29
+
30
+ return () => {
31
+ window.removeEventListener('scroll', callback, false);
32
+ };
33
+ }, [items]);
34
+
35
+ if (!items?.length) {
36
+ return null;
37
+ }
38
+
39
+ const indent = (size: number) => {
40
+ if (size <= 2) {
41
+ return '0px';
42
+ }
43
+
44
+ return `${(size - 2) * 10}px`;
45
+ };
46
+
47
+ return (
48
+ <div className="flex h-max min-w-60 flex-col gap-3 text-sm">
49
+ <div className="font-bold text-foreground">{title}</div>
50
+ <nav className="flex flex-col" aria-label={navigationLabel}>
51
+ {items.map(({ name, id, size }) => {
52
+ const isActive = hash === id;
53
+
54
+ return (
55
+ <a
56
+ key={id}
57
+ href={`#${id}`}
58
+ className={classNames('block border-l px-3 py-1.5', {
59
+ 'border-l-primary font-medium text-primary': isActive,
60
+ 'border-border text-muted-foreground hover:text-foreground': !isActive,
61
+ })}
62
+ aria-current={isActive ? 'location' : undefined}
63
+ >
64
+ <span style={{ marginLeft: indent(size), display: 'block' }}>{name}</span>
65
+ </a>
66
+ );
67
+ })}
68
+ </nav>
69
+ </div>
70
+ );
71
+ }