@terrafirmagreg-team/guide 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 +58 -3
- package/src/components/book-switcher.tsx +46 -0
- package/src/components/views/browser.tsx +124 -0
- package/src/components/views/entry/pages.tsx +186 -0
- package/src/components/views/entry/view.tsx +60 -0
- package/src/components/views/kind/links.tsx +111 -0
- package/src/components/views/kind/media.tsx +167 -0
- package/src/components/views/kind/recipe.tsx +134 -0
- package/src/components/views/kind/table.tsx +138 -0
- package/src/components/views/kind/unknown.tsx +23 -0
- package/src/components/views/page/category-body.tsx +56 -0
- package/src/components/views/page/entry-body.tsx +79 -0
- package/src/components/views/page/overview-body.tsx +97 -0
- package/src/components/views/page/text.tsx +57 -0
- package/src/components/views/page/view.tsx +279 -0
- package/src/components/views/text.tsx +130 -0
- package/src/hooks/use-left-sidebar-tree.ts +19 -0
- package/src/hooks/use-tree.ts +33 -0
- package/src/lib/book/convert.ts +74 -0
- package/src/lib/book/index-rows.ts +134 -0
- package/src/lib/book/index.ts +2 -0
- package/src/lib/book/localize.ts +76 -0
- package/src/lib/book/raw.ts +80 -0
- package/src/lib/books.ts +29 -0
- package/src/lib/breadcrumb.ts +54 -0
- package/src/lib/client/guides.ts +189 -0
- package/src/lib/client-payload/extras.ts +101 -0
- package/src/lib/client-payload/index.ts +112 -0
- package/src/lib/client-payload/links.ts +101 -0
- package/src/lib/config.ts +46 -0
- package/src/lib/data/dto.ts +77 -0
- package/src/lib/data/parse.ts +93 -0
- package/src/lib/dump.ts +5 -0
- package/src/lib/format.ts +231 -0
- package/src/lib/links.ts +2 -0
- package/src/lib/markdown/book.ts +73 -0
- package/src/lib/markdown/ctx.ts +26 -0
- package/src/lib/markdown/index.ts +8 -0
- package/src/lib/markdown/page.ts +154 -0
- package/src/lib/markdown/table.ts +35 -0
- package/src/lib/markdown/text.ts +39 -0
- package/src/lib/markdown-page.ts +72 -0
- package/src/lib/messages.ts +13 -0
- package/src/lib/meta.ts +9 -0
- package/src/lib/multiblock/index.ts +8 -0
- package/src/lib/multiblock/url.ts +61 -0
- package/src/lib/names.ts +35 -0
- package/src/lib/page-kind.ts +117 -0
- package/src/lib/paths.ts +52 -0
- package/src/lib/preview.ts +33 -0
- package/src/lib/query.ts +28 -0
- package/src/lib/recipe.ts +30 -0
- package/src/lib/reference/index.ts +40 -0
- package/src/lib/reference/resolver.ts +88 -0
- package/src/lib/search/filter.ts +17 -0
- package/src/lib/search/index.ts +156 -0
- package/src/lib/sprites.ts +17 -0
- package/src/lib/table.ts +10 -0
- package/src/lib/toc.ts +137 -0
- package/src/lib/tree/children.ts +70 -0
- package/src/lib/tree/index.ts +117 -0
- package/src/locales/en_us.json +22 -0
- package/src/locales/ru_ru.json +22 -0
- package/src/locales.ts +5 -0
- package/src/types/index.ts +215 -0
- package/README.md +0 -3
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
2
|
+
import type { GuideCategoryRef } from '@terrafirmagreg-team/guide/types';
|
|
3
|
+
import { guideHref as guideQueryHref } from '@terrafirmagreg-team/guide/lib/links';
|
|
4
|
+
|
|
5
|
+
export function bareId(id: string): string {
|
|
6
|
+
const trimmed = id.replace(/^\/+|\/+$/g, '');
|
|
7
|
+
return trimmed.includes(':')
|
|
8
|
+
? trimmed.slice(trimmed.indexOf(':') + 1)
|
|
9
|
+
: trimmed;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function bookOptionId(modId: string, bookPath: string): string {
|
|
13
|
+
return `${modId}:${bookPath}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function guideBookHref(
|
|
17
|
+
dataset: string,
|
|
18
|
+
lang: Lang | undefined,
|
|
19
|
+
modId?: string,
|
|
20
|
+
bookPath?: string,
|
|
21
|
+
entryId?: string,
|
|
22
|
+
hash?: string,
|
|
23
|
+
categoryId?: string,
|
|
24
|
+
): string {
|
|
25
|
+
const bookId = modId && bookPath ? bookOptionId(modId, bookPath) : undefined;
|
|
26
|
+
return guideQueryHref(dataset, lang, bookId, entryId, hash, categoryId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function guideCategoryHref(
|
|
30
|
+
dataset: string,
|
|
31
|
+
lang: Lang | undefined,
|
|
32
|
+
modId: string,
|
|
33
|
+
bookPath: string,
|
|
34
|
+
category: { id: string },
|
|
35
|
+
): string {
|
|
36
|
+
return guideBookHref(
|
|
37
|
+
dataset,
|
|
38
|
+
lang,
|
|
39
|
+
modId,
|
|
40
|
+
bookPath,
|
|
41
|
+
undefined,
|
|
42
|
+
undefined,
|
|
43
|
+
category.id,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isRootCategory(
|
|
48
|
+
_category: GuideCategoryRef,
|
|
49
|
+
_all: GuideCategoryRef[],
|
|
50
|
+
): boolean {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { registerPreviewResolver } from '@terrafirmagreg-team/kit/lib/preview';
|
|
2
|
+
import { loadClientGuideBook } from '@terrafirmagreg-team/guide/lib/client/guides';
|
|
3
|
+
import { entryPreviewIcon } from '@terrafirmagreg-team/game/lib/preview';
|
|
4
|
+
import type { PreviewResolverCtx } from '@terrafirmagreg-team/kit/lib/preview';
|
|
5
|
+
import type { PagePreviewData } from '@terrafirmagreg-team/ui/types/preview';
|
|
6
|
+
|
|
7
|
+
export async function guideLinkPreview({
|
|
8
|
+
parts,
|
|
9
|
+
query,
|
|
10
|
+
lang,
|
|
11
|
+
}: PreviewResolverCtx): Promise<PagePreviewData | undefined> {
|
|
12
|
+
const dataset = parts[1];
|
|
13
|
+
const bookId = query.get('book');
|
|
14
|
+
if (!dataset || !bookId) return undefined;
|
|
15
|
+
const index = await loadClientGuideBook(dataset, lang, bookId);
|
|
16
|
+
if (!index) return undefined;
|
|
17
|
+
const entryId = query.get('entry');
|
|
18
|
+
if (entryId) {
|
|
19
|
+
const entry = index.entries.find((row) => row.id === entryId);
|
|
20
|
+
if (!entry) return undefined;
|
|
21
|
+
return {
|
|
22
|
+
title: entry.name ?? entry.id,
|
|
23
|
+
icon: entryPreviewIcon(entry.icon, dataset),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
title: index.book?.name ?? bookId,
|
|
28
|
+
description: index.book?.landingText,
|
|
29
|
+
icon: entryPreviewIcon(index.book?.cover ?? undefined, dataset),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
registerPreviewResolver('guide', guideLinkPreview);
|
package/src/lib/query.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GuideIndex } from '@terrafirmagreg-team/guide/types';
|
|
2
|
+
|
|
3
|
+
type GuideQuery = {
|
|
4
|
+
bookId?: string;
|
|
5
|
+
entryId?: string;
|
|
6
|
+
categoryId?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function guideQueryFromParams(params: {
|
|
10
|
+
get(name: string): string | null;
|
|
11
|
+
}): GuideQuery {
|
|
12
|
+
const book = params.get('book') ?? undefined;
|
|
13
|
+
const entry = params.get('entry') ?? undefined;
|
|
14
|
+
const category = params.get('cat') ?? undefined;
|
|
15
|
+
return {
|
|
16
|
+
...(book ? { bookId: book } : {}),
|
|
17
|
+
...(entry ? { entryId: entry } : {}),
|
|
18
|
+
...(category ? { categoryId: category } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function firstGuideEntryId(index: GuideIndex): string | undefined {
|
|
23
|
+
return (
|
|
24
|
+
index.categories.flatMap((category) =>
|
|
25
|
+
category.entries.map((entry) => entry.id),
|
|
26
|
+
)[0] ?? index.entries[0]?.id
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GuidePage } from '@terrafirmagreg-team/guide/types';
|
|
2
|
+
import type { Recipe } from '@terrafirmagreg-team/recipe/lib/data/dto';
|
|
3
|
+
import { stackDisplayName } from '@terrafirmagreg-team/game/lib/entry/display';
|
|
4
|
+
|
|
5
|
+
export function recipeIds(page: GuidePage): string[] {
|
|
6
|
+
return (page.recipes ?? []).filter(
|
|
7
|
+
(id): id is string => typeof id === 'string' && Boolean(id),
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function recipeNameScope(
|
|
12
|
+
langMap: Record<string, string>,
|
|
13
|
+
recipe: Recipe,
|
|
14
|
+
): Record<string, string> {
|
|
15
|
+
const ids = new Set<string>([recipe.id]);
|
|
16
|
+
for (const ing of [...recipe.inputs, ...recipe.outputs]) ids.add(ing.id);
|
|
17
|
+
for (const fill of Object.values(recipe.slots ?? {})) {
|
|
18
|
+
ids.add(fill.id);
|
|
19
|
+
for (const alt of fill.alternatives ?? []) ids.add(alt.id);
|
|
20
|
+
}
|
|
21
|
+
for (const widget of recipe.widgets ?? []) {
|
|
22
|
+
if (widget.type === 'slot') {
|
|
23
|
+
ids.add(widget.id);
|
|
24
|
+
for (const alt of widget.alternatives ?? []) ids.add(alt);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const names: Record<string, string> = { ...langMap };
|
|
28
|
+
for (const id of ids) names[id] = stackDisplayName(langMap, id);
|
|
29
|
+
return names;
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseGuideReference,
|
|
3
|
+
resolveGuideReference,
|
|
4
|
+
} from '@terrafirmagreg-team/guide/lib/reference/resolver';
|
|
5
|
+
import type {
|
|
6
|
+
GuideReferenceContext,
|
|
7
|
+
PageRendererProps,
|
|
8
|
+
} from '@terrafirmagreg-team/guide/types';
|
|
9
|
+
|
|
10
|
+
export function referenceHref(
|
|
11
|
+
props: PageRendererProps,
|
|
12
|
+
target: string,
|
|
13
|
+
): { href: string; external: boolean } | null {
|
|
14
|
+
if (/^https?:\/\//i.test(target)) return { href: target, external: true };
|
|
15
|
+
const reference = resolveGuideReference(parseGuideReference(target), {
|
|
16
|
+
dataset: props.dataset,
|
|
17
|
+
lang: props.lang,
|
|
18
|
+
modId: props.bookModId,
|
|
19
|
+
bookPath: props.bookPath,
|
|
20
|
+
categoryByEntry: props.categoryByEntry,
|
|
21
|
+
questLinks: props.questLinks,
|
|
22
|
+
});
|
|
23
|
+
return reference.href && reference.available
|
|
24
|
+
? { href: reference.href, external: false }
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function guideLink(
|
|
29
|
+
node: { link?: string },
|
|
30
|
+
ctx: GuideReferenceContext,
|
|
31
|
+
): { href: string | undefined; external: boolean } {
|
|
32
|
+
const target = node.link;
|
|
33
|
+
if (!target) return { href: undefined, external: false };
|
|
34
|
+
if (/^https?:\/\//i.test(target)) return { href: target, external: true };
|
|
35
|
+
const resolved = resolveGuideReference(parseGuideReference(target), ctx);
|
|
36
|
+
return {
|
|
37
|
+
href: resolved.available ? resolved.href : undefined,
|
|
38
|
+
external: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { recipeHref } from '@terrafirmagreg-team/recipe/lib/links';
|
|
2
|
+
import { questHref } from '@terrafirmagreg-team/quest/lib/links';
|
|
3
|
+
import {
|
|
4
|
+
bareId,
|
|
5
|
+
guideBookHref,
|
|
6
|
+
} from '@terrafirmagreg-team/guide/lib/paths';
|
|
7
|
+
import type {
|
|
8
|
+
GuideReference,
|
|
9
|
+
GuideReferenceContext,
|
|
10
|
+
ResolvedGuideReference,
|
|
11
|
+
} from '@terrafirmagreg-team/guide/types';
|
|
12
|
+
|
|
13
|
+
export function parseGuideReference(target: string): GuideReference {
|
|
14
|
+
const [rawId, anchor] = target.replace(/\.json$/i, '').split('#', 2);
|
|
15
|
+
const [prefix, ...rest] = rawId.split(':');
|
|
16
|
+
if ((prefix === 'recipe' || prefix === 'quest') && rest.length) {
|
|
17
|
+
return { kind: prefix, id: rest.join(':'), anchor };
|
|
18
|
+
}
|
|
19
|
+
return { kind: 'guide', id: rawId.replace(/^guide:/, ''), anchor };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function matchGuideEntryKey(
|
|
23
|
+
ids: Iterable<string>,
|
|
24
|
+
query: string,
|
|
25
|
+
): string | undefined {
|
|
26
|
+
const keys = ids instanceof Set ? ids : new Set(ids);
|
|
27
|
+
if (keys.has(query)) return query;
|
|
28
|
+
const bare = bareId(query);
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
if (bareId(key) === bare) return key;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveGuideReference(
|
|
36
|
+
reference: GuideReference,
|
|
37
|
+
context: GuideReferenceContext,
|
|
38
|
+
): ResolvedGuideReference {
|
|
39
|
+
switch (reference.kind) {
|
|
40
|
+
case 'guide': {
|
|
41
|
+
const entryId = matchGuideEntryKey(
|
|
42
|
+
context.categoryByEntry.keys(),
|
|
43
|
+
reference.id,
|
|
44
|
+
);
|
|
45
|
+
return {
|
|
46
|
+
...reference,
|
|
47
|
+
href: entryId
|
|
48
|
+
? guideBookHref(
|
|
49
|
+
context.dataset,
|
|
50
|
+
undefined,
|
|
51
|
+
context.modId,
|
|
52
|
+
context.bookPath,
|
|
53
|
+
entryId,
|
|
54
|
+
reference.anchor,
|
|
55
|
+
)
|
|
56
|
+
: guideBookHref(
|
|
57
|
+
context.dataset,
|
|
58
|
+
undefined,
|
|
59
|
+
context.modId,
|
|
60
|
+
context.bookPath,
|
|
61
|
+
bareId(reference.id),
|
|
62
|
+
reference.anchor,
|
|
63
|
+
),
|
|
64
|
+
available: Boolean(entryId ?? reference.id),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
case 'recipe': {
|
|
68
|
+
const href =
|
|
69
|
+
context.targets?.recipe?.(reference.id) ??
|
|
70
|
+
recipeHref(context.dataset, undefined, { id: reference.id });
|
|
71
|
+
return { ...reference, href, available: Boolean(href) };
|
|
72
|
+
}
|
|
73
|
+
case 'quest': {
|
|
74
|
+
const hit = context.questLinks?.[reference.id];
|
|
75
|
+
const href = hit
|
|
76
|
+
? questHref(context.dataset, undefined, {
|
|
77
|
+
category: hit.filename,
|
|
78
|
+
id: hit.questId,
|
|
79
|
+
})
|
|
80
|
+
: context.targets?.quest?.(reference.id);
|
|
81
|
+
return { ...reference, href, available: Boolean(href) };
|
|
82
|
+
}
|
|
83
|
+
default: {
|
|
84
|
+
const _exhaustive: never = reference.kind;
|
|
85
|
+
return _exhaustive;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { registerSearchHitFilter } from '@terrafirmagreg-team/kit/lib/search';
|
|
2
|
+
import type { SortedResult } from '@terrafirmagreg-team/ui/types/search';
|
|
3
|
+
|
|
4
|
+
export function guideHitFilter(
|
|
5
|
+
params: URLSearchParams,
|
|
6
|
+
): ((hit: SortedResult) => boolean) | undefined {
|
|
7
|
+
const book = params.get('book');
|
|
8
|
+
if (!book) return undefined;
|
|
9
|
+
return (hit) => {
|
|
10
|
+
const queryString = hit.url.split('?')[1];
|
|
11
|
+
if (!queryString) return true;
|
|
12
|
+
const docBook = new URLSearchParams(queryString).get('book');
|
|
13
|
+
return docBook === null || docBook === book;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
registerSearchHitFilter('guide', guideHitFilter);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { AdvancedIndex } from 'fumadocs-core/search/server';
|
|
2
|
+
import { dumpTextString, langString } from '@terrafirmagreg-team/game/lib/dump/assets/lang/names';
|
|
3
|
+
import { isIndexPage } from '@terrafirmagreg-team/guide/lib/page-kind';
|
|
4
|
+
import type { DumpIndex } from '@terrafirmagreg-team/game/lib/dump/mods/dto';
|
|
5
|
+
|
|
6
|
+
export type GuideIndex = AdvancedIndex & { locale: string };
|
|
7
|
+
|
|
8
|
+
export interface DumpSource {
|
|
9
|
+
getJson(rel: string): Promise<unknown | undefined>;
|
|
10
|
+
getLang(lang: string): Promise<Record<string, string>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type DumpTextNode = Parameters<typeof dumpTextString>[1];
|
|
14
|
+
|
|
15
|
+
function resolveText(
|
|
16
|
+
map: Record<string, string>,
|
|
17
|
+
node: unknown,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
if (typeof node === 'string') {
|
|
20
|
+
const bare =
|
|
21
|
+
node.startsWith('{') && node.endsWith('}')
|
|
22
|
+
? node.slice(1, -1).trim()
|
|
23
|
+
: undefined;
|
|
24
|
+
return langString(map, node) ?? bare ?? (bare ? undefined : node);
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(node)) {
|
|
27
|
+
const parts = node
|
|
28
|
+
.map((item) => resolveText(map, item))
|
|
29
|
+
.filter((part): part is string => Boolean(part));
|
|
30
|
+
return parts.length ? parts.join('\n') : undefined;
|
|
31
|
+
}
|
|
32
|
+
if (node && typeof node === 'object') {
|
|
33
|
+
return dumpTextString(map, node as DumpTextNode) || undefined;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RawPage {
|
|
39
|
+
anchor?: unknown;
|
|
40
|
+
title?: unknown;
|
|
41
|
+
texts?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RawEntry {
|
|
45
|
+
display?: { name?: unknown };
|
|
46
|
+
pages?: RawPage[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RawBook {
|
|
50
|
+
id?: unknown;
|
|
51
|
+
entries?: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function entryIndex(
|
|
55
|
+
dataset: string,
|
|
56
|
+
bookId: string,
|
|
57
|
+
entryId: string,
|
|
58
|
+
entry: RawEntry,
|
|
59
|
+
map: Record<string, string>,
|
|
60
|
+
lang: string,
|
|
61
|
+
) {
|
|
62
|
+
const title =
|
|
63
|
+
resolveText(map, entry.display?.name) ?? entryId.split('/').pop() ?? entryId;
|
|
64
|
+
const contents = (entry.pages ?? [])
|
|
65
|
+
.map((page) => {
|
|
66
|
+
if (isIndexPage(page, map)) return null;
|
|
67
|
+
const heading = resolveText(map, page.title);
|
|
68
|
+
const content = resolveText(map, page.texts);
|
|
69
|
+
return content ? { heading: heading ?? title, content } : null;
|
|
70
|
+
})
|
|
71
|
+
.filter((c): c is { heading: string; content: string } => c !== null);
|
|
72
|
+
const body = contents.map((c) => c.content).join('\n');
|
|
73
|
+
if (!body && !title) return null;
|
|
74
|
+
return {
|
|
75
|
+
title,
|
|
76
|
+
url: `/guide/${dataset}?book=${bookId}&entry=${entryId}`,
|
|
77
|
+
id: `guide:${dataset}:${bookId}:${entryId}:${lang}`,
|
|
78
|
+
tag: `guide:${dataset}`,
|
|
79
|
+
locale: lang,
|
|
80
|
+
structuredData: { headings: [], contents },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function bookIndexes(
|
|
85
|
+
dataset: string,
|
|
86
|
+
dir: string,
|
|
87
|
+
bookId: string,
|
|
88
|
+
langs: string[],
|
|
89
|
+
source: DumpSource,
|
|
90
|
+
): Promise<GuideIndex[]> {
|
|
91
|
+
const book = (await source.getJson(`${dir}/book.json`)) as
|
|
92
|
+
| RawBook
|
|
93
|
+
| undefined;
|
|
94
|
+
const buckets = book?.entries;
|
|
95
|
+
if (!buckets || typeof buckets !== 'object') return [];
|
|
96
|
+
const maps = new Map(
|
|
97
|
+
await Promise.all(
|
|
98
|
+
langs.map(
|
|
99
|
+
async (lang) => [lang, await source.getLang(lang)] as const,
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
const bucketDocs = new Map<string, Record<string, RawEntry> | undefined>();
|
|
104
|
+
await Promise.all(
|
|
105
|
+
[...new Set(Object.values(buckets))].map(async (rel) => {
|
|
106
|
+
bucketDocs.set(
|
|
107
|
+
rel,
|
|
108
|
+
(await source.getJson(`${dir}/${rel}`)) as
|
|
109
|
+
| Record<string, RawEntry>
|
|
110
|
+
| undefined,
|
|
111
|
+
);
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
const out: GuideIndex[] = [];
|
|
115
|
+
for (const [entryId, rel] of Object.entries(buckets)) {
|
|
116
|
+
const entry = bucketDocs.get(rel)?.[entryId];
|
|
117
|
+
if (!entry) continue;
|
|
118
|
+
for (const lang of langs) {
|
|
119
|
+
const index = entryIndex(
|
|
120
|
+
dataset,
|
|
121
|
+
bookId,
|
|
122
|
+
entryId,
|
|
123
|
+
entry,
|
|
124
|
+
maps.get(lang) ?? {},
|
|
125
|
+
lang,
|
|
126
|
+
);
|
|
127
|
+
if (index) out.push(index);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function guideSearchIndexes(
|
|
134
|
+
dataset: string,
|
|
135
|
+
dump: DumpIndex,
|
|
136
|
+
source: DumpSource,
|
|
137
|
+
): Promise<GuideIndex[]> {
|
|
138
|
+
const langs = dump.manifest.languages?.length
|
|
139
|
+
? dump.manifest.languages
|
|
140
|
+
: ['en_us'];
|
|
141
|
+
const out: GuideIndex[] = [];
|
|
142
|
+
for (const mod of dump.mods) {
|
|
143
|
+
for (const book of mod.books) {
|
|
144
|
+
out.push(
|
|
145
|
+
...(await bookIndexes(dataset, book.dir, book.bookId, langs, source)),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const seen = new Set<string>();
|
|
150
|
+
return out.filter((index) => {
|
|
151
|
+
const key = `${index.locale}:${index.id}`;
|
|
152
|
+
if (seen.has(key)) return false;
|
|
153
|
+
seen.add(key);
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { collectItemIds } from '@terrafirmagreg-team/guide/lib/data/parse';
|
|
2
|
+
import type { GuideEntry } from '@terrafirmagreg-team/guide/types';
|
|
3
|
+
|
|
4
|
+
export function spriteIdsFromEntry(entry: GuideEntry): string[] {
|
|
5
|
+
const ids: string[] = [];
|
|
6
|
+
if (entry.icon && entry.icon.kind !== 'texture') ids.push(entry.icon.id);
|
|
7
|
+
for (const page of entry.pages ?? []) {
|
|
8
|
+
ids.push(...collectItemIds(page.item), ...collectItemIds(page.icon));
|
|
9
|
+
if (typeof page.entity === 'string') ids.push(page.entity);
|
|
10
|
+
ids.push(
|
|
11
|
+
...(page.recipes ?? []).filter(
|
|
12
|
+
(id): id is string => typeof id === 'string' && Boolean(id),
|
|
13
|
+
),
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return ids;
|
|
17
|
+
}
|
package/src/lib/table.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GuideTableCell } from '@terrafirmagreg-team/guide/types';
|
|
2
|
+
|
|
3
|
+
export function cssColor(value: string | undefined): string | undefined {
|
|
4
|
+
return value && /^0x[0-9a-f]{6}$/i.test(value) ? `#${value.slice(2)}` : value;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function cellTexts(cell: GuideTableCell): string[] {
|
|
8
|
+
if (Array.isArray(cell.texts)) return cell.texts.filter(Boolean);
|
|
9
|
+
return typeof cell.text === 'string' && cell.text ? [cell.text] : [];
|
|
10
|
+
}
|
package/src/lib/toc.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { TOCItemType } from 'fumadocs-core/toc';
|
|
2
|
+
import type { TaggedReference } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
|
|
3
|
+
import { langString } from '@terrafirmagreg-team/game/lib/dump/assets/lang/names';
|
|
4
|
+
import { stackDisplayName } from '@terrafirmagreg-team/game/lib/entry/display';
|
|
5
|
+
import {
|
|
6
|
+
classifyGuidePageType,
|
|
7
|
+
collectItemIds,
|
|
8
|
+
parseGuideIcon,
|
|
9
|
+
parseGuideItems,
|
|
10
|
+
} from '@terrafirmagreg-team/guide/lib/data/parse';
|
|
11
|
+
import type { GuidePage } from '@terrafirmagreg-team/guide/lib/data/dto';
|
|
12
|
+
import { isIndexPage } from '@terrafirmagreg-team/guide/lib/page-kind';
|
|
13
|
+
import type { GuideTocLabels } from '@terrafirmagreg-team/guide/types';
|
|
14
|
+
|
|
15
|
+
function resourceIds(page: GuidePage): string[] {
|
|
16
|
+
return [
|
|
17
|
+
...collectItemIds(page.item),
|
|
18
|
+
...collectItemIds(page.icon),
|
|
19
|
+
page.entity,
|
|
20
|
+
...(page.recipes ?? []),
|
|
21
|
+
].filter((id): id is string => typeof id === 'string' && Boolean(id));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function localizedField(
|
|
25
|
+
map: Record<string, string>,
|
|
26
|
+
value: unknown,
|
|
27
|
+
): string | undefined {
|
|
28
|
+
if (typeof value !== 'string' || !value) return undefined;
|
|
29
|
+
const resolved = langString(map, value);
|
|
30
|
+
if (resolved) return resolved;
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
if (!trimmed) return undefined;
|
|
33
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) return undefined;
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function typeOf(page: GuidePage): string {
|
|
38
|
+
return (page.type ?? '').toLowerCase().trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pageTitle(
|
|
42
|
+
page: GuidePage,
|
|
43
|
+
langMap: Record<string, string> = {},
|
|
44
|
+
labels: GuideTocLabels = {},
|
|
45
|
+
entityNames?: Record<string, string>,
|
|
46
|
+
): string | undefined {
|
|
47
|
+
const display =
|
|
48
|
+
page.display &&
|
|
49
|
+
typeof page.display === 'object' &&
|
|
50
|
+
!Array.isArray(page.display)
|
|
51
|
+
? (page.display as Record<string, unknown>)
|
|
52
|
+
: undefined;
|
|
53
|
+
const fromFields =
|
|
54
|
+
localizedField(langMap, page.title) ??
|
|
55
|
+
localizedField(
|
|
56
|
+
langMap,
|
|
57
|
+
typeof display?.name === 'string' ? display.name : undefined,
|
|
58
|
+
) ??
|
|
59
|
+
localizedField(langMap, page.name);
|
|
60
|
+
if (fromFields && fromFields !== page.entity) return fromFields;
|
|
61
|
+
|
|
62
|
+
const type = typeOf(page);
|
|
63
|
+
if (type === 'relations') return labels.related;
|
|
64
|
+
if (type === 'quest') return labels.objective;
|
|
65
|
+
|
|
66
|
+
for (const id of resourceIds(page)) {
|
|
67
|
+
const name = entityNames?.[id] ?? stackDisplayName(langMap, id);
|
|
68
|
+
if (name !== id) return name;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pageAnchor(
|
|
74
|
+
page: GuidePage,
|
|
75
|
+
index: number,
|
|
76
|
+
taken?: Set<string>,
|
|
77
|
+
): string {
|
|
78
|
+
const anchor = typeof page.anchor === 'string' ? page.anchor.trim() : '';
|
|
79
|
+
if (anchor && !taken?.has(anchor)) {
|
|
80
|
+
taken?.add(anchor);
|
|
81
|
+
return anchor;
|
|
82
|
+
}
|
|
83
|
+
return `page-${index + 1}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function pageAnchors(pages: GuidePage[] | undefined): string[] {
|
|
87
|
+
if (!pages?.length) return [];
|
|
88
|
+
const taken = new Set<string>();
|
|
89
|
+
return pages.map((page, i) => pageAnchor(page, i, taken));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function pageIcon(page: GuidePage): TaggedReference | undefined {
|
|
93
|
+
return pageIcons(page)[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function pageIcons(page: GuidePage): TaggedReference[] {
|
|
97
|
+
const kind = classifyGuidePageType(page.type);
|
|
98
|
+
if (kind === 'entity') return [];
|
|
99
|
+
const icon = parseGuideIcon(page.icon);
|
|
100
|
+
if (icon) return [icon];
|
|
101
|
+
return parseGuideItems(page.item);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function guidePageTitles(
|
|
105
|
+
pages: GuidePage[] | undefined,
|
|
106
|
+
langMap: Record<string, string> = {},
|
|
107
|
+
labels: GuideTocLabels = {},
|
|
108
|
+
entityNames?: Record<string, string>,
|
|
109
|
+
): (string | undefined)[] {
|
|
110
|
+
if (!pages?.length) return [];
|
|
111
|
+
return pages.map((page) =>
|
|
112
|
+
isIndexPage(page, langMap)
|
|
113
|
+
? undefined
|
|
114
|
+
: pageTitle(page, langMap, labels, entityNames),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function repeatedTitle(
|
|
119
|
+
titles: (string | undefined)[],
|
|
120
|
+
index: number,
|
|
121
|
+
): boolean {
|
|
122
|
+
return index > 0 && titles[index] !== undefined && titles[index] === titles[index - 1];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function guideToc(
|
|
126
|
+
pages: GuidePage[] | undefined,
|
|
127
|
+
langMap: Record<string, string> = {},
|
|
128
|
+
labels: GuideTocLabels = {},
|
|
129
|
+
entityNames?: Record<string, string>,
|
|
130
|
+
): TOCItemType[] {
|
|
131
|
+
const titles = guidePageTitles(pages, langMap, labels, entityNames);
|
|
132
|
+
const anchors = pageAnchors(pages);
|
|
133
|
+
return titles.flatMap((title, i) => {
|
|
134
|
+
if (!title || repeatedTitle(titles, i)) return [];
|
|
135
|
+
return [{ title, url: `#${anchors[i]}`, depth: 2 }];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import type * as PageTree from 'fumadocs-core/page-tree';
|
|
3
|
+
import { Entry } from '@terrafirmagreg-team/game/components/entry/entry';
|
|
4
|
+
import { encodeQueryValue } from '@terrafirmagreg-team/translate/lib/url';
|
|
5
|
+
import { sectionUrl } from '@terrafirmagreg-team/kit/lib/location';
|
|
6
|
+
import { bookOptionId, guideCategoryHref } from '@terrafirmagreg-team/guide/lib/paths';
|
|
7
|
+
import type { GuideIndex } from '@terrafirmagreg-team/guide/types';
|
|
8
|
+
import type { TaggedReference } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
|
|
9
|
+
|
|
10
|
+
function nodeIcon(
|
|
11
|
+
dataset: string,
|
|
12
|
+
icon: TaggedReference | undefined,
|
|
13
|
+
name: string,
|
|
14
|
+
): React.ReactNode {
|
|
15
|
+
if (!icon?.id) return undefined;
|
|
16
|
+
return createElement(
|
|
17
|
+
'span',
|
|
18
|
+
{
|
|
19
|
+
'data-icon': true,
|
|
20
|
+
className:
|
|
21
|
+
'inline-flex size-3.5 shrink-0 items-center justify-center overflow-hidden',
|
|
22
|
+
},
|
|
23
|
+
createElement(Entry, {
|
|
24
|
+
dataset,
|
|
25
|
+
id: icon.id,
|
|
26
|
+
kind: icon.kind,
|
|
27
|
+
name,
|
|
28
|
+
size: 14,
|
|
29
|
+
href: false,
|
|
30
|
+
tooltip: false,
|
|
31
|
+
missing: 'hide',
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function guideTreeChildren(
|
|
37
|
+
dataset: string,
|
|
38
|
+
index: GuideIndex,
|
|
39
|
+
): PageTree.Node[] {
|
|
40
|
+
const bookId =
|
|
41
|
+
index.bookId ??
|
|
42
|
+
(index.modId && index.bookPath
|
|
43
|
+
? bookOptionId(index.modId, index.bookPath)
|
|
44
|
+
: undefined);
|
|
45
|
+
return index.categories.map((category): PageTree.Folder => ({
|
|
46
|
+
type: 'folder',
|
|
47
|
+
name: category.name,
|
|
48
|
+
icon: nodeIcon(dataset, category.icon, category.name),
|
|
49
|
+
index: {
|
|
50
|
+
type: 'page',
|
|
51
|
+
name: category.name,
|
|
52
|
+
url: guideCategoryHref(
|
|
53
|
+
dataset,
|
|
54
|
+
undefined,
|
|
55
|
+
index.modId,
|
|
56
|
+
index.bookPath ?? '',
|
|
57
|
+
category,
|
|
58
|
+
),
|
|
59
|
+
$id: `${dataset}/${index.modId}/${index.bookPath}/${category.id}#index`,
|
|
60
|
+
},
|
|
61
|
+
$id: `${dataset}/${index.modId}/${index.bookPath}/${category.id}`,
|
|
62
|
+
children: category.entries.map((entry): PageTree.Item => ({
|
|
63
|
+
type: 'page',
|
|
64
|
+
name: entry.name,
|
|
65
|
+
url: `${sectionUrl('guide', dataset)}?book=${encodeQueryValue(bookId ?? '')}&entry=${encodeQueryValue(entry.id)}`,
|
|
66
|
+
icon: nodeIcon(dataset, entry.icon, entry.name),
|
|
67
|
+
$id: `${dataset}/${index.modId}/${index.bookPath}/${category.id}/${entry.id}`,
|
|
68
|
+
})),
|
|
69
|
+
}));
|
|
70
|
+
}
|