@terrafirmagreg-team/kit 1.2.9 → 1.2.10

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.10",
4
4
  "type": "module",
5
5
  "description": "Fumadocs content engine",
6
6
  "license": "LGPL-3.0-only",
@@ -25,10 +25,10 @@
25
25
  "@base-ui/react": "^1.9.0",
26
26
  "@icons-pack/react-simple-icons": "13.15.1",
27
27
  "@terrafirmagreg-team/data": "^1.2.2",
28
- "@terrafirmagreg-team/mdx": "^1.2.3",
28
+ "@terrafirmagreg-team/mdx": "^1.2.4",
29
29
  "@terrafirmagreg-team/sites": "^1.2.1",
30
- "@terrafirmagreg-team/translate": "^1.2.5",
31
- "@terrafirmagreg-team/ui": "^1.2.6",
30
+ "@terrafirmagreg-team/translate": "^1.2.6",
31
+ "@terrafirmagreg-team/ui": "^1.2.7",
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
+ }
@@ -37,6 +37,7 @@ import { LangLocalesScope } from '@terrafirmagreg-team/translate/provider';
37
37
  import { localeItems } from '@terrafirmagreg-team/translate/create';
38
38
  import { siteLinks } from '@terrafirmagreg-team/sites';
39
39
  import { useCurrentDataset } from '@terrafirmagreg-team/kit/hooks/use-current-dataset';
40
+ import { useFailedDatasets } from '@terrafirmagreg-team/kit/hooks/use-dump-status';
40
41
  import { useDatasetAccent } from '@terrafirmagreg-team/kit/hooks/use-dataset-accent';
41
42
  import { useNavOptions } from '@terrafirmagreg-team/kit/hooks/use-nav-options';
42
43
  import { pinTreeDataset } from '@terrafirmagreg-team/kit/lib/pin-tree';
@@ -57,6 +58,7 @@ export type LayoutProps = {
57
58
  useTree?: (tree: PageTree.Root) => PageTree.Root;
58
59
  sidebar?: Pick<LeftSidebarSlotProps, 'feature'>;
59
60
  navTitle?: ComponentType<ComponentProps<'a'>>;
61
+ defaultOpenLevel?: number;
60
62
  children?: ReactNode;
61
63
  };
62
64
 
