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