@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.
- package/package.json +68 -3
- package/src/components/blocks/nav-title.tsx +65 -0
- package/src/components/docs-image.tsx +49 -0
- package/src/components/outline/dropdown.tsx +67 -0
- package/src/components/outline/index.ts +10 -0
- package/src/components/outline/list.tsx +120 -0
- package/src/components/outline/outline.tsx +49 -0
- package/src/components/outline/popover.tsx +76 -0
- package/src/components/outline/types.ts +29 -0
- package/src/components/page-actions.tsx +201 -0
- package/src/components/relative-link.tsx +18 -0
- package/src/components/search/dialog.tsx +145 -0
- package/src/components/toc/base.tsx +65 -0
- package/src/components/toc/block.tsx +111 -0
- package/src/components/toc/clerk.tsx +265 -0
- package/src/components/toc/default.tsx +333 -0
- package/src/components/version-menu.tsx +128 -0
- package/src/components/views/dataset.tsx +63 -0
- package/src/components/views/section.tsx +108 -0
- package/src/components/workspace/content.tsx +53 -0
- package/src/components/workspace/index.ts +12 -0
- package/src/components/workspace/pagination.tsx +73 -0
- package/src/components/workspace/workspace.tsx +148 -0
- package/src/hooks/toc.ts +9 -0
- package/src/hooks/use-catalog.ts +11 -0
- package/src/hooks/use-current-dataset.ts +14 -0
- package/src/hooks/use-dataset-accent.ts +13 -0
- package/src/hooks/use-nav-options.ts +84 -0
- package/src/hooks/use-version-pref.ts +30 -0
- package/src/lib/breadcrumb.ts +78 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/dataset-links.ts +30 -0
- package/src/lib/datasets.ts +211 -0
- package/src/lib/landing.ts +46 -0
- package/src/lib/layout.ts +15 -0
- package/src/lib/legacy.ts +18 -0
- package/src/lib/location.ts +63 -0
- package/src/lib/markdown/dom.ts +90 -0
- package/src/lib/markdown/index.ts +13 -0
- package/src/lib/markdown/providers.ts +25 -0
- package/src/lib/mdx/accordions.ts +93 -0
- package/src/lib/mdx/banner.ts +52 -0
- package/src/lib/mdx/directive-utils.ts +125 -0
- package/src/lib/mdx/directives.ts +141 -0
- package/src/lib/mdx/game-export.ts +44 -0
- package/src/lib/mdx/html-style.ts +135 -0
- package/src/lib/mdx/preview.ts +252 -0
- package/src/lib/mdx/references.ts +56 -0
- package/src/lib/mdx/tabs.ts +80 -0
- package/src/lib/mdx-components.tsx +139 -0
- package/src/lib/order.ts +88 -0
- package/src/lib/pack-assets.ts +45 -0
- package/src/lib/page.ts +108 -0
- package/src/lib/pin-tree.ts +35 -0
- package/src/lib/preview/index.ts +8 -0
- package/src/lib/preview/lookup.ts +48 -0
- package/src/lib/preview/og.tsx +115 -0
- package/src/lib/preview/resolvers.ts +98 -0
- package/src/lib/search/config.ts +13 -0
- package/src/lib/search/filters.ts +22 -0
- package/src/lib/search/index.ts +19 -0
- package/src/lib/search/indexes.ts +43 -0
- package/src/lib/search/merge.ts +22 -0
- package/src/lib/search/server.ts +18 -0
- package/src/lib/search/sources.ts +19 -0
- package/src/lib/search/structured.ts +23 -0
- package/src/lib/section/schema.ts +36 -0
- package/src/lib/section/source.ts +40 -0
- package/src/lib/sources.ts +21 -0
- package/src/lib/tree.ts +60 -0
- package/src/lib/urls.ts +16 -0
- package/src/locales/en_us.json +10 -0
- package/src/locales/ru_ru.json +10 -0
- package/src/locales.ts +5 -0
- package/src/types/index.ts +83 -0
- package/src/types/mdx.ts +55 -0
- package/src/types/turndown-plugin-gfm.d.ts +9 -0
- package/README.md +0 -3
package/src/lib/order.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DocsImageProps } from '@terrafirmagreg-team/kit/types/mdx';
|
|
2
|
+
|
|
3
|
+
export function packPublicUrl(pack: string, ...segments: string[]): string {
|
|
4
|
+
const rel = segments
|
|
5
|
+
.map((s) => s.replace(/^\/+/, '').replaceAll('\\', '/'))
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
.join('/');
|
|
8
|
+
return rel ? `/${pack}/${rel}` : `/${pack}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assetUrl(
|
|
12
|
+
pack: string,
|
|
13
|
+
modId: string,
|
|
14
|
+
...pathSegments: string[]
|
|
15
|
+
): string {
|
|
16
|
+
return packPublicUrl(pack, 'assets', modId, ...pathSegments);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveSrc(
|
|
20
|
+
props: Pick<DocsImageProps, 'src' | 'pack' | 'mod' | 'path'>,
|
|
21
|
+
packFallback?: string,
|
|
22
|
+
): string | null {
|
|
23
|
+
if (props.src) {
|
|
24
|
+
const s = props.src.trim();
|
|
25
|
+
if (
|
|
26
|
+
s.startsWith('/') ||
|
|
27
|
+
s.startsWith('http://') ||
|
|
28
|
+
s.startsWith('https://')
|
|
29
|
+
) {
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
return `/${s.replace(/^\/+/, '')}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pack = props.pack ?? packFallback;
|
|
36
|
+
if (!pack) return null;
|
|
37
|
+
|
|
38
|
+
if (props.mod && props.path) {
|
|
39
|
+
return assetUrl(pack, props.mod, props.path);
|
|
40
|
+
}
|
|
41
|
+
if (props.path) {
|
|
42
|
+
return packPublicUrl(pack, props.path);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
package/src/lib/page.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
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 = generateParams(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
|
+
function generateParams(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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type * as PageTree from 'fumadocs-core/page-tree';
|
|
2
|
+
|
|
3
|
+
function pinUrl(url: string, from: string, to: string): string {
|
|
4
|
+
const next = url[from.length];
|
|
5
|
+
return url.startsWith(from) &&
|
|
6
|
+
(next === undefined || next === '/' || next === '?' || next === '#')
|
|
7
|
+
? `${to}${url.slice(from.length)}`
|
|
8
|
+
: url;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function pinTreeDataset(
|
|
12
|
+
tree: PageTree.Root,
|
|
13
|
+
section: string,
|
|
14
|
+
datasetId: string,
|
|
15
|
+
version: string,
|
|
16
|
+
): PageTree.Root {
|
|
17
|
+
const from = `/${section}/${datasetId}`;
|
|
18
|
+
const to = `${from}@${version}`;
|
|
19
|
+
const pinNode = (node: PageTree.Node): PageTree.Node => {
|
|
20
|
+
if (node.type === 'page') {
|
|
21
|
+
return { ...node, url: pinUrl(node.url, from, to) };
|
|
22
|
+
}
|
|
23
|
+
if (node.type === 'folder') {
|
|
24
|
+
return {
|
|
25
|
+
...node,
|
|
26
|
+
index: node.index
|
|
27
|
+
? { ...node.index, url: pinUrl(node.index.url, from, to) }
|
|
28
|
+
: node.index,
|
|
29
|
+
children: node.children.map(pinNode),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return node;
|
|
33
|
+
};
|
|
34
|
+
return { ...tree, children: tree.children.map(pinNode) };
|
|
35
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { resolveLang } from '@terrafirmagreg-team/translate/lib/code';
|
|
3
|
+
import { loadLocaleRegistry } from '@terrafirmagreg-team/translate/server';
|
|
4
|
+
import { sectionConfig } from '../config';
|
|
5
|
+
import { isSectionKey } from '../location';
|
|
6
|
+
import { resolvePage } from '../page';
|
|
7
|
+
import { sections } from '../sources';
|
|
8
|
+
import type {
|
|
9
|
+
DatasetCatalog,
|
|
10
|
+
SectionKey,
|
|
11
|
+
SectionPage,
|
|
12
|
+
} from '@terrafirmagreg-team/kit/types';
|
|
13
|
+
import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
|
|
14
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
15
|
+
|
|
16
|
+
export function lookupPagePreview(
|
|
17
|
+
catalog: DatasetCatalog,
|
|
18
|
+
href: string | undefined,
|
|
19
|
+
lang?: Lang,
|
|
20
|
+
): PagePreviewData | undefined {
|
|
21
|
+
if (!href || /^(https?:|mailto:|\/\/|#)/i.test(href)) return undefined;
|
|
22
|
+
const [withoutHash] = href.split('#');
|
|
23
|
+
const [pathname, qs] = withoutHash.split('?');
|
|
24
|
+
if (!pathname.startsWith('/')) return undefined;
|
|
25
|
+
|
|
26
|
+
const { defaultLanguage } = loadLocaleRegistry();
|
|
27
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
28
|
+
if (!isSectionKey(parts[0])) return undefined;
|
|
29
|
+
|
|
30
|
+
const section = sections[parts[0]];
|
|
31
|
+
if (!section) return undefined;
|
|
32
|
+
const dataset = catalog[parts[0] as SectionKey]?.find(
|
|
33
|
+
(row) => row.id === parts[1],
|
|
34
|
+
);
|
|
35
|
+
const pageLang = resolveLang(
|
|
36
|
+
new URLSearchParams(qs).get('lang') ?? lang,
|
|
37
|
+
dataset?.languages ?? [...sectionConfig().knownLanguages],
|
|
38
|
+
defaultLanguage,
|
|
39
|
+
);
|
|
40
|
+
const resolved = resolvePage(section.source, parts.slice(1), pageLang);
|
|
41
|
+
const page = resolved?.page as SectionPage | undefined;
|
|
42
|
+
if (!page) return undefined;
|
|
43
|
+
return {
|
|
44
|
+
title: page.data.title,
|
|
45
|
+
description: page.data.description,
|
|
46
|
+
icon: dataset?.icon ? { type: 'icon', name: dataset.icon } : undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ImageResponse } from 'next/og';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types';
|
|
4
|
+
|
|
5
|
+
interface GenerateProps {
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
description?: ReactNode;
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
primaryColor?: string;
|
|
10
|
+
primaryTextColor?: string;
|
|
11
|
+
site?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function generateOGImage(options: GenerateProps & ImageResponseOptions): ImageResponse {
|
|
15
|
+
const { title, description, icon, site, primaryColor, primaryTextColor, ...rest } = options;
|
|
16
|
+
|
|
17
|
+
return new ImageResponse(
|
|
18
|
+
generate({
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
icon,
|
|
22
|
+
site,
|
|
23
|
+
primaryTextColor,
|
|
24
|
+
primaryColor,
|
|
25
|
+
}),
|
|
26
|
+
{
|
|
27
|
+
width: 1200,
|
|
28
|
+
height: 630,
|
|
29
|
+
...rest,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function generate({
|
|
35
|
+
primaryColor = 'rgba(255,150,255,0.3)',
|
|
36
|
+
primaryTextColor = 'rgb(255,150,255)',
|
|
37
|
+
icon = (
|
|
38
|
+
<svg
|
|
39
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
40
|
+
width="56"
|
|
41
|
+
height="56"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
stroke="currentColor"
|
|
45
|
+
strokeWidth="2"
|
|
46
|
+
strokeLinecap="round"
|
|
47
|
+
strokeLinejoin="round"
|
|
48
|
+
className="lucide lucide-book-icon lucide-book"
|
|
49
|
+
>
|
|
50
|
+
<circle cx="12" cy="12" r="11" stroke={primaryTextColor} strokeWidth="2" />
|
|
51
|
+
</svg>
|
|
52
|
+
),
|
|
53
|
+
...props
|
|
54
|
+
}: GenerateProps) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
style={{
|
|
58
|
+
display: 'flex',
|
|
59
|
+
flexDirection: 'column',
|
|
60
|
+
width: '100%',
|
|
61
|
+
height: '100%',
|
|
62
|
+
color: 'white',
|
|
63
|
+
padding: '4rem',
|
|
64
|
+
backgroundColor: '#0c0c0c',
|
|
65
|
+
borderBottom: `18px solid ${primaryColor}`,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<p
|
|
69
|
+
style={{
|
|
70
|
+
fontWeight: 800,
|
|
71
|
+
fontSize: '82px',
|
|
72
|
+
margin: 0,
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{props.title}
|
|
76
|
+
</p>
|
|
77
|
+
<p
|
|
78
|
+
style={{
|
|
79
|
+
fontSize: '52px',
|
|
80
|
+
color: 'rgba(240,240,240,0.8)',
|
|
81
|
+
margin: 0,
|
|
82
|
+
marginTop: '16px',
|
|
83
|
+
paddingBottom: '28px',
|
|
84
|
+
borderBottom: `10px dashed ${primaryColor}`,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{props.description}
|
|
88
|
+
</p>
|
|
89
|
+
|
|
90
|
+
<div
|
|
91
|
+
style={{
|
|
92
|
+
display: 'flex',
|
|
93
|
+
flexDirection: 'row',
|
|
94
|
+
alignItems: 'center',
|
|
95
|
+
gap: '20px',
|
|
96
|
+
marginTop: 'auto',
|
|
97
|
+
color: primaryTextColor,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{icon}
|
|
101
|
+
{props.site && (
|
|
102
|
+
<p
|
|
103
|
+
style={{
|
|
104
|
+
fontSize: '56px',
|
|
105
|
+
fontWeight: 600,
|
|
106
|
+
margin: 0,
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{props.site}
|
|
110
|
+
</p>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
2
|
+
import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
|
|
3
|
+
|
|
4
|
+
export interface PreviewResolverCtx {
|
|
5
|
+
|
|
6
|
+
parts: string[];
|
|
7
|
+
query: URLSearchParams;
|
|
8
|
+
lang: Lang;
|
|
9
|
+
langMap: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PreviewResolver = (
|
|
13
|
+
ctx: PreviewResolverCtx,
|
|
14
|
+
) => Promise<PagePreviewData | undefined>;
|
|
15
|
+
|
|
16
|
+
export interface PreviewResolverOptions {
|
|
17
|
+
|
|
18
|
+
langMap?: (dataset: string, lang: Lang) => Promise<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolvers = new Map<
|
|
22
|
+
string,
|
|
23
|
+
{ resolve: PreviewResolver; langMap?: PreviewResolverOptions['langMap'] }
|
|
24
|
+
>();
|
|
25
|
+
|
|
26
|
+
export function registerPreviewResolver(
|
|
27
|
+
section: string,
|
|
28
|
+
resolve: PreviewResolver,
|
|
29
|
+
options?: PreviewResolverOptions,
|
|
30
|
+
): void {
|
|
31
|
+
resolvers.set(section, { resolve, langMap: options?.langMap });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseHref(href: string | undefined) {
|
|
35
|
+
if (!href || /^(https?:|mailto:|\/\/|#)/i.test(href)) return undefined;
|
|
36
|
+
const [withoutHash] = href.split('#');
|
|
37
|
+
const [pathname, qs] = withoutHash.split('?');
|
|
38
|
+
if (!pathname.startsWith('/')) return undefined;
|
|
39
|
+
return {
|
|
40
|
+
withoutHash,
|
|
41
|
+
query: new URLSearchParams(qs),
|
|
42
|
+
parts: pathname.split('/').filter(Boolean),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function resolveLinkPreview(
|
|
47
|
+
href: string,
|
|
48
|
+
lang?: string,
|
|
49
|
+
): Promise<PagePreviewData | undefined> {
|
|
50
|
+
const parsed = parseHref(href);
|
|
51
|
+
if (!parsed) return undefined;
|
|
52
|
+
const resolvedLang = (parsed.query.get('lang') ?? lang) as Lang | undefined;
|
|
53
|
+
const entry = resolvers.get(parsed.parts[0] ?? '');
|
|
54
|
+
if (entry && resolvedLang) {
|
|
55
|
+
const dataset = parsed.parts[1];
|
|
56
|
+
if (!dataset) return undefined;
|
|
57
|
+
const langMap = entry.langMap
|
|
58
|
+
? await entry.langMap(dataset, resolvedLang)
|
|
59
|
+
: {};
|
|
60
|
+
return entry.resolve({
|
|
61
|
+
parts: parsed.parts,
|
|
62
|
+
query: parsed.query,
|
|
63
|
+
lang: resolvedLang,
|
|
64
|
+
langMap,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const res = await fetch(
|
|
68
|
+
`/api/preview?href=${encodeURIComponent(parsed.withoutHash)}${
|
|
69
|
+
resolvedLang ? `&lang=${encodeURIComponent(resolvedLang)}` : ''
|
|
70
|
+
}`,
|
|
71
|
+
);
|
|
72
|
+
if (!res.ok) return undefined;
|
|
73
|
+
return ((await res.json()) ?? undefined) as PagePreviewData | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function loadClientPreviews(
|
|
77
|
+
lang: Lang,
|
|
78
|
+
langMap: Record<string, string>,
|
|
79
|
+
hrefs: Iterable<string>,
|
|
80
|
+
): Promise<Record<string, PagePreviewData>> {
|
|
81
|
+
const out: Record<string, PagePreviewData> = {};
|
|
82
|
+
await Promise.all(
|
|
83
|
+
[...hrefs].map(async (href) => {
|
|
84
|
+
const parsed = parseHref(href);
|
|
85
|
+
if (!parsed) return;
|
|
86
|
+
const entry = resolvers.get(parsed.parts[0] ?? '');
|
|
87
|
+
if (!entry) return;
|
|
88
|
+
const preview = await entry.resolve({
|
|
89
|
+
parts: parsed.parts,
|
|
90
|
+
query: parsed.query,
|
|
91
|
+
lang,
|
|
92
|
+
langMap,
|
|
93
|
+
});
|
|
94
|
+
if (preview) out[href] = preview;
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LangConfig } from '@terrafirmagreg-team/translate/lang';
|
|
2
|
+
import { createConfig } from '@terrafirmagreg-team/kit/lib/config';
|
|
3
|
+
import type { LocalizedIndex } from './indexes';
|
|
4
|
+
|
|
5
|
+
export interface SearchConfig {
|
|
6
|
+
|
|
7
|
+
indexes(): Promise<LocalizedIndex[]>;
|
|
8
|
+
|
|
9
|
+
i18n(): LangConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const { register: registerSearchConfig, get: searchConfig } =
|
|
13
|
+
createConfig<SearchConfig>('Search');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SortedResult } from '@terrafirmagreg-team/ui/types/search';
|
|
2
|
+
|
|
3
|
+
export type SearchHitFilter = (hit: SortedResult) => boolean;
|
|
4
|
+
export type SearchHitFilterFactory = (
|
|
5
|
+
params: URLSearchParams,
|
|
6
|
+
) => SearchHitFilter | undefined;
|
|
7
|
+
|
|
8
|
+
const hitFilters = new Map<string, SearchHitFilterFactory>();
|
|
9
|
+
|
|
10
|
+
export function registerSearchHitFilter(
|
|
11
|
+
section: string,
|
|
12
|
+
factory: SearchHitFilterFactory,
|
|
13
|
+
): void {
|
|
14
|
+
hitFilters.set(section, factory);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function searchHitFilter(
|
|
18
|
+
section: string,
|
|
19
|
+
params: URLSearchParams,
|
|
20
|
+
): SearchHitFilter | undefined {
|
|
21
|
+
return hitFilters.get(section)?.(params);
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { mergeClients } from './merge';
|
|
2
|
+
export { pageStructuredData } from './structured';
|
|
3
|
+
export {
|
|
4
|
+
buildSearchIndexes,
|
|
5
|
+
type LocalizedIndex,
|
|
6
|
+
type SearchIndexPage,
|
|
7
|
+
type SearchPageSource,
|
|
8
|
+
} from './indexes';
|
|
9
|
+
export {
|
|
10
|
+
registerSearchConfig,
|
|
11
|
+
searchConfig,
|
|
12
|
+
type SearchConfig,
|
|
13
|
+
} from './config';
|
|
14
|
+
export {
|
|
15
|
+
registerSearchHitFilter,
|
|
16
|
+
searchHitFilter,
|
|
17
|
+
type SearchHitFilter,
|
|
18
|
+
type SearchHitFilterFactory,
|
|
19
|
+
} from './filters';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AdvancedIndex } from 'fumadocs-core/search/server';
|
|
2
|
+
import { stripLangFromUrl } from '@terrafirmagreg-team/translate/lib/slug';
|
|
3
|
+
import { pageStructuredData } from './structured';
|
|
4
|
+
|
|
5
|
+
export type LocalizedIndex = AdvancedIndex & { locale: string };
|
|
6
|
+
|
|
7
|
+
export interface SearchIndexPage {
|
|
8
|
+
slugs: string[];
|
|
9
|
+
url: string;
|
|
10
|
+
data: { title?: string; description?: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchPageSource {
|
|
14
|
+
source: { getPages(): SearchIndexPage[] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function buildSearchIndexes(
|
|
18
|
+
sources: Record<string, SearchPageSource | undefined>,
|
|
19
|
+
languages: readonly string[],
|
|
20
|
+
): Promise<LocalizedIndex[]> {
|
|
21
|
+
const rows = (
|
|
22
|
+
await Promise.all(
|
|
23
|
+
Object.entries(sources).flatMap(([section, entry]) =>
|
|
24
|
+
entry?.source.getPages().map(async (page) => {
|
|
25
|
+
const [dataset, lang] = page.slugs;
|
|
26
|
+
if (!dataset || !lang) return null;
|
|
27
|
+
return {
|
|
28
|
+
title: page.data.title ?? page.url,
|
|
29
|
+
description: page.data.description,
|
|
30
|
+
url: stripLangFromUrl(page.url, [...languages]),
|
|
31
|
+
id: page.url,
|
|
32
|
+
tag: `${section}:${dataset}`,
|
|
33
|
+
locale: lang,
|
|
34
|
+
structuredData: await pageStructuredData(
|
|
35
|
+
page.data as Record<string, unknown>,
|
|
36
|
+
),
|
|
37
|
+
} satisfies LocalizedIndex;
|
|
38
|
+
}) ?? [],
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
).filter((index) => index !== null);
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SearchClient } from 'fumadocs-core/search/client';
|
|
2
|
+
|
|
3
|
+
export function mergeClients(...clients: SearchClient[]): SearchClient {
|
|
4
|
+
return {
|
|
5
|
+
deps: clients.flatMap((client) => client.deps ?? []),
|
|
6
|
+
async search(query) {
|
|
7
|
+
const lists = await Promise.all(
|
|
8
|
+
clients.map((client) => client.search(query)),
|
|
9
|
+
);
|
|
10
|
+
const seen = new Set<string>();
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const list of lists) {
|
|
13
|
+
for (const hit of list) {
|
|
14
|
+
if (seen.has(hit.id)) continue;
|
|
15
|
+
seen.add(hit.id);
|
|
16
|
+
out.push(hit);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createI18nSearchAPI } from 'fumadocs-core/search/server';
|
|
3
|
+
import { searchConfig } from './config';
|
|
4
|
+
|
|
5
|
+
async function createSearchApi() {
|
|
6
|
+
const config = searchConfig();
|
|
7
|
+
return createI18nSearchAPI('advanced', {
|
|
8
|
+
i18n: config.i18n(),
|
|
9
|
+
indexes: await config.indexes(),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let cached: ReturnType<typeof createSearchApi> | undefined;
|
|
14
|
+
|
|
15
|
+
export function loadSearchApi() {
|
|
16
|
+
cached ??= createSearchApi();
|
|
17
|
+
return cached;
|
|
18
|
+
}
|