@terrafirmagreg-team/kit 0.0.0-stage → 1.2.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 (51) hide show
  1. package/package.json +56 -3
  2. package/src/components/blocks/nav-title.tsx +62 -0
  3. package/src/components/outline/dropdown.tsx +68 -0
  4. package/src/components/outline/index.ts +11 -0
  5. package/src/components/outline/list.tsx +121 -0
  6. package/src/components/outline/outline.tsx +50 -0
  7. package/src/components/outline/popover.tsx +77 -0
  8. package/src/components/page-actions.tsx +202 -0
  9. package/src/components/relative-link.tsx +19 -0
  10. package/src/components/toc/base.tsx +66 -0
  11. package/src/components/toc/block.tsx +112 -0
  12. package/src/components/toc/clerk.tsx +266 -0
  13. package/src/components/toc/default.tsx +334 -0
  14. package/src/components/version-menu.tsx +130 -0
  15. package/src/components/views/dataset.tsx +72 -0
  16. package/src/components/views/section.tsx +152 -0
  17. package/src/components/workspace/content.tsx +54 -0
  18. package/src/components/workspace/index.ts +13 -0
  19. package/src/components/workspace/pagination.tsx +74 -0
  20. package/src/components/workspace/workspace.tsx +149 -0
  21. package/src/hooks/use-catalog.ts +12 -0
  22. package/src/hooks/use-current-dataset.ts +24 -0
  23. package/src/hooks/use-dataset-accent.ts +14 -0
  24. package/src/hooks/use-nav-options.ts +87 -0
  25. package/src/hooks/use-section-names.ts +40 -0
  26. package/src/hooks/use-toc.ts +10 -0
  27. package/src/hooks/use-version-pref.ts +31 -0
  28. package/src/hooks/use-versioned-dataset.ts +21 -0
  29. package/src/lib/breadcrumb.ts +65 -0
  30. package/src/lib/config.ts +111 -0
  31. package/src/lib/dataset-links.ts +31 -0
  32. package/src/lib/datasets.ts +241 -0
  33. package/src/lib/landing.ts +48 -0
  34. package/src/lib/layout.ts +16 -0
  35. package/src/lib/locale/renderer.ts +49 -0
  36. package/src/lib/order.ts +89 -0
  37. package/src/lib/page.ts +109 -0
  38. package/src/lib/pin-tree.ts +38 -0
  39. package/src/lib/preview/lookup.ts +51 -0
  40. package/src/lib/section/schema.ts +32 -0
  41. package/src/lib/section/source.ts +77 -0
  42. package/src/lib/sources.ts +22 -0
  43. package/src/lib/tree.ts +61 -0
  44. package/src/lib/urls.ts +17 -0
  45. package/src/locales/en_us.json +7 -0
  46. package/src/locales/ru_ru.json +7 -0
  47. package/src/locales.ts +6 -0
  48. package/src/types/embeds.ts +54 -0
  49. package/src/types/index.ts +87 -0
  50. package/src/types/outline.ts +30 -0
  51. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "description": "Fumadocs content engine",
