@terrafirmagreg-team/kit 1.2.10 → 1.2.12
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/layouts/layout.tsx +8 -5
- package/src/components/page-actions.tsx +9 -27
- package/src/components/page-features.tsx +30 -0
- package/src/components/views/dataset.tsx +2 -15
- package/src/components/views/not-found.tsx +74 -8
- package/src/components/views/routes.tsx +4 -13
- package/src/components/views/section.tsx +17 -1
- package/src/components/views/unavailable.tsx +13 -4
- package/src/lib/config.ts +1 -0
- package/src/lib/datasets.ts +68 -31
- package/src/lib/jsonld.ts +46 -0
- package/src/lib/landing.ts +34 -0
- package/src/lib/page.ts +3 -17
- package/src/lib/urls.ts +18 -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.12",
|
|
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.6",
|
|
29
|
+
"@terrafirmagreg-team/sites": "^1.2.2",
|
|
30
|
+
"@terrafirmagreg-team/translate": "^1.2.8",
|
|
31
|
+
"@terrafirmagreg-team/ui": "^1.2.9",
|
|
32
32
|
"fumadocs-core": "16.17.10",
|
|
33
33
|
"lucide-react": "^1.63.0",
|
|
34
34
|
"server-only": "^0.0.1",
|
|
@@ -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,
|
|
@@ -92,7 +93,7 @@ export function Layout({
|
|
|
92
93
|
const pinned = current ? current.version : undefined;
|
|
93
94
|
const meta = sectionDatasets.find((item) => item.id === baseId);
|
|
94
95
|
const failedDatasets = useFailedDatasets();
|
|
95
|
-
const
|
|
96
|
+
const datasetFailed = baseId !== undefined && failedDatasets.includes(baseId);
|
|
96
97
|
useDatasetAccent(meta?.accent);
|
|
97
98
|
const nav = useNavOptions({ section, catalog });
|
|
98
99
|
const pathname = usePathname();
|
|
@@ -110,12 +111,14 @@ export function Layout({
|
|
|
110
111
|
$id: `${section}:empty`,
|
|
111
112
|
};
|
|
112
113
|
const tree = useTree(serverTree);
|
|
114
|
+
const effectiveTree =
|
|
115
|
+
datasetFailed && !tree.children.length ? skeletonTree() : tree;
|
|
113
116
|
const pinnedTree = useMemo(
|
|
114
117
|
() =>
|
|
115
118
|
pinned && baseId
|
|
116
|
-
? pinTreeDataset(
|
|
117
|
-
:
|
|
118
|
-
[
|
|
119
|
+
? pinTreeDataset(effectiveTree, section, baseId, pinned)
|
|
120
|
+
: effectiveTree,
|
|
121
|
+
[effectiveTree, section, baseId, pinned],
|
|
119
122
|
);
|
|
120
123
|
const locales = meta?.languages ? localeItems(meta.languages) : undefined;
|
|
121
124
|
const links = baseId
|
|
@@ -177,7 +180,7 @@ export function Layout({
|
|
|
177
180
|
<LeftSidebarSearch />
|
|
178
181
|
<Switcher {...nav.section} />
|
|
179
182
|
<Switcher {...nav.dataset} />
|
|
180
|
-
{Feature
|
|
183
|
+
{Feature ? (
|
|
181
184
|
<>
|
|
182
185
|
<LeftSidebarSeparator />
|
|
183
186
|
<Feature />
|
|
@@ -20,10 +20,6 @@ import { ItemRow } from '@terrafirmagreg-team/ui/components/blocks/list/row';
|
|
|
20
20
|
import { useUntranslated } from '@terrafirmagreg-team/ui/hooks/use-lang';
|
|
21
21
|
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
22
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
23
|
import type { TranslationState } from '@terrafirmagreg-team/translate/server';
|
|
28
24
|
|
|
29
25
|
function ActionButton({
|
|
@@ -70,12 +66,10 @@ export function MarkdownCopyButton({
|
|
|
70
66
|
);
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
export function CopyPageMarkdown({
|
|
69
|
+
export function CopyPageMarkdown({ mdUrl }: { mdUrl: string }) {
|
|
74
70
|
return (
|
|
75
71
|
<MarkdownCopyButton
|
|
76
|
-
getMarkdown={() =>
|
|
77
|
-
pageMarkdown(document.querySelector('[data-md-root]'), title)
|
|
78
|
-
}
|
|
72
|
+
getMarkdown={() => fetch(mdUrl).then((res) => res.text())}
|
|
79
73
|
/>
|
|
80
74
|
);
|
|
81
75
|
}
|
|
@@ -107,12 +101,10 @@ export function UntranslatedActions({
|
|
|
107
101
|
|
|
108
102
|
export function ViewOptionsPopover({
|
|
109
103
|
githubUrl,
|
|
110
|
-
title,
|
|
111
104
|
mdUrl,
|
|
112
105
|
...props
|
|
113
106
|
}: ComponentProps<typeof PopoverTrigger> & {
|
|
114
107
|
githubUrl?: string;
|
|
115
|
-
title?: string;
|
|
116
108
|
mdUrl?: string;
|
|
117
109
|
}) {
|
|
118
110
|
const t = useTranslations();
|
|
@@ -122,17 +114,9 @@ export function ViewOptionsPopover({
|
|
|
122
114
|
href: githubUrl,
|
|
123
115
|
icon: <SiGithub color="currentColor" className="size-4" />,
|
|
124
116
|
},
|
|
125
|
-
|
|
117
|
+
mdUrl && {
|
|
126
118
|
title: t('View as Markdown', { note: 'ui.view_markdown' }),
|
|
127
|
-
|
|
128
|
-
? { href: mdUrl }
|
|
129
|
-
: {
|
|
130
|
-
onClick: () =>
|
|
131
|
-
void openPageMarkdown(
|
|
132
|
-
document.querySelector('[data-md-root]'),
|
|
133
|
-
title!,
|
|
134
|
-
),
|
|
135
|
-
}),
|
|
119
|
+
href: mdUrl,
|
|
136
120
|
icon: <TextIcon className="size-4" />,
|
|
137
121
|
},
|
|
138
122
|
].filter((v) => !!v);
|
|
@@ -163,9 +147,9 @@ export function ViewOptionsPopover({
|
|
|
163
147
|
{items.map((item) => (
|
|
164
148
|
<ItemRow
|
|
165
149
|
key={item.title}
|
|
166
|
-
{
|
|
167
|
-
|
|
168
|
-
|
|
150
|
+
href={item.href}
|
|
151
|
+
target="_blank"
|
|
152
|
+
rel="noreferrer noopener"
|
|
169
153
|
media={item.icon}
|
|
170
154
|
title={item.title}
|
|
171
155
|
trailing={
|
|
@@ -181,20 +165,18 @@ export function ViewOptionsPopover({
|
|
|
181
165
|
export function PageActions({
|
|
182
166
|
copied,
|
|
183
167
|
crowdinUrl,
|
|
184
|
-
title,
|
|
185
168
|
mdUrl,
|
|
186
169
|
githubUrl,
|
|
187
170
|
}: {
|
|
188
171
|
copied?: TranslationState;
|
|
189
172
|
crowdinUrl?: string;
|
|
190
|
-
title: string;
|
|
191
173
|
mdUrl?: string;
|
|
192
174
|
githubUrl?: string;
|
|
193
175
|
}) {
|
|
194
176
|
return (
|
|
195
177
|
<>
|
|
196
|
-
<CopyPageMarkdown
|
|
197
|
-
<ViewOptionsPopover
|
|
178
|
+
{mdUrl ? <CopyPageMarkdown mdUrl={mdUrl} /> : null}
|
|
179
|
+
<ViewOptionsPopover mdUrl={mdUrl} githubUrl={githubUrl} />
|
|
198
180
|
<UntranslatedActions copied={copied} crowdinUrl={crowdinUrl} />
|
|
199
181
|
</>
|
|
200
182
|
);
|
|
@@ -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
|
+
}
|
|
@@ -3,13 +3,11 @@ import { notFound } from 'next/navigation';
|
|
|
3
3
|
import type { Metadata } from 'next';
|
|
4
4
|
import { LangContentSwitch } from '@terrafirmagreg-team/ui/components/blocks/lang/content';
|
|
5
5
|
import {
|
|
6
|
+
datasetLandingMetadata,
|
|
6
7
|
generateDatasetLandingParams,
|
|
7
|
-
getDatasetLanding,
|
|
8
8
|
resolveDatasetLanding,
|
|
9
9
|
} from '@terrafirmagreg-team/kit/lib/landing';
|
|
10
10
|
import { sectionDatasets } from '@terrafirmagreg-team/kit/lib/datasets';
|
|
11
|
-
import { sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
12
|
-
import { canonicalUrl } from '@terrafirmagreg-team/kit/lib/urls';
|
|
13
11
|
import type { DatasetLanding, SectionKey } from '@terrafirmagreg-team/kit/types';
|
|
14
12
|
import {
|
|
15
13
|
localeLoader,
|
|
@@ -58,22 +56,11 @@ export function createDatasetLandingPage<S extends SectionKey>(
|
|
|
58
56
|
params,
|
|
59
57
|
}: DatasetPageParams): Promise<Metadata> {
|
|
60
58
|
const { slug } = await params;
|
|
61
|
-
|
|
59
|
+
return datasetLandingMetadata(
|
|
62
60
|
section,
|
|
63
61
|
slug,
|
|
64
|
-
'en_us',
|
|
65
62
|
await sectionDatasets(section),
|
|
66
63
|
);
|
|
67
|
-
if (!page) notFound();
|
|
68
|
-
return {
|
|
69
|
-
title: page.title,
|
|
70
|
-
description: page.description,
|
|
71
|
-
alternates: {
|
|
72
|
-
canonical: canonicalUrl(
|
|
73
|
-
`${sectionUrl(section)}/${(slug ?? []).join('/')}`,
|
|
74
|
-
),
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
64
|
}
|
|
78
65
|
|
|
79
66
|
return { Page, generateStaticParams, generateMetadata };
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useEffect, useRef } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
3
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
4
|
+
import { isSectionKey, sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
6
5
|
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
7
6
|
import { LangLink } from '@terrafirmagreg-team/ui/components/blocks/lang/link';
|
|
8
7
|
import { EmptyState } from '@terrafirmagreg-team/ui/components/blocks/page/empty-state';
|
|
@@ -10,6 +9,7 @@ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
|
|
|
10
9
|
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
11
10
|
import { requestDumpRetry } from '@terrafirmagreg-team/data/lib/dump-status';
|
|
12
11
|
import { useDumpStatus } from '@terrafirmagreg-team/kit/hooks/use-dump-status';
|
|
12
|
+
import { useSectionCatalog } from '@terrafirmagreg-team/kit/hooks/use-catalog';
|
|
13
13
|
import type { SectionDataset, SectionKey } from '@terrafirmagreg-team/kit/types';
|
|
14
14
|
|
|
15
15
|
export function SectionNotFound({
|
|
@@ -39,7 +39,7 @@ export function SectionNotFound({
|
|
|
39
39
|
|
|
40
40
|
if (!datasets.length)
|
|
41
41
|
return (
|
|
42
|
-
<div className="flex flex-col items-center gap-3
|
|
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
43
|
<EmptyState variant="missing">
|
|
44
44
|
{t('Section data is temporarily unavailable. Try again later.', {
|
|
45
45
|
note: 'kit.section_unavailable',
|
|
@@ -55,13 +55,27 @@ export function SectionNotFound({
|
|
|
55
55
|
</div>
|
|
56
56
|
);
|
|
57
57
|
return (
|
|
58
|
-
<div className="flex flex-col items-center gap-
|
|
59
|
-
<
|
|
60
|
-
|
|
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>
|
|
61
75
|
<LangLink
|
|
62
76
|
href={sectionUrl(section, datasets[0].id)}
|
|
63
77
|
className={cn(
|
|
64
|
-
buttonVariants({ variant: 'outline', size: '
|
|
78
|
+
buttonVariants({ variant: 'outline', size: 'sm' }),
|
|
65
79
|
'no-underline',
|
|
66
80
|
)}
|
|
67
81
|
>
|
|
@@ -70,3 +84,55 @@ export function SectionNotFound({
|
|
|
70
84
|
</div>
|
|
71
85
|
);
|
|
72
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>
|
|
136
|
+
</main>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
sectionDatasets,
|
|
21
21
|
} from '@terrafirmagreg-team/kit/lib/datasets';
|
|
22
22
|
import {
|
|
23
|
+
datasetLandingMetadata,
|
|
23
24
|
generateDatasetLandingParams,
|
|
24
25
|
getDatasetLanding,
|
|
25
26
|
} from '@terrafirmagreg-team/kit/lib/landing';
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
localeLoader,
|
|
34
35
|
registerLocaleRenderer,
|
|
35
36
|
} from '@terrafirmagreg-team/kit/lib/locale/renderer';
|
|
36
|
-
import { canonicalUrl } from '@terrafirmagreg-team/kit/lib/urls';
|
|
37
37
|
import { sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
38
38
|
import { langContent, loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
39
39
|
import type {
|
|
@@ -170,6 +170,7 @@ export function createSectionRoutes<
|
|
|
170
170
|
}
|
|
171
171
|
if (!PageView) return null;
|
|
172
172
|
return renderSectionWorkspace({
|
|
173
|
+
section,
|
|
173
174
|
source: src as SectionSource,
|
|
174
175
|
slug,
|
|
175
176
|
contentDir: source.contentDir ?? `content/${section}`,
|
|
@@ -275,22 +276,12 @@ export function createSectionRoutes<
|
|
|
275
276
|
|
|
276
277
|
async function generateMetadata({ params }: RouteParams): Promise<Metadata> {
|
|
277
278
|
const { slug } = await params;
|
|
278
|
-
if (slug?.length === 1 && landing)
|
|
279
|
-
|
|
279
|
+
if (slug?.length === 1 && landing)
|
|
280
|
+
return datasetLandingMetadata(
|
|
280
281
|
section,
|
|
281
282
|
slug,
|
|
282
|
-
'en_us',
|
|
283
283
|
await sectionDatasets(section),
|
|
284
284
|
);
|
|
285
|
-
if (!page) notFound();
|
|
286
|
-
return {
|
|
287
|
-
title: page.title,
|
|
288
|
-
description: page.description,
|
|
289
|
-
alternates: {
|
|
290
|
-
canonical: canonicalUrl(`${sectionUrl(section)}/${slug.join('/')}`),
|
|
291
|
-
},
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
285
|
if (!PageView) return {};
|
|
295
286
|
const src = (await source.getSource()) as SectionSource | undefined;
|
|
296
287
|
if (src) {
|
|
@@ -10,11 +10,13 @@ import {
|
|
|
10
10
|
import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
11
11
|
import { readPageSource, translationState } from '@terrafirmagreg-team/translate/server';
|
|
12
12
|
import { resolvePage, type SectionSource } from '@terrafirmagreg-team/kit/lib/page';
|
|
13
|
+
import { pageJsonLd } from '@terrafirmagreg-team/kit/lib/jsonld';
|
|
13
14
|
import { findNeighbour } from 'fumadocs-core/page-tree';
|
|
14
15
|
import { stripLangFromUrl } from '@terrafirmagreg-team/translate/lib/slug';
|
|
15
16
|
import { sectionConfig } from '@terrafirmagreg-team/kit/lib/config';
|
|
16
17
|
import type {
|
|
17
18
|
DatasetCatalog,
|
|
19
|
+
SectionKey,
|
|
18
20
|
SectionPage,
|
|
19
21
|
SectionTrees,
|
|
20
22
|
} from '@terrafirmagreg-team/kit/types';
|
|
@@ -53,6 +55,7 @@ export type SectionPageViewFn = (
|
|
|
53
55
|
) => SectionPageView | Promise<SectionPageView>;
|
|
54
56
|
|
|
55
57
|
export async function renderSectionWorkspace({
|
|
58
|
+
section,
|
|
56
59
|
source,
|
|
57
60
|
slug,
|
|
58
61
|
contentDir,
|
|
@@ -64,6 +67,7 @@ export async function renderSectionWorkspace({
|
|
|
64
67
|
page,
|
|
65
68
|
lang,
|
|
66
69
|
}: {
|
|
70
|
+
section: SectionKey;
|
|
67
71
|
source: SectionSource;
|
|
68
72
|
slug: string[] | undefined;
|
|
69
73
|
contentDir: string;
|
|
@@ -117,6 +121,18 @@ export async function renderSectionWorkspace({
|
|
|
117
121
|
history,
|
|
118
122
|
features,
|
|
119
123
|
});
|
|
120
|
-
|
|
124
|
+
const jsonLd = JSON.stringify(pageJsonLd({ section, page, crumbs, lang }));
|
|
125
|
+
return renderSectionView({
|
|
126
|
+
...view,
|
|
127
|
+
node: (
|
|
128
|
+
<>
|
|
129
|
+
<script
|
|
130
|
+
type="application/ld+json"
|
|
131
|
+
dangerouslySetInnerHTML={{ __html: jsonLd }}
|
|
132
|
+
/>
|
|
133
|
+
{view.node}
|
|
134
|
+
</>
|
|
135
|
+
),
|
|
136
|
+
});
|
|
121
137
|
}
|
|
122
138
|
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
+
import { CloudOff } from 'lucide-react';
|
|
4
5
|
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
5
6
|
import { requestDumpRetry } from '@terrafirmagreg-team/data/lib/dump-status';
|
|
6
|
-
import { EmptyState } from '@terrafirmagreg-team/ui/components/blocks/page/empty-state';
|
|
7
7
|
import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
|
|
8
8
|
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
9
9
|
|
|
10
10
|
export function DumpUnavailable({ children }: { children?: ReactNode }) {
|
|
11
11
|
const t = useTranslations();
|
|
12
12
|
return (
|
|
13
|
-
<div className="flex flex-col items-center gap-
|
|
14
|
-
<
|
|
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">
|
|
15
16
|
{children ??
|
|
16
17
|
t('Section data is temporarily unavailable. Try again later.', {
|
|
17
18
|
note: 'kit.section_unavailable',
|
|
18
19
|
})}
|
|
19
|
-
</
|
|
20
|
+
</p>
|
|
20
21
|
<button
|
|
21
22
|
type="button"
|
|
22
23
|
onClick={requestDumpRetry}
|
|
@@ -27,3 +28,11 @@ export function DumpUnavailable({ children }: { children?: ReactNode }) {
|
|
|
27
28
|
</div>
|
|
28
29
|
);
|
|
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
|
+
}
|
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,47 +169,59 @@ 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).catch(() => null);
|
|
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(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { stripLangFromUrl } from '@terrafirmagreg-team/translate/lib/slug';
|
|
3
|
+
import { sectionConfig } from './config';
|
|
4
|
+
import { canonicalUrl } from './urls';
|
|
5
|
+
import type { SectionCrumb } from './breadcrumb';
|
|
6
|
+
import type { SectionKey, SectionPage } from '@terrafirmagreg-team/kit/types';
|
|
7
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
8
|
+
|
|
9
|
+
export function pageJsonLd({
|
|
10
|
+
section,
|
|
11
|
+
page,
|
|
12
|
+
crumbs,
|
|
13
|
+
lang,
|
|
14
|
+
}: {
|
|
15
|
+
section: SectionKey;
|
|
16
|
+
page: SectionPage;
|
|
17
|
+
crumbs: readonly SectionCrumb[];
|
|
18
|
+
lang: Lang;
|
|
19
|
+
}): Record<string, unknown>[] {
|
|
20
|
+
const languages = [...sectionConfig().knownLanguages];
|
|
21
|
+
const url = canonicalUrl(stripLangFromUrl(page.url, languages));
|
|
22
|
+
const items = crumbs.length ? crumbs : [{ name: page.data.title }];
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
'@context': 'https://schema.org',
|
|
26
|
+
'@type': 'Article',
|
|
27
|
+
headline: page.data.title,
|
|
28
|
+
description: page.data.description,
|
|
29
|
+
url,
|
|
30
|
+
inLanguage: lang.replace('_', '-'),
|
|
31
|
+
isPartOf: { '@type': 'WebSite', url: canonicalUrl(`/${section}`) },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
'@context': 'https://schema.org',
|
|
35
|
+
'@type': 'BreadcrumbList',
|
|
36
|
+
itemListElement: items.map((crumb, index) => ({
|
|
37
|
+
'@type': 'ListItem',
|
|
38
|
+
position: index + 1,
|
|
39
|
+
name: crumb.name,
|
|
40
|
+
item: crumb.url
|
|
41
|
+
? canonicalUrl(stripLangFromUrl(crumb.url, languages))
|
|
42
|
+
: url,
|
|
43
|
+
})),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
package/src/lib/landing.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
+
import type { Metadata } from 'next';
|
|
3
|
+
import { notFound } from 'next/navigation';
|
|
2
4
|
import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
5
|
+
import { sectionUrl } from '@terrafirmagreg-team/sites/location';
|
|
6
|
+
import { canonicalUrl, getDatasetImageUrl } from './urls';
|
|
3
7
|
import { datasetSlugs, findDataset } from './datasets';
|
|
4
8
|
import type {
|
|
5
9
|
DatasetLanding,
|
|
@@ -30,6 +34,36 @@ export function getDatasetLanding(
|
|
|
30
34
|
};
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
export function datasetLandingMetadata(
|
|
38
|
+
section: SectionKey,
|
|
39
|
+
slug: string[] | undefined,
|
|
40
|
+
datasets: readonly SectionDataset[],
|
|
41
|
+
): Metadata {
|
|
42
|
+
const page = getDatasetLanding(section, slug, 'en_us', datasets);
|
|
43
|
+
if (!page) notFound();
|
|
44
|
+
const meta = findDataset(datasets, { id: slug?.[0] ?? '' });
|
|
45
|
+
const description = page.description ?? meta?.description;
|
|
46
|
+
const title = page.datasetTitle ?? page.title;
|
|
47
|
+
const url = canonicalUrl(`${sectionUrl(section)}/${(slug ?? []).join('/')}`);
|
|
48
|
+
return {
|
|
49
|
+
title,
|
|
50
|
+
description,
|
|
51
|
+
alternates: { canonical: url },
|
|
52
|
+
openGraph: {
|
|
53
|
+
type: 'website',
|
|
54
|
+
url,
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
images: getDatasetImageUrl(section, slug?.[0] ?? '').url,
|
|
58
|
+
},
|
|
59
|
+
twitter: {
|
|
60
|
+
card: 'summary_large_image',
|
|
61
|
+
title,
|
|
62
|
+
description,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
export function resolveDatasetLanding(
|
|
34
68
|
section: SectionKey,
|
|
35
69
|
slug: string[] | undefined,
|
package/src/lib/page.ts
CHANGED
|
@@ -1,30 +1,16 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
|
-
import { createGetUrl } from 'fumadocs-core/source';
|
|
4
3
|
import { firstLocalizedPage } from '@terrafirmagreg-team/translate/server';
|
|
5
4
|
import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
6
5
|
import { stripLangFromUrl, toInternalSlugs, toPublicSlug } from '@terrafirmagreg-team/translate/lib/slug';
|
|
7
6
|
import { sectionConfig } from './config';
|
|
8
7
|
import { sectionDatasets } from './datasets';
|
|
8
|
+
import { canonicalUrl, getPageImageUrl } from './urls';
|
|
9
9
|
import type { SectionKey, SectionPage, SectionSource } from '@terrafirmagreg-team/kit/types';
|
|
10
10
|
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
11
11
|
|
|
12
12
|
export type { SectionSource } from '@terrafirmagreg-team/kit/types';
|
|
13
|
-
|
|
14
|
-
const canonicalUrl = (pathname: string): string =>
|
|
15
|
-
new URL(
|
|
16
|
-
pathname.startsWith('/') ? pathname : `/${pathname}`,
|
|
17
|
-
sectionConfig().siteUrl,
|
|
18
|
-
).toString();
|
|
19
|
-
|
|
20
|
-
let imageUrl: ReturnType<typeof createGetUrl> | undefined;
|
|
21
|
-
const getImageUrl = () =>
|
|
22
|
-
(imageUrl ??= createGetUrl(sectionConfig().previewRoute));
|
|
23
|
-
|
|
24
|
-
function getDatasetImageUrl(section: string, dataset: string) {
|
|
25
|
-
const segments = [section, dataset, 'image.png'];
|
|
26
|
-
return { segments, url: getImageUrl()(segments) };
|
|
27
|
-
}
|
|
13
|
+
export { getDatasetImageUrl } from './urls';
|
|
28
14
|
|
|
29
15
|
export function getPage<S extends SectionSource>(
|
|
30
16
|
source: S,
|
|
@@ -97,7 +83,7 @@ export async function sectionMetadata(
|
|
|
97
83
|
url,
|
|
98
84
|
title: doc.data.title,
|
|
99
85
|
description: doc.data.description,
|
|
100
|
-
images:
|
|
86
|
+
images: getPageImageUrl(section, doc.slugs).url,
|
|
101
87
|
},
|
|
102
88
|
twitter: {
|
|
103
89
|
card: 'summary_large_image',
|
package/src/lib/urls.ts
CHANGED
|
@@ -15,3 +15,21 @@ export function getDatasetImageUrl(section: string, dataset: string) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function getPageImageUrl(section: string, slugs: string[]) {
|
|
19
|
+
const segments = [section, ...slugs, 'image.png'];
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
segments,
|
|
23
|
+
url: createGetUrl(sectionConfig().previewRoute)(segments),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createGitFileUrl(git: {
|
|
28
|
+
user: string;
|
|
29
|
+
repo: string;
|
|
30
|
+
branch: string;
|
|
31
|
+
}) {
|
|
32
|
+
return (filePath: string) =>
|
|
33
|
+
`https://github.com/${git.user}/${git.repo}/blob/${git.branch}/${filePath}`;
|
|
34
|
+
}
|
|
35
|
+
|