@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/kit",
3
- "version": "1.2.9",
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.2",
28
- "@terrafirmagreg-team/mdx": "^1.2.3",
29
- "@terrafirmagreg-team/sites": "^1.2.1",
30
- "@terrafirmagreg-team/translate": "^1.2.5",
31
- "@terrafirmagreg-team/ui": "^1.2.6",
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 current = useCurrentDataset(catalog[section]);
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 = catalog[section].find((item) => item.id === baseId);
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(tree, section, baseId, pinned)
111
- : tree,
112
- [tree, section, baseId, pinned],
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 { FileQuestion } from 'lucide-react';
3
- import { sectionUrl } from '@terrafirmagreg-team/sites/location';
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
- <EmptyState variant="missing">
20
- {t('Section data is temporarily unavailable. Try again later.', {
21
- note: 'kit.section_unavailable',
22
- })}
23
- </EmptyState>
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
- <main className="flex min-h-dvh flex-col items-center justify-center gap-3 p-8 text-muted-foreground">
27
- <FileQuestion className="size-10" />
28
- <p>{t('Page not found', { note: 'ui.not_found' })}</p>
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="text-sm underline underline-offset-4 transition-colors hover:text-foreground"
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 ? trees() : Promise.resolve<SectionTrees>({}),
207
- datasetCatalog(),
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 current = useCurrentDataset(catalog[section]);
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].find(({ id }) => id === baseId)
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
- catalog[section].map((datasetItem) => ({
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, catalog],
74
+ [section, sectionDatasets],
72
75
  );
73
76
 
74
77
  return {
package/src/lib/config.ts CHANGED
@@ -43,6 +43,7 @@ export type SectionConfigInput = {
43
43
  url: string;
44
44
  icon?: string;
45
45
  }[];
46
+ sections?: readonly SectionKey[];
46
47
  guide?: { bookOrder?: readonly string[] };
47
48
  }
48
49
  >;
@@ -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
- for (const ref of refs) {
150
- const { id, version } = ref;
151
- if (version) {
152
- const list = versions.get(id) ?? [];
153
- list.push(version);
154
- versions.set(id, list);
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
- usable
172
- .filter((row): row is { id: string; languages: Lang[] } => row !== null)
173
- .map(async ({ id, languages }) => ({
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 datasetMeta(section, id).then((meta) =>
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
- ).sort(datasetOrder);
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
- return hasContent
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 }) => [key, await sectionDatasets(key)] as const,
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',
@@ -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
+
@@ -8,6 +8,7 @@ export interface SectionPageContext {
8
8
  datasetId?: string;
9
9
  copied?: TranslationState;
10
10
  history?: boolean;
11
+ exports?: Record<string, unknown>;
11
12
  }
12
13
 
13
14
  export interface SectionFeatures {