6
+ "license": "LGPL-3.0-only",
7
+ "exports": {
8
+ "./lib/*": [
9
+ "./src/lib/*.ts",
10
+ "./src/lib/*/index.ts"
11
+ ],
12
+ "./types/*": "./src/types/*.ts",
13
+ "./hooks/*": [
14
+ "./src/hooks/*.ts",
15
+ "./src/hooks/*/index.ts"
16
+ ],
17
+ "./types": "./src/types/index.ts",
18
+ "./components/*": [
19
+ "./src/components/*.tsx",
20
+ "./src/components/*/index.tsx",
21
+ "./src/components/*/index.ts"
22
+ ],
23
+ "./locales": "./src/locales.ts"
24
+ },
25
+ "dependencies": {
26
+ "@base-ui/react": "^1.9.0",
27
+ "@icons-pack/react-simple-icons": "13.15.1",
28
+ "@terrafirmagreg-team/data": "1.2.0",
29
+ "@terrafirmagreg-team/mdx": "1.2.0",
30
+ "@terrafirmagreg-team/sites": "1.2.0",
31
+ "@terrafirmagreg-team/translate": "1.2.0",
32
+ "@terrafirmagreg-team/ui": "1.2.0",
33
+ "fumadocs-core": "16.17.8",
34
+ "lucide-react": "^0.577.0",
35
+ "server-only": "^0.0.1",
36
+ "zod": "4.6.7"
37
+ },
38
+ "peerDependencies": {
39
+ "next": ">=15",
40
+ "react": ">=19"
41
+ },
42
+ "devDependencies": {
43
+ "@types/mdx": "^2.0.14",
44
+ "@types/node": "^26.6.5",
45
+ "@types/react": "^19.3.0",
46
+ "@types/react-dom": "^19.3.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
51
+ "directory": "packages/kit"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "files": [
57
+ "src"
58
+ ]
6
59
  }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+ import type { ComponentProps } from 'react';
