@terrafirmagreg-team/kit 1.2.9 → 1.2.11
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.
- package/package.json +6 -6
- package/src/components/blocks/nav-title.tsx +4 -0
- package/src/components/dump-status/stale-badge.tsx +18 -0
- package/src/components/layouts/layout.tsx +15 -5
- package/src/components/page-features.tsx +30 -0
- package/src/components/views/not-found.tsx +112 -11
- package/src/components/views/routes.tsx +8 -6
- package/src/components/views/unavailable.tsx +38 -0
- package/src/hooks/use-dump-status.ts +32 -0
- package/src/hooks/use-nav-options.ts +7 -4
- package/src/lib/config.ts +1 -0
- package/src/lib/datasets.ts +72 -33
- package/src/lib/toc-title.ts +22 -0
- package/src/lib/urls.ts +9 -0
- package/src/types/features.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/kit",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Fumadocs content engine",
|
|
6
6
|
"license": "LGPL-3.0-only",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@base-ui/react": "^1.9.0",
|
|
26
26
|
"@icons-pack/react-simple-icons": "13.15.1",
|
|
27
|
-
"@terrafirmagreg-team/data": "^1.2.
|
|
28
|
-
"@terrafirmagreg-team/mdx": "^1.2.
|
|
29
|
-
"@terrafirmagreg-team/sites": "^1.2.
|
|
30
|
-
"@terrafirmagreg-team/translate": "^1.2.
|
|
31
|
-
"@terrafirmagreg-team/ui": "^1.2.
|
|
27
|
+
"@terrafirmagreg-team/data": "^1.2.3",
|
|
28
|
+
"@terrafirmagreg-team/mdx": "^1.2.5",
|
|
29
|
+
"@terrafirmagreg-team/sites": "^1.2.2",
|
|
30
|
+
"@terrafirmagreg-team/translate": "^1.2.7",
|
|
31
|
+
"@terrafirmagreg-team/ui": "^1.2.8",
|
|
32
32
|
"fumadocs-core": "16.17.10",
|
|
33
33
|
"lucide-react": "^1.63.0",
|
|
34
34
|
"server-only": "^0.0.1",
|
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
} from '@terrafirmagreg-team/sites/location';
|
|
14
14
|
import { setVersionPreference } from '@terrafirmagreg-team/kit/hooks/use-version-pref';
|
|
15
15
|
import { useVersionedDataset } from '@terrafirmagreg-team/kit/hooks/use-versioned-dataset';
|
|
16
|
+
import { useSectionNames } from '@terrafirmagreg-team/kit/hooks/use-section-names';
|
|
16
17
|
import { VersionMenu } from '@terrafirmagreg-team/kit/components/version-menu';
|
|
17
18
|
|
|
18
19
|
export function NavTitle({ className, ...props }: ComponentProps<'a'>) {
|
|
19
20
|
const { section, dataset } = parseSectionLocation(usePathname());
|
|
21
|
+
const sectionNames = useSectionNames();
|
|
20
22
|
const datasetId = dataset;
|
|
21
23
|
|
|
22
24
|
const gameData = isDumpSection(section) && datasetId;
|
|
@@ -52,6 +54,8 @@ export function NavTitle({ className, ...props }: ComponentProps<'a'>) {
|
|
|
52
54
|
versions={versions}
|
|
53
55
|
onSelect={(next) => setVersionPreference(datasetId, next)}
|
|
54
56
|
/>
|
|
57
|
+
) : section ? (
|
|
58
|
+
sectionNames[section]?.title
|
|
55
59
|
) : undefined
|
|
56
60
|
}
|
|
57
61
|
className={className}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
4
|
+
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
5
|
+
|
|
6
|
+
export function StaleBadge({ className }: { className?: string }) {
|
|
7
|
+
const t = useTranslations();
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
className={cn(
|
|
11
|
+
'pointer-events-none absolute end-3 top-3 z-20 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-300',
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
>
|
|
15
|
+
{t('cached', { note: 'kit.stale_badge' })}
|
|
16
|
+
</span>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '@terrafirmagreg-team/ui/components/blocks/nav/site-menu';
|
|
22
22
|
import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
|
|
23
23
|
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
24
|
+
import { skeletonTree } from '@terrafirmagreg-team/ui/lib/tree';
|
|
24
25
|
import { Globe } from 'lucide-react';
|
|
25
26
|
import {
|
|
26
27
|
SiCurseforge,
|
|
@@ -37,6 +38,7 @@ import { LangLocalesScope } from '@terrafirmagreg-team/translate/provider';
|
|
|
37
38
|
import { localeItems } from '@terrafirmagreg-team/translate/create';
|
|
38
39
|
import { siteLinks } from '@terrafirmagreg-team/sites';
|
|
39
40
|
import { useCurrentDataset } from '@terrafirmagreg-team/kit/hooks/use-current-dataset';
|
|
41
|
+
import { useFailedDatasets } from '@terrafirmagreg-team/kit/hooks/use-dump-status';
|
|
40
42
|
import { useDatasetAccent } from '@terrafirmagreg-team/kit/hooks/use-dataset-accent';
|
|
41
43
|
import { useNavOptions } from '@terrafirmagreg-team/kit/hooks/use-nav-options';
|
|
42
44
|
import { pinTreeDataset } from '@terrafirmagreg-team/kit/lib/pin-tree';
|
|
@@ -57,6 +59,7 @@ export type LayoutProps = {
|
|
|
57
59
|
useTree?: (tree: PageTree.Root) => PageTree.Root;
|
|
58
60
|
sidebar?: Pick<LeftSidebarSlotProps, 'feature'>;
|
|
59
61
|
navTitle?: ComponentType<ComponentProps<'a'>>;
|
|
62
|
+
defaultOpenLevel?: number;
|
|
60
63
|
children?: ReactNode;
|
|
61
64
|
};
|
|
62
65
|
|
|
@@ -78,15 +81,19 @@ export function Layout({
|
|
|
78
81
|
useTree = identityTree,
|
|
79
82
|
sidebar,
|
|
80
83
|
navTitle,
|
|
84
|
+
defaultOpenLevel,
|
|
81
85
|
children,
|
|
82
86
|
}: LayoutProps) {
|
|
83
87
|
const t = useTranslations();
|
|
84
88
|
const { defaultLanguage } = useLocaleRegistry();
|
|
85
89
|
const lang: Lang = useResolvedLang();
|
|
86
|
-
const
|
|
90
|
+
const sectionDatasets = catalog[section] ?? [];
|
|
91
|
+
const current = useCurrentDataset(sectionDatasets);
|
|
87
92
|
const baseId = current ? current.id : undefined;
|
|
88
93
|
const pinned = current ? current.version : undefined;
|
|
89
|
-
const meta =
|
|
94
|
+
const meta = sectionDatasets.find((item) => item.id === baseId);
|
|
95
|
+
const failedDatasets = useFailedDatasets();
|
|
96
|
+
const datasetFailed = baseId !== undefined && failedDatasets.includes(baseId);
|
|
90
97
|
useDatasetAccent(meta?.accent);
|
|
91
98
|
const nav = useNavOptions({ section, catalog });
|
|
92
99
|
const pathname = usePathname();
|
|
@@ -104,12 +111,14 @@ export function Layout({
|
|
|
104
111
|
$id: `${section}:empty`,
|
|
105
112
|
};
|
|
106
113
|
const tree = useTree(serverTree);
|
|
114
|
+
const effectiveTree =
|
|
115
|
+
datasetFailed && !tree.children.length ? skeletonTree() : tree;
|
|
107
116
|
const pinnedTree = useMemo(
|
|
108
117
|
() =>
|
|
109
118
|
pinned && baseId
|
|
110
|
-
? pinTreeDataset(
|
|
111
|
-
:
|
|
112
|
-
[
|
|
119
|
+
? pinTreeDataset(effectiveTree, section, baseId, pinned)
|
|
120
|
+
: effectiveTree,
|
|
121
|
+
[effectiveTree, section, baseId, pinned],
|
|
113
122
|
);
|
|
114
123
|
const locales = meta?.languages ? localeItems(meta.languages) : undefined;
|
|
115
124
|
const links = baseId
|
|
@@ -139,6 +148,7 @@ export function Layout({
|
|
|
139
148
|
<UiLayout
|
|
140
149
|
tree={pinnedTree}
|
|
141
150
|
navTitle={navTitle}
|
|
151
|
+
defaultOpenLevel={defaultOpenLevel}
|
|
142
152
|
sidebar={
|
|
143
153
|
<LeftSidebar
|
|
144
154
|
navKeys={NAV_KEYS}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SectionFeatures,
|
|
3
|
+
SectionPageContext,
|
|
4
|
+
} from '../types/features.ts';
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
export function pageFeatureNodes(
|
|
8
|
+
ctx: SectionPageContext,
|
|
9
|
+
features: SectionFeatures,
|
|
10
|
+
body: ReactNode,
|
|
11
|
+
): {
|
|
12
|
+
actionsRight: ReactNode;
|
|
13
|
+
body: ReactNode;
|
|
14
|
+
footer: ReactNode;
|
|
15
|
+
comments: ReactNode;
|
|
16
|
+
} {
|
|
17
|
+
const wrapped = features.wrapBody ? (
|
|
18
|
+
features.wrapBody(body, ctx)
|
|
19
|
+
) : (
|
|
20
|
+
<div data-md-root className="contents">
|
|
21
|
+
{body}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
return {
|
|
25
|
+
actionsRight: features.actionsRight?.(ctx),
|
|
26
|
+
body: wrapped,
|
|
27
|
+
footer: features.footer?.(ctx),
|
|
28
|
+
comments: features.comments?.(ctx),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
4
|
+
import { isSectionKey, sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
4
5
|
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
5
6
|
import { LangLink } from '@terrafirmagreg-team/ui/components/blocks/lang/link';
|
|
6
7
|
import { EmptyState } from '@terrafirmagreg-team/ui/components/blocks/page/empty-state';
|
|
8
|
+
import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
|
|
9
|
+
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
10
|
+
import { requestDumpRetry } from '@terrafirmagreg-team/data/lib/dump-status';
|
|
11
|
+
import { useDumpStatus } from '@terrafirmagreg-team/kit/hooks/use-dump-status';
|
|
12
|
+
import { useSectionCatalog } from '@terrafirmagreg-team/kit/hooks/use-catalog';
|
|
7
13
|
import type { SectionDataset, SectionKey } from '@terrafirmagreg-team/kit/types';
|
|
8
14
|
|
|
9
15
|
export function SectionNotFound({
|
|
@@ -14,24 +20,119 @@ export function SectionNotFound({
|
|
|
14
20
|
datasets: readonly SectionDataset[];
|
|
15
21
|
}) {
|
|
16
22
|
const t = useTranslations();
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
const { statuses } = useDumpStatus();
|
|
25
|
+
const hadFailure = useRef(false);
|
|
26
|
+
const anyFailed = Object.values(statuses).some(
|
|
27
|
+
(state) => state.status === 'failed',
|
|
28
|
+
);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (anyFailed) {
|
|
31
|
+
hadFailure.current = true;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (hadFailure.current && Object.keys(statuses).length) {
|
|
35
|
+
hadFailure.current = false;
|
|
36
|
+
router.refresh();
|
|
37
|
+
}
|
|
38
|
+
}, [anyFailed, statuses, router]);
|
|
39
|
+
|
|
17
40
|
if (!datasets.length)
|
|
18
41
|
return (
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
<div className="mx-2 flex min-w-0 flex-1 flex-col items-center justify-center gap-3 bg-background [grid-area:main] md:my-2 md:overflow-clip md:rounded-2xl md:border md:shadow-sm">
|
|
43
|
+
<EmptyState variant="missing">
|
|
44
|
+
{t('Section data is temporarily unavailable. Try again later.', {
|
|
45
|
+
note: 'kit.section_unavailable',
|
|
46
|
+
})}
|
|
47
|
+
</EmptyState>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={requestDumpRetry}
|
|
51
|
+
className={cn(buttonVariants({ variant: 'outline', size: 'xs' }))}
|
|
52
|
+
>
|
|
53
|
+
{t('Retry now', { note: 'kit.retry_now' })}
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
24
56
|
);
|
|
25
57
|
return (
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
58
|
+
<div className="mx-2 flex min-w-0 flex-1 flex-col items-center justify-center gap-6 bg-background [grid-area:main] md:my-2 md:overflow-clip md:rounded-2xl md:border md:shadow-sm">
|
|
59
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
60
|
+
<span
|
|
61
|
+
aria-hidden
|
|
62
|
+
className="select-none text-6xl leading-none font-semibold tracking-tighter text-muted-foreground/25"
|
|
63
|
+
>
|
|
64
|
+
404
|
|
65
|
+
</span>
|
|
66
|
+
<p className="font-medium text-foreground">
|
|
67
|
+
{t('Page not found', { note: 'ui.not_found' })}
|
|
68
|
+
</p>
|
|
69
|
+
<p className="max-w-sm text-sm text-balance text-muted-foreground">
|
|
70
|
+
{t("The page you're looking for doesn't exist or was moved.", {
|
|
71
|
+
note: 'ui.not_found_description',
|
|
72
|
+
})}
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
29
75
|
<LangLink
|
|
30
76
|
href={sectionUrl(section, datasets[0].id)}
|
|
31
|
-
className=
|
|
77
|
+
className={cn(
|
|
78
|
+
buttonVariants({ variant: 'outline', size: 'sm' }),
|
|
79
|
+
'no-underline',
|
|
80
|
+
)}
|
|
32
81
|
>
|
|
33
82
|
{t('Back', { note: 'ui.back' })}
|
|
34
83
|
</LangLink>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function RootNotFound() {
|
|
89
|
+
const pathname = usePathname();
|
|
90
|
+
const catalog = useSectionCatalog();
|
|
91
|
+
const t = useTranslations();
|
|
92
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
93
|
+
const section = isSectionKey(parts[0]) ? parts[0] : undefined;
|
|
94
|
+
const datasets = section ? catalog[section] : undefined;
|
|
95
|
+
const known = datasets?.some((dataset) => dataset.id === parts[1]);
|
|
96
|
+
const href =
|
|
97
|
+
section && datasets?.length
|
|
98
|
+
? `/${section}/${known ? parts[1] : datasets[0].id}`
|
|
99
|
+
: '/';
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<main className="flex min-h-dvh flex-col items-center justify-center gap-8 p-8">
|
|
103
|
+
<div className="flex flex-col items-center gap-3 text-center">
|
|
104
|
+
<span
|
|
105
|
+
aria-hidden
|
|
106
|
+
className="select-none bg-gradient-to-b from-muted-foreground/40 to-muted-foreground/10 bg-clip-text text-[8rem] leading-none font-semibold tracking-tighter text-transparent"
|
|
107
|
+
>
|
|
108
|
+
404
|
|
109
|
+
</span>
|
|
110
|
+
<h1 className="text-xl font-semibold text-foreground">
|
|
111
|
+
{t('Page not found', { note: 'ui.not_found' })}
|
|
112
|
+
</h1>
|
|
113
|
+
<p className="max-w-sm text-sm text-balance text-muted-foreground">
|
|
114
|
+
{t("The page you're looking for doesn't exist or was moved.", {
|
|
115
|
+
note: 'ui.not_found_description',
|
|
116
|
+
})}
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex items-center gap-2">
|
|
120
|
+
<LangLink
|
|
121
|
+
href={href}
|
|
122
|
+
className={cn(buttonVariants({ size: 'sm' }), 'no-underline')}
|
|
123
|
+
>
|
|
124
|
+
{t('Back', { note: 'ui.back' })}
|
|
125
|
+
</LangLink>
|
|
126
|
+
<LangLink
|
|
127
|
+
href="/"
|
|
128
|
+
className={cn(
|
|
129
|
+
buttonVariants({ variant: 'ghost', size: 'sm' }),
|
|
130
|
+
'no-underline',
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
{t('Go home', { note: 'ui.go_home' })}
|
|
134
|
+
</LangLink>
|
|
135
|
+
</div>
|
|
35
136
|
</main>
|
|
36
137
|
);
|
|
37
138
|
}
|
|
@@ -193,8 +193,8 @@ export function createSectionRoutes<
|
|
|
193
193
|
render(
|
|
194
194
|
await source.getSource(),
|
|
195
195
|
slug,
|
|
196
|
-
await trees?.(),
|
|
197
|
-
await datasetCatalog(),
|
|
196
|
+
await trees?.().catch(() => undefined),
|
|
197
|
+
await datasetCatalog().catch((): DatasetCatalog => ({}) as DatasetCatalog),
|
|
198
198
|
page,
|
|
199
199
|
lang,
|
|
200
200
|
),
|
|
@@ -203,8 +203,10 @@ export function createSectionRoutes<
|
|
|
203
203
|
async function SectionLayout({ children }: { children?: ReactNode }) {
|
|
204
204
|
const { slots } = baseOptions();
|
|
205
205
|
const [resolvedTrees, catalog] = await Promise.all([
|
|
206
|
-
trees
|
|
207
|
-
|
|
206
|
+
trees
|
|
207
|
+
? trees().catch((): SectionTrees => ({}))
|
|
208
|
+
: Promise.resolve<SectionTrees>({}),
|
|
209
|
+
datasetCatalog().catch((): DatasetCatalog => ({}) as DatasetCatalog),
|
|
208
210
|
]);
|
|
209
211
|
return (
|
|
210
212
|
<LayoutView
|
|
@@ -237,8 +239,8 @@ export function createSectionRoutes<
|
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
const [resolvedTrees, catalog] = await Promise.all([
|
|
240
|
-
trees?.(),
|
|
241
|
-
datasetCatalog(),
|
|
242
|
+
trees?.().catch(() => undefined),
|
|
243
|
+
datasetCatalog().catch((): DatasetCatalog => ({}) as DatasetCatalog),
|
|
242
244
|
]);
|
|
243
245
|
const content = langContent(
|
|
244
246
|
slug,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { CloudOff } from 'lucide-react';
|
|
5
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
6
|
+
import { requestDumpRetry } from '@terrafirmagreg-team/data/lib/dump-status';
|
|
7
|
+
import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
|
|
8
|
+
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
9
|
+
|
|
10
|
+
export function DumpUnavailable({ children }: { children?: ReactNode }) {
|
|
11
|
+
const t = useTranslations();
|
|
12
|
+
return (
|
|
13
|
+
<div className="pointer-events-auto flex max-w-xs flex-col items-center gap-2.5 rounded-lg border border-border bg-popover px-5 py-4 text-center shadow-lg">
|
|
14
|
+
<CloudOff className="size-5 text-muted-foreground" />
|
|
15
|
+
<p className="text-sm text-muted-foreground">
|
|
16
|
+
{children ??
|
|
17
|
+
t('Section data is temporarily unavailable. Try again later.', {
|
|
18
|
+
note: 'kit.section_unavailable',
|
|
19
|
+
})}
|
|
20
|
+
</p>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
onClick={requestDumpRetry}
|
|
24
|
+
className={cn(buttonVariants({ variant: 'outline', size: 'xs' }))}
|
|
25
|
+
>
|
|
26
|
+
{t('Retry now', { note: 'kit.retry_now' })}
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function DumpUnavailableOverlay({ children }: { children?: ReactNode }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center p-6">
|
|
35
|
+
{children ?? <DumpUnavailable />}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
dumpStatusSnapshot,
|
|
6
|
+
subscribeDumpStatus,
|
|
7
|
+
type DumpStatusSnapshot,
|
|
8
|
+
} from '@terrafirmagreg-team/data/lib/dump-status';
|
|
9
|
+
|
|
10
|
+
const SERVER_SNAPSHOT: DumpStatusSnapshot = {
|
|
11
|
+
statuses: {},
|
|
12
|
+
retryNonce: 0,
|
|
13
|
+
dismissed: false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function useDumpStatus(): DumpStatusSnapshot {
|
|
17
|
+
return useSyncExternalStore(
|
|
18
|
+
subscribeDumpStatus,
|
|
19
|
+
dumpStatusSnapshot,
|
|
20
|
+
() => SERVER_SNAPSHOT,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useDumpRetryNonce(): number {
|
|
25
|
+
return useDumpStatus().retryNonce;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useFailedDatasets(): string[] {
|
|
29
|
+
return Object.entries(useDumpStatus().statuses)
|
|
30
|
+
.filter(([, state]) => state.status === 'failed')
|
|
31
|
+
.map(([id]) => id);
|
|
32
|
+
}
|
|
@@ -17,6 +17,8 @@ import type {
|
|
|
17
17
|
SwitcherProps,
|
|
18
18
|
} from '@terrafirmagreg-team/ui/types/nav';
|
|
19
19
|
|
|
20
|
+
const EMPTY_DATASETS: never[] = [];
|
|
21
|
+
|
|
20
22
|
function datasetIcon(dataset: { icon?: IconName }): ReactNode {
|
|
21
23
|
if (!dataset.icon) return undefined;
|
|
22
24
|
return createElement(DynamicIcon, {
|
|
@@ -31,7 +33,8 @@ export function useNavOptions({ section, catalog }: LeftSidebarScope): {
|
|
|
31
33
|
} {
|
|
32
34
|
const t = useTranslations();
|
|
33
35
|
const sectionNames = useSectionNames();
|
|
34
|
-
const
|
|
36
|
+
const sectionDatasets = catalog[section] ?? EMPTY_DATASETS;
|
|
37
|
+
const current = useCurrentDataset(sectionDatasets);
|
|
35
38
|
const baseId = current ? current.id : undefined;
|
|
36
39
|
const pinned = current ? current.version : undefined;
|
|
37
40
|
const preferred = useVersionPreference(baseId);
|
|
@@ -41,7 +44,7 @@ export function useNavOptions({ section, catalog }: LeftSidebarScope): {
|
|
|
41
44
|
() =>
|
|
42
45
|
sectionTabs.map(({ key, icon: Icon }) => {
|
|
43
46
|
const meta = baseId
|
|
44
|
-
? catalog[key]
|
|
47
|
+
? catalog[key]?.find(({ id }) => id === baseId)
|
|
45
48
|
: undefined;
|
|
46
49
|
const pinned =
|
|
47
50
|
isDumpSection(key) && baseId
|
|
@@ -61,14 +64,14 @@ export function useNavOptions({ section, catalog }: LeftSidebarScope): {
|
|
|
61
64
|
|
|
62
65
|
const datasetOptions: SwitcherOption[] = useMemo(
|
|
63
66
|
() =>
|
|
64
|
-
|
|
67
|
+
sectionDatasets.map((datasetItem) => ({
|
|
65
68
|
value: datasetItem.id,
|
|
66
69
|
title: datasetItem.title ?? datasetItem.id,
|
|
67
70
|
description: datasetItem.description,
|
|
68
71
|
icon: datasetIcon(datasetItem),
|
|
69
72
|
url: sectionUrl(section, datasetItem.id),
|
|
70
73
|
})),
|
|
71
|
-
[section,
|
|
74
|
+
[section, sectionDatasets],
|
|
72
75
|
);
|
|
73
76
|
|
|
74
77
|
return {
|
package/src/lib/config.ts
CHANGED
package/src/lib/datasets.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { sectionConfig } from './config';
|
|
|
4
4
|
import { sections } from './sources';
|
|
5
5
|
import { datasetId } from './tree';
|
|
6
6
|
import { createAsyncCache } from '@terrafirmagreg-team/data/lib/async-cache';
|
|
7
|
+
import { dumpStatusSnapshot } from '@terrafirmagreg-team/data/lib/dump-status';
|
|
7
8
|
import { loadLocaleRegistry, sortLangCodes } from '@terrafirmagreg-team/translate/server';
|
|
8
9
|
import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
|
|
9
10
|
import { sectionTabs } from '@terrafirmagreg-team/sites/sections';
|
|
@@ -79,6 +80,30 @@ async function datasetMeta(
|
|
|
79
80
|
return meta ? metaFields(meta) : {};
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
async function sectionDatasetMeta(
|
|
84
|
+
section: SectionKey,
|
|
85
|
+
id: string,
|
|
86
|
+
): Promise<Omit<SectionDataset, 'id' | 'languages'>> {
|
|
87
|
+
const meta = await datasetMeta(section, id);
|
|
88
|
+
return Object.values(meta).some((value) => value !== undefined)
|
|
89
|
+
? meta
|
|
90
|
+
: datasetMeta('docs', id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lastGoodDatasets = new Map<SectionKey, Map<string, SectionDataset>>();
|
|
94
|
+
|
|
95
|
+
function rememberDataset(section: SectionKey, row: SectionDataset) {
|
|
96
|
+
let good = lastGoodDatasets.get(section);
|
|
97
|
+
if (!good) lastGoodDatasets.set(section, (good = new Map()));
|
|
98
|
+
good.set(row.id, row);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function datasetMembership(section: SectionKey, id: string): boolean {
|
|
102
|
+
const declared = sectionConfig().datasetConfig[id]?.sections;
|
|
103
|
+
if (declared) return declared.includes(section);
|
|
104
|
+
return lastGoodDatasets.get(section)?.has(id) === true;
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
function metaFields(
|
|
83
108
|
meta: Record<string, unknown>,
|
|
84
109
|
): Omit<SectionDataset, 'id' | 'languages'> {
|
|
@@ -144,56 +169,69 @@ async function dumpDatasets(
|
|
|
144
169
|
): Promise<SectionDataset[]> {
|
|
145
170
|
const dump = sectionConfig().dump;
|
|
146
171
|
if (!dump) return [];
|
|
147
|
-
const refs = await dump.discoverDatasets();
|
|
172
|
+
const refs = await dump.discoverDatasets().catch(() => [] as DatasetRef[]);
|
|
148
173
|
const versions = new Map<string, string[]>();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
const ids: string[] = [];
|
|
175
|
+
if (refs.length) {
|
|
176
|
+
for (const ref of refs) {
|
|
177
|
+
const { id, version } = ref;
|
|
178
|
+
if (version) {
|
|
179
|
+
const list = versions.get(id) ?? [];
|
|
180
|
+
list.push(version);
|
|
181
|
+
versions.set(id, list);
|
|
182
|
+
} else {
|
|
183
|
+
ids.push(id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
for (const id of Object.keys(sectionConfig().datasetConfig)) {
|
|
188
|
+
if (datasetMembership(section, id)) ids.push(id);
|
|
155
189
|
}
|
|
156
190
|
}
|
|
157
|
-
const ids = refs.filter((ref) => !ref.version);
|
|
158
|
-
const usable = await Promise.all(
|
|
159
|
-
ids.map(async (ref) => {
|
|
160
|
-
const { id } = ref;
|
|
161
|
-
const index = await dump.loadDataset(ref);
|
|
162
|
-
if (!index || !hasContent(index)) return null;
|
|
163
|
-
return {
|
|
164
|
-
id,
|
|
165
|
-
languages: datasetLanguages(index.manifest.languages ?? []),
|
|
166
|
-
};
|
|
167
|
-
}),
|
|
168
|
-
);
|
|
169
191
|
return (
|
|
170
192
|
await Promise.all(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
193
|
+
ids.map(async (id): Promise<SectionDataset | null> => {
|
|
194
|
+
const index = await dump.loadDataset({ id }).catch(() => null);
|
|
195
|
+
if (!index) {
|
|
196
|
+
if (dumpStatusSnapshot().statuses[id]?.status !== 'failed')
|
|
197
|
+
return null;
|
|
198
|
+
const good = lastGoodDatasets.get(section)?.get(id);
|
|
199
|
+
if (good) return good;
|
|
200
|
+
if (!datasetMembership(section, id)) return null;
|
|
201
|
+
return {
|
|
202
|
+
id,
|
|
203
|
+
languages: datasetLanguages([]),
|
|
204
|
+
...(await sectionDatasetMeta(section, id)),
|
|
205
|
+
...datasetAppearance(id),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!hasContent(index)) return null;
|
|
209
|
+
const row: SectionDataset = {
|
|
174
210
|
id,
|
|
175
|
-
languages,
|
|
211
|
+
languages: datasetLanguages(index.manifest.languages ?? []),
|
|
176
212
|
...(versions.get(id)?.length
|
|
177
213
|
? { versions: versions.get(id)!.sort(compareVersions).reverse() }
|
|
178
214
|
: {}),
|
|
179
|
-
...(await
|
|
180
|
-
Object.values(meta).some((value) => value !== undefined)
|
|
181
|
-
? meta
|
|
182
|
-
: datasetMeta('docs', id),
|
|
183
|
-
)),
|
|
215
|
+
...(await sectionDatasetMeta(section, id)),
|
|
184
216
|
...datasetAppearance(id),
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
217
|
+
};
|
|
218
|
+
rememberDataset(section, row);
|
|
219
|
+
return row;
|
|
220
|
+
}),
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
.filter((row): row is SectionDataset => row !== null)
|
|
224
|
+
.sort(datasetOrder);
|
|
188
225
|
}
|
|
189
226
|
|
|
190
227
|
export function sectionDatasets(
|
|
191
228
|
section: SectionKey,
|
|
192
229
|
): Promise<SectionDataset[]> {
|
|
193
230
|
const hasContent = sectionConfig().dump?.hasContent[section];
|
|
194
|
-
|
|
231
|
+
const promise = hasContent
|
|
195
232
|
? dumpSectionDatasets(section, hasContent)
|
|
196
233
|
: contentSectionDatasetsCached(section);
|
|
234
|
+
return promise.catch(() => []);
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
export async function defaultDataset(
|
|
@@ -228,7 +266,8 @@ const datasetCatalogCached = createAsyncCache(
|
|
|
228
266
|
async (): Promise<DatasetCatalog> =>
|
|
229
267
|
Promise.all(
|
|
230
268
|
sectionTabs.map(
|
|
231
|
-
async ({ key }) =>
|
|
269
|
+
async ({ key }) =>
|
|
270
|
+
[key, await sectionDatasets(key).catch(() => [])] as const,
|
|
232
271
|
),
|
|
233
272
|
).then((rows) => Object.fromEntries(rows) as DatasetCatalog),
|
|
234
273
|
() => 'catalog',
|
package/src/lib/toc-title.ts
CHANGED
|
@@ -8,6 +8,27 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from 'react';
|
|
10
10
|
|
|
11
|
+
function isPresentation(element: ReactElement): boolean {
|
|
12
|
+
const props = element.props as Record<string, unknown>;
|
|
13
|
+
if (props['data-md'] !== undefined) return true;
|
|
14
|
+
const type = element.type as { mdPresentation?: boolean } | string;
|
|
15
|
+
return typeof type !== 'string' && type.mdPresentation === true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function textOf(node: ReactNode): string {
|
|
19
|
+
return Children.toArray(node)
|
|
20
|
+
.map((child) => {
|
|
21
|
+
if (typeof child === 'string' || typeof child === 'number')
|
|
22
|
+
return String(child);
|
|
23
|
+
if (isValidElement(child)) {
|
|
24
|
+
const element = child as ReactElement<{ children?: ReactNode }>;
|
|
25
|
+
return textOf(element.props.children);
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
})
|
|
29
|
+
.join('');
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
function unwrap(node: ReactNode): ReactNode {
|
|
12
33
|
return Children.map(node, (child) => {
|
|
13
34
|
if (!isValidElement(child)) return child;
|
|
@@ -15,6 +36,7 @@ function unwrap(node: ReactNode): ReactNode {
|
|
|
15
36
|
href?: unknown;
|
|
16
37
|
children?: ReactNode;
|
|
17
38
|
}>;
|
|
39
|
+
if (isPresentation(element)) return textOf(element.props.children);
|
|
18
40
|
if (element.props.href !== undefined) {
|
|
19
41
|
return unwrap(element.props.children);
|
|
20
42
|
}
|
package/src/lib/urls.ts
CHANGED
|
@@ -15,3 +15,12 @@ export function getDatasetImageUrl(section: string, dataset: string) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function createGitFileUrl(git: {
|
|
19
|
+
user: string;
|
|
20
|
+
repo: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
}) {
|
|
23
|
+
return (filePath: string) =>
|
|
24
|
+
`https://github.com/${git.user}/${git.repo}/blob/${git.branch}/${filePath}`;
|
|
25
|
+
}
|
|
26
|
+
|