@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.
Files changed (66) hide show
  1. package/package.json +58 -3
  2. package/src/components/book-switcher.tsx +46 -0
  3. package/src/components/views/browser.tsx +124 -0
  4. package/src/components/views/entry/pages.tsx +186 -0
  5. package/src/components/views/entry/view.tsx +60 -0
  6. package/src/components/views/kind/links.tsx +111 -0
  7. package/src/components/views/kind/media.tsx +167 -0
  8. package/src/components/views/kind/recipe.tsx +134 -0
  9. package/src/components/views/kind/table.tsx +138 -0
  10. package/src/components/views/kind/unknown.tsx +23 -0
  11. package/src/components/views/page/category-body.tsx +56 -0
  12. package/src/components/views/page/entry-body.tsx +79 -0
  13. package/src/components/views/page/overview-body.tsx +97 -0
  14. package/src/components/views/page/text.tsx +57 -0
  15. package/src/components/views/page/view.tsx +279 -0
  16. package/src/components/views/text.tsx +130 -0
  17. package/src/hooks/use-left-sidebar-tree.ts +19 -0
  18. package/src/hooks/use-tree.ts +33 -0
  19. package/src/lib/book/convert.ts +74 -0
  20. package/src/lib/book/index-rows.ts +134 -0
  21. package/src/lib/book/index.ts +2 -0
  22. package/src/lib/book/localize.ts +76 -0
  23. package/src/lib/book/raw.ts +80 -0
  24. package/src/lib/books.ts +29 -0
  25. package/src/lib/breadcrumb.ts +54 -0
  26. package/src/lib/client/guides.ts +189 -0
  27. package/src/lib/client-payload/extras.ts +101 -0
  28. package/src/lib/client-payload/index.ts +112 -0
  29. package/src/lib/client-payload/links.ts +101 -0
  30. package/src/lib/config.ts +46 -0
  31. package/src/lib/data/dto.ts +77 -0
  32. package/src/lib/data/parse.ts +93 -0
  33. package/src/lib/dump.ts +5 -0
  34. package/src/lib/format.ts +231 -0
  35. package/src/lib/links.ts +2 -0
  36. package/src/lib/markdown/book.ts +73 -0
  37. package/src/lib/markdown/ctx.ts +26 -0
  38. package/src/lib/markdown/index.ts +8 -0
  39. package/src/lib/markdown/page.ts +154 -0
  40. package/src/lib/markdown/table.ts +35 -0
  41. package/src/lib/markdown/text.ts +39 -0
  42. package/src/lib/markdown-page.ts +72 -0
  43. package/src/lib/messages.ts +13 -0
  44. package/src/lib/meta.ts +9 -0
  45. package/src/lib/multiblock/index.ts +8 -0
  46. package/src/lib/multiblock/url.ts +61 -0
  47. package/src/lib/names.ts +35 -0
  48. package/src/lib/page-kind.ts +117 -0
  49. package/src/lib/paths.ts +52 -0
  50. package/src/lib/preview.ts +33 -0
  51. package/src/lib/query.ts +28 -0
  52. package/src/lib/recipe.ts +30 -0
  53. package/src/lib/reference/index.ts +40 -0
  54. package/src/lib/reference/resolver.ts +88 -0
  55. package/src/lib/search/filter.ts +17 -0
  56. package/src/lib/search/index.ts +156 -0
  57. package/src/lib/sprites.ts +17 -0
  58. package/src/lib/table.ts +10 -0
  59. package/src/lib/toc.ts +137 -0
  60. package/src/lib/tree/children.ts +70 -0
  61. package/src/lib/tree/index.ts +117 -0
  62. package/src/locales/en_us.json +22 -0
  63. package/src/locales/ru_ru.json +22 -0
  64. package/src/locales.ts +5 -0
  65. package/src/types/index.ts +215 -0
  66. package/README.md +0 -3