5
+
6
+ import { uiConfig } from '@terrafirmagreg-team/ui/lib/config';
7
+ import { SiteTitle } from '@terrafirmagreg-team/ui/components/blocks/nav/site-title';
8
+ import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
9
+ import { clientDumpLoader } from '@terrafirmagreg-team/kit/lib/config';
10
+ import {
11
+ isDumpSection,
12
+ parseSectionLocation,
13
+ } from '@terrafirmagreg-team/sites/location';
14
+ import { setVersionPreference } from '@terrafirmagreg-team/kit/hooks/use-version-pref';
15
+ import { useVersionedDataset } from '@terrafirmagreg-team/kit/hooks/use-versioned-dataset';
16
+ import { VersionMenu } from '@terrafirmagreg-team/kit/components/version-menu';
17
+
18
+ export function NavTitle({ className, ...props }: ComponentProps<'a'>) {
19
+ const { section, dataset } = parseSectionLocation(usePathname());
20
+ const datasetId = dataset;
21
+
22
+ const gameData = isDumpSection(section) && datasetId;
23
+ const versions = useAsyncValue(
24
+ gameData ? () => clientDumpLoader().loadVersions(datasetId) : false,
25
+ [datasetId],
26
+ );
27
+ const ref = useVersionedDataset(
28
+ gameData ? datasetId : undefined,
29
+ versions ?? undefined,
30
+ );
31
+ const version = useAsyncValue(
32
+ ref
33
+ ? () =>
34
+ clientDumpLoader()
35
+ .loadDump(ref)
36
+ .then((dump) => dump?.manifest.pack?.version ?? undefined)
37
+ : false,
38
+ [ref],
39
+ );
40
+ return (
41
+ <SiteTitle
42
+ href="/"
43
+ icon={uiConfig().brand?.icon ?? ''}
44
+ name={uiConfig().brand?.name ?? ''}
45
+ subtitle={
46
+ dataset && datasetId && gameData ? (
47
+ <VersionMenu
48
+ section={section}
49
+ datasetId={datasetId}
50
+ currentRef={ref ?? { id: datasetId }}
51
+ label={version}
52
+ versions={versions}
53
+ onSelect={(next) => setVersionPreference(datasetId, next)}
54
+ />
55
+ ) : undefined
56
+ }
57
+ className={className}
58
+ {...props}
59
+ />
60
+ );
61
+ }
62
+
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+ import { Popover } from '@base-ui/react/popover';
3
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+ import { ChevronDownIcon } from 'lucide-react';
6
+ import { useState } from 'react';
7
+ import { OutlineActiveHeading, OutlineList, OutlineProgress } from './list';
8
+ import type { WorkspaceOutlineDropdownProps } from '@terrafirmagreg-team/kit/types/outline';
9
+
10
+ export function WorkspaceOutlineDropdown({
11
+ trigger,
12
+ content,
13
+ header,
14
+ footer,
15
+ ...props
16
+ }: WorkspaceOutlineDropdownProps) {
17
+ const t = useTranslations();
18
+ const [open, setOpen] = useState(false);
19
+
20
+ return (
21
+ <Popover.Root open={open} onOpenChange={setOpen}>
22
+ <Popover.Trigger
23
+ {...trigger}
24
+ className={cn(
25
+ 'flex items-center gap-2 min-w-0 h-8 px-1.5 -mx-1.5 rounded-lg text-start text-sm transition-colors',
26
+ open
27
+ ? 'bg-accent text-accent-foreground'
28
+ : 'text-muted-foreground hover:bg-accent/60 hover:text-accent-foreground',
29
+ trigger?.className,
30
+ )}
31
+ >
32
+ <OutlineProgress className="shrink-0" />
33
+ <OutlineActiveHeading
34
+ fallback={t('On this page', { note: 'ui.on_this_page' })}
35
+ activeClassName="text-foreground"
36
+ />
37
+ <ChevronDownIcon
38
+ className={cn('size-3.5 shrink-0 transition-transform', open && 'rotate-180')}
39
+ />
40
+ </Popover.Trigger>
41
+ <Popover.Portal>
42
+ <Popover.Positioner
43
+ align="start"
44
+ sideOffset={8}
45
+ positionMethod="fixed"
46
+ className="z-50"
47
+ >
48
+ <Popover.Popup
49
+ {...content}
50
+ className={cn(
51
+ 'flex flex-col w-88 max-w-(--available-width) max-h-[min(28rem,var(--available-height))] p-3 rounded-2xl border bg-popover text-popover-foreground shadow-xl outline-none origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] data-starting-style:opacity-0 data-starting-style:scale-95 data-ending-style:opacity-0 data-ending-style:scale-95 motion-reduce:transition-none',
52
+ content?.className,
53
+ )}
54
+ >
55
+ {header}
56
+ <OutlineList
57
+ {...props}
58
+ className="py-1"
59
+ onSelect={() => setOpen(false)}
60
+ />
61
+ {footer}
62
+ </Popover.Popup>
63
+ </Popover.Positioner>
64
+ </Popover.Portal>
65
+ </Popover.Root>
66
+ );
67
+ }
68
+
@@ -0,0 +1,11 @@
1
+ export type {
2
+ OutlineStyle,
3
+ OutlineContent,
4
+ OutlineProps,
5
+ WorkspaceOutlineDropdownProps,
6
+ WorkspaceOutlinePopoverProps,
7
+ } from '@terrafirmagreg-team/kit/types/outline';
8
+ export { Outline, RightSidebarOutline } from './outline';
9
+ export { WorkspaceOutlinePopover } from './popover';
10
+ export { WorkspaceOutlineDropdown } from './dropdown';
11
+
@@ -0,0 +1,121 @@
1
+ 'use client';
2
+ import { type TOCItemInfo, useTOCSelector } from '@terrafirmagreg-team/kit/components/toc/base';
3
+ import { useTOCItems } from '@terrafirmagreg-team/kit/hooks/use-toc';
4
+ import * as Base from '@terrafirmagreg-team/kit/components/toc/base';
5
+ import * as OutlineDefault from '@terrafirmagreg-team/kit/components/toc/default';
6
+ import * as OutlineClerk from '@terrafirmagreg-team/kit/components/toc/clerk';
7
+ import * as OutlineBlock from '@terrafirmagreg-team/kit/components/toc/block';
8
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
9
+ import {
10
+ type ComponentProps,
11
+ type CSSProperties,
12
+ type ReactNode,
13
+ useState,
14
+ } from 'react';
15
+ import type { OutlineStyle } from '@terrafirmagreg-team/kit/types/outline';
16
+
17
+ const variants = {
18
+ normal: OutlineDefault,
19
+ clerk: OutlineClerk,
20
+ block: OutlineBlock,
21
+ };
22
+
23
+ const selectTopmost = (items: TOCItemInfo[]) =>
24
+ items.find((item) => item.active)?.original;
25
+ const selectProgress = (items: TOCItemInfo[]) =>
26
+ (items.findLastIndex((item) => item.active) + 1) / Math.max(1, items.length);
27
+
28
+ export function OutlineList({
29
+ style = 'block',
30
+ list,
31
+ className,
32
+ onSelect,
33
+ }: OutlineStyle & { className?: string; onSelect?: () => void }) {
34
+ const items = useTOCItems();
35
+ const { TOCItems, TOCItem, TOCEmpty } = variants[style];
36
+
37
+ return (
38
+ <Base.TOCScrollArea className={className}>
39
+ <TOCItems {...list}>
40
+ {items.length === 0 && <TOCEmpty />}
41
+ {items.map((item) => (
42
+ <TOCItem key={item.url} item={item} onClick={onSelect} />
43
+ ))}
44
+ </TOCItems>
45
+ </Base.TOCScrollArea>
46
+ );
47
+ }
48
+
49
+ export function OutlineActiveHeading({
50
+ fallback,
51
+ className,
52
+ activeClassName,
53
+ }: {
54
+ fallback: ReactNode;
55
+ className?: string;
56
+ activeClassName?: string;
57
+ }) {
58
+ const items = useTOCItems();
59
+ const active = useTOCSelector(selectTopmost);
60
+ const [shown, setShown] = useState({ item: active, roll: 1 });
61
+ if (shown.item !== active) {
62
+ setShown({
63
+ item: active,
64
+ roll: items.indexOf(active!) >= items.indexOf(shown.item!) ? 1 : -1,
65
+ });
66
+ }
67
+
68
+ return (
69
+ <span
70
+ className={cn('flex min-w-0 overflow-hidden', active && activeClassName, className)}
71
+ >
72
+ <span
73
+ key={active?.url}
74
+ className="truncate motion-safe:animate-roll-in"
75
+ style={{ '--roll': shown.roll } as CSSProperties}
76
+ >
77
+ {active ? active.title : fallback}
78
+ </span>
79
+ </span>
80
+ );
81
+ }
82
+
83
+ export function OutlineProgress({
84
+ size = 16,
85
+ ...props
86
+ }: ComponentProps<'svg'> & { size?: number }) {
87
+ const value = useTOCSelector(selectProgress);
88
+ const circle = {
89
+ cx: size / 2,
90
+ cy: size / 2,
91
+ r: size / 2 - 1.5,
92
+ fill: 'none',
93
+ strokeWidth: 1.5,
94
+ };
95
+ const circumference = 2 * Math.PI * circle.r;
96
+
97
+ return (
98
+ <svg
99
+ role="progressbar"
100
+ viewBox={`0 0 ${size} ${size}`}
101
+ width={size}
102
+ height={size}
103
+ aria-valuenow={value}
104
+ aria-valuemin={0}
105
+ aria-valuemax={1}
106
+ {...props}
107
+ >
108
+ <circle {...circle} className="stroke-current/25" />
109
+ <circle
110
+ {...circle}
111
+ stroke="currentColor"
112
+ strokeLinecap="round"
113
+ strokeDasharray={circumference}
114
+ strokeDashoffset={circumference * (1 - value)}
115
+ transform={`rotate(-90 ${circle.cx} ${circle.cy})`}
116
+ className="transition-[stroke-dashoffset] duration-300"
117
+ />
118
+ </svg>
119
+ );
120
+ }
121
+
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+ import { useTOCItems } from '@terrafirmagreg-team/kit/hooks/use-toc';
3
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+ import { TextIcon } from 'lucide-react';
6
+ import {
7
+ RightSidebarPanel,
8
+ RightSidebarPortal,
9
+ } from '@terrafirmagreg-team/ui/components/layouts/right-sidebar';
10
+ import { OutlineList } from './list';
11
+ import type { OutlineProps } from '@terrafirmagreg-team/kit/types/outline';
12
+
13
+ export function Outline({ container, header, footer, ...props }: OutlineProps) {
14
+ const t = useTranslations();
15
+ const items = useTOCItems();
16
+ if (items.length === 0 && !header && !footer) return;
17
+
18
+ return (
19
+ <div
20
+ data-slot="outline"
21
+ {...container}
22
+ className={cn('flex min-h-0 flex-col overflow-clip', container?.className)}
23
+ >
24
+ {header}
25
+ <h3 className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
26
+ <TextIcon className="size-4" />
27
+ {t('On this page', { note: 'ui.on_this_page' })}
28
+ </h3>
29
+ <OutlineList {...props} />
30
+ {footer}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export function RightSidebarOutline({ container, ...props }: OutlineProps) {
36
+ return (
37
+ <RightSidebarPortal>
38
+ <RightSidebarPanel className="pt-8 pb-4">
39
+ <Outline
40
+ {...props}
41
+ container={{
42
+ ...container,
43
+ className: cn('flex-1', container?.className),
44
+ }}
45
+ />
46
+ </RightSidebarPanel>
47
+ </RightSidebarPortal>
48
+ );
49
+ }
50
+
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+ import {
3
+ Collapsible,
4
+ CollapsibleContent,
5
+ CollapsibleTrigger,
6
+ } from '@terrafirmagreg-team/ui/components/ui/collapsible';
7
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
8
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
9
+ import { ChevronDownIcon } from 'lucide-react';
10
+ import { useEffect, useRef, useState } from 'react';
11
+ import { useTreePath } from '@terrafirmagreg-team/ui/hooks/use-tree';
12
+ import { OutlineActiveHeading, OutlineList, OutlineProgress } from './list';
13
+ import type { WorkspaceOutlinePopoverProps } from '@terrafirmagreg-team/kit/types/outline';
14
+
15
+ export function WorkspaceOutlinePopover({
16
+ container,
17
+ trigger,
18
+ content,
19
+ header,
20
+ footer,
21
+ ...props
22
+ }: WorkspaceOutlinePopoverProps) {
23
+ const t = useTranslations();
24
+ const page = useTreePath().at(-1);
25
+ const ref = useRef<HTMLElement>(null);
26
+ const [open, setOpen] = useState(false);
27
+
28
+ useEffect(() => {
29
+ if (!open) return;
30
+ const onClick = (e: MouseEvent) => {
31
+ if (e.target instanceof Node && !ref.current?.contains(e.target))
32
+ setOpen(false);
33
+ };
34
+
35
+ window.addEventListener('click', onClick);
36
+ return () => window.removeEventListener('click', onClick);
37
+ }, [open]);
38
+
39
+ return (
40
+ <Collapsible
41
+ open={open}
42
+ onOpenChange={setOpen}
43
+ {...container}
44
+ className={cn('max-md:layout:[--toc-popover-height:--spacing(10)]', container?.className)}
45
+ >
46
+ <header ref={ref} className="bg-card">
47
+ <CollapsibleTrigger
48
+ {...trigger}
49
+ className={cn(
50
+ 'flex w-full h-10 items-center gap-2.5 px-4 text-start text-sm text-muted-foreground focus-visible:outline-none',
51
+ trigger?.className,
52
+ )}
53
+ >
54
+ <OutlineProgress
55
+ size={18}
56
+ className={cn('shrink-0', open && 'text-primary')}
57
+ />
58
+ <OutlineActiveHeading
59
+ fallback={page?.name ?? t('On this page', { note: 'ui.on_this_page' })}
60
+ className={cn('flex-1 transition-colors', open && 'text-foreground')}
61
+ />
62
+ <ChevronDownIcon
63
+ className={cn('size-4 shrink-0 mx-0.5 transition-transform', open && 'rotate-180')}
64
+ />
65
+ </CollapsibleTrigger>
66
+ <CollapsibleContent {...content}>
67
+ <div className="flex flex-col px-4 max-h-[50vh]">
68
+ {header}
69
+ <OutlineList {...props} onSelect={() => setOpen(false)} />
70
+ {footer}
71
+ </div>
72
+ </CollapsibleContent>
73
+ </header>
74
+ </Collapsible>
75
+ );
76
+ }
77
+
@@ -0,0 +1,202 @@
1
+ 'use client';
2
+ import { type ComponentProps } from 'react';
3
+ import {
4
+ Check,
5
+ ChevronDown,
6
+ Copy,
7
+ ExternalLinkIcon,
8
+ Languages,
9
+ TextIcon,
10
+ } from 'lucide-react';
11
+ import { SiGithub } from '@icons-pack/react-simple-icons';
12
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
13
+ import { Button } from '@terrafirmagreg-team/ui/components/ui/button';
14
+ import {
15
+ Popover,
16
+ PopoverTrigger,
17
+ PopoverContent,
18
+ } from '@terrafirmagreg-team/ui/components/ui/popover';
19
+ import { ItemRow } from '@terrafirmagreg-team/ui/components/blocks/list/row';
20
+ import { useUntranslated } from '@terrafirmagreg-team/ui/hooks/use-lang';
21
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
22
+ import { useCopyButton } from '@terrafirmagreg-team/ui/hooks/use-copy-button';
23
+ import {
24
+ openPageMarkdown,
25
+ pageMarkdown,
26
+ } from '@terrafirmagreg-team/mdx/lib/markdown';
27
+ import type { TranslationState } from '@terrafirmagreg-team/translate/server';
28
+
29
+ function ActionButton({
30
+ className,
31
+ ...props
32
+ }: ComponentProps<typeof Button>) {
33
+ return (
34
+ <Button
35
+ variant="secondary"
36
+ size="sm"
37
+ className={cn(
38
+ 'gap-2 border-foreground/10 hover:bg-background dark:hover:bg-accent [&_svg]:size-3.5 [&_svg]:text-muted-foreground',
39
+ className,
40
+ )}
41
+ {...props}
42
+ />
43
+ );
44
+ }
45
+
46
+ export function MarkdownCopyButton({
47
+ getMarkdown,
48
+ ...props
49
+ }: ComponentProps<'button'> & {
50
+ getMarkdown: () => Promise<string>;
51
+ }) {
52
+ const t = useTranslations();
53
+ const { isLoading, checked, error, onClick } = useCopyButton(getMarkdown);
54
+
55
+ return (
56
+ <ActionButton
57
+ disabled={isLoading}
58
+ aria-live="polite"
59
+ onClick={onClick}
60
+ {...props}
61
+ >
62
+ {checked ? <Check /> : <Copy />}
63
+ {props.children ??
64
+ (error
65
+ ? t('Failed to copy', { note: 'ui.copy_error' })
66
+ : checked
67
+ ? t('Copied Markdown', { note: 'ui.copied_markdown' })
68
+ : t('Copy Markdown', { note: 'ui.copy_markdown' }))}
69
+ </ActionButton>
70
+ );
71
+ }
72
+
73
+ export function CopyPageMarkdown({ title }: { title: string }) {
74
+ return (
75
+ <MarkdownCopyButton
76
+ getMarkdown={() =>
77
+ pageMarkdown(document.querySelector('[data-md-root]'), title)
78
+ }
79
+ />
80
+ );
81
+ }
82
+
83
+ export function CrowdinButton({ url }: { url: string }) {
84
+ const t = useTranslations();
85
+ return (
86
+ <ActionButton
87
+ nativeButton={false}
88
+ render={<a href={url} target="_blank" rel="noreferrer" />}
89
+ >
90
+ <Languages />
91
+ {t('Translate on Crowdin', { note: 'untranslated.translate' })}
92
+ </ActionButton>
93
+ );
94
+ }
95
+
96
+ export function UntranslatedActions({
97
+ copied,
98
+ crowdinUrl,
99
+ }: {
100
+ copied?: TranslationState;
101
+ crowdinUrl?: string;
102
+ }) {
103
+ const missing = useUntranslated();
104
+ if (!crowdinUrl || (!missing && !copied)) return null;
105
+ return <CrowdinButton url={crowdinUrl} />;
106
+ }
107
+
108
+ export function ViewOptionsPopover({
109
+ githubUrl,
110
+ title,
111
+ mdUrl,
112
+ ...props
113
+ }: ComponentProps<typeof PopoverTrigger> & {
114
+ githubUrl?: string;
115
+ title?: string;
116
+ mdUrl?: string;
117
+ }) {
118
+ const t = useTranslations();
119
+ const items = [
120
+ githubUrl && {
121
+ title: t('Open in GitHub', { note: 'ui.open_github' }),
122
+ href: githubUrl,
123
+ icon: <SiGithub color="currentColor" className="size-4" />,
124
+ },
125
+ (mdUrl || title !== undefined) && {
126
+ title: t('View as Markdown', { note: 'ui.view_markdown' }),
127
+ ...(mdUrl
128
+ ? { href: mdUrl }
129
+ : {
130
+ onClick: () =>
131
+ void openPageMarkdown(
132
+ document.querySelector('[data-md-root]'),
133
+ title!,
134
+ ),
135
+ }),
136
+ icon: <TextIcon className="size-4" />,
137
+ },
138
+ ].filter((v) => !!v);
139
+ if (!items.length) return null;
140
+
141
+ return (
142
+ <Popover>
143
+ <PopoverTrigger
144
+ {...props}
145
+ render={
146
+ <Button
147
+ variant="secondary"
148
+ size="sm"
149
+ className="border-foreground/10 hover:bg-background dark:hover:bg-accent"
150
+ />
151
+ }
152
+ className={(state) =>
153
+ cn(
154
+ 'gap-2 data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground',
155
+ typeof props.className === 'function' ? props.className(state) : props.className,
156
+ )
157
+ }
158
+ >
159
+ {props.children ?? t('Open', { note: 'ui.open' })}
160
+ <ChevronDown className="size-3.5 text-muted-foreground" />
161
+ </PopoverTrigger>
162
+ <PopoverContent className="flex flex-col">
163
+ {items.map((item) => (
164
+ <ItemRow
165
+ key={item.title}
166
+ {...('href' in item
167
+ ? { href: item.href, target: '_blank', rel: 'noreferrer noopener' }
168
+ : { onClick: item.onClick })}
169
+ media={item.icon}
170
+ title={item.title}
171
+ trailing={
172
+ <ExternalLinkIcon className="size-3.5 text-muted-foreground" />
173
+ }
174
+ />
175
+ ))}
176
+ </PopoverContent>
177
+ </Popover>
178
+ );
179
+ }
180
+
181
+ export function PageActions({
182
+ copied,
183
+ crowdinUrl,
184
+ title,
185
+ mdUrl,
186
+ githubUrl,
187
+ }: {
188
+ copied?: TranslationState;
189
+ crowdinUrl?: string;
190
+ title: string;
191
+ mdUrl?: string;
192
+ githubUrl?: string;
193
+ }) {
194
+ return (
195
+ <>
196
+ <CopyPageMarkdown title={title} />
197
+ <ViewOptionsPopover title={title} mdUrl={mdUrl} githubUrl={githubUrl} />
198
+ <UntranslatedActions copied={copied} crowdinUrl={crowdinUrl} />
199
+ </>
200
+ );
201
+ }
202
+
@@ -0,0 +1,19 @@
1
+ import type { LoaderConfig, LoaderOutput, Page } from 'fumadocs-core/source';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+ import Link from 'fumadocs-core/link';
4
+
5
+ export function createRelativeLink<C extends LoaderConfig>(
6
+ source: LoaderOutput<C>,
7
+ page: Page | C['page'],
8
+ OverrideLink: (props: ComponentProps<'a'>) => ReactNode = Link,
9
+ ): (props: ComponentProps<'a'>) => ReactNode | Promise<ReactNode> {
10
+ return async function RelativeLink({ href, ...props }) {
11
+ return (
12
+ <OverrideLink
13
+ href={href ? source.resolveHref(href, page) : href}
14
+ {...props}
15
+ />
16
+ );
17
+ };
18
+ }
19
+