@terrafirmagreg-team/kit 0.0.0-stage → 1.2.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.
- package/package.json +56 -3
- package/src/components/blocks/nav-title.tsx +62 -0
- package/src/components/outline/dropdown.tsx +68 -0
- package/src/components/outline/index.ts +11 -0
- package/src/components/outline/list.tsx +121 -0
- package/src/components/outline/outline.tsx +50 -0
- package/src/components/outline/popover.tsx +77 -0
- package/src/components/page-actions.tsx +202 -0
- package/src/components/relative-link.tsx +19 -0
- package/src/components/toc/base.tsx +66 -0
- package/src/components/toc/block.tsx +112 -0
- package/src/components/toc/clerk.tsx +266 -0
- package/src/components/toc/default.tsx +334 -0
- package/src/components/version-menu.tsx +130 -0
- package/src/components/views/dataset.tsx +72 -0
- package/src/components/views/section.tsx +152 -0
- package/src/components/workspace/content.tsx +54 -0
- package/src/components/workspace/index.ts +13 -0
- package/src/components/workspace/pagination.tsx +74 -0
- package/src/components/workspace/workspace.tsx +149 -0
- package/src/hooks/use-catalog.ts +12 -0
- package/src/hooks/use-current-dataset.ts +24 -0
- package/src/hooks/use-dataset-accent.ts +14 -0
- package/src/hooks/use-nav-options.ts +87 -0
- package/src/hooks/use-section-names.ts +40 -0
- package/src/hooks/use-toc.ts +10 -0
- package/src/hooks/use-version-pref.ts +31 -0
- package/src/hooks/use-versioned-dataset.ts +21 -0
- package/src/lib/breadcrumb.ts +65 -0
- package/src/lib/config.ts +111 -0
- package/src/lib/dataset-links.ts +31 -0
- package/src/lib/datasets.ts +241 -0
- package/src/lib/landing.ts +48 -0
- package/src/lib/layout.ts +16 -0
- package/src/lib/locale/renderer.ts +49 -0
- package/src/lib/order.ts +89 -0
- package/src/lib/page.ts +109 -0
- package/src/lib/pin-tree.ts +38 -0
- package/src/lib/preview/lookup.ts +51 -0
- package/src/lib/section/schema.ts +32 -0
- package/src/lib/section/source.ts +77 -0
- package/src/lib/sources.ts +22 -0
- package/src/lib/tree.ts +61 -0
- package/src/lib/urls.ts +17 -0
- package/src/locales/en_us.json +7 -0
- package/src/locales/ru_ru.json +7 -0
- package/src/locales.ts +6 -0
- package/src/types/embeds.ts +54 -0
- package/src/types/index.ts +87 -0
- package/src/types/outline.ts +30 -0
- package/README.md +0 -3
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
|
|
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: {
|
|
10
|
+
languages?: readonly string[];
|
|
11
|
+
pack?: { version?: string | null } | null;
|
|
12
|
+
};
|
|
13
|
+
mods: readonly {
|
|
14
|
+
namespace: string;
|
|
15
|
+
books: readonly unknown[];
|
|
16
|
+
questData: string | null;
|
|
17
|
+
recipeCategories: string | null;
|
|
18
|
+
recipeIndex: string | null;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SectionConfigInput = {
|
|
23
|
+
|
|
24
|
+
basePath: string;
|
|
25
|
+
siteUrl: string;
|
|
26
|
+
previewRoute: string;
|
|
27
|
+
docsRoute: string;
|
|
28
|
+
|
|
29
|
+
datasetConfig: Record<
|
|
30
|
+
string,
|
|
31
|
+
{
|
|
32
|
+
accent?: string;
|
|
33
|
+
order?: number;
|
|
34
|
+
|
|
35
|
+
repo?: `${string}/${string}`;
|
|
36
|
+
|
|
37
|
+
discussions?: { repoId: string; categoryId: string };
|
|
38
|
+
|
|
39
|
+
crowdin?: string;
|
|
40
|
+
|
|
41
|
+
links?: readonly {
|
|
42
|
+
label: string;
|
|
43
|
+
url: string;
|
|
44
|
+
icon?: string;
|
|
45
|
+
}[];
|
|
46
|
+
guide?: { bookOrder?: readonly string[] };
|
|
47
|
+
}
|
|
48
|
+
>;
|
|
49
|
+
knownLanguages: readonly Lang[];
|
|
50
|
+
|
|
51
|
+
sources: Partial<Record<SectionKey, SectionSourceEntry>>;
|
|
52
|
+
|
|
53
|
+
dump?: {
|
|
54
|
+
discoverDatasets(): Promise<DatasetRef[]>;
|
|
55
|
+
loadDataset(ref: DatasetRef): Promise<SectionDump | null | undefined>;
|
|
56
|
+
hasContent: Record<string, (dump: SectionDump) => boolean>;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const registry = ((globalThis as Record<symbol, unknown>)[
|
|
61
|
+
Symbol.for('terrafirmagreg.config')
|
|
62
|
+
] ??= new Map<string, unknown>()) as Map<string, unknown>;
|
|
63
|
+
|
|
64
|
+
export function registerSectionConfig(
|
|
65
|
+
input: Partial<SectionConfigInput>,
|
|
66
|
+
): void {
|
|
67
|
+
registry.set('section', {
|
|
68
|
+
...(registry.get('section') as SectionConfigInput | undefined),
|
|
69
|
+
...input,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sectionConfig(): SectionConfigInput {
|
|
74
|
+
const config = registry.get('section') as SectionConfigInput | undefined;
|
|
75
|
+
if (!config) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'Section config is not registered: call registerSectionConfig() at app startup.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ClientDumpLoader = {
|
|
84
|
+
loadDump(dataset: DatasetRef): Promise<SectionDump | null | undefined>;
|
|
85
|
+
loadVersions(
|
|
86
|
+
datasetId: string,
|
|
87
|
+
): Promise<{ latest?: string; versions: string[] } | null>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const {
|
|
91
|
+
register: registerClientDumpLoader,
|
|
92
|
+
get: clientDumpLoader,
|
|
93
|
+
} = createConfig<ClientDumpLoader>('ClientDump');
|
|
94
|
+
|
|
95
|
+
export function createConfig<T>(label: string) {
|
|
96
|
+
return {
|
|
97
|
+
register(input: T) {
|
|
98
|
+
registry.set(label, input);
|
|
99
|
+
},
|
|
100
|
+
get(): T {
|
|
101
|
+
const config = registry.get(label) as T | undefined;
|
|
102
|
+
if (!config) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${label} config is not registered: call register${label}Config() at app startup.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return config;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
};
|
|
31
|
+
|
|
@@ -0,0 +1,241 @@
|
|
|
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 { createAsyncCache } from '@terrafirmagreg-team/data/lib/async-cache';
|
|
7
|
+
import { loadLocaleRegistry, sortLangCodes } from '@terrafirmagreg-team/translate/server';
|
|
8
|
+
import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
|
|
9
|
+
import { sectionTabs } from '@terrafirmagreg-team/sites/sections';
|
|
10
|
+
import type {
|
|
11
|
+
DatasetCatalog,
|
|
12
|
+
SectionDataset,
|
|
13
|
+
SectionKey,
|
|
14
|
+
} from '@terrafirmagreg-team/kit/types';
|
|
15
|
+
import type { SectionDump } from './config';
|
|
16
|
+
import type { IconName } from 'lucide-react/dynamic';
|
|
17
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
18
|
+
|
|
19
|
+
const sectionMeta = new Map<
|
|
20
|
+
SectionKey,
|
|
21
|
+
Promise<Map<string, Record<string, unknown>>>
|
|
22
|
+
>();
|
|
23
|
+
|
|
24
|
+
function staticSectionMeta(
|
|
25
|
+
section: SectionKey,
|
|
26
|
+
): Promise<Map<string, Record<string, unknown>>> {
|
|
27
|
+
let meta = sectionMeta.get(section);
|
|
28
|
+
if (meta) return meta;
|
|
29
|
+
meta = (async () => {
|
|
30
|
+
const map = new Map<string, Record<string, unknown>>();
|
|
31
|
+
const source = await sections[section]?.getSource();
|
|
32
|
+
if (source) {
|
|
33
|
+
for (const node of source.getPageTree().children) {
|
|
34
|
+
if (node.type !== 'folder') continue;
|
|
35
|
+
const id = datasetId(node);
|
|
36
|
+
const data = id
|
|
37
|
+
? (source.getNodeMeta?.(node) as { data?: Record<string, unknown> })
|
|
38
|
+
?.data
|
|
39
|
+
: undefined;
|
|
40
|
+
if (id && data) map.set(id, data);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return map;
|
|
44
|
+
})();
|
|
45
|
+
sectionMeta.set(section, meta);
|
|
46
|
+
meta.catch(() => {
|
|
47
|
+
if (sectionMeta.get(section) === meta) sectionMeta.delete(section);
|
|
48
|
+
});
|
|
49
|
+
return meta;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function contentSectionDatasets(
|
|
53
|
+
section: SectionKey,
|
|
54
|
+
): Promise<SectionDataset[]> {
|
|
55
|
+
const source = await sections[section]?.getSource();
|
|
56
|
+
if (!source) return [];
|
|
57
|
+
const languages = new Map<string, Set<string>>();
|
|
58
|
+
for (const page of source.getPages()) {
|
|
59
|
+
const [dataset, lang] = page.path.split('/');
|
|
60
|
+
if (!dataset || !lang) continue;
|
|
61
|
+
let set = languages.get(dataset);
|
|
62
|
+
if (!set) languages.set(dataset, (set = new Set()));
|
|
63
|
+
set.add(lang);
|
|
64
|
+
}
|
|
65
|
+
const meta = await staticSectionMeta(section);
|
|
66
|
+
return [...languages.keys()].map((id) => ({
|
|
67
|
+
id,
|
|
68
|
+
languages: datasetLanguages([...(languages.get(id) ?? [])]),
|
|
69
|
+
...metaFields(meta.get(id) ?? {}),
|
|
70
|
+
...datasetAppearance(id),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function datasetMeta(
|
|
75
|
+
section: SectionKey,
|
|
76
|
+
id: string,
|
|
77
|
+
): Promise<Omit<SectionDataset, 'id' | 'languages'>> {
|
|
78
|
+
const meta = (await staticSectionMeta(section)).get(id);
|
|
79
|
+
return meta ? metaFields(meta) : {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function metaFields(
|
|
83
|
+
meta: Record<string, unknown>,
|
|
84
|
+
): Omit<SectionDataset, 'id' | 'languages'> {
|
|
85
|
+
const { title, description, icon } = meta;
|
|
86
|
+
return {
|
|
87
|
+
title: typeof title === 'string' ? title : undefined,
|
|
88
|
+
description: typeof description === 'string' ? description : undefined,
|
|
89
|
+
icon:
|
|
90
|
+
typeof icon === 'string' && icon in dynamicIconImports
|
|
91
|
+
? (icon as IconName)
|
|
92
|
+
: undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function datasetAppearance(id: string) {
|
|
97
|
+
return { accent: sectionConfig().datasetConfig[id]?.accent };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function datasetLanguages(languages: readonly string[]): Lang[] {
|
|
101
|
+
if (languages.length) return sortLangCodes(languages) as Lang[];
|
|
102
|
+
return [loadLocaleRegistry().defaultLanguage as Lang];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const DUMP_DATASETS_TTL = 60_000;
|
|
106
|
+
|
|
107
|
+
const dumpSectionDatasets = createAsyncCache(
|
|
108
|
+
async (section: SectionKey, hasContent: (dump: SectionDump) => boolean) =>
|
|
109
|
+
dumpDatasets(section, hasContent),
|
|
110
|
+
(section) => section,
|
|
111
|
+
DUMP_DATASETS_TTL,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const contentSectionDatasetsCached = createAsyncCache(
|
|
115
|
+
async (section: SectionKey): Promise<SectionDataset[]> => {
|
|
116
|
+
const rows = await contentSectionDatasets(section);
|
|
117
|
+
rows.sort(datasetOrder);
|
|
118
|
+
return rows;
|
|
119
|
+
},
|
|
120
|
+
(section) => section,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
function datasetOrder(a: SectionDataset, b: SectionDataset): number {
|
|
124
|
+
const cfg = sectionConfig().datasetConfig;
|
|
125
|
+
const order = (cfg[a.id]?.order ?? 0) - (cfg[b.id]?.order ?? 0);
|
|
126
|
+
return order !== 0 ? order : a.id.localeCompare(b.id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function compareVersions(a: string, b: string): number {
|
|
130
|
+
const pa = a.split('.');
|
|
131
|
+
const pb = b.split('.');
|
|
132
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
133
|
+
const d =
|
|
134
|
+
(Number.parseInt(pa[i] ?? '0', 10) || 0) -
|
|
135
|
+
(Number.parseInt(pb[i] ?? '0', 10) || 0);
|
|
136
|
+
if (d) return d;
|
|
137
|
+
}
|
|
138
|
+
return a.localeCompare(b);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function dumpDatasets(
|
|
142
|
+
section: SectionKey,
|
|
143
|
+
hasContent: (dump: SectionDump) => boolean,
|
|
144
|
+
): Promise<SectionDataset[]> {
|
|
145
|
+
const dump = sectionConfig().dump;
|
|
146
|
+
if (!dump) return [];
|
|
147
|
+
const refs = await dump.discoverDatasets();
|
|
148
|
+
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);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
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
|
+
return (
|
|
170
|
+
await Promise.all(
|
|
171
|
+
usable
|
|
172
|
+
.filter((row): row is { id: string; languages: Lang[] } => row !== null)
|
|
173
|
+
.map(async ({ id, languages }) => ({
|
|
174
|
+
id,
|
|
175
|
+
languages,
|
|
176
|
+
...(versions.get(id)?.length
|
|
177
|
+
? { versions: versions.get(id)!.sort(compareVersions).reverse() }
|
|
178
|
+
: {}),
|
|
179
|
+
...(await datasetMeta(section, id).then((meta) =>
|
|
180
|
+
Object.values(meta).some((value) => value !== undefined)
|
|
181
|
+
? meta
|
|
182
|
+
: datasetMeta('docs', id),
|
|
183
|
+
)),
|
|
184
|
+
...datasetAppearance(id),
|
|
185
|
+
})),
|
|
186
|
+
)
|
|
187
|
+
).sort(datasetOrder);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function sectionDatasets(
|
|
191
|
+
section: SectionKey,
|
|
192
|
+
): Promise<SectionDataset[]> {
|
|
193
|
+
const hasContent = sectionConfig().dump?.hasContent[section];
|
|
194
|
+
return hasContent
|
|
195
|
+
? dumpSectionDatasets(section, hasContent)
|
|
196
|
+
: contentSectionDatasetsCached(section);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function defaultDataset(
|
|
200
|
+
section: SectionKey,
|
|
201
|
+
): Promise<string | undefined> {
|
|
202
|
+
return (await sectionDatasets(section))[0]?.id;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function findDataset(
|
|
206
|
+
datasets: readonly SectionDataset[],
|
|
207
|
+
ref: DatasetRef,
|
|
208
|
+
): SectionDataset | undefined {
|
|
209
|
+
const { id, version } = ref;
|
|
210
|
+
const meta = datasets.find((row) => row.id === id);
|
|
211
|
+
if (!meta) return undefined;
|
|
212
|
+
if (version && !meta.versions?.includes(version)) return undefined;
|
|
213
|
+
return meta;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function resolveDatasetMeta(
|
|
217
|
+
section: SectionKey,
|
|
218
|
+
ref: DatasetRef,
|
|
219
|
+
): Promise<SectionDataset | undefined> {
|
|
220
|
+
return findDataset(await sectionDatasets(section), ref);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function datasetSlugs(section: SectionKey): Promise<string[]> {
|
|
224
|
+
return (await sectionDatasets(section)).map(({ id }) => id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const datasetCatalogCached = createAsyncCache(
|
|
228
|
+
async (): Promise<DatasetCatalog> =>
|
|
229
|
+
Promise.all(
|
|
230
|
+
sectionTabs.map(
|
|
231
|
+
async ({ key }) => [key, await sectionDatasets(key)] as const,
|
|
232
|
+
),
|
|
233
|
+
).then((rows) => Object.fromEntries(rows) as DatasetCatalog),
|
|
234
|
+
() => 'catalog',
|
|
235
|
+
DUMP_DATASETS_TTL,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
export function datasetCatalog(): Promise<DatasetCatalog> {
|
|
239
|
+
return datasetCatalogCached();
|
|
240
|
+
}
|
|
241
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
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, { id: 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: { id: ref },
|
|
27
|
+
datasetTitle: meta.title ?? meta.id,
|
|
28
|
+
lang,
|
|
29
|
+
...(meta.versions ? { versions: meta.versions } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveDatasetLanding(
|
|
34
|
+
section: SectionKey,
|
|
35
|
+
slug: string[] | undefined,
|
|
36
|
+
lang: Lang,
|
|
37
|
+
datasets: readonly SectionDataset[],
|
|
38
|
+
): { page: DatasetLanding; isFallback: boolean } | undefined {
|
|
39
|
+
const page = getDatasetLanding(section, slug, lang, datasets);
|
|
40
|
+
return page ? { page, isFallback: false } : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function generateDatasetLandingParams(
|
|
44
|
+
section: SectionKey,
|
|
45
|
+
): Promise<{ slug: string[] }[]> {
|
|
46
|
+
return (await datasetSlugs(section)).map((ref) => ({ slug: [ref] }));
|
|
47
|
+
}
|
|
48
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
}
|
|
16
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
3
|
+
|
|
4
|
+
export type LocaleScope = { section: string; slug?: string[] };
|
|
5
|
+
|
|
6
|
+
export type LocaleResolve = (
|
|
7
|
+
slug: string[] | undefined,
|
|
8
|
+
lang: Lang,
|
|
9
|
+
) => Promise<{ page: unknown; isFallback: boolean } | undefined>;
|
|
10
|
+
|
|
11
|
+
export type LocaleRender = (
|
|
12
|
+
page: unknown,
|
|
13
|
+
slug: string[] | undefined,
|
|
14
|
+
lang: Lang,
|
|
15
|
+
) => ReactNode | Promise<ReactNode>;
|
|
16
|
+
|
|
17
|
+
export type LocaleLoadResult = { node: ReactNode; missing: boolean } | null;
|
|
18
|
+
|
|
19
|
+
export type LocaleLoader = (
|
|
20
|
+
scope: LocaleScope,
|
|
21
|
+
lang: Lang,
|
|
22
|
+
) => Promise<LocaleLoadResult>;
|
|
23
|
+
|
|
24
|
+
const renderers = new Map<
|
|
25
|
+
string,
|
|
26
|
+
{ resolve: LocaleResolve; render: LocaleRender }
|
|
27
|
+
>();
|
|
28
|
+
|
|
29
|
+
export function registerLocaleRenderer(
|
|
30
|
+
section: string,
|
|
31
|
+
entry: { resolve: LocaleResolve; render: LocaleRender },
|
|
32
|
+
): void {
|
|
33
|
+
renderers.set(section, entry);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function localeRenderer(section: string) {
|
|
37
|
+
return renderers.get(section);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let loader: LocaleLoader | undefined;
|
|
41
|
+
|
|
42
|
+
export function registerLocaleLoader(next: LocaleLoader): void {
|
|
43
|
+
loader = next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function localeLoader(): LocaleLoader | undefined {
|
|
47
|
+
return loader;
|
|
48
|
+
}
|
|
49
|
+
|
package/src/lib/order.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LoaderPlugin,
|
|
3
|
+
PageTreeBuilderContext,
|
|
4
|
+
} from 'fumadocs-core/source';
|
|
5
|
+
import type { Folder, Item, Node } from 'fumadocs-core/page-tree';
|
|
6
|
+
|
|
7
|
+
type Storage = PageTreeBuilderContext['storage'];
|
|
8
|
+
type NodeLike = Item | Folder;
|
|
9
|
+
|
|
10
|
+
type PageFrontmatter = {
|
|
11
|
+
order?: number;
|
|
12
|
+
unlisted?: boolean;
|
|
13
|
+
defaultOpen?: boolean;
|
|
14
|
+
collapsible?: boolean;
|
|
15
|
+
link?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function frontmatter(
|
|
19
|
+
storage: Storage,
|
|
20
|
+
filePath?: string,
|
|
21
|
+
): PageFrontmatter | undefined {
|
|
22
|
+
if (!filePath) return undefined;
|
|
23
|
+
const file = storage.read(filePath);
|
|
24
|
+
return file?.format === 'page' ? (file.data as PageFrontmatter) : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function orderOf(storage: Storage, node: NodeLike): number {
|
|
28
|
+
const ref =
|
|
29
|
+
node.type === 'page'
|
|
30
|
+
? node.$ref
|
|
31
|
+
: (node.index?.$ref ??
|
|
32
|
+
(node as Folder & { $indexRef?: string }).$indexRef);
|
|
33
|
+
const data = frontmatter(storage, ref);
|
|
34
|
+
return typeof data?.order === 'number' ? data.order : Number.MAX_SAFE_INTEGER;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isUnlisted(storage: Storage, node: NodeLike): boolean {
|
|
38
|
+
const data = frontmatter(
|
|
39
|
+
storage,
|
|
40
|
+
node.type === 'page' ? node.$ref : node.index?.$ref,
|
|
41
|
+
);
|
|
42
|
+
return data?.unlisted === true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasExplicitOrder(storage: Storage, metaPath?: string): boolean {
|
|
46
|
+
if (!metaPath) return false;
|
|
47
|
+
const file = storage.read(metaPath);
|
|
48
|
+
return (
|
|
49
|
+
file?.format === 'meta' &&
|
|
50
|
+
Array.isArray((file.data as { pages?: unknown }).pages)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function arrange(storage: Storage, children: Node[], metaPath?: string): Node[] {
|
|
55
|
+
const kept = children.filter(
|
|
56
|
+
(child) => child.type === 'separator' || !isUnlisted(storage, child),
|
|
57
|
+
);
|
|
58
|
+
if (hasExplicitOrder(storage, metaPath)) return kept;
|
|
59
|
+
return [...kept].sort(
|
|
60
|
+
(a, b) => orderOf(storage, a as NodeLike) - orderOf(storage, b as NodeLike),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function orderPlugin(): LoaderPlugin {
|
|
65
|
+
return {
|
|
66
|
+
name: 'wiki:order',
|
|
67
|
+
transformPageTree: {
|
|
68
|
+
folder(node, _folderPath, metaPath) {
|
|
69
|
+
const indexRef = node.index?.$ref;
|
|
70
|
+
const index = frontmatter(this.storage, indexRef);
|
|
71
|
+
const next: Folder = {
|
|
72
|
+
...node,
|
|
73
|
+
defaultOpen: index?.defaultOpen ?? node.defaultOpen,
|
|
74
|
+
collapsible: index?.collapsible ?? node.collapsible,
|
|
75
|
+
children: arrange(this.storage, node.children, metaPath),
|
|
76
|
+
};
|
|
77
|
+
if (index?.link === false && indexRef) {
|
|
78
|
+
delete next.index;
|
|
79
|
+
(next as Folder & { $indexRef?: string }).$indexRef = indexRef;
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
},
|
|
83
|
+
root(node) {
|
|
84
|
+
return { ...node, children: arrange(this.storage, node.children) };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
package/src/lib/page.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { createGetUrl } from 'fumadocs-core/source';
|
|
4
|
+
import { firstLocalizedPage } from '@terrafirmagreg-team/translate/server';
|
|
5
|
+
import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
6
|
+
import { stripLangFromUrl, toInternalSlugs, toPublicSlug } from '@terrafirmagreg-team/translate/lib/slug';
|
|
7
|
+
import { sectionConfig } from './config';
|
|
8
|
+
import { sectionDatasets } from './datasets';
|
|
9
|
+
import type { SectionKey, SectionPage, SectionSource } from '@terrafirmagreg-team/kit/types';
|
|
10
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
11
|
+
|
|
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
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getPage<S extends SectionSource>(
|
|
30
|
+
source: S,
|
|
31
|
+
slug: string[] | undefined,
|
|
32
|
+
lang: Lang,
|
|
33
|
+
): ReturnType<S['getPage']> {
|
|
34
|
+
return source.getPage(toInternalSlugs(slug, lang)) as ReturnType<S['getPage']>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolvePage<S extends SectionSource>(
|
|
38
|
+
source: S,
|
|
39
|
+
slug: string[] | undefined,
|
|
40
|
+
lang: Lang,
|
|
41
|
+
) {
|
|
42
|
+
const { defaultLanguage } = loadLocaleRegistry();
|
|
43
|
+
const exact = getPage(source, slug, lang);
|
|
44
|
+
if (exact) return { page: exact, isFallback: false as const };
|
|
45
|
+
if (lang === defaultLanguage) return undefined;
|
|
46
|
+
const fallback = getPage(source, slug, defaultLanguage);
|
|
47
|
+
if (!fallback) return undefined;
|
|
48
|
+
return { page: fallback, isFallback: true as const };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function generateDatasetParams(
|
|
52
|
+
section: SectionKey,
|
|
53
|
+
source: SectionSource,
|
|
54
|
+
): Promise<{ slug: string[] }[]> {
|
|
55
|
+
const params = generatePageParams(source);
|
|
56
|
+
const seen = new Set(params.map((row) => row.slug.join('/')));
|
|
57
|
+
for (const { id } of await sectionDatasets(section)) {
|
|
58
|
+
if (!seen.has(id)) {
|
|
59
|
+
seen.add(id);
|
|
60
|
+
params.push({ slug: [id] });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return params.length ? params : [{ slug: ['_'] }];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function generatePageParams(source: SectionSource): { slug: string[] }[] {
|
|
67
|
+
const languages = [...sectionConfig().knownLanguages];
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const params: { slug: string[] }[] = [];
|
|
70
|
+
for (const page of source.getPages()) {
|
|
71
|
+
const slug = toPublicSlug(page.slugs, languages);
|
|
72
|
+
const key = slug.join('/');
|
|
73
|
+
if (seen.has(key)) continue;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
params.push({ slug });
|
|
76
|
+
}
|
|
77
|
+
return params;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function sectionMetadata(
|
|
81
|
+
section: SectionKey,
|
|
82
|
+
source: SectionSource,
|
|
83
|
+
slug: string[] | undefined,
|
|
84
|
+
): Promise<Metadata> {
|
|
85
|
+
const page = firstLocalizedPage(slug, (s, l) => getPage(source, s, l));
|
|
86
|
+
if (!page) notFound();
|
|
87
|
+
const doc = page as SectionPage;
|
|
88
|
+
const languages = sectionConfig().knownLanguages;
|
|
89
|
+
|
|
90
|
+
const url = canonicalUrl(stripLangFromUrl(doc.url, [...languages]));
|
|
91
|
+
return {
|
|
92
|
+
title: doc.data.title,
|
|
93
|
+
description: doc.data.description,
|
|
94
|
+
alternates: { canonical: url },
|
|
95
|
+
openGraph: {
|
|
96
|
+
type: 'article',
|
|
97
|
+
url,
|
|
98
|
+
title: doc.data.title,
|
|
99
|
+
description: doc.data.description,
|
|
100
|
+
images: getDatasetImageUrl(section, doc.slugs[0]).url,
|
|
101
|
+
},
|
|
102
|
+
twitter: {
|
|
103
|
+
card: 'summary_large_image',
|
|
104
|
+
title: doc.data.title,
|
|
105
|
+
description: doc.data.description,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|