@@ -0,0 +1,57 @@
1
+ import { GuideText } from '@terrafirmagreg-team/guide/components/views/text';
2
+ import { pageTexts } from '@terrafirmagreg-team/guide/lib/page-kind';
3
+ import type { PageRendererProps } from '@terrafirmagreg-team/guide/types';
4
+
5
+ type PageTextProps = Pick<
6
+ PageRendererProps,
7
+ | 'page'
8
+ | 'dataset'
9
+ | 'lang'
10
+ | 'bookPath'
11
+ | 'bookModId'
12
+ | 'categoryByEntry'
13
+ | 'langMap'
14
+ | 'keybinds'
15
+ | 'book'
16
+ | 'questLinks'
17
+ | 'previews'
18
+ >;
19
+
20
+ export function PageText({
21
+ page,
22
+ dataset,
23
+ lang,
24
+ bookPath,
25
+ bookModId,
26
+ categoryByEntry,
27
+ book,
28
+ langMap,
29
+ keybinds,
30
+ questLinks,
31
+ previews,
32
+ }: PageTextProps) {
33
+ return (
34
+ <>
35
+ {pageTexts(page).map((text, index) => (
36
+ <div
37
+ key={index}
38
+ className="whitespace-pre-wrap text-sm leading-relaxed"
39
+ >
40
+ <GuideText
41
+ text={text}
42
+ dataset={dataset}
43
+ lang={lang}
44
+ modId={bookModId}
45
+ bookPath={bookPath}
46
+ categoryByEntry={categoryByEntry}
47
+ macros={book?.macros}
48
+ langMap={langMap}
49
+ keybinds={keybinds}
50
+ questLinks={questLinks}
51
+ previews={previews}
52
+ />
53
+ </div>
54
+ ))}
55
+ </>
56
+ );
57
+ }
@@ -0,0 +1,279 @@
1
+ import { EntryProvider } from '@terrafirmagreg-team/game/hooks/use-entry-context';
2
+ import { Entry } from '@terrafirmagreg-team/game/components/entry/entry';
3
+ import {
4
+ Workspace,
5
+ WorkspaceBody,
6
+ WorkspaceDescription,
7
+ WorkspaceTitle,
8
+ } from '@terrafirmagreg-team/kit/components/workspace';
9
+ import { guideConfig } from '@terrafirmagreg-team/guide/lib/config';
10
+ import { GithubInfo } from '@terrafirmagreg-team/ui/components/blocks/git/github-info';
11
+ import { sectionConfig } from '@terrafirmagreg-team/kit/lib/config';
12
+ import { parseDatasetRef } from '@terrafirmagreg-team/kit/lib/location';
13
+ import { categoryMap, guideBreadcrumbItems } from '@terrafirmagreg-team/guide/lib/breadcrumb';
14
+ import { guideToc } from '@terrafirmagreg-team/guide/lib/toc';
15
+ import { GuideText } from '@terrafirmagreg-team/guide/components/views/text';
16
+ import { GuideOverviewBody } from '@terrafirmagreg-team/guide/components/views/page/overview-body';
17
+ import { GuideCategoryBody } from '@terrafirmagreg-team/guide/components/views/page/category-body';
18
+ import { GuideEntryBody } from '@terrafirmagreg-team/guide/components/views/page/entry-body';
19
+ import { guideMessage } from '@terrafirmagreg-team/guide/lib/messages';
20
+ import { Comments } from '@terrafirmagreg-team/comments';
21
+ import {
22
+ guideBookHref,
23
+ guideCategoryHref,
24
+ } from '@terrafirmagreg-team/guide/lib/paths';
25
+ import type { GuideIndex, GuidePagePayload } from '@terrafirmagreg-team/guide/types';
26
+
27
+ type NavItem = { name: string; url: string };
28
+
29
+ function guideNavItems(
30
+ payload: GuidePagePayload,
31
+ overview: GuideIndex | undefined,
32
+ ): { previous?: NavItem; next?: NavItem } {
33
+ const modId = payload.bookModId ?? payload.modId;
34
+ const bookPath = payload.bookPath;
35
+ if (!overview || !modId || !bookPath) return {};
36
+ const { dataset, lang } = payload;
37
+ const chain: NavItem[] = [];
38
+ if (overview.book)
39
+ chain.push({
40
+ name: overview.book.name,
41
+ url: guideBookHref(dataset, lang, modId, bookPath),
42
+ });
43
+ for (const category of overview.categories) {
44
+ chain.push({
45
+ name: category.name,
46
+ url: guideCategoryHref(dataset, lang, modId, bookPath, category),
47
+ });
48
+ for (const entry of category.entries)
49
+ chain.push({
50
+ name: entry.name,
51
+ url: guideBookHref(dataset, lang, modId, bookPath, entry.id),
52
+ });
53
+ }
54
+ const current =
55
+ payload.kind === 'entry' && payload.entry
56
+ ? guideBookHref(dataset, lang, modId, bookPath, payload.entry.id)
57
+ : payload.kind === 'category' && payload.categoryId
58
+ ? guideCategoryHref(dataset, lang, modId, bookPath, {
59
+ id: payload.categoryId,
60
+ })
61
+ : payload.kind === 'overview'
62
+ ? guideBookHref(dataset, lang, modId, bookPath)
63
+ : undefined;
64
+ const index = current
65
+ ? chain.findIndex((item) => item.url === current)
66
+ : -1;
67
+ return {
68
+ previous: index > 0 ? chain[index - 1] : undefined,
69
+ next:
70
+ index >= 0 && index < chain.length - 1 ? chain[index + 1] : undefined,
71
+ };
72
+ }
73
+
74
+ export function GuidePageView({
75
+ payload,
76
+ langMap,
77
+ }: {
78
+ payload: GuidePagePayload;
79
+ langMap: Record<string, string>;
80
+ }) {
81
+ const { dataset, lang, kind } = payload;
82
+ const overview: GuideIndex | undefined =
83
+ payload.book !== undefined || payload.categories !== undefined
84
+ ? {
85
+ status: payload.book ? 'ready' : 'missing',
86
+ root: '',
87
+ modId: payload.bookModId ?? payload.modId ?? '',
88
+ bookPath: payload.bookPath,
89
+ book: payload.book ?? null,
90
+ categories: payload.categories ?? [],
91
+ entries: payload.entries ?? [],
92
+ language: lang,
93
+ isFallback: false,
94
+ }
95
+ : undefined;
96
+ const categories = overview
97
+ ? categoryMap(overview.categories)
98
+ : new Map<string, string>();
99
+ const labels = {
100
+ related: guideMessage(payload.messages, 'guide.related'),
101
+ objective: guideMessage(payload.messages, 'guide.objective'),
102
+ };
103
+
104
+ let toc: ReturnType<typeof guideToc> | undefined;
105
+ let body: React.ReactNode = null;
106
+
107
+ switch (kind) {
108
+ case 'pack':
109
+ break;
110
+ case 'overview':
111
+ if (overview) {
112
+ body = (
113
+ <GuideOverviewBody
114
+ dataset={dataset}
115
+ lang={lang}
116
+ modId={payload.modId}
117
+ bookPath={payload.bookPath}
118
+ overview={overview}
119
+ categories={categories}
120
+ langMap={langMap}
121
+ questLinks={payload.questLinks}
122
+ previews={payload.previews}
123
+ messages={payload.messages}
124
+ />
125
+ );
126
+ }
127
+ break;
128
+ case 'category':
129
+ body = (
130
+ <GuideCategoryBody
131
+ dataset={dataset}
132
+ lang={lang}
133
+ modId={payload.modId}
134
+ bookPath={payload.bookPath}
135
+ overview={overview}
136
+ categoryId={payload.categoryId}
137
+ messages={payload.messages}
138
+ />
139
+ );
140
+ break;
141
+ case 'entry':
142
+ toc = guideToc(
143
+ payload.entry?.pages,
144
+ langMap,
145
+ labels,
146
+ payload.extras?.entityNames,
147
+ );
148
+ body = overview && payload.bookPath && (
149
+ <GuideEntryBody
150
+ dataset={dataset}
151
+ lang={lang}
152
+ bookPath={payload.bookPath}
153
+ overview={overview}
154
+ entry={payload.entry}
155
+ categories={categories}
156
+ langMap={langMap}
157
+ extras={payload.extras}
158
+ questLinks={payload.questLinks}
159
+ previews={payload.previews}
160
+ messages={payload.messages}
161
+ />
162
+ );
163
+ break;
164
+ default: {
165
+ const _exhaustive: never = kind;
166
+ body = <p>{String(_exhaustive)}</p>;
167
+ }
168
+ }
169
+
170
+ const copied =
171
+ payload.entry?.translation ??
172
+ overview?.categories.find((item) => item.id === payload.categoryId)
173
+ ?.translation ??
174
+ payload.book?.translation;
175
+
176
+ const crumbs = guideBreadcrumbItems({
177
+ kind,
178
+ title: payload.title,
179
+ dataset,
180
+ modId: payload.modId,
181
+ bookPath: payload.bookPath,
182
+ overview,
183
+ categoryId: payload.categoryId,
184
+ });
185
+
186
+ const datasetConf =
187
+ sectionConfig().datasetConfig[parseDatasetRef(dataset).id];
188
+ const repoSlug = datasetConf?.repo?.split('/');
189
+ const mdQuery = new URLSearchParams();
190
+ if (payload.modId && payload.bookPath) {
191
+ mdQuery.set('book', `${payload.modId}:${payload.bookPath}`);
192
+ }
193
+ if (kind === 'entry' && payload.entry) {
194
+ mdQuery.set('entry', payload.entry.id);
195
+ } else if (kind === 'category' && payload.categoryId) {
196
+ mdQuery.set('cat', payload.categoryId);
197
+ }
198
+ mdQuery.set('lang', lang);
199
+ const mdUrl = `/guide/${dataset}.md?${mdQuery}`;
200
+
201
+ const nav = guideNavItems(payload, overview);
202
+ const {
203
+ PageActions,
204
+ UntranslatedCallout,
205
+ } = guideConfig().components;
206
+
207
+ return (
208
+ <EntryProvider dataset={dataset} lang={lang} langMap={langMap}>
209
+ <Workspace
210
+ toc={toc ?? []}
211
+ className="min-h-full"
212
+ breadcrumb={{ items: crumbs, previews: payload.previews }}
213
+ pagination={{ items: nav }}
214
+ >
215
+ <UntranslatedCallout copied={copied} />
216
+ <WorkspaceTitle>
217
+ {kind === 'entry' && payload.previewIcon ? (
218
+ <span className="inline-flex items-center gap-2">
219
+ <Entry
220
+ id={payload.previewIcon}
221
+ kind={payload.previewKind}
222
+ name={payload.title}
223
+ href={false}
224
+ tooltip={false}
225
+ />
226
+ {payload.title}
227
+ </span>
228
+ ) : (
229
+ payload.title
230
+ )}
231
+ </WorkspaceTitle>
232
+ {payload.description ? (
233
+ <WorkspaceDescription className="mb-0">
234
+ <GuideText
235
+ text={payload.description}
236
+ dataset={dataset}
237
+ lang={lang}
238
+ modId={payload.modId}
239
+ bookPath={payload.bookPath}
240
+ categoryByEntry={categories}
241
+ macros={overview?.book?.macros}
242
+ langMap={langMap}
243
+ questLinks={payload.questLinks}
244
+ previews={payload.previews}
245
+ />
246
+ </WorkspaceDescription>
247
+ ) : null}
248
+ <div className="flex flex-row gap-2 items-center border-b pb-6">
249
+ <PageActions
250
+ copied={copied}
251
+ crowdinUrl={datasetConf?.crowdin}
252
+ title={payload.title}
253
+ mdUrl={mdUrl}
254
+ githubUrl={
255
+ datasetConf?.repo
256
+ ? `https://github.com/${datasetConf.repo}`
257
+ : undefined
258
+ }
259
+ />
260
+ </div>
261
+ {kind === 'pack' && repoSlug ? (
262
+ <GithubInfo
263
+ owner={repoSlug[0]}
264
+ repo={repoSlug[1]}
265
+ className="not-prose mb-6 w-full"
266
+ />
267
+ ) : null}
268
+ <WorkspaceBody>
269
+ <div data-md-root className="contents">
270
+ {body}
271
+ </div>
272
+ </WorkspaceBody>
273
+ <Comments
274
+ page={`guide:${dataset}:${payload.modId}:${payload.bookPath}:${payload.entry?.id ?? payload.categoryId ?? 'book'}#${lang}`}
275
+ />
276
+ </Workspace>
277
+ </EntryProvider>
278
+ );
279
+ }
@@ -0,0 +1,130 @@
1
+ import { Fragment, type ReactNode } from 'react';
2
+ import { LinkPreview } from '@terrafirmagreg-team/ui/components/blocks/link/link-preview';
3
+ import { guideLink } from '@terrafirmagreg-team/guide/lib/reference';
4
+ import { formatGuide } from '@terrafirmagreg-team/guide/lib/format';
5
+ import { keybindLabels } from '@terrafirmagreg-team/guide/lib/format';
6
+ import { Kbd } from '@terrafirmagreg-team/ui/components/ui/kbd';
7
+ import {
8
+ Tooltip,
9
+ TooltipContent,
10
+ TooltipTrigger,
11
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
12
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
13
+ import type { GuideFormatOptions } from '@terrafirmagreg-team/guide/types';
14
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
15
+
16
+ const LINK_STYLE = 'text-primary underline underline-offset-2';
17
+
18
+ export function GuideText({
19
+ text,
20
+ dataset,
21
+ lang,
22
+ modId,
23
+ bookPath,
24
+ categoryByEntry,
25
+ macros,
26
+ langMap,
27
+ keybinds,
28
+ questLinks,
29
+ previews,
30
+ }: {
31
+ text: string;
32
+ dataset?: string;
33
+ lang?: Lang;
34
+ modId?: string;
35
+ bookPath?: string;
36
+ } & Omit<GuideFormatOptions, 'dataset' | 'lang' | 'modId' | 'bookPath'>) {
37
+ if (!text) return null;
38
+ const nodes = formatGuide(text, {
39
+ dataset,
40
+ lang,
41
+ modId,
42
+ bookPath,
43
+ categoryByEntry,
44
+ macros,
45
+ langMap,
46
+ keybinds,
47
+ questLinks,
48
+ previews,
49
+ });
50
+ const children: ReactNode[] = [];
51
+ for (const [index, node] of nodes.entries()) {
52
+ let el: ReactNode = null;
53
+ if (node.type === 'break') {
54
+ el = <br />;
55
+ } else if (node.type === 'keybind') {
56
+ const { label, title } = keybindLabels(
57
+ node.value,
58
+ langMap ?? {},
59
+ keybinds ?? {},
60
+ );
61
+ el = (
62
+ <Tooltip>
63
+ <TooltipTrigger render={<Kbd>{label}</Kbd>} />
64
+ <TooltipContent>{title}</TooltipContent>
65
+ </Tooltip>
66
+ );
67
+ } else if (node.type === 'command') {
68
+ el = <code className="font-mono text-sm">{node.value}</code>;
69
+ } else if (node.type === 'text') {
70
+ el = node.value;
71
+ }
72
+ if (el === null) continue;
73
+ if (node.type === 'text' && node.styles?.length) {
74
+ el = (
75
+ <span
76
+ className={cn(
77
+ node.styles.includes('bold') && 'font-bold',
78
+ node.styles.includes('italic') && 'italic',
79
+ node.styles.includes('underline') && 'underline',
80
+ node.styles.includes('strike') && 'line-through',
81
+ )}
82
+ >
83
+ {el}
84
+ </span>
85
+ );
86
+ }
87
+ if (node.type === 'text' && node.color) {
88
+ el = <span style={{ color: node.color }}>{el}</span>;
89
+ }
90
+ if (node.type === 'text' && node.link) {
91
+ const { href, external } = guideLink(node, {
92
+ dataset: dataset ?? '',
93
+ lang: lang ?? 'en_us',
94
+ modId: modId ?? '',
95
+ bookPath: bookPath ?? '',
96
+ categoryByEntry: categoryByEntry ?? new Map(),
97
+ questLinks,
98
+ });
99
+ el = href ? (
100
+ <LinkPreview
101
+ preview={previews?.[href]}
102
+ href={href}
103
+ variant="raw"
104
+ className={LINK_STYLE}
105
+ {...(external ? { target: '_blank', rel: 'noreferrer' } : {})}
106
+ >
107
+ {el}
108
+ </LinkPreview>
109
+ ) : (
110
+ el
111
+ );
112
+ }
113
+ if (node.type === 'text' && node.tooltip) {
114
+ el = (
115
+ <Tooltip>
116
+ <TooltipTrigger
117
+ render={
118
+ <span className="cursor-help underline decoration-dotted underline-offset-4">
119
+ {el}
120
+ </span>
121
+ }
122
+ />
123
+ <TooltipContent>{node.tooltip}</TooltipContent>
124
+ </Tooltip>
125
+ );
126
+ }
127
+ children.push(<Fragment key={index}>{el}</Fragment>);
128
+ }
129
+ return <>{children}</>;
130
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+ import { usePathname } from 'next/navigation';
3
+ import type * as PageTree from 'fumadocs-core/page-tree';
4
+ import { useLocationSearch } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
5
+ import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
6
+ import { useLastValue } from '@terrafirmagreg-team/ui/hooks/use-last-value';
7
+ import { useGuideTree } from '@terrafirmagreg-team/guide/hooks/use-tree';
8
+ import { guideQueryFromParams } from '@terrafirmagreg-team/guide/lib/query';
9
+ import { parseSectionLocation } from '@terrafirmagreg-team/kit/lib/location';
10
+
11
+ export function useGuideLeftSidebarTree(tree: PageTree.Root): PageTree.Root {
12
+ const { dataset } = parseSectionLocation(usePathname());
13
+ const lang = useResolvedLang();
14
+ const { bookId } = guideQueryFromParams(
15
+ new URLSearchParams(useLocationSearch()),
16
+ );
17
+ const runtime = useLastValue(useGuideTree(dataset, lang, bookId), dataset);
18
+ return runtime ?? tree;
19
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import type * as PageTree from 'fumadocs-core/page-tree';
4
+ import { useAsyncValue } from '@terrafirmagreg-team/ui/hooks/use-async-value';
5
+ import { loadClientGuideBook } from '@terrafirmagreg-team/guide/lib/client/guides';
6
+ import { listGuideBooks } from '@terrafirmagreg-team/guide/lib/books';
7
+ import { guideTreeChildren } from '@terrafirmagreg-team/guide/lib/tree/children';
8
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
9
+
10
+ export function useGuideTree(
11
+ dataset: string | undefined,
12
+ lang: Lang,
13
+ bookId: string | undefined,
14
+ ): PageTree.Root | undefined {
15
+ return useAsyncValue(
16
+ dataset
17
+ ? async () => {
18
+ const summaries = await listGuideBooks(dataset, lang);
19
+ if (!summaries.length) return undefined;
20
+ const resolvedId = bookId ?? summaries[0]?.bookId;
21
+ const index = resolvedId
22
+ ? await loadClientGuideBook(dataset, lang, resolvedId)
23
+ : undefined;
24
+ return {
25
+ name: 'guide',
26
+ children: index ? guideTreeChildren(dataset, index) : [],
27
+ $id: `guide/${dataset}/${resolvedId ?? 'none'}/${lang}`,
28
+ } satisfies PageTree.Root;
29
+ }
30
+ : undefined,
31
+ [dataset, lang, bookId],
32
+ );
33
+ }
@@ -0,0 +1,74 @@
1
+ import { dumpTextLines } from '@terrafirmagreg-team/game/lib/dump/assets/lang/names';
2
+ import type {
3
+ GuideBookJson,
4
+ GuideBookRef,
5
+ } from '@terrafirmagreg-team/guide/lib/data/dto';
6
+ import type { GuideBook } from '@terrafirmagreg-team/guide/types';
7
+ import {
8
+ type FallbackTracker,
9
+ fallbackTracker,
10
+ localize,
11
+ localizeGuideValue,
12
+ markTextKeys,
13
+ } from './localize';
14
+ import {
15
+ asCover,
16
+ asLanding,
17
+ displayRecord,
18
+ macrosFrom,
19
+ stringField,
20
+ urlSafeSlug,
21
+ } from './raw';
22
+
23
+ function bookName(
24
+ raw: GuideBookJson | undefined,
25
+ ref: GuideBookRef,
26
+ slug: string,
27
+ langMap: Record<string, string>,
28
+ tracker?: FallbackTracker,
29
+ ): string {
30
+ const display = displayRecord(raw);
31
+ const nameKey = stringField(display, 'name');
32
+ return nameKey
33
+ ? localize(langMap, nameKey, nameKey, tracker)
34
+ : (stringField(raw, 'id') ?? ref.bookId ?? slug);
35
+ }
36
+
37
+ export function toBook(
38
+ raw: GuideBookJson | undefined,
39
+ ref: GuideBookRef,
40
+ slug: string,
41
+ langMap: Record<string, string>,
42
+ rawLang?: Record<string, string>,
43
+ ): GuideBook {
44
+ const display = displayRecord(raw);
45
+ const landing = asLanding(display?.landing);
46
+ const tracker = fallbackTracker(rawLang);
47
+ markTextKeys(landing, tracker);
48
+ return {
49
+ id: stringField(raw, 'id') ?? ref.bookId,
50
+ slug,
51
+ name: bookName(raw, ref, slug, langMap, tracker),
52
+ description: localizeGuideValue(
53
+ stringField(raw, 'subtitle'),
54
+ langMap,
55
+ tracker,
56
+ ) as string | undefined,
57
+ landing,
58
+ landingText: landing.length
59
+ ? dumpTextLines(langMap, landing).join('\n')
60
+ : undefined,
61
+ cover: asCover(raw?.cover),
62
+ macros: macrosFrom(raw),
63
+ ...(tracker?.flag ? { translation: tracker.flag } : {}),
64
+ };
65
+ }
66
+
67
+ export function bookSlug(
68
+ raw: GuideBookJson | undefined,
69
+ ref: GuideBookRef,
70
+ ): string {
71
+ const explicit = stringField(raw, 'slug');
72
+ if (explicit) return urlSafeSlug(explicit);
73
+ return urlSafeSlug(ref.bookPath);
74
+ }