@terrafirmagreg-team/kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +68 -3
  2. package/src/components/blocks/nav-title.tsx +65 -0
  3. package/src/components/docs-image.tsx +49 -0
  4. package/src/components/outline/dropdown.tsx +67 -0
  5. package/src/components/outline/index.ts +10 -0
  6. package/src/components/outline/list.tsx +120 -0
  7. package/src/components/outline/outline.tsx +49 -0
  8. package/src/components/outline/popover.tsx +76 -0
  9. package/src/components/outline/types.ts +29 -0
  10. package/src/components/page-actions.tsx +201 -0
  11. package/src/components/relative-link.tsx +18 -0
  12. package/src/components/search/dialog.tsx +145 -0
  13. package/src/components/toc/base.tsx +65 -0
  14. package/src/components/toc/block.tsx +111 -0
  15. package/src/components/toc/clerk.tsx +265 -0
  16. package/src/components/toc/default.tsx +333 -0
  17. package/src/components/version-menu.tsx +128 -0
  18. package/src/components/views/dataset.tsx +63 -0
  19. package/src/components/views/section.tsx +108 -0
  20. package/src/components/workspace/content.tsx +53 -0
  21. package/src/components/workspace/index.ts +12 -0
  22. package/src/components/workspace/pagination.tsx +73 -0
  23. package/src/components/workspace/workspace.tsx +148 -0
  24. package/src/hooks/toc.ts +9 -0
  25. package/src/hooks/use-catalog.ts +11 -0
  26. package/src/hooks/use-current-dataset.ts +14 -0
  27. package/src/hooks/use-dataset-accent.ts +13 -0
  28. package/src/hooks/use-nav-options.ts +84 -0
  29. package/src/hooks/use-version-pref.ts +30 -0
  30. package/src/lib/breadcrumb.ts +78 -0
  31. package/src/lib/config.ts +109 -0
  32. package/src/lib/dataset-links.ts +30 -0
  33. package/src/lib/datasets.ts +211 -0
  34. package/src/lib/landing.ts +46 -0
  35. package/src/lib/layout.ts +15 -0
  36. package/src/lib/legacy.ts +18 -0
  37. package/src/lib/location.ts +63 -0
  38. package/src/lib/markdown/dom.ts +90 -0
  39. package/src/lib/markdown/index.ts +13 -0
  40. package/src/lib/markdown/providers.ts +25 -0
  41. package/src/lib/mdx/accordions.ts +93 -0
  42. package/src/lib/mdx/banner.ts +52 -0
  43. package/src/lib/mdx/directive-utils.ts +125 -0
  44. package/src/lib/mdx/directives.ts +141 -0
  45. package/src/lib/mdx/game-export.ts +44 -0
  46. package/src/lib/mdx/html-style.ts +135 -0
  47. package/src/lib/mdx/preview.ts +252 -0
  48. package/src/lib/mdx/references.ts +56 -0
  49. package/src/lib/mdx/tabs.ts +80 -0
  50. package/src/lib/mdx-components.tsx +139 -0
  51. package/src/lib/order.ts +88 -0
  52. package/src/lib/pack-assets.ts +45 -0
  53. package/src/lib/page.ts +108 -0
  54. package/src/lib/pin-tree.ts +35 -0
  55. package/src/lib/preview/index.ts +8 -0
  56. package/src/lib/preview/lookup.ts +48 -0
  57. package/src/lib/preview/og.tsx +115 -0
  58. package/src/lib/preview/resolvers.ts +98 -0
  59. package/src/lib/search/config.ts +13 -0
  60. package/src/lib/search/filters.ts +22 -0
  61. package/src/lib/search/index.ts +19 -0
  62. package/src/lib/search/indexes.ts +43 -0
  63. package/src/lib/search/merge.ts +22 -0
  64. package/src/lib/search/server.ts +18 -0
  65. package/src/lib/search/sources.ts +19 -0
  66. package/src/lib/search/structured.ts +23 -0
  67. package/src/lib/section/schema.ts +36 -0
  68. package/src/lib/section/source.ts +40 -0
  69. package/src/lib/sources.ts +21 -0
  70. package/src/lib/tree.ts +60 -0
  71. package/src/lib/urls.ts +16 -0
  72. package/src/locales/en_us.json +10 -0
  73. package/src/locales/ru_ru.json +10 -0
  74. package/src/locales.ts +5 -0
  75. package/src/types/index.ts +83 -0
  76. package/src/types/mdx.ts +55 -0
  77. package/src/types/turndown-plugin-gfm.d.ts +9 -0
  78. package/README.md +0 -3
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+ import { type ReactNode, createElement, useMemo } from 'react';
3
+ import { DynamicIcon, type IconName } from 'lucide-react/dynamic';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+ import { sectionConfig } from '@terrafirmagreg-team/kit/lib/config';
6
+ import {
7
+ datasetRef,
8
+ isDumpSection,
9
+ parseDatasetRef,
10
+ sectionUrl,
11
+ } from '@terrafirmagreg-team/kit/lib/location';
12
+ import { useCurrentDataset } from '@terrafirmagreg-team/kit/hooks/use-current-dataset';
13
+ import { useVersionPreference } from '@terrafirmagreg-team/kit/hooks/use-version-pref';
14
+ import type { LeftSidebarScope } from '@terrafirmagreg-team/kit/types';
15
+ import type {
16
+ SwitcherOption,
17
+ SwitcherProps,
18
+ } from '@terrafirmagreg-team/ui/types/nav';
19
+
20
+ function datasetIcon(dataset: { icon?: IconName }): ReactNode {
21
+ if (!dataset.icon) return undefined;
22
+ return createElement(DynamicIcon, {
23
+ name: dataset.icon,
24
+ className: 'size-4 shrink-0',
25
+ });
26
+ }
27
+
28
+ export function useNavOptions({ section, catalog }: LeftSidebarScope): {
29
+ section: SwitcherProps;
30
+ dataset: SwitcherProps;
31
+ } {
32
+ const t = useTranslations();
33
+ const current = useCurrentDataset(catalog[section]);
34
+ const baseId = current ? parseDatasetRef(current).id : undefined;
35
+ const pinned = current ? parseDatasetRef(current).version : undefined;
36
+ const preferred = useVersionPreference(baseId);
37
+ const version = pinned ?? preferred;
38
+
39
+ const sectionOptions: SwitcherOption[] = useMemo(
40
+ () =>
41
+ sectionConfig().sectionTabs.map(({ key, icon: Icon }) => {
42
+ const meta = baseId
43
+ ? catalog[key].find(({ id }) => id === baseId)
44
+ : undefined;
45
+ const pinnedRef =
46
+ isDumpSection(key) && version && meta?.versions?.includes(version)
47
+ ? datasetRef(baseId!, version)
48
+ : baseId;
49
+ return {
50
+ value: key,
51
+ title: t(`sections.${key}`),
52
+ description: t(`sections.desc.${key}`),
53
+ icon: Icon ? createElement(Icon) : undefined,
54
+ url: meta ? sectionUrl(key, pinnedRef) : sectionUrl(key),
55
+ };
56
+ }),
57
+ [t, baseId, version, catalog],
58
+ );
59
+
60
+ const datasetOptions: SwitcherOption[] = useMemo(
61
+ () =>
62
+ catalog[section].map((datasetItem) => ({
63
+ value: datasetItem.id,
64
+ title: datasetItem.title ?? datasetItem.id,
65
+ description: datasetItem.description,
66
+ icon: datasetIcon(datasetItem),
67
+ url: sectionUrl(section, datasetItem.id),
68
+ })),
69
+ [section, catalog],
70
+ );
71
+
72
+ return {
73
+ section: {
74
+ options: sectionOptions,
75
+ selected: section,
76
+ placeholder: t('ui.select_section'),
77
+ },
78
+ dataset: {
79
+ options: datasetOptions,
80
+ selected: baseId,
81
+ placeholder: t('ui.select_dataset'),
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+ import {
5
+ jsonCodec,
6
+ readStoredValue,
7
+ subscribeStoredValue,
8
+ writeStoredValue,
9
+ } from '@terrafirmagreg-team/ui/lib/utils/stored-value';
10
+
11
+ const STORAGE_KEY = 'tfg.dataset-versions';
12
+ const codec = jsonCodec<Record<string, string>>({});
13
+
14
+ export function setVersionPreference(datasetId: string, version?: string) {
15
+ const next = { ...readStoredValue(STORAGE_KEY, codec.read) };
16
+ if (version) next[datasetId] = version;
17
+ else delete next[datasetId];
18
+ writeStoredValue(STORAGE_KEY, next, codec.write);
19
+ }
20
+
21
+ export function useVersionPreference(datasetId?: string): string | undefined {
22
+ return useSyncExternalStore(
23
+ (onChange) => subscribeStoredValue(STORAGE_KEY, onChange),
24
+ () =>
25
+ datasetId
26
+ ? readStoredValue(STORAGE_KEY, codec.read)[datasetId]
27
+ : undefined,
28
+ () => undefined,
29
+ );
30
+ }
@@ -0,0 +1,78 @@
1
+ import 'server-only';
2
+ import type * as PageTree from 'fumadocs-core/page-tree';
3
+ import { stripLangFromUrl } from '@terrafirmagreg-team/translate/lib/slug';
4
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
5
+ import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
6
+ import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
7
+ import { localizePageTree } from '@terrafirmagreg-team/translate/server';
8
+ import { sectionConfig } from './config';
9
+ import { lookupPagePreview } from './preview/lookup';
10
+ import { datasetId } from './tree';
11
+ import type { SectionSource } from './page';
12
+ import type { DatasetCatalog, SectionPage } from '@terrafirmagreg-team/kit/types';
13
+
14
+ export type SectionCrumb = { name: string; url?: string };
15
+
16
+ function findTrail(
17
+ nodes: readonly PageTree.Node[],
18
+ url: string,
19
+ ): PageTree.Node[] | undefined {
20
+ for (const node of nodes) {
21
+ if (node.type === 'page' && node.url === url) return [node];
22
+ if (node.type === 'folder') {
23
+ if (node.index?.url === url) return [node];
24
+ const inner = findTrail(node.children, url);
25
+ if (inner) return [node, ...inner];
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ function nodeName(name: unknown): string {
32
+ return typeof name === 'string' ? name : '';
33
+ }
34
+
35
+ export function sectionBreadcrumbItems(
36
+ source: SectionSource,
37
+ page: SectionPage,
38
+ lang: Lang,
39
+ ): SectionCrumb[] {
40
+ const languages = [...sectionConfig().knownLanguages];
41
+ const tree = localizePageTree(source.getPageTree(), lang, {
42
+ languages,
43
+ fallbackLanguage: loadLocaleRegistry().defaultLanguage,
44
+ });
45
+ const dataset = page.slugs[0];
46
+ const folder = tree.children.find(
47
+ (node): node is PageTree.Folder =>
48
+ node.type === 'folder' && datasetId(node) === dataset,
49
+ );
50
+ if (!folder) return [{ name: page.data.title }];
51
+
52
+ const pageUrl = stripLangFromUrl(page.url, languages);
53
+ if (folder.index?.url === pageUrl) return [];
54
+
55
+ const trail = findTrail(folder.children, pageUrl);
56
+ const items: SectionCrumb[] = [];
57
+ for (const node of trail?.slice(0, -1) ?? []) {
58
+ if (node.type === 'folder') {
59
+ items.push({ name: nodeName(node.name), url: node.index?.url });
60
+ }
61
+ }
62
+ items.push({ name: page.data.title });
63
+ return items;
64
+ }
65
+
66
+ export function sectionBreadcrumbPreviews(
67
+ items: readonly SectionCrumb[],
68
+ lang: Lang,
69
+ catalog: DatasetCatalog,
70
+ ): Record<string, PagePreviewData> {
71
+ const previews: Record<string, PagePreviewData> = {};
72
+ for (const item of items) {
73
+ if (!item.url) continue;
74
+ const preview = lookupPagePreview(catalog, item.url, lang);
75
+ if (preview) previews[item.url] = preview;
76
+ }
77
+ return previews;
78
+ }
@@ -0,0 +1,109 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
3
+ import type {
4
+ SectionKey,
5
+ SectionSourceEntry,
6
+ } from '@terrafirmagreg-team/kit/types';
7
+
8
+ export type SectionDump = {
9
+ manifest: { languages?: readonly string[] };
10
+ };
11
+
12
+ export type SectionLegacyRoute = {
13
+ dataset: string;
14
+ path?: string;
15
+ overrides?: Record<string, string>;
16
+ };
17
+
18
+ export type SectionConfigInput = {
19
+
20
+ basePath: string;
21
+ siteUrl: string;
22
+ previewRoute: string;
23
+ docsRoute: string;
24
+ legacyRoutes: Record<string, SectionLegacyRoute>;
25
+
26
+ sectionTabs: readonly {
27
+ key: SectionKey;
28
+ url: string;
29
+ icon?: ComponentType<{ className?: string }>;
30
+ }[];
31
+
32
+ dumpSections?: readonly SectionKey[];
33
+
34
+ datasetConfig: Record<
35
+ string,
36
+ {
37
+ accent?: string;
38
+ order?: number;
39
+
40
+ repo?: `${string}/${string}`;
41
+
42
+ crowdin?: string;
43
+
44
+ links?: readonly {
45
+ label: string;
46
+ url: string;
47
+ icon?: string;
48
+ }[];
49
+ guide?: { bookOrder?: readonly string[] };
50
+ }
51
+ >;
52
+ knownLanguages: readonly Lang[];
53
+
54
+ sources: Partial<Record<SectionKey, SectionSourceEntry>>;
55
+
56
+ dump?: {
57
+ discoverDatasets(): Promise<string[]>;
58
+ loadDataset(id: string): Promise<SectionDump | null | undefined>;
59
+ hasContent: Record<string, (dump: SectionDump) => boolean>;
60
+ };
61
+ };
62
+
63
+ let config: SectionConfigInput | undefined;
64
+
65
+ export function registerSectionConfig(
66
+ input: Partial<SectionConfigInput>,
67
+ ): void {
68
+ config = { ...config, ...input } as SectionConfigInput;
69
+ }
70
+
71
+ export function sectionConfig(): SectionConfigInput {
72
+ if (!config) {
73
+ throw new Error(
74
+ 'Section config is not registered — call registerSectionConfig() at app startup.',
75
+ );
76
+ }
77
+ return config;
78
+ }
79
+
80
+ export type ClientDumpLoader = {
81
+ loadDump(dataset: string): Promise<{
82
+ manifest: { pack?: { version?: string | null } | null };
83
+ } | null | undefined>;
84
+ loadVersions(
85
+ datasetId: string,
86
+ ): Promise<{ latest?: string; versions: string[] } | null>;
87
+ };
88
+
89
+ export const {
90
+ register: registerClientDumpLoader,
91
+ get: clientDumpLoader,
92
+ } = createConfig<ClientDumpLoader>('ClientDump');
93
+
94
+ export function createConfig<T>(label: string) {
95
+ let config: T | undefined;
96
+ return {
97
+ register(input: T) {
98
+ config = input;
99
+ },
100
+ get(): T {
101
+ if (!config) {
102
+ throw new Error(
103
+ `${label} config is not registered — call register${label}Config() at app startup.`,
104
+ );
105
+ }
106
+ return config;
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,30 @@
1
+ export type DatasetLinkIcon = 'github' | 'curseforge' | 'modrinth' | 'discord';
2
+
3
+ export type DatasetLink = {
4
+ label: string;
5
+ url: string;
6
+ icon?: DatasetLinkIcon;
7
+ };
8
+
9
+ export const datasetLink = {
10
+ github: (repo: `${string}/${string}`): DatasetLink => ({
11
+ label: 'GitHub',
12
+ url: `https://github.com/${repo}`,
13
+ icon: 'github',
14
+ }),
15
+ curseforge: (slug: string): DatasetLink => ({
16
+ label: 'CurseForge',
17
+ url: `https://www.curseforge.com/minecraft/modpacks/${slug}`,
18
+ icon: 'curseforge',
19
+ }),
20
+ modrinth: (slug: string): DatasetLink => ({
21
+ label: 'Modrinth',
22
+ url: `https://modrinth.com/modpack/${slug}`,
23
+ icon: 'modrinth',
24
+ }),
25
+ discord: (url: string): DatasetLink => ({
26
+ label: 'Discord',
27
+ url,
28
+ icon: 'discord',
29
+ }),
30
+ };
@@ -0,0 +1,211 @@
1
+ import 'server-only';
2
+ import { dynamicIconImports } from 'lucide-react/dynamic';
3
+ import { sectionConfig } from './config';
4
+ import { sections } from './sources';
5
+ import { datasetId } from './tree';
6
+ import { loadLocaleRegistry, sortLangCodes } from '@terrafirmagreg-team/translate/server';
7
+ import { parseDatasetRef } from './location';
8
+ import type {
9
+ DatasetCatalog,
10
+ SectionDataset,
11
+ SectionKey,
12
+ } from '@terrafirmagreg-team/kit/types';
13
+ import type { SectionDump } from './config';
14
+ import type { IconName } from 'lucide-react/dynamic';
15
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
16
+
17
+ const sectionMeta = new Map<SectionKey, Map<string, Record<string, unknown>>>();
18
+
19
+ function staticSectionMeta(
20
+ section: SectionKey,
21
+ ): Map<string, Record<string, unknown>> {
22
+ let meta = sectionMeta.get(section);
23
+ if (meta) return meta;
24
+ meta = new Map();
25
+ const source = sections[section]?.source;
26
+ if (source) {
27
+ for (const node of source.getPageTree().children) {
28
+ if (node.type !== 'folder') continue;
29
+ const id = datasetId(node);
30
+ const data = id
31
+ ? (source.getNodeMeta?.(node) as { data?: Record<string, unknown> })
32
+ ?.data
33
+ : undefined;
34
+ if (id && data) meta.set(id, data);
35
+ }
36
+ }
37
+ sectionMeta.set(section, meta);
38
+ return meta;
39
+ }
40
+
41
+ function contentSectionDatasets(section: SectionKey): SectionDataset[] {
42
+ const source = sections[section]?.source;
43
+ if (!source) return [];
44
+ const languages = new Map<string, Set<string>>();
45
+ for (const page of source.getPages()) {
46
+ const [dataset, lang] = page.slugs;
47
+ if (!dataset || !lang) continue;
48
+ let set = languages.get(dataset);
49
+ if (!set) languages.set(dataset, (set = new Set()));
50
+ set.add(lang);
51
+ }
52
+ const meta = staticSectionMeta(section);
53
+ return [...languages.keys()].map((id) => ({
54
+ id,
55
+ languages: datasetLanguages([...(languages.get(id) ?? [])]),
56
+ ...metaFields(meta.get(id) ?? {}),
57
+ ...datasetAppearance(id),
58
+ }));
59
+ }
60
+
61
+ function datasetMeta(
62
+ section: SectionKey,
63
+ id: string,
64
+ ): Omit<SectionDataset, 'id' | 'languages'> {
65
+ const meta = staticSectionMeta(section).get(id);
66
+ return meta ? metaFields(meta) : {};
67
+ }
68
+
69
+ function metaFields(
70
+ meta: Record<string, unknown>,
71
+ ): Omit<SectionDataset, 'id' | 'languages'> {
72
+ const { title, description, icon } = meta;
73
+ return {
74
+ title: typeof title === 'string' ? title : undefined,
75
+ description: typeof description === 'string' ? description : undefined,
76
+ icon:
77
+ typeof icon === 'string' && icon in dynamicIconImports
78
+ ? (icon as IconName)
79
+ : undefined,
80
+ };
81
+ }
82
+
83
+ function datasetAppearance(id: string) {
84
+ return { accent: sectionConfig().datasetConfig[id]?.accent };
85
+ }
86
+
87
+ function datasetLanguages(languages: readonly string[]): Lang[] {
88
+ if (languages.length) return sortLangCodes(languages) as Lang[];
89
+ return [loadLocaleRegistry().defaultLanguage as Lang];
90
+ }
91
+
92
+ const bySection = new Map<SectionKey, Promise<SectionDataset[]>>();
93
+
94
+ function datasetOrder(a: SectionDataset, b: SectionDataset): number {
95
+ const cfg = sectionConfig().datasetConfig;
96
+ const order = (cfg[a.id]?.order ?? 0) - (cfg[b.id]?.order ?? 0);
97
+ return order !== 0 ? order : a.id.localeCompare(b.id);
98
+ }
99
+
100
+ function compareVersions(a: string, b: string): number {
101
+ const pa = a.split('.');
102
+ const pb = b.split('.');
103
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
104
+ const d =
105
+ (Number.parseInt(pa[i] ?? '0', 10) || 0) -
106
+ (Number.parseInt(pb[i] ?? '0', 10) || 0);
107
+ if (d) return d;
108
+ }
109
+ return a.localeCompare(b);
110
+ }
111
+
112
+ async function dumpDatasets(
113
+ hasContent: (dump: SectionDump) => boolean,
114
+ ): Promise<SectionDataset[]> {
115
+ const dump = sectionConfig().dump;
116
+ if (!dump) return [];
117
+ const refs = await dump.discoverDatasets();
118
+ const versions = new Map<string, string[]>();
119
+ for (const ref of refs) {
120
+ const { id, version } = parseDatasetRef(ref);
121
+ if (version) {
122
+ const list = versions.get(id) ?? [];
123
+ list.push(version);
124
+ versions.set(id, list);
125
+ }
126
+ }
127
+ const ids = refs.filter((ref) => !parseDatasetRef(ref).version);
128
+ const usable = await Promise.all(
129
+ ids.map(async (id) => {
130
+ const index = await dump.loadDataset(id);
131
+ if (!index || !hasContent(index)) return null;
132
+ return {
133
+ id,
134
+ languages: datasetLanguages(index.manifest.languages ?? []),
135
+ };
136
+ }),
137
+ );
138
+ return usable
139
+ .filter((row): row is { id: string; languages: Lang[] } => row !== null)
140
+ .map(({ id, languages }) => ({
141
+ id,
142
+ languages,
143
+ ...(versions.get(id)?.length
144
+ ? { versions: versions.get(id)!.sort(compareVersions).reverse() }
145
+ : {}),
146
+ ...datasetMeta('docs', id),
147
+ ...datasetAppearance(id),
148
+ }))
149
+ .sort(datasetOrder);
150
+ }
151
+
152
+ export function sectionDatasets(
153
+ section: SectionKey,
154
+ ): Promise<SectionDataset[]> {
155
+ let cached = bySection.get(section);
156
+ if (!cached) {
157
+ const hasContent = sectionConfig().dump?.hasContent[section];
158
+ if (hasContent) {
159
+ cached = dumpDatasets(hasContent);
160
+ bySection.set(section, cached);
161
+ return cached;
162
+ }
163
+ const rows = contentSectionDatasets(section);
164
+ rows.sort(datasetOrder);
165
+ cached = Promise.resolve(rows);
166
+ bySection.set(section, cached);
167
+ }
168
+ return cached;
169
+ }
170
+
171
+ export async function defaultDataset(
172
+ section: SectionKey,
173
+ ): Promise<string | undefined> {
174
+ return (await sectionDatasets(section))[0]?.id;
175
+ }
176
+
177
+ export function findDataset(
178
+ datasets: readonly SectionDataset[],
179
+ ref: string,
180
+ ): SectionDataset | undefined {
181
+ const { id, version } = parseDatasetRef(ref);
182
+ const meta = datasets.find((row) => row.id === id);
183
+ if (!meta) return undefined;
184
+ if (version && !meta.versions?.includes(version)) return undefined;
185
+ return meta;
186
+ }
187
+
188
+ export async function resolveDatasetRef(
189
+ section: SectionKey,
190
+ ref: string,
191
+ ): Promise<SectionDataset | undefined> {
192
+ return findDataset(await sectionDatasets(section), ref);
193
+ }
194
+
195
+ export async function datasetSlugs(section: SectionKey): Promise<string[]> {
196
+ return (await sectionDatasets(section)).flatMap(({ id, versions }) => [
197
+ id,
198
+ ...(versions ?? []).map((version) => `${id}@${version}`),
199
+ ]);
200
+ }
201
+
202
+ let catalogCached: Promise<DatasetCatalog> | undefined;
203
+
204
+ export function datasetCatalog(): Promise<DatasetCatalog> {
205
+ catalogCached ??= Promise.all(
206
+ sectionConfig().sectionTabs.map(
207
+ async ({ key }) => [key, await sectionDatasets(key)] as const,
208
+ ),
209
+ ).then((rows) => Object.fromEntries(rows) as DatasetCatalog);
210
+ return catalogCached;
211
+ }
@@ -0,0 +1,46 @@
1
+ import 'server-only';
2
+ import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
3
+ import { datasetSlugs, findDataset } from './datasets';
4
+ import type {
5
+ DatasetLanding,
6
+ SectionDataset,
7
+ SectionKey,
8
+ } from '@terrafirmagreg-team/kit/types';
9
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
10
+
11
+ export function getDatasetLanding(
12
+ section: SectionKey,
13
+ slug: string[] | undefined,
14
+ lang: Lang,
15
+ datasets: readonly SectionDataset[],
16
+ ): DatasetLanding | undefined {
17
+ if (!slug || slug.length !== 1) return undefined;
18
+ const [ref] = slug;
19
+ const meta = findDataset(datasets, ref);
20
+ if (!meta) return undefined;
21
+ const { messages, defaultLanguage } = loadLocaleRegistry();
22
+ const table = messages[lang] ?? messages[defaultLanguage] ?? {};
23
+ return {
24
+ title: table[`sections.${section}`] ?? section,
25
+ description: table[`sections.desc.${section}`],
26
+ dataset: ref,
27
+ datasetTitle: meta.title ?? meta.id,
28
+ lang,
29
+ };
30
+ }
31
+
32
+ export function resolveDatasetLanding(
33
+ section: SectionKey,
34
+ slug: string[] | undefined,
35
+ lang: Lang,
36
+ datasets: readonly SectionDataset[],
37
+ ): { page: DatasetLanding; isFallback: boolean } | undefined {
38
+ const page = getDatasetLanding(section, slug, lang, datasets);
39
+ return page ? { page, isFallback: false } : undefined;
40
+ }
41
+
42
+ export async function generateDatasetLandingParams(
43
+ section: SectionKey,
44
+ ): Promise<{ slug: string[] }[]> {
45
+ return (await datasetSlugs(section)).map((ref) => ({ slug: [ref] }));
46
+ }
@@ -0,0 +1,15 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+ import { uiConfig } from '@terrafirmagreg-team/ui/lib/config';
3
+ import { NavTitle } from '@terrafirmagreg-team/kit/components/blocks/nav-title';
4
+
5
+ export interface BaseLayoutProps {
6
+ nav?: { title?: string };
7
+ slots?: { navTitle?: (props: ComponentProps<'a'>) => ReactNode };
8
+ }
9
+
10
+ export function baseOptions(): BaseLayoutProps {
11
+ return {
12
+ nav: { title: uiConfig().brand?.name },
13
+ slots: { navTitle: NavTitle },
14
+ };
15
+ }
@@ -0,0 +1,18 @@
1
+ import { sectionConfig } from './config';
2
+
3
+ export function legacyTarget(pathname: string): string | undefined {
4
+ const { docsRoute, legacyRoutes } = sectionConfig();
5
+ const prefixes = Object.keys(legacyRoutes).sort((a, b) => b.length - a.length);
6
+ const parts = pathname.split('/').filter(Boolean);
7
+ const prefix = prefixes.find((p) => p === parts[0] || p === `${parts[0]}/${parts[2]}`);
8
+ if (!prefix) return undefined;
9
+ const prefixParts = prefix.split('/');
10
+ const lang = parts[1];
11
+ const rest = parts.slice(prefixParts.length + 1).join('/');
12
+ const route = legacyRoutes[prefix];
13
+ const override = route.overrides?.[rest];
14
+ const target =
15
+ override ??
16
+ `${docsRoute}/${route.dataset}${[route.path, rest].filter(Boolean).map((p) => `/${p}`).join('')}`;
17
+ return lang ? `${target}?lang=${lang}` : target;
18
+ }
@@ -0,0 +1,63 @@
1
+ import { sectionConfig } from './config';
2
+ import type { SectionKey } from '@terrafirmagreg-team/kit/types';
3
+
4
+ let urls: Record<SectionKey, string> | undefined;
5
+ const sectionUrls = () =>
6
+ (urls ??= Object.fromEntries(
7
+ sectionConfig().sectionTabs.map(({ key, url }) => [key, url]),
8
+ ) as Record<SectionKey, string>);
9
+
10
+ let keys: Set<SectionKey> | undefined;
11
+ const sectionKeys = () =>
12
+ (keys ??= new Set(sectionConfig().sectionTabs.map(({ key }) => key)));
13
+
14
+ export function sectionUrl(section: SectionKey, dataset?: string): string {
15
+ const base = sectionUrls()[section];
16
+ return dataset ? `${base}/${dataset}` : base;
17
+ }
18
+
19
+ export function isSectionKey(value: string | undefined): value is SectionKey {
20
+ return value !== undefined && sectionKeys().has(value);
21
+ }
22
+
23
+ export type SectionLocation = {
24
+ section?: SectionKey;
25
+
26
+ dataset?: string;
27
+ };
28
+
29
+ export function isDumpSection(section: SectionKey | undefined): boolean {
30
+ return (
31
+ section !== undefined &&
32
+ (sectionConfig().dumpSections ?? []).includes(section)
33
+ );
34
+ }
35
+
36
+ export function dumpSectionKeys(): readonly SectionKey[] {
37
+ return sectionConfig().dumpSections ?? [];
38
+ }
39
+
40
+ export function parseDatasetRef(ref: string): {
41
+ id: string;
42
+ version?: string;
43
+ } {
44
+ const decoded = decodeURIComponent(ref);
45
+ const at = decoded.lastIndexOf('@');
46
+ if (at <= 0 || at === decoded.length - 1) return { id: decoded };
47
+ return { id: decoded.slice(0, at), version: decoded.slice(at + 1) };
48
+ }
49
+
50
+ export function datasetRef(id: string, version?: string): string {
51
+ return version ? `${id}@${version}` : id;
52
+ }
53
+
54
+ export function parseSectionLocation(pathname: string): SectionLocation {
55
+ const { basePath } = sectionConfig();
56
+ const path =
57
+ basePath && pathname.startsWith(basePath)
58
+ ? pathname.slice(basePath.length)
59
+ : pathname;
60
+ const [section, dataset] = path.split('/').filter(Boolean);
61
+ if (!isSectionKey(section)) return {};
62
+ return { section, dataset };
63
+ }