@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
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
3
+ import type { ComponentProps } from 'react';
4
+
5
+ export function WorkspaceBody({ className, ...props }: ComponentProps<'div'>) {
6
+ return (
7
+ <div
8
+ data-slot="workspace-body"
9
+ {...props}
10
+ className={cn('prose flex-1', className)}
11
+ />
12
+ );
13
+ }
14
+
15
+ export function WorkspaceDescription({
16
+ children,
17
+ className,
18
+ ...props
19
+ }: ComponentProps<'p'>) {
20
+ if (children === undefined) return null;
21
+ return (
22
+ <p
23
+ data-slot="workspace-description"
24
+ {...props}
25
+ className={cn('mb-8 text-lg text-muted-foreground', className)}
26
+ >
27
+ {children}
28
+ </p>
29
+ );
30
+ }
31
+
32
+ export function WorkspaceTitle({ className, ...props }: ComponentProps<'h1'>) {
33
+ return (
34
+ <h1
35
+ data-slot="workspace-title"
36
+ {...props}
37
+ className={cn('text-[1.75em] font-semibold', className)}
38
+ />
39
+ );
40
+ }
41
+
42
+ export function WorkspaceFooter({
43
+ className,
44
+ ...props
45
+ }: ComponentProps<'div'>) {
46
+ return (
47
+ <div
48
+ data-slot="workspace-footer"
49
+ className={cn('mt-auto empty:hidden', className)}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+
@@ -0,0 +1,13 @@
1
+ export type {
2
+ WorkspacePaginationItems,
3
+ WorkspaceProps,
4
+ } from './workspace';
5
+ export { Workspace } from './workspace';
6
+ export {
7
+ WorkspaceBody,
8
+ WorkspaceDescription,
9
+ WorkspaceTitle,
10
+ WorkspaceFooter,
11
+ } from './content';
12
+ export { WorkspacePagination } from './pagination';
13
+
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+ import { type ComponentProps, useMemo } from 'react';
3
+ import { usePathname } from 'next/navigation';
4
+ import Link from '@terrafirmagreg-team/ui/components/blocks/nav/link';
5
+ import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
6
+ import { useFooterItems } from '@terrafirmagreg-team/ui/hooks/use-tree';
7
+ import { useLocationSearch } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
8
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
9
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
10
+ import { isNavItemActive } from '@terrafirmagreg-team/ui/lib/utils/nav-active';
11
+ import type { WorkspacePaginationItems } from './workspace';
12
+
13
+ export function WorkspacePagination({
14
+ items,
15
+ className,
16
+ ...props
17
+ }: ComponentProps<'nav'> & { items?: WorkspacePaginationItems }) {
18
+ const footerList = useFooterItems();
19
+ const pathname = usePathname();
20
+ const search = useLocationSearch();
21
+ const { previous, next } = useMemo(() => {
22
+ if (items) return items;
23
+ const idx = footerList.findIndex((item) =>
24
+ isNavItemActive(item.url, pathname, search),
25
+ );
26
+ if (idx === -1) return {};
27
+ return { previous: footerList[idx - 1], next: footerList[idx + 1] };
28
+ }, [footerList, items, pathname, search]);
29
+
30
+ if (!previous && !next) return null;
31
+ return (
32
+ <nav
33
+ data-slot="workspace-pagination"
34
+ className={cn('@container grid grid-cols-2 gap-3 mt-8', className)}
35
+ {...props}
36
+ >
37
+ {previous && <WorkspacePaginationItem item={previous} next={false} />}
38
+ {next && <WorkspacePaginationItem item={next} next />}
39
+ </nav>
40
+ );
41
+ }
42
+
43
+ function WorkspacePaginationItem({
44
+ item,
45
+ next,
46
+ }: {
47
+ item: NonNullable<WorkspacePaginationItems['previous']>;
48
+ next: boolean;
49
+ }) {
50
+ const t = useTranslations();
51
+ const Icon = next ? ArrowRightIcon : ArrowLeftIcon;
52
+
53
+ return (
54
+ <Link
55
+ href={item.url}
56
+ className={cn(
57
+ 'group flex flex-col gap-1 rounded-2xl border p-4 text-sm transition-colors hover:bg-accent/40 @max-md:col-span-full',
58
+ next && 'col-start-2 items-end text-end',
59
+ )}
60
+ >
61
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
62
+ {!next && (
63
+ <Icon className="size-3.5 transition-transform group-hover:-translate-x-0.5 rtl:rotate-180" />
64
+ )}
65
+ {next ? t('Next Page', { note: 'ui.next_page' }) : t('Previous Page', { note: 'ui.prev_page' })}
66
+ {next && (
67
+ <Icon className="size-3.5 transition-transform group-hover:translate-x-0.5 rtl:rotate-180" />
68
+ )}
69
+ </span>
70
+ <span className="font-medium">{item.name}</span>
71
+ </Link>
72
+ );
73
+ }
74
+
@@ -0,0 +1,149 @@
1
+ 'use client';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+ import { usePathname } from 'next/navigation';
4
+ import type * as PageTree from '@terrafirmagreg-team/translate/types/tree';
5
+ import type { TOCItemType } from '@terrafirmagreg-team/kit/components/toc/base';
6
+ import { TOCProvider } from '@terrafirmagreg-team/kit/components/toc/base';
7
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
8
+ import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
9
+ import {
10
+ RightSidebarOutline,
11
+ WorkspaceOutlineDropdown,
12
+ WorkspaceOutlinePopover,
13
+ type OutlineProps,
14
+ type WorkspaceOutlinePopoverProps,
15
+ } from '@terrafirmagreg-team/kit/components/outline';
16
+ import { LeftSidebarExpand } from '@terrafirmagreg-team/ui/components/layouts/left-sidebar';
17
+ import { Breadcrumb } from '@terrafirmagreg-team/ui/components/blocks/nav/breadcrumb';
18
+ import { WorkspaceFooter } from './content';
19
+ import { WorkspacePagination } from './pagination';
20
+
21
+ export type WorkspacePaginationItems = {
22
+ previous?: Pick<PageTree.Item, 'name' | 'description' | 'url'>;
23
+ next?: Pick<PageTree.Item, 'name' | 'description' | 'url'>;
24
+ };
25
+
26
+ export type WorkspaceProps = ComponentProps<'article'> & {
27
+ toc?: TOCItemType[];
28
+
29
+ full?: boolean;
30
+ outline?: OutlineProps & { enabled?: boolean; single?: boolean };
31
+ outlinePopover?: WorkspaceOutlinePopoverProps & { enabled?: boolean };
32
+ breadcrumb?: {
33
+ items?: { name: string; url?: string }[];
34
+ previews?: Record<string, PagePreviewData>;
35
+ };
36
+ pagination?: { items?: WorkspacePaginationItems } | false;
37
+
38
+ actions?: ReactNode;
39
+
40
+ footer?: ReactNode;
41
+ };
42
+
43
+ export function Workspace({
44
+ toc = [],
45
+ full = false,
46
+ outline = {},
47
+ outlinePopover = {},
48
+ breadcrumb,
49
+ pagination = {},
50
+ actions,
51
+ footer,
52
+ className,
53
+ children,
54
+ ...props
55
+ }: WorkspaceProps) {
56
+ const { enabled: outlineEnabled = !full, single, ...outlineProps } = outline;
57
+ const { enabled: popoverEnabled = Boolean(toc.length > 0 || outlinePopover.header || outlinePopover.footer), ...popoverProps } = outlinePopover;
58
+ const crumbs = breadcrumb?.items ?? [];
59
+ const hasCrumbs = crumbs.length > 0;
60
+ const pathname = usePathname();
61
+
62
+ return (
63
+ <TOCProvider
64
+ single={single}
65
+ toc={outlineEnabled || popoverEnabled ? toc : []}
66
+ >
67
+ <div
68
+ data-slot="workspace-outline-popover"
69
+ className="sticky top-[calc(var(--banner-height,0px)+var(--header-height))] z-30 [grid-area:toc-popover] self-start md:hidden"
70
+ >
71
+ {popoverEnabled && <WorkspaceOutlinePopover {...popoverProps} />}
72
+ <div className="absolute inset-x-2 top-full h-[calc(var(--layout-height)_-_var(--header-height)_-_100%)] rounded-t-2xl border-x border-t outline-8 outline-card [clip-path:inset(0_-8px_-8px)] bg-origin-border bg-linear-to-b from-background to-transparent to-[16px] pointer-events-none" />
73
+ </div>
74
+ <div
75
+ key={pathname}
76
+ id="page-panel"
77
+ data-slot="workspace-panel"
78
+ className="@container relative flex flex-col min-w-0 min-h-0 [grid-area:main] mx-2 bg-background md:ms-0 md:my-2 md:overflow-clip md:rounded-2xl md:border md:shadow-sm print:overflow-visible"
79
+ >
80
+ <header
81
+ data-slot="workspace-header"
82
+ className={cn(
83
+ 'absolute inset-x-0 top-0 z-10 flex items-center gap-1.5 h-(--header-height) ps-6 pe-4 bg-linear-to-b from-background to-transparent pointer-events-none *:pointer-events-auto max-md:hidden',
84
+ hasCrumbs || popoverEnabled ? 'from-50%' : 'to-40%',
85
+ )}
86
+ >
87
+ <LeftSidebarExpand />
88
+ {hasCrumbs && (
89
+ <Breadcrumb
90
+ items={crumbs}
91
+ previews={breadcrumb?.previews}
92
+ />
93
+ )}
94
+ {popoverEnabled && (
95
+ <div
96
+ className={cn(
97
+ 'flex items-center gap-1.5 min-w-0 transition-[opacity,visibility] duration-300 motion-reduce:transition-none',
98
+ outlineEnabled &&
99
+ 'invisible opacity-0 @max-5xl:visible @max-5xl:opacity-100 @max-5xl:transition-none',
100
+ )}
101
+ >
102
+ {hasCrumbs && (
103
+ <span className="text-sm text-muted-foreground/50">/</span>
104
+ )}
105
+ <WorkspaceOutlineDropdown {...popoverProps} />
106
+ </div>
107
+ )}
108
+ <div data-slot="workspace-actions" className="ms-auto">
109
+ {actions}
110
+ </div>
111
+ </header>
112
+ <div
113
+ data-slot="workspace-scroll"
114
+ className="flex flex-1 items-start px-4 md:min-h-0 md:pt-(--header-height) md:px-[max(--spacing(6),calc((100%_-_var(--page-width)_-_var(--toc-width))/2))] md:overflow-y-auto md:overscroll-y-contain md:scrollbar-thin md:scrollbar-gutter-stable md:[--docs-height:calc(var(--layout-height)_-_var(--spacing)*4_-_2px_-_var(--header-height))]"
115
+ >
116
+ <article
117
+ id="page"
118
+ data-slot="workspace"
119
+ data-full={full}
120
+ className={cn(
121
+ 'flex flex-col gap-4 w-full min-w-0 max-w-(--page-width) pt-6 pb-8',
122
+ className,
123
+ )}
124
+ {...props}
125
+ >
126
+ {hasCrumbs && (
127
+ <Breadcrumb
128
+ items={crumbs}
129
+ previews={breadcrumb?.previews}
130
+ className="md:hidden"
131
+ />
132
+ )}
133
+ {children}
134
+ {pagination !== false || footer ? (
135
+ <WorkspaceFooter>
136
+ {pagination !== false && (
137
+ <WorkspacePagination items={pagination.items} />
138
+ )}
139
+ {footer}
140
+ </WorkspaceFooter>
141
+ ) : null}
142
+ </article>
143
+ {outlineEnabled && <RightSidebarOutline {...outlineProps} />}
144
+ </div>
145
+ </div>
146
+ </TOCProvider>
147
+ );
148
+ }
149
+
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+ import { createContext, useContext } from 'react';
3
+ import type { DatasetCatalog } from '@terrafirmagreg-team/kit/types';
4
+
5
+ export const SectionCatalogContext = createContext<DatasetCatalog | null>(null);
6
+
7
+ export function useSectionCatalog(): DatasetCatalog {
8
+ const catalog = useContext(SectionCatalogContext);
9
+ if (!catalog) throw new Error('SectionCatalogContext is not provided');
10
+ return catalog;
11
+ }
12
+
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+ import { useMemo } from 'react';
3
+ import { usePathname } from 'next/navigation';
4
+ import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
5
+ import {
6
+ type DatasetRef,
7
+ parseSectionLocation,
8
+ resolveDatasetRef,
9
+ } from '@terrafirmagreg-team/sites/location';
10
+ import type { SectionDataset } from '@terrafirmagreg-team/kit/types';
11
+
12
+ export function useCurrentDataset(
13
+ datasets: readonly SectionDataset[],
14
+ ): DatasetRef | undefined {
15
+ const pathname = usePathname();
16
+ const versionParam = useLocationSearchParams().get('version') ?? undefined;
17
+ const { dataset } = parseSectionLocation(pathname);
18
+ const meta = datasets.find((row) => row.id === dataset);
19
+ return useMemo(
20
+ () => dataset && meta ? resolveDatasetRef(dataset, versionParam, meta).ref : undefined,
21
+ [dataset, versionParam, meta],
22
+ );
23
+ }
24
+
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+ import { useLayoutEffect } from 'react';
3
+
4
+ export function useDatasetAccent(accent?: string) {
5
+ useLayoutEffect(() => {
6
+ if (!accent) return;
7
+ const root = document.documentElement;
8
+ root.style.setProperty('--color-link-accent', accent);
9
+ return () => {
10
+ root.style.removeProperty('--color-link-accent');
11
+ };
12
+ }, [accent]);
13
+ }
14
+
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+ import { type ReactNode, createElement, useMemo } from 'react';
3
+ import { DynamicIcon, type IconName } from 'lucide-react/dynamic';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+ import { sectionTabs } from '@terrafirmagreg-team/sites/sections';
6
+ import {
7
+ isDumpSection,
8
+ resolveDatasetRef,
9
+ sectionUrl,
10
+ } from '@terrafirmagreg-team/sites/location';
11
+ import { useCurrentDataset } from '@terrafirmagreg-team/kit/hooks/use-current-dataset';
12
+ import { useSectionNames } from '@terrafirmagreg-team/kit/hooks/use-section-names';
13
+ import { useVersionPreference } from '@terrafirmagreg-team/kit/hooks/use-version-pref';
14
+ import type { LeftSidebarScope } from '@terrafirmagreg-team/kit/types';
15
+ import type {
16
+ SwitcherOption,
17
+ SwitcherProps,
18
+ } from '@terrafirmagreg-team/ui/types/nav';
19
+
20
+ function datasetIcon(dataset: { icon?: IconName }): ReactNode {
21
+ if (!dataset.icon) return undefined;
22
+ return createElement(DynamicIcon, {
23
+ name: dataset.icon,
24
+ className: 'size-4 shrink-0',
25
+ });
26
+ }
27
+
28
+ export function useNavOptions({ section, catalog }: LeftSidebarScope): {
29
+ section: SwitcherProps;
30
+ dataset: SwitcherProps;
31
+ } {
32
+ const t = useTranslations();
33
+ const sectionNames = useSectionNames();
34
+ const current = useCurrentDataset(catalog[section]);
35
+ const baseId = current ? current.id : undefined;
36
+ const pinned = current ? current.version : undefined;
37
+ const preferred = useVersionPreference(baseId);
38
+ const version = pinned ?? preferred;
39
+
40
+ const sectionOptions: SwitcherOption[] = useMemo(
41
+ () =>
42
+ sectionTabs.map(({ key, icon: Icon }) => {
43
+ const meta = baseId
44
+ ? catalog[key].find(({ id }) => id === baseId)
45
+ : undefined;
46
+ const pinned =
47
+ isDumpSection(key) && baseId
48
+ ? resolveDatasetRef(baseId, version, meta).pinned
49
+ : undefined;
50
+ const suffix = pinned ? `?version=${encodeURIComponent(pinned)}` : '';
51
+ return {
52
+ value: key,
53
+ title: sectionNames[key]?.title ?? key,
54
+ description: sectionNames[key]?.desc,
55
+ icon: Icon ? createElement(Icon) : undefined,
56
+ url: meta ? `${sectionUrl(key, baseId)}${suffix}` : sectionUrl(key),
57
+ };
58
+ }),
59
+ [sectionNames, baseId, version, catalog],
60
+ );
61
+
62
+ const datasetOptions: SwitcherOption[] = useMemo(
63
+ () =>
64
+ catalog[section].map((datasetItem) => ({
65
+ value: datasetItem.id,
66
+ title: datasetItem.title ?? datasetItem.id,
67
+ description: datasetItem.description,
68
+ icon: datasetIcon(datasetItem),
69
+ url: sectionUrl(section, datasetItem.id),
70
+ })),
71
+ [section, catalog],
72
+ );
73
+
74
+ return {
75
+ section: {
76
+ options: sectionOptions,
77
+ selected: section,
78
+ placeholder: t('Select section', { note: 'ui.select_section' }),
79
+ },
80
+ dataset: {
81
+ options: datasetOptions,
82
+ selected: baseId,
83
+ placeholder: t('Select dataset', { note: 'ui.select_dataset' }),
84
+ },
85
+ };
86
+ }
87
+
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+ import { useMemo } from 'react';
3
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
4
+
5
+ export function useSectionNames(): Record<
6
+ string,
7
+ { title: string; desc: string }
8
+ > {
9
+ const t = useTranslations();
10
+ return useMemo(
11
+ () => ({
12
+ docs: {
13
+ title: t('Docs', { note: 'sections.docs' }),
14
+ desc: t('Documentation', { note: 'sections.desc.docs' }),
15
+ },
16
+ changelog: {
17
+ title: t('Changelog', { note: 'sections.changelog' }),
18
+ desc: t('Release history and version notes', {
19
+ note: 'sections.desc.changelog',
20
+ }),
21
+ },
22
+ recipe: {
23
+ title: t('Recipes', { note: 'sections.recipe' }),
24
+ desc: t('Crafting and processing recipes', {
25
+ note: 'sections.desc.recipe',
26
+ }),
27
+ },
28
+ quest: {
29
+ title: t('Quests', { note: 'sections.quest' }),
30
+ desc: t('Quest chapters and objectives', { note: 'sections.desc.quest' }),
31
+ },
32
+ guide: {
33
+ title: t('Guide', { note: 'sections.guide' }),
34
+ desc: t('Gameplay guide', { note: 'sections.desc.guide' }),
35
+ },
36
+ }),
37
+ [t],
38
+ );
39
+ }
40
+
@@ -0,0 +1,10 @@
1
+ 'use client';
2
+ import type { TOCItemType } from 'fumadocs-core/toc';
3
+ import { createContext, use } from 'react';
4
+
5
+ export const TOCContext = createContext<TOCItemType[]>([]);
6
+
7
+ export function useTOCItems(): TOCItemType[] {
8
+ return use(TOCContext);
9
+ }
10
+
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+ import {
5
+ jsonCodec,
6
+ readStoredValue,
7
+ subscribeStoredValue,
8
+ writeStoredValue,
9
+ } from '@terrafirmagreg-team/ui/lib/utils/stored-value';
10
+
11
+ const STORAGE_KEY = 'tfg.dataset-versions';
12
+ const codec = jsonCodec<Record<string, string>>({});
13
+
14
+ export function setVersionPreference(datasetId: string, version?: string) {
15
+ const next = { ...readStoredValue(STORAGE_KEY, codec.read) };
16
+ if (version) next[datasetId] = version;
17
+ else delete next[datasetId];
18
+ writeStoredValue(STORAGE_KEY, next, codec.write);
19
+ }
20
+
21
+ export function useVersionPreference(datasetId?: string): string | undefined {
22
+ return useSyncExternalStore(
23
+ (onChange) => subscribeStoredValue(STORAGE_KEY, onChange),
24
+ () =>
25
+ datasetId
26
+ ? readStoredValue(STORAGE_KEY, codec.read)[datasetId]
27
+ : undefined,
28
+ () => undefined,
29
+ );
30
+ }
31
+
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+ import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
3
+ import {
4
+ resolveDatasetRef,
5
+ type DatasetVersionInfo,
6
+ type DatasetRef,
7
+ } from '@terrafirmagreg-team/sites/location';
8
+
9
+ const refs = new Map<string, DatasetRef | undefined>();
10
+
11
+ export function useVersionedDataset(
12
+ id: string | undefined,
13
+ info?: DatasetVersionInfo,
14
+ ): DatasetRef | undefined {
15
+ const version = useLocationSearchParams().get('version') ?? undefined;
16
+ const key = `${id ?? ''} ${version ?? ''} ${info?.latest ?? ''} ${info?.versions?.join(',') ?? ''}`;
17
+ if (!refs.has(key))
18
+ refs.set(key, id ? resolveDatasetRef(id, version, info).ref : undefined);
19
+ return refs.get(key);
20
+ }
21
+
@@ -0,0 +1,65 @@
1
+ import 'server-only';
2
+ import type * as PageTree from 'fumadocs-core/page-tree';
3
+ import { searchPath } from 'fumadocs-core/breadcrumb';
4
+ import { stripLangFromUrl } from '@terrafirmagreg-team/translate/lib/slug';
5
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
6
+ import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
7
+ import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
8
+ import { localizePageTree } from '@terrafirmagreg-team/translate/server';
9
+ import { sectionConfig } from './config';
10
+ import { lookupPagePreview } from './preview/lookup';
11
+ import { datasetId } from './tree';
12
+ import type { SectionSource } from './page';
13
+ import type { DatasetCatalog, SectionPage } from '@terrafirmagreg-team/kit/types';
14
+
15
+ export type SectionCrumb = { name: string; url?: string };
16
+
17
+ function nodeName(name: unknown): string {
18
+ return typeof name === 'string' ? name : '';
19
+ }
20
+
21
+ export function sectionBreadcrumbItems(
22
+ source: SectionSource,
23
+ page: SectionPage,
24
+ lang: Lang,
25
+ ): SectionCrumb[] {
26
+ const languages = [...sectionConfig().knownLanguages];
27
+ const tree = localizePageTree(source.getPageTree(), lang, {
28
+ languages,
29
+ fallbackLanguage: loadLocaleRegistry().defaultLanguage,
30
+ });
31
+ const dataset = page.slugs[0];
32
+ const folder = tree.children.find(
33
+ (node): node is PageTree.Folder =>
34
+ node.type === 'folder' && datasetId(node) === dataset,
35
+ );
36
+ if (!folder) return [{ name: page.data.title }];
37
+
38
+ const pageUrl = stripLangFromUrl(page.url, languages);
39
+ if (folder.index?.url === pageUrl) return [];
40
+
41
+ const trail = searchPath(folder.children, pageUrl);
42
+ const items: SectionCrumb[] = [];
43
+ for (const node of trail?.slice(0, -1) ?? []) {
44
+ if (node.type === 'folder') {
45
+ items.push({ name: nodeName(node.name), url: node.index?.url });
46
+ }
47
+ }
48
+ items.push({ name: page.data.title });
49
+ return items;
50
+ }
51
+
52
+ export function sectionBreadcrumbPreviews(
53
+ items: readonly SectionCrumb[],
54
+ lang: Lang,
55
+ catalog: DatasetCatalog,
56
+ ): Record<string, PagePreviewData> {
57
+ const previews: Record<string, PagePreviewData> = {};
58
+ for (const item of items) {
59
+ if (!item.url) continue;
60
+ const preview = lookupPagePreview(catalog, item.url, lang);
61
+ if (preview) previews[item.url] = preview;
62
+ }
63
+ return previews;
64
+ }
65
+