@@ -78,15 +80,19 @@ export function Layout({
78
80
  useTree = identityTree,
79
81
  sidebar,
80
82
  navTitle,
83
+ defaultOpenLevel,
81
84
  children,
82
85
  }: LayoutProps) {
83
86
  const t = useTranslations();
84
87
  const { defaultLanguage } = useLocaleRegistry();
85
88
  const lang: Lang = useResolvedLang();
86
- const current = useCurrentDataset(catalog[section]);
89
+ const sectionDatasets = catalog[section] ?? [];
90
+ const current = useCurrentDataset(sectionDatasets);
87
91
  const baseId = current ? current.id : undefined;
88
92
  const pinned = current ? current.version : undefined;
89
- const meta = catalog[section].find((item) => item.id === baseId);
93
+ const meta = sectionDatasets.find((item) => item.id === baseId);
94
+ const failedDatasets = useFailedDatasets();
95
+ const featureHidden = baseId !== undefined && failedDatasets.includes(baseId);
90
96
  useDatasetAccent(meta?.accent);
91
97
  const nav = useNavOptions({ section, catalog });
92
98
  const pathname = usePathname();
@@ -139,6 +145,7 @@ export function Layout({
139
145
  <UiLayout
140
146
  tree={pinnedTree}
141
147
  navTitle={navTitle}
148
+ defaultOpenLevel={defaultOpenLevel}
142
149
  sidebar={
143
150
  <LeftSidebar
144
151
  navKeys={NAV_KEYS}
@@ -170,7 +177,7 @@ export function Layout({
170
177
  <LeftSidebarSearch />
171
178
  <Switcher {...nav.section} />
172
179
  <Switcher {...nav.dataset} />
173
- {Feature ? (
180
+ {Feature && !featureHidden ? (
174
181
  <>
175
182
  <LeftSidebarSeparator />
176
183
  <Feature />
@@ -1,9 +1,15 @@
1
1
  'use client';
2
+ import { useEffect, useRef } from 'react';
2
3
  import { FileQuestion } from 'lucide-react';
4
+ import { useRouter } from 'next/navigation';
3
5
  import { sectionUrl } from '@terrafirmagreg-team/sites/location';
4
6
  import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
7
  import { LangLink } from '@terrafirmagreg-team/ui/components/blocks/lang/link';
6
8
  import { EmptyState } from '@terrafirmagreg-team/ui/components/blocks/page/empty-state';
9
+ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
10
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
11
+ import { requestDumpRetry } from '@terrafirmagreg-team/data/lib/dump-status';
12
+ import { useDumpStatus } from '@terrafirmagreg-team/kit/hooks/use-dump-status';
7
13
  import type { SectionDataset, SectionKey } from '@terrafirmagreg-team/kit/types';
8
14
 
9
15
  export function SectionNotFound({
@@ -14,24 +20,53 @@ 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="flex flex-col items-center gap-3 py-16">
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">
58
+ <div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
27
59
  <FileQuestion className="size-10" />
28
60
  <p>{t('Page not found', { note: 'ui.not_found' })}</p>
29
61
  <LangLink
30
62
  href={sectionUrl(section, datasets[0].id)}
31
- className="text-sm underline underline-offset-4 transition-colors hover:text-foreground"
63
+ className={cn(
64
+ buttonVariants({ variant: 'outline', size: 'xs' }),
65
+ 'no-underline',
66
+ )}
32
67
  >
33
68
  {t('Back', { note: 'ui.back' })}
34
69
  </LangLink>
35
- </main>
70
+ </div>
36
71
  );
37
72
  }
@@ -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,29 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+ import { requestDumpRetry } from '@terrafirmagreg-team/data/lib/dump-status';
6
+ import { EmptyState } from '@terrafirmagreg-team/ui/components/blocks/page/empty-state';
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="flex flex-col items-center gap-3 py-10">
14
+ <EmptyState variant="missing">
15
+ {children ??
16
+ t('Section data is temporarily unavailable. Try again later.', {
17
+ note: 'kit.section_unavailable',
18
+ })}
19
+ </EmptyState>
20
+ <button
21
+ type="button"
22
+ onClick={requestDumpRetry}
23
+ className={cn(buttonVariants({ variant: 'outline', size: 'xs' }))}
24
+ >
25
+ {t('Retry now', { note: 'kit.retry_now' })}
26
+ </button>
27
+ </div>
28
+ );
29
+ }
@@ -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 {
@@ -158,7 +158,7 @@ async function dumpDatasets(
158
158
  const usable = await Promise.all(
159
159
  ids.map(async (ref) => {
160
160
  const { id } = ref;
161
- const index = await dump.loadDataset(ref);
161
+ const index = await dump.loadDataset(ref).catch(() => null);
162
162
  if (!index || !hasContent(index)) return null;
163
163
  return {
164
164
  id,
@@ -191,9 +191,10 @@ export function sectionDatasets(
191
191
  section: SectionKey,
192
192
  ): Promise<SectionDataset[]> {
193
193
  const hasContent = sectionConfig().dump?.hasContent[section];
194
- return hasContent
194
+ const promise = hasContent
195
195
  ? dumpSectionDatasets(section, hasContent)
196
196
  : contentSectionDatasetsCached(section);
197
+ return promise.catch(() => []);
197
198
  }
198
199
 
199
200
  export async function defaultDataset(
@@ -228,7 +229,8 @@ const datasetCatalogCached = createAsyncCache(
228
229
  async (): Promise<DatasetCatalog> =>
229
230
  Promise.all(
230
231
  sectionTabs.map(
231
- async ({ key }) => [key, await sectionDatasets(key)] as const,
232
+ async ({ key }) =>
233
+ [key, await sectionDatasets(key).catch(() => [])] as const,
232
234
  ),
233
235
  ).then((rows) => Object.fromEntries(rows) as DatasetCatalog),
234
236
  () => '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
  }
@@ -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 {