@terrafirmagreg-team/guide 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.
Files changed (69) hide show
  1. package/package.json +57 -3
  2. package/src/components/book-switcher.tsx +52 -0
  3. package/src/components/mdx/guide-entry.tsx +48 -0
  4. package/src/components/mdx/multiblock.tsx +86 -0
  5. package/src/components/tree/children.tsx +75 -0
  6. package/src/components/tree/index.tsx +127 -0
  7. package/src/components/views/browser.tsx +133 -0
  8. package/src/components/views/entry/pages.tsx +188 -0
  9. package/src/components/views/entry/view.tsx +62 -0
  10. package/src/components/views/kind/links.tsx +112 -0
  11. package/src/components/views/kind/media.tsx +168 -0
  12. package/src/components/views/kind/recipe.tsx +89 -0
  13. package/src/components/views/kind/table.tsx +140 -0
  14. package/src/components/views/kind/unknown.tsx +24 -0
  15. package/src/components/views/page/category-body.tsx +58 -0
  16. package/src/components/views/page/entry-body.tsx +81 -0
  17. package/src/components/views/page/overview-body.tsx +99 -0
  18. package/src/components/views/page/text.tsx +58 -0
  19. package/src/components/views/page/view.tsx +279 -0
  20. package/src/components/views/text.tsx +132 -0
  21. package/src/hooks/use-left-sidebar-tree.ts +28 -0
  22. package/src/hooks/use-tree.ts +35 -0
  23. package/src/lib/book/convert.ts +75 -0
  24. package/src/lib/book/index-rows.ts +136 -0
  25. package/src/lib/book/index.ts +3 -0
  26. package/src/lib/book/localize.ts +77 -0
  27. package/src/lib/book/raw.ts +81 -0
  28. package/src/lib/books.ts +31 -0
  29. package/src/lib/breadcrumb.ts +56 -0
  30. package/src/lib/client/guides.ts +262 -0
  31. package/src/lib/client-payload/extras.ts +103 -0
  32. package/src/lib/client-payload/index.ts +113 -0
  33. package/src/lib/client-payload/links.ts +103 -0
  34. package/src/lib/config.ts +47 -0
  35. package/src/lib/data/dto.ts +78 -0
  36. package/src/lib/data/parse.ts +94 -0
  37. package/src/lib/dump.ts +6 -0
  38. package/src/lib/embeds.ts +12 -0
  39. package/src/lib/format.ts +232 -0
  40. package/src/lib/links.ts +2 -0
  41. package/src/lib/markdown/book.ts +74 -0
  42. package/src/lib/markdown/ctx.ts +28 -0
  43. package/src/lib/markdown/index.ts +9 -0
  44. package/src/lib/markdown/page.ts +153 -0
  45. package/src/lib/markdown/table.ts +36 -0
  46. package/src/lib/markdown/text.ts +40 -0
  47. package/src/lib/markdown-page.ts +74 -0
  48. package/src/lib/messages.ts +14 -0
  49. package/src/lib/meta.ts +10 -0
  50. package/src/lib/multiblock/index.ts +9 -0
  51. package/src/lib/multiblock/url.ts +75 -0
  52. package/src/lib/names.ts +37 -0
  53. package/src/lib/page-kind.ts +118 -0
  54. package/src/lib/paths.ts +54 -0
  55. package/src/lib/preview.ts +37 -0
  56. package/src/lib/query.ts +29 -0
  57. package/src/lib/recipe.ts +31 -0
  58. package/src/lib/reference/index.ts +41 -0
  59. package/src/lib/reference/resolver.ts +88 -0
  60. package/src/lib/search/filter.ts +18 -0
  61. package/src/lib/search/index.ts +183 -0
  62. package/src/lib/sprites.ts +18 -0
  63. package/src/lib/table.ts +11 -0
  64. package/src/lib/toc.ts +138 -0
  65. package/src/locales/en_us.json +14 -0
  66. package/src/locales/ru_ru.json +14 -0
  67. package/src/locales.ts +6 -0
  68. package/src/types/index.ts +217 -0
  69. package/README.md +0 -3
@@ -0,0 +1,74 @@
1
+ import type {
2
+ GuideIndex,
3
+ GuidePage,
4
+ GuidePagePayload,
5
+ } from '@terrafirmagreg-team/guide/types';
6
+ import { guideBookHref } from '@terrafirmagreg-team/guide/lib/paths';
7
+ import type { GuideMarkdownCtx } from './ctx';
8
+ import { guidePageMarkdown } from './page';
9
+ import { guideTextMarkdown } from './text';
10
+
11
+ export function guideEntryMarkdown(
12
+ entry: { name?: string; pages?: GuidePage[] },
13
+ ctx: GuideMarkdownCtx,
14
+ payload?: Pick<GuidePagePayload, 'extras'>,
15
+ ): string {
16
+ const body = (entry.pages ?? [])
17
+ .map((page) => guidePageMarkdown(page, ctx, payload))
18
+ .filter(Boolean)
19
+ .join('\n\n');
20
+ return `# ${entry.name ?? 'Guide Entry'}\n\n${body}\n`;
21
+ }
22
+
23
+ export function guideOverviewMarkdown(
24
+ overview: GuideIndex,
25
+ ctx: GuideMarkdownCtx,
26
+ ): string {
27
+ const parts: string[] = [`# ${overview.book?.name ?? overview.bookId ?? 'Guide'}`];
28
+ if (overview.book?.landingText) {
29
+ const md = guideTextMarkdown(overview.book.landingText, ctx);
30
+ if (md) parts.push(md);
31
+ }
32
+ for (const cat of overview.categories) {
33
+ parts.push(`## ${cat.name}`);
34
+ const items = cat.entries.map((ref) => {
35
+ const href = guideBookHref(
36
+ ctx.dataset,
37
+ undefined,
38
+ overview.modId,
39
+ overview.bookPath ?? '',
40
+ ref.id,
41
+ );
42
+ return `- [${ref.name}](${href})`;
43
+ });
44
+ if (items.length) parts.push(items.join('\n'));
45
+ }
46
+ return `${parts.filter(Boolean).join('\\')}\n`;
47
+ }
48
+
49
+ export function guideCategoryMarkdown(
50
+ overview: GuideIndex,
51
+ categoryId: string,
52
+ ctx: GuideMarkdownCtx,
53
+ ): string {
54
+ const cat = overview.categories.find((item) => item.id === categoryId);
55
+ if (!cat) return '';
56
+ const parts: string[] = [`# ${cat.name}`];
57
+ if (cat.description) {
58
+ const md = guideTextMarkdown(cat.description, ctx);
59
+ if (md) parts.push(md);
60
+ }
61
+ const items = cat.entries.map((ref) => {
62
+ const href = guideBookHref(
63
+ ctx.dataset,
64
+ undefined,
65
+ overview.modId,
66
+ overview.bookPath ?? '',
67
+ ref.id,
68
+ );
69
+ return `- [${ref.name}](${href})`;
70
+ });
71
+ if (items.length) parts.push(items.join('\n'));
72
+ return `${parts.filter(Boolean).join('\\')}\n`;
73
+ }
74
+
@@ -0,0 +1,28 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
3
+ import type { GuideReferenceContext } from '@terrafirmagreg-team/guide/types';
4
+
5
+ export type GuideMarkdownCtx = {
6
+ dataset: DatasetRef;
7
+ lang: Lang;
8
+ langMap: Record<string, string>;
9
+ keybinds?: Record<string, string>;
10
+ macros?: Record<string, string>;
11
+ questLinks?: Record<string, { filename: string; questId: string }>;
12
+ categoryByEntry: Map<string, string>;
13
+ entryNames?: Map<string, string>;
14
+ modId: string;
15
+ bookPath: string;
16
+ };
17
+
18
+ export function refCtx(ctx: GuideMarkdownCtx): GuideReferenceContext {
19
+ return {
20
+ dataset: ctx.dataset,
21
+ lang: ctx.lang,
22
+ modId: ctx.modId,
23
+ bookPath: ctx.bookPath,
24
+ categoryByEntry: ctx.categoryByEntry,
25
+ questLinks: ctx.questLinks,
26
+ };
27
+ }
28
+
@@ -0,0 +1,9 @@
1
+ export {
2
+ guideCategoryMarkdown,
3
+ guideEntryMarkdown,
4
+ guideOverviewMarkdown,
5
+ } from './book';
6
+ export type { GuideMarkdownCtx } from './ctx';
7
+ export { guidePageMarkdown } from './page';
8
+ export { guideTextMarkdown } from './text';
9
+
@@ -0,0 +1,153 @@
1
+ import type {
2
+ GuidePage,
3
+ GuidePagePayload,
4
+ } from '@terrafirmagreg-team/guide/types';
5
+ import { classify, isIndexPage, pageTexts } from '@terrafirmagreg-team/guide/lib/page-kind';
6
+ import { guideLink } from '@terrafirmagreg-team/guide/lib/reference';
7
+ import { bareId } from '@terrafirmagreg-team/guide/lib/paths';
8
+ import { guideEmbeds } from '@terrafirmagreg-team/guide/lib/embeds';
9
+ import { texturePublicUrl } from '@terrafirmagreg-team/dump/lib/dump/assets/urls';
10
+ import { collectItemIds } from '@terrafirmagreg-team/guide/lib/data/parse';
11
+ import { stackDisplayName } from '@terrafirmagreg-team/dump/lib/dump/assets/lang/names';
12
+ import { type GuideMarkdownCtx, refCtx } from './ctx';
13
+ import { guideTextMarkdown } from './text';
14
+ import { tableMarkdown } from './table';
15
+
16
+ function blockTitle(page: GuidePage): string {
17
+ return typeof page.title === 'string' && page.title
18
+ ? page.title
19
+ : typeof page.name === 'string'
20
+ ? page.name
21
+ : '';
22
+ }
23
+
24
+ export function guidePageMarkdown(
25
+ page: GuidePage,
26
+ ctx: GuideMarkdownCtx,
27
+ payload?: Pick<GuidePagePayload, 'extras'>,
28
+ ): string {
29
+ const kind = classify(page.type);
30
+ const title = blockTitle(page);
31
+ const parts: string[] = [];
32
+ if (title && kind !== 'text') parts.push(`### ${title}`);
33
+
34
+ switch (kind) {
35
+ case 'empty':
36
+ break;
37
+ case 'text': {
38
+ if (title) parts.push(`### ${title}`);
39
+ if (isIndexPage(page, ctx.langMap)) {
40
+ for (const raw of pageTexts(page)) {
41
+ const lines = guideTextMarkdown(raw, ctx).split('\n').filter(Boolean);
42
+ parts.push(lines.map((l) => (l.startsWith('- ') ? l : `- ${l}`)).join('\n'));
43
+ }
44
+ } else {
45
+ for (const raw of pageTexts(page)) {
46
+ const md = guideTextMarkdown(raw, ctx);
47
+ if (md) parts.push(md);
48
+ }
49
+ }
50
+ break;
51
+ }
52
+ case 'image': {
53
+ const images = [
54
+ ...(page.images ?? []),
55
+ ...(typeof page.image === 'string' ? [page.image] : []),
56
+ ];
57
+ for (const id of images) {
58
+ const url = texturePublicUrl(ctx.dataset, id);
59
+ if (url) parts.push(`![${title || id}](${url})`);
60
+ }
61
+ for (const raw of pageTexts(page)) parts.push(guideTextMarkdown(raw, ctx));
62
+ break;
63
+ }
64
+ case 'recipe': {
65
+ const ids = [
66
+ ...(page.recipes ?? []),
67
+ ...(typeof page.recipe === 'string' ? [page.recipe] : []),
68
+ ];
69
+ for (const id of ids) {
70
+ const recipe = payload?.extras?.recipes[id];
71
+ const label = recipe ? guideEmbeds().recipe?.title(recipe, ctx.langMap) : id;
72
+ parts.push(`- [${label}](${guideEmbeds().recipe?.href(ctx.dataset, undefined, { id })})`);
73
+ }
74
+ for (const raw of pageTexts(page)) parts.push(guideTextMarkdown(raw, ctx));
75
+ break;
76
+ }
77
+ case 'spotlight': {
78
+ const ids = collectItemIds(page.item);
79
+ for (const id of ids) {
80
+ const name = stackDisplayName(ctx.langMap, id, 'item');
81
+ parts.push(`**${name}**`);
82
+ }
83
+ for (const raw of pageTexts(page)) parts.push(guideTextMarkdown(raw, ctx));
84
+ break;
85
+ }
86
+ case 'entity': {
87
+ const id = typeof page.entity === 'string' ? page.entity : '';
88
+ const name =
89
+ (id ? payload?.extras?.entityNames[id] : undefined) ??
90
+ (id ? stackDisplayName(ctx.langMap, id, 'entity') : id);
91
+ if (name) parts.push(`**${name}**`);
92
+ for (const raw of pageTexts(page)) parts.push(guideTextMarkdown(raw, ctx));
93
+ break;
94
+ }
95
+ case 'multiblock':
96
+ case 'multimultiblock': {
97
+ for (const raw of pageTexts(page)) parts.push(guideTextMarkdown(raw, ctx));
98
+ break;
99
+ }
100
+ case 'link': {
101
+ const target = page.url || page.link || page.entry;
102
+ const label =
103
+ (typeof page.link_text === 'string' && page.link_text) ||
104
+ (typeof page.text === 'string' && page.text) ||
105
+ target ||
106
+ '';
107
+ const { href } = guideLink(
108
+ { link: typeof target === 'string' ? target : undefined },
109
+ refCtx(ctx),
110
+ );
111
+ parts.push(href ? `[${label}](${href})` : label);
112
+ break;
113
+ }
114
+ case 'relations': {
115
+ for (const target of page.entries ?? []) {
116
+ const { href } = guideLink({ link: target }, refCtx(ctx));
117
+ const label =
118
+ ctx.entryNames?.get(target) ??
119
+ ctx.entryNames?.get(bareId(target)) ??
120
+ target;
121
+ parts.push(`- [${label}](${href ?? ''})`);
122
+ }
123
+ break;
124
+ }
125
+ case 'quest': {
126
+ const id = page.trigger || page.quest || page.link || '';
127
+ const { href } = guideLink({ link: id }, refCtx(ctx));
128
+ const hit = id ? ctx.questLinks?.[id] : undefined;
129
+ const url =
130
+ href ??
131
+ (hit
132
+ ? guideEmbeds().quest?.href(ctx.dataset, undefined, {
133
+ category: hit.filename,
134
+ id: hit.questId,
135
+ })
136
+ : undefined);
137
+ parts.push(url ? `- [${id}](${url})` : `- ${id}`);
138
+ for (const raw of pageTexts(page)) parts.push(guideTextMarkdown(raw, ctx));
139
+ break;
140
+ }
141
+ case 'table': {
142
+ const md = tableMarkdown(page, ctx);
143
+ if (md) parts.push(md);
144
+ break;
145
+ }
146
+ default: {
147
+ for (const raw of pageTexts(page)) parts.push(guideTextMarkdown(raw, ctx));
148
+ break;
149
+ }
150
+ }
151
+ return parts.filter(Boolean).join('\n\n');
152
+ }
153
+
@@ -0,0 +1,36 @@
1
+ import type { GuidePage } from '@terrafirmagreg-team/guide/types';
2
+ import type { GuideTableCell } from '@terrafirmagreg-team/guide/lib/data/dto';
3
+ import type { GuideMarkdownCtx } from './ctx';
4
+ import { guideTextMarkdown } from './text';
5
+
6
+ function cellText(cell: string | GuideTableCell | undefined): string {
7
+ if (typeof cell === 'string') return cell;
8
+ if (!cell || typeof cell !== 'object') return '';
9
+ const lines = Array.isArray(cell.texts)
10
+ ? cell.texts
11
+ : cell.text
12
+ ? [cell.text]
13
+ : [];
14
+ return lines.filter(Boolean).join(' ');
15
+ }
16
+
17
+ export function tableMarkdown(page: GuidePage, ctx: GuideMarkdownCtx): string {
18
+ const strings = page.strings ?? [];
19
+ const rows = strings
20
+ .map((row) => (Array.isArray(row) ? row : [row]))
21
+ .filter((row) => row.length);
22
+ const columns = Array.isArray(page.columns)
23
+ ? page.columns.map((c) => String(c))
24
+ : [];
25
+ const all = columns.length ? [columns, ...rows.map((r) => r.map(cellText))] : rows.map((r) => r.map(cellText));
26
+ if (!all.length) return '';
27
+ const width = Math.max(...all.map((row) => row.length));
28
+ const norm = (row: unknown[]) =>
29
+ Array.from({ length: width }, (_v, i) =>
30
+ guideTextMarkdown(String(row[i] ?? ''), ctx).replace(/\|/g, '\\|').replace(/\n+/g, ' '),
31
+ );
32
+ const lines = all.map((row) => `| ${norm(row).join(' | ')} |`);
33
+ lines.splice(1, 0, `| ${Array.from({ length: width }, () => '---').join(' | ')} |`);
34
+ return lines.join('\n');
35
+ }
36
+
@@ -0,0 +1,40 @@
1
+ import type { GuideFormatNode } from '@terrafirmagreg-team/guide/types';
2
+ import { formatGuide, keybindLabels } from '@terrafirmagreg-team/guide/lib/format';
3
+ import { guideLink } from '@terrafirmagreg-team/guide/lib/reference';
4
+ import { type GuideMarkdownCtx, refCtx } from './ctx';
5
+
6
+ function wrap(text: string, mark: string): string {
7
+ return text.replace(/^(\s*)(.*?)(\s*)$/s, (_m, a, b, c) =>
8
+ b ? `${a}${mark}${b}${mark}${c}` : `${a}${c}`,
9
+ );
10
+ }
11
+
12
+ function nodeMarkdown(node: GuideFormatNode, ctx: GuideMarkdownCtx): string {
13
+ if (node.type === 'break') return '\n';
14
+ if (node.type === 'keybind') {
15
+ return `\`${keybindLabels(node.value, ctx.langMap, ctx.keybinds ?? {}).label}\``;
16
+ }
17
+ if (node.type === 'command') return `\`${node.value}\``;
18
+ let text = node.value;
19
+ if (node.styles.includes('bold')) text = wrap(text, '**');
20
+ if (node.styles.includes('italic')) text = wrap(text, '_');
21
+ if (node.styles.includes('strike')) text = wrap(text, '~~');
22
+ if (node.link) {
23
+ const { href } = guideLink(node, refCtx(ctx));
24
+ if (href) text = `[${text.trim() || node.link}](${href})`;
25
+ }
26
+ return text;
27
+ }
28
+
29
+ export function guideTextMarkdown(
30
+ raw: string,
31
+ ctx: GuideMarkdownCtx,
32
+ ): string {
33
+ return formatGuide(raw, { macros: ctx.macros })
34
+ .map((node) => nodeMarkdown(node, ctx))
35
+ .join('')
36
+ .replace(/(^|\n)(\s*)• /g, '$1$2- ')
37
+ .replace(/\n{3,}/g, '\n\n')
38
+ .trim();
39
+ }
40
+
@@ -0,0 +1,74 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ import { registerMarkdownProvider } from '@terrafirmagreg-team/mdx/lib/markdown';
3
+ import { loadClientGuidePayload } from '@terrafirmagreg-team/guide/lib/client-payload';
4
+ import { clientDumpLang } from '@terrafirmagreg-team/dump/lib/dump/client/dump';
5
+ import { categoryMap } from '@terrafirmagreg-team/guide/lib/breadcrumb';
6
+ import {
7
+ guideCategoryMarkdown,
8
+ guideEntryMarkdown,
9
+ guideOverviewMarkdown,
10
+ type GuideMarkdownCtx,
11
+ } from '@terrafirmagreg-team/guide/lib/markdown';
12
+ import type { GuideIndex } from '@terrafirmagreg-team/guide/types';
13
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
14
+
15
+ export async function guideMarkdown(
16
+ req: Request,
17
+ dataset: DatasetRef | undefined,
18
+ lang: Lang,
19
+ ): Promise<Response | undefined> {
20
+ if (!dataset) return undefined;
21
+ const search = new URL(req.url).searchParams;
22
+ const langMap = await clientDumpLang(dataset, lang);
23
+ const payload = await loadClientGuidePayload(
24
+ dataset,
25
+ lang,
26
+ search.get('book') ?? undefined,
27
+ search.get('entry') ?? undefined,
28
+ search.get('cat') ?? undefined,
29
+ langMap,
30
+ {},
31
+ );
32
+ if (!payload) return undefined;
33
+ const overview: GuideIndex = {
34
+ status: 'ready',
35
+ root: '',
36
+ modId: payload.bookModId ?? payload.modId ?? '',
37
+ bookPath: payload.bookPath,
38
+ book: payload.book ?? null,
39
+ categories: payload.categories ?? [],
40
+ entries: payload.entries ?? [],
41
+ language: lang,
42
+ isFallback: false,
43
+ };
44
+ const ctx: GuideMarkdownCtx = {
45
+ dataset,
46
+ lang,
47
+ langMap,
48
+ keybinds: payload.extras?.keybinds,
49
+ macros: payload.book?.macros,
50
+ questLinks: payload.questLinks,
51
+ categoryByEntry: categoryMap(payload.categories ?? []),
52
+ entryNames: new Map(
53
+ (payload.entries ?? []).flatMap((row) =>
54
+ row.name ? [[row.id, row.name]] : [],
55
+ ),
56
+ ),
57
+ modId: overview.modId,
58
+ bookPath: payload.bookPath ?? '',
59
+ };
60
+ const markdown =
61
+ payload.kind === 'entry' && payload.entry
62
+ ? guideEntryMarkdown(payload.entry, ctx, payload)
63
+ : payload.kind === 'category' && payload.categoryId
64
+ ? guideCategoryMarkdown(overview, payload.categoryId, ctx)
65
+ : guideOverviewMarkdown(overview, ctx);
66
+ return new Response(markdown, {
67
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
68
+ });
69
+ }
70
+
71
+ registerMarkdownProvider('guide', (req, { slugs, lang }) =>
72
+ guideMarkdown(req, slugs[0] ? { id: slugs[0] } : undefined, lang),
73
+ );
74
+
@@ -0,0 +1,14 @@
1
+ export function guideMessage(
2
+ messages: Record<string, string>,
3
+ key: string,
4
+ vars?: Record<string, string | number>,
5
+ ): string {
6
+ let text = messages[key] ?? key;
7
+ if (vars) {
8
+ for (const [name, value] of Object.entries(vars)) {
9
+ text = text.replaceAll(`{${name}}`, String(value));
10
+ }
11
+ }
12
+ return text;
13
+ }
14
+
@@ -0,0 +1,10 @@
1
+ import type { GuideMultiblock } from '@terrafirmagreg-team/guide/lib/data/dto';
2
+
3
+ export function asMultiblock(
4
+ value: string | GuideMultiblock | undefined,
5
+ ): { id?: string; data?: GuideMultiblock } {
6
+ if (!value) return {};
7
+ if (typeof value === 'string') return { id: value };
8
+ return { data: value };
9
+ }
10
+
@@ -0,0 +1,9 @@
1
+ import type { GuideMultiblock } from '@terrafirmagreg-team/guide/types';
2
+
3
+ export function multiblockKey(
4
+ spec: { id?: string; data?: GuideMultiblock } | undefined,
5
+ ): string | undefined {
6
+ if (!spec) return undefined;
7
+ return spec.id ?? (spec.data ? `data:${JSON.stringify(spec.data)}` : undefined);
8
+ }
9
+
@@ -0,0 +1,75 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ import { asRecord, asString } from '@terrafirmagreg-team/dump/lib/dump/contract/read';
3
+ import { splitDumpId } from '@terrafirmagreg-team/dump/lib/dump/paths';
4
+ import { modelPublicUrl } from '@terrafirmagreg-team/dump/lib/dump/assets/urls';
5
+ import {
6
+ clientDumpJson,
7
+ dumpModelUrl,
8
+ } from '@terrafirmagreg-team/dump/lib/dump/client/dump';
9
+ import { loadDatasetDump } from '@terrafirmagreg-team/dump/lib/dump/mods/load';
10
+ import { createDatasetCache } from '@terrafirmagreg-team/dump/lib/dump/reader';
11
+ import { loadMultiblockNameIndex } from '@terrafirmagreg-team/guide/lib/client/guides';
12
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
13
+ import type { GuideMultiblock } from '@terrafirmagreg-team/guide/types';
14
+
15
+ async function sha256Hex(text: string): Promise<string> {
16
+ const bytes = new TextEncoder().encode(text);
17
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
18
+ return [...new Uint8Array(digest)]
19
+ .map((byte) => byte.toString(16).padStart(2, '0'))
20
+ .join('');
21
+ }
22
+
23
+ function inlineMultiblockHash(data: unknown): Promise<string> {
24
+ return sha256Hex(JSON.stringify(data));
25
+ }
26
+
27
+ const multiblockIndex = createDatasetCache(async (dataset: DatasetRef) => {
28
+ const dump = await loadDatasetDump(dataset);
29
+ const entries = await Promise.all(
30
+ (dump?.mods ?? []).map(async (ns) => {
31
+ const index = asRecord(
32
+ await clientDumpJson(
33
+ dataset,
34
+ `data/${ns.namespace}/models/multiblock.json`,
35
+ ),
36
+ );
37
+ return Object.entries(index ?? {}).map(
38
+ ([id, rel]) => [id, { ns: ns.namespace, rel: asString(rel) }] as const,
39
+ );
40
+ }),
41
+ );
42
+ return Object.fromEntries(entries.flat());
43
+ });
44
+
45
+ export async function glbFor(
46
+ dataset: DatasetRef,
47
+ _bookModId: string,
48
+ spec: { id?: string; data?: GuideMultiblock },
49
+ lang?: string,
50
+ ): Promise<string | null> {
51
+ if (spec.id) {
52
+ const direct = await dumpModelUrl(dataset, spec.id);
53
+ if (direct) return direct;
54
+ if (lang) {
55
+ const names = await loadMultiblockNameIndex(dataset, lang as Lang);
56
+ const named = names[spec.id.trim().toLowerCase()];
57
+ if (named) return glbFor(dataset, _bookModId, { data: named }, lang);
58
+ }
59
+ return null;
60
+ }
61
+ if (!spec.data) return null;
62
+ const hash = await inlineMultiblockHash(spec.data);
63
+ const index = await multiblockIndex(dataset);
64
+ for (const [id, { ns, rel }] of Object.entries(index)) {
65
+ if (id.includes(hash) || rel?.includes(hash)) {
66
+ return (
67
+ (await dumpModelUrl(dataset, id)) ??
68
+ modelPublicUrl(dataset, splitDumpId(id)?.ns ?? ns, rel) ??
69
+ null
70
+ );
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
@@ -0,0 +1,37 @@
1
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
2
+ import type { AtlasBundle } from '@terrafirmagreg-team/dump/lib/dump/assets/atlas/dto';
3
+ import { lookupSpriteInBundle } from '@terrafirmagreg-team/dump/lib/dump/assets/atlas/lookup';
4
+ import { stackDisplayName } from '@terrafirmagreg-team/game/lib/entry/display';
5
+ import { matchGuideEntryKey } from '@terrafirmagreg-team/guide/lib/reference/resolver';
6
+ import type { GuideEntryRef } from '@terrafirmagreg-team/guide/types';
7
+
8
+ export function spriteName(
9
+ dataset: DatasetRef,
10
+ langMap: Record<string, string>,
11
+ atlases: Record<string, AtlasBundle> | undefined,
12
+ id: string,
13
+ ): string {
14
+ const mod = id.replace(/^#/, '').split(':', 2)[0];
15
+ const langKey =
16
+ mod && !id.startsWith('#')
17
+ ? lookupSpriteInBundle(atlases?.[mod], id)?.sprite.langKey
18
+ : undefined;
19
+ return stackDisplayName(langMap, id, undefined, langKey);
20
+ }
21
+
22
+ export function entryRef(
23
+ refs: Map<string, GuideEntryRef>,
24
+ id: string,
25
+ ): GuideEntryRef | undefined {
26
+ const key = matchGuideEntryKey(refs.keys(), id);
27
+ return key !== undefined ? refs.get(key) : undefined;
28
+ }
29
+
30
+ export function entryLabel(
31
+ refs: Map<string, GuideEntryRef>,
32
+ langMap: Record<string, string>,
33
+ id: string,
34
+ ): string {
35
+ return entryRef(refs, id)?.name ?? stackDisplayName(langMap, id);
36
+ }
37
+
@@ -0,0 +1,118 @@
1
+ import type { GuidePage } from '@terrafirmagreg-team/guide/types';
2
+ import { langString } from '@terrafirmagreg-team/dump/lib/dump/assets/lang/names';
3
+
4
+ export type PageKind =
5
+ | 'empty'
6
+ | 'text'
7
+ | 'image'
8
+ | 'recipe'
9
+ | 'spotlight'
10
+ | 'entity'
11
+ | 'multiblock'
12
+ | 'multimultiblock'
13
+ | 'link'
14
+ | 'relations'
15
+ | 'quest'
16
+ | 'table'
17
+ | 'unknown';
18
+
19
+ const RECIPE_TYPES = new Set([
20
+ 'recipe',
21
+ 'crafting',
22
+ 'smelting',
23
+ 'blasting',
24
+ 'smoking',
25
+ 'campfire',
26
+ 'smithing',
27
+ 'stonecutting',
28
+ 'knapping',
29
+ 'anvil',
30
+ 'welding',
31
+ 'barrel',
32
+ 'quern',
33
+ 'loom',
34
+ 'heat',
35
+ 'drying',
36
+ 'glassworking',
37
+ ]);
38
+
39
+ export function classify(type: string | undefined): PageKind {
40
+ const raw = (type ?? '').toLowerCase().trim();
41
+ const value = raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : raw;
42
+ if (!value || value === 'text') return 'text';
43
+ if (value === 'empty') return 'empty';
44
+ if (value === 'image') return 'image';
45
+ if (value === 'spotlight') return 'spotlight';
46
+ if (value === 'entity') return 'entity';
47
+ if (value === 'multiblock') return 'multiblock';
48
+ if (value === 'multimultiblock') return 'multimultiblock';
49
+ if (value === 'link') return 'link';
50
+ if (value === 'relations') return 'relations';
51
+ if (value === 'quest') return 'quest';
52
+ if (value === 'table' || value === 'table_small') return 'table';
53
+ if (value.endsWith('_recipe') || RECIPE_TYPES.has(value)) return 'recipe';
54
+ return 'unknown';
55
+ }
56
+
57
+ export function pageTexts(page: GuidePage): string[] {
58
+ if (Array.isArray(page.texts)) {
59
+ return page.texts.filter(
60
+ (text): text is string => typeof text === 'string' && Boolean(text),
61
+ );
62
+ }
63
+ return typeof page.text === 'string' && page.text ? [page.text] : [];
64
+ }
65
+
66
+ const INDEX_BULLET = /\$\(li\d*\)/;
67
+ const INDEX_BULLET_ALL = /\$\(li\d*\)/g;
68
+ const MACRO = /\$\([^)]*\)/g;
69
+ const INDEX_LABEL_MAX = 48;
70
+
71
+ function linkListOnly(text: string): boolean {
72
+ if (!INDEX_BULLET.test(text)) return false;
73
+ const items = text.split(INDEX_BULLET_ALL);
74
+ const head = (items[0] ?? '').replace(MACRO, '').trim();
75
+ if (head) return false;
76
+ const entries = items.slice(1).filter((item) => item.trim());
77
+ return (
78
+ entries.length > 0 &&
79
+ entries.every((item) => {
80
+ const plain = item.replace(MACRO, '').trim();
81
+ return (
82
+ plain.length <= INDEX_LABEL_MAX &&
83
+ (item.includes('$(l:') ||
84
+ item.includes('$(url:') ||
85
+ !/[.!?]/.test(plain))
86
+ );
87
+ })
88
+ );
89
+ }
90
+
91
+ export function isIndexPage(
92
+ page: {
93
+ anchor?: unknown;
94
+ type?: unknown;
95
+ text?: unknown;
96
+ texts?: unknown;
97
+ },
98
+ langMap: Record<string, string> = {},
99
+ ): boolean {
100
+ if (page.anchor === 'index') return true;
101
+ if (classify(typeof page.type === 'string' ? page.type : undefined) !== 'text') {
102
+ return false;
103
+ }
104
+ const rawTexts = Array.isArray(page.texts)
105
+ ? page.texts
106
+ : typeof page.text === 'string'
107
+ ? [page.text]
108
+ : [];
109
+ const texts = rawTexts.filter(
110
+ (text): text is string => typeof text === 'string' && Boolean(text),
111
+ );
112
+ if (!texts.length) return false;
113
+ return texts.every((raw) => {
114
+ const text = langString(langMap, raw) ?? raw;
115
+ return linkListOnly(text);
116
+ });
117
+ }
118
+