@dsbasko/cookbook-engine 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.

Potentially problematic release.


This version of @dsbasko/cookbook-engine might be problematic. Click here for more details.

Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/assets/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 +0 -0
  4. package/assets/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 +0 -0
  5. package/assets/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 +0 -0
  6. package/assets/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 +0 -0
  7. package/assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 +0 -0
  8. package/assets/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 +0 -0
  9. package/package.json +92 -0
  10. package/scripts/check-course-coverage.mts +32 -0
  11. package/scripts/fix-static-image-extensions.mjs +78 -0
  12. package/scripts/generate-readme-toc.mts +32 -0
  13. package/scripts/resolve-course-paths.mjs +28 -0
  14. package/scripts/sync-images.mjs +88 -0
  15. package/src/components/AppShell/AppShell.module.css +40 -0
  16. package/src/components/AppShell/AppShell.tsx +135 -0
  17. package/src/components/AppShell/index.ts +1 -0
  18. package/src/components/Callout/Callout.module.css +68 -0
  19. package/src/components/Callout/Callout.tsx +83 -0
  20. package/src/components/Callout/index.ts +1 -0
  21. package/src/components/CodeBlock/CodeBlock.module.css +68 -0
  22. package/src/components/CodeBlock/CodeBlock.tsx +65 -0
  23. package/src/components/CodeBlock/index.ts +1 -0
  24. package/src/components/GateProvider/GateProvider.tsx +207 -0
  25. package/src/components/GateProvider/index.ts +1 -0
  26. package/src/components/Header/Breadcrumbs.tsx +50 -0
  27. package/src/components/Header/Header.module.css +131 -0
  28. package/src/components/Header/Header.tsx +26 -0
  29. package/src/components/Header/HeaderLessonNav.tsx +118 -0
  30. package/src/components/Header/index.ts +1 -0
  31. package/src/components/HomePage/HomePage.module.css +538 -0
  32. package/src/components/HomePage/HomePage.tsx +295 -0
  33. package/src/components/HomePage/index.ts +1 -0
  34. package/src/components/LessonAwareLink/LessonAwareLink.module.css +12 -0
  35. package/src/components/LessonAwareLink/LessonAwareLink.tsx +86 -0
  36. package/src/components/LessonAwareLink/index.ts +1 -0
  37. package/src/components/LessonLayout/LessonLayout.module.css +35 -0
  38. package/src/components/LessonLayout/LessonLayout.tsx +18 -0
  39. package/src/components/LessonLayout/index.ts +1 -0
  40. package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.module.css +367 -0
  41. package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.tsx +256 -0
  42. package/src/components/LessonLockedInterstitial/index.ts +1 -0
  43. package/src/components/LessonNav/LessonNav.module.css +84 -0
  44. package/src/components/LessonNav/LessonNav.tsx +64 -0
  45. package/src/components/LessonNav/index.ts +1 -0
  46. package/src/components/LessonPageLayout/LessonPageLayout.module.css +118 -0
  47. package/src/components/LessonPageLayout/LessonPageLayout.tsx +46 -0
  48. package/src/components/LessonPageLayout/index.ts +1 -0
  49. package/src/components/LessonSideMeta/LessonSideMeta.module.css +68 -0
  50. package/src/components/LessonSideMeta/LessonSideMeta.tsx +87 -0
  51. package/src/components/LessonSideMeta/index.ts +1 -0
  52. package/src/components/ModulePage/ModulePage.module.css +693 -0
  53. package/src/components/ModulePage/ModulePage.tsx +301 -0
  54. package/src/components/ModulePage/index.ts +1 -0
  55. package/src/components/ProgramDrawer/LockIcon.tsx +19 -0
  56. package/src/components/ProgramDrawer/ProgramDrawer.module.css +563 -0
  57. package/src/components/ProgramDrawer/ProgramDrawer.tsx +481 -0
  58. package/src/components/ProgramDrawer/index.ts +1 -0
  59. package/src/components/ProgressBar/ProgressBar.module.css +46 -0
  60. package/src/components/ProgressBar/ProgressBar.tsx +45 -0
  61. package/src/components/ProgressBar/index.ts +1 -0
  62. package/src/components/ProgressModeProvider/ProgressModeProvider.tsx +87 -0
  63. package/src/components/ProgressModeProvider/index.ts +1 -0
  64. package/src/components/ReadingPrefsProvider/ReadingPrefsProvider.tsx +100 -0
  65. package/src/components/ReadingPrefsProvider/index.ts +1 -0
  66. package/src/components/ReadingProgress/ReadingProgress.module.css +19 -0
  67. package/src/components/ReadingProgress/ReadingProgress.tsx +53 -0
  68. package/src/components/ReadingProgress/index.ts +1 -0
  69. package/src/components/SettingsToggle/SettingsToggle.module.css +888 -0
  70. package/src/components/SettingsToggle/SettingsToggle.tsx +688 -0
  71. package/src/components/SettingsToggle/index.ts +1 -0
  72. package/src/components/Sidebar/Sidebar.module.css +157 -0
  73. package/src/components/Sidebar/Sidebar.tsx +63 -0
  74. package/src/components/Sidebar/icons/GitHubIcon.tsx +17 -0
  75. package/src/components/Sidebar/icons/HomeIcon.tsx +22 -0
  76. package/src/components/Sidebar/icons/LanguageIcon.tsx +24 -0
  77. package/src/components/Sidebar/icons/ProgramIcon.tsx +23 -0
  78. package/src/components/Sidebar/icons/SettingsIcon.tsx +26 -0
  79. package/src/components/Sidebar/icons/ThemeIcon.tsx +22 -0
  80. package/src/components/Sidebar/icons/index.ts +6 -0
  81. package/src/components/Sidebar/index.ts +1 -0
  82. package/src/components/ThemeProvider/ThemeProvider.tsx +68 -0
  83. package/src/components/ThemeProvider/index.ts +1 -0
  84. package/src/components/Toc/Toc.module.css +78 -0
  85. package/src/components/Toc/Toc.tsx +92 -0
  86. package/src/components/Toc/index.ts +1 -0
  87. package/src/components/TranslationBanner/TranslationBanner.module.css +32 -0
  88. package/src/components/TranslationBanner/TranslationBanner.tsx +40 -0
  89. package/src/components/TranslationBanner/index.ts +1 -0
  90. package/src/config.d.mts +12 -0
  91. package/src/config.mjs +110 -0
  92. package/src/index.ts +62 -0
  93. package/src/layout/lang.tsx +44 -0
  94. package/src/layout/root.tsx +223 -0
  95. package/src/lib/course-loader.ts +33 -0
  96. package/src/lib/course.ts +429 -0
  97. package/src/lib/coverage.ts +141 -0
  98. package/src/lib/description.ts +43 -0
  99. package/src/lib/extract-toc.ts +59 -0
  100. package/src/lib/format.ts +55 -0
  101. package/src/lib/frontier-link.ts +37 -0
  102. package/src/lib/gate-init-script.ts +40 -0
  103. package/src/lib/gate-mark-script.ts +324 -0
  104. package/src/lib/i18n.ts +474 -0
  105. package/src/lib/lang.ts +90 -0
  106. package/src/lib/lesson-gate.ts +79 -0
  107. package/src/lib/lesson.ts +66 -0
  108. package/src/lib/markdown-components.tsx +51 -0
  109. package/src/lib/markdown.ts +180 -0
  110. package/src/lib/mdx-plugins/rehype-callout.ts +80 -0
  111. package/src/lib/mdx-plugins/remark-lesson-images.ts +109 -0
  112. package/src/lib/mdx-plugins/remark-link-rewrite.ts +231 -0
  113. package/src/lib/paths.ts +36 -0
  114. package/src/lib/program-drawer.ts +8 -0
  115. package/src/lib/progress-mode.ts +69 -0
  116. package/src/lib/progress.ts +182 -0
  117. package/src/lib/reading-prefs.ts +127 -0
  118. package/src/lib/readme-toc.ts +69 -0
  119. package/src/lib/site-url.ts +33 -0
  120. package/src/lib/sitemap.ts +112 -0
  121. package/src/lib/slug.ts +15 -0
  122. package/src/lib/theme.ts +78 -0
  123. package/src/lib/use-i18n.ts +25 -0
  124. package/src/og/icon.tsx +40 -0
  125. package/src/og/opengraph-image.tsx +126 -0
  126. package/src/pages/home.tsx +66 -0
  127. package/src/pages/lesson.tsx +260 -0
  128. package/src/pages/module.tsx +80 -0
  129. package/src/pages/not-found-lang.tsx +51 -0
  130. package/src/pages/not-found-root.tsx +48 -0
  131. package/src/pages/root.tsx +44 -0
  132. package/src/seo/robots.ts +16 -0
  133. package/src/seo/sitemap.ts +10 -0
  134. package/src/styles/globals.css +139 -0
  135. package/src/styles/markdown.css +265 -0
  136. package/src/styles/reset.css +89 -0
  137. package/src/styles/tokens.css +270 -0
@@ -0,0 +1,474 @@
1
+ import { type Lang } from './lang';
2
+
3
+ /**
4
+ * UI string dictionary. Every key here must exist in every entry of
5
+ * UI_STRINGS — TypeScript enforces this via the `Record<Lang, UIDict>`
6
+ * shape below. Add new keys here first, then translations.
7
+ *
8
+ * Scope: strings rendered by components listed in Task 10 of the i18n
9
+ * plan (Header/Breadcrumbs, Sidebar, ProgramDrawer, LessonNav, Toc,
10
+ * ReadingProgress, LessonLockedInterstitial, SettingsToggle, HomePage,
11
+ * LessonPageLayout, not-found) plus a few keys for adjacent surfaces
12
+ * already touched by lang switching (settings popover, theme labels
13
+ * previously lived in `lib/theme.ts`).
14
+ */
15
+ export type UIDict = {
16
+ // Sidebar
17
+ sidebarLabel: string;
18
+ navMainLabel: string;
19
+ home: string;
20
+ programCourse: string;
21
+ githubRepo: string;
22
+
23
+ // Header / Breadcrumbs
24
+ breadcrumbsLabel: string;
25
+
26
+ // ProgramDrawer
27
+ close: string;
28
+ moduleListLabel: string;
29
+ lessonLockTitle: string;
30
+
31
+ // LessonNav
32
+ lessonNavLabel: string;
33
+ prevLesson: string;
34
+ nextLesson: string;
35
+
36
+ // Mobile drawer lesson-nav: short eyebrow used next to a chevron icon
37
+ prevLessonShort: string;
38
+ nextLessonShort: string;
39
+ currentLessonEyebrow: string;
40
+ currentLessonNumberPrefix: string;
41
+
42
+ // Mobile settings: "Resources" section + GitHub link sub-label
43
+ settingsResourcesLabel: string;
44
+ githubRepoSub: string;
45
+
46
+ // HeaderLessonNav (chevron buttons reuse a shorter form without the arrow)
47
+ prevLessonAria: string;
48
+ nextLessonAria: string;
49
+ firstLessonTitle: string;
50
+ lastLessonTitle: string;
51
+
52
+ // Toc
53
+ tocLabel: string;
54
+
55
+ // ReadingProgress
56
+ readingProgressLabel: string;
57
+
58
+ // LessonLockedInterstitial
59
+ locked: string;
60
+ moduleNumberPrefix: string;
61
+ lockedTitle: string;
62
+ lockedDesc: string;
63
+ attemptedLessonLabel: string;
64
+ attemptedYouTried: string;
65
+ continueAction: string;
66
+ startFromFirst: string;
67
+ openProgram: string;
68
+ courseProgress: string;
69
+ progress: string;
70
+ nextStep: string;
71
+ untilThisLesson: string;
72
+
73
+ // SettingsToggle — theme section labels
74
+ themeLight: string;
75
+ themeDark: string;
76
+ themePaper: string;
77
+
78
+ // HomePage
79
+ heroTitleLead: string;
80
+ heroTitleAccent: string;
81
+ heroTitleTail: string;
82
+ continueLessonPrefix: string;
83
+ startFromScratch: string;
84
+ progressSummary: string;
85
+ modulesLabel: string;
86
+ lessonsLabel: string;
87
+ durationLabel: string;
88
+ stackLabel: string;
89
+ moduleLockTitle: string;
90
+
91
+ // LessonPageLayout
92
+ lessonInfoLabel: string;
93
+
94
+ // not-found
95
+ notFoundTitle: string;
96
+ notFoundDesc: string;
97
+ goHome: string;
98
+
99
+ // SettingsToggle — language section title (also used as radiogroup aria-label)
100
+ language: string;
101
+
102
+ // TranslationBanner (rendered on EN lesson pages that fall back to RU)
103
+ translationFallbackTitle: string;
104
+ translationFallbackBody: string;
105
+
106
+ // ModulePage
107
+ startModule: string;
108
+ continueModulePrefix: string;
109
+ rereadModule: string;
110
+ nextModule: string;
111
+ moduleProgress: string;
112
+ lessonsCount: string;
113
+ durationLabelShort: string;
114
+ stackLabelShort: string;
115
+ moduleLessonsHeading: string;
116
+ lessonLockShort: string;
117
+ lessonHintContinue: string;
118
+ lessonNeighbourModulesLabel: string;
119
+ prevModule: string;
120
+
121
+ // LessonSideMeta
122
+ moduleMetaKey: string;
123
+ readingTimeMetaKey: string;
124
+ tagsMetaKey: string;
125
+ markUnread: string;
126
+
127
+ // ProgressBar / aria
128
+ progressBarAriaLabel: string;
129
+ progressAriaConnector: string;
130
+
131
+ // Metadata fallbacks. The brand wordmark is composed in at the call site
132
+ // (notFound title = `${notFoundTitle} · <brand name>`); `ogImageAlt` is the
133
+ // default alt text, overridable via brand.ogImage.alt.
134
+ ogImageAlt: string;
135
+
136
+ // HomePage module status (CSS-only pseudo-element labels lifted into JSX)
137
+ statusNotStarted: string;
138
+ statusInProgress: string;
139
+ statusComplete: string;
140
+
141
+ // Callout titles (rendered server-side via MDX pipeline)
142
+ calloutNote: string;
143
+ calloutTip: string;
144
+ calloutWarning: string;
145
+ calloutImportant: string;
146
+ calloutCaution: string;
147
+
148
+ // CodeBlock copy button
149
+ codeBlockCopy: string;
150
+ codeBlockCopied: string;
151
+ codeBlockCopyAriaLabel: string;
152
+ codeBlockCopiedAriaLabel: string;
153
+
154
+ // SettingsToggle — trigger label and section titles
155
+ settingsLabel: string;
156
+ settingsEyebrow: string;
157
+ settingsThemeSection: string;
158
+
159
+ // SettingsToggle — prose/code sections
160
+ readingPrefsProseSection: string;
161
+ readingPrefsCodeSection: string;
162
+ readingPrefsSize: string;
163
+ readingPrefsFont: string;
164
+ readingPrefsDecrease: string;
165
+ readingPrefsIncrease: string;
166
+ readingPrefsFontSerif: string;
167
+ readingPrefsFontSans: string;
168
+ readingPrefsFontSlab: string;
169
+ readingPrefsFontJetBrains: string;
170
+ readingPrefsFontFira: string;
171
+ readingPrefsPreviewProse: string;
172
+ readingPrefsPreviewCode: string;
173
+
174
+ // SettingsToggle — free-reading (disable progress) section
175
+ freeReadingSection: string;
176
+ freeReadingTitle: string;
177
+ freeReadingDesc: string;
178
+ freeReadingResetWarning: string;
179
+ freeReadingResetConfirm: string;
180
+ freeReadingResetCancel: string;
181
+ };
182
+
183
+ export const UI_STRINGS: Record<Lang, UIDict> = {
184
+ ru: {
185
+ sidebarLabel: 'Боковая навигация',
186
+ navMainLabel: 'Основная навигация',
187
+ home: 'Главная',
188
+ programCourse: 'Программа курса',
189
+ githubRepo: 'Репозиторий на GitHub',
190
+
191
+ breadcrumbsLabel: 'Хлебные крошки',
192
+
193
+ close: 'Закрыть',
194
+ moduleListLabel: 'Список модулей и уроков',
195
+ lessonLockTitle: 'Урок откроется после прохождения предыдущих',
196
+
197
+ lessonNavLabel: 'Навигация по урокам',
198
+ prevLesson: '← Предыдущий урок',
199
+ nextLesson: 'Следующий урок →',
200
+
201
+ prevLessonShort: 'пред.',
202
+ nextLessonShort: 'след.',
203
+ currentLessonEyebrow: '/ текущий урок',
204
+ currentLessonNumberPrefix: 'урок',
205
+
206
+ settingsResourcesLabel: 'Ресурсы',
207
+ githubRepoSub: 'исходный код курса',
208
+
209
+ prevLessonAria: 'Предыдущий урок',
210
+ nextLessonAria: 'Следующий урок',
211
+ firstLessonTitle: 'Это первый урок',
212
+ lastLessonTitle: 'Это последний урок',
213
+
214
+ tocLabel: 'Содержание',
215
+
216
+ readingProgressLabel: 'Прогресс чтения',
217
+
218
+ locked: 'LOCKED',
219
+ moduleNumberPrefix: 'Модуль',
220
+ lockedTitle: 'Этот урок ещё впереди',
221
+ lockedDesc:
222
+ 'Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.',
223
+ attemptedLessonLabel: 'Урок, который вы открыли',
224
+ attemptedYouTried: '/ вы пытались открыть',
225
+ continueAction: 'Продолжить',
226
+ startFromFirst: 'Начать с первого урока',
227
+ openProgram: 'Открыть программу',
228
+ courseProgress: 'Прогресс курса',
229
+ progress: 'Прогресс',
230
+ nextStep: 'Следующий шаг',
231
+ untilThisLesson: 'До этого урока',
232
+
233
+ themeLight: 'Светлая',
234
+ themeDark: 'Тёмная',
235
+ themePaper: 'Бумага',
236
+
237
+ heroTitleLead: 'Kafka',
238
+ heroTitleAccent: 'для тех, кто',
239
+ heroTitleTail: 'пишет на Go',
240
+ continueLessonPrefix: 'Продолжить · урок',
241
+ startFromScratch: 'Начать с начала',
242
+ progressSummary: 'Сводка прогресса',
243
+ modulesLabel: 'Модулей',
244
+ lessonsLabel: 'Уроков',
245
+ durationLabel: 'Длительность',
246
+ stackLabel: 'Стек',
247
+ moduleLockTitle: 'Модуль откроется после прохождения предыдущих уроков',
248
+
249
+ lessonInfoLabel: 'Сведения об уроке',
250
+
251
+ notFoundTitle: 'Страница не найдена',
252
+ notFoundDesc: 'Похоже, такой лекции в курсе нет. Вернитесь на главную.',
253
+ goHome: 'На главную',
254
+
255
+ language: 'Язык',
256
+
257
+ translationFallbackTitle: 'Перевод в процессе',
258
+ translationFallbackBody:
259
+ 'Английская версия этой лекции пока не готова. Показан оригинал на русском.',
260
+
261
+ startModule: 'Начать модуль',
262
+ continueModulePrefix: 'Продолжить',
263
+ rereadModule: 'Перечитать модуль',
264
+ nextModule: 'Следующий модуль',
265
+ moduleProgress: 'Прогресс модуля',
266
+ lessonsCount: 'Уроков',
267
+ durationLabelShort: 'Длительность',
268
+ stackLabelShort: 'Стек',
269
+ moduleLessonsHeading: 'Уроки модуля',
270
+ lessonLockShort: 'Урок откроется после прохождения предыдущих',
271
+ lessonHintContinue: '↳ продолжить отсюда',
272
+ lessonNeighbourModulesLabel: 'Соседние модули',
273
+ prevModule: '← Предыдущий модуль',
274
+
275
+ moduleMetaKey: 'модуль',
276
+ readingTimeMetaKey: 'время чтения',
277
+ tagsMetaKey: 'теги',
278
+ markUnread: 'Пометить непрочитанным',
279
+
280
+ progressBarAriaLabel: 'Прогресс прохождения курса',
281
+ progressAriaConnector: 'из',
282
+
283
+ ogImageAlt: 'Kafka Cookbook — курс по Apache Kafka на Go',
284
+
285
+ statusNotStarted: 'не начато',
286
+ statusInProgress: 'в процессе',
287
+ statusComplete: 'пройдено',
288
+
289
+ calloutNote: 'Заметка',
290
+ calloutTip: 'Подсказка',
291
+ calloutWarning: 'Внимание',
292
+ calloutImportant: 'Важно',
293
+ calloutCaution: 'Осторожно',
294
+
295
+ codeBlockCopy: 'copy',
296
+ codeBlockCopied: '✓ скопировано',
297
+ codeBlockCopyAriaLabel: 'Скопировать код',
298
+ codeBlockCopiedAriaLabel: 'Скопировано',
299
+
300
+ settingsLabel: 'Настройки',
301
+ settingsEyebrow: '/ config',
302
+ settingsThemeSection: 'Тема',
303
+ readingPrefsProseSection: 'Текст лекции',
304
+ readingPrefsCodeSection: 'Текст кода',
305
+ readingPrefsSize: 'Размер',
306
+ readingPrefsFont: 'Шрифт',
307
+ readingPrefsDecrease: 'A−',
308
+ readingPrefsIncrease: 'A+',
309
+ readingPrefsFontSerif: 'Literata',
310
+ readingPrefsFontSans: 'Inter',
311
+ readingPrefsFontSlab: 'Roboto Slab',
312
+ readingPrefsFontJetBrains: 'JetBrains',
313
+ readingPrefsFontFira: 'Fira',
314
+ readingPrefsPreviewProse:
315
+ 'Шрифт и размер выбираются так, чтобы длинные лекции читались легко.',
316
+ readingPrefsPreviewCode: 'consumer.subscribe(topics)',
317
+
318
+ freeReadingSection: 'Прогресс',
319
+ freeReadingTitle: 'Свободное чтение',
320
+ freeReadingDesc: 'Открыть все уроки и скрыть прогресс',
321
+ freeReadingResetWarning: 'Накопленный прогресс будет сброшен.',
322
+ freeReadingResetConfirm: 'Сбросить',
323
+ freeReadingResetCancel: 'Отмена',
324
+ },
325
+ en: {
326
+ sidebarLabel: 'Side navigation',
327
+ navMainLabel: 'Main navigation',
328
+ home: 'Home',
329
+ programCourse: 'Course outline',
330
+ githubRepo: 'GitHub repository',
331
+
332
+ breadcrumbsLabel: 'Breadcrumbs',
333
+
334
+ close: 'Close',
335
+ moduleListLabel: 'Module and lesson list',
336
+ lessonLockTitle: 'Lesson unlocks after the previous ones are complete',
337
+
338
+ lessonNavLabel: 'Lesson navigation',
339
+ prevLesson: '← Previous lesson',
340
+ nextLesson: 'Next lesson →',
341
+
342
+ prevLessonShort: 'prev',
343
+ nextLessonShort: 'next',
344
+ currentLessonEyebrow: '/ current lesson',
345
+ currentLessonNumberPrefix: 'lesson',
346
+
347
+ settingsResourcesLabel: 'Resources',
348
+ githubRepoSub: 'course source code',
349
+
350
+ prevLessonAria: 'Previous lesson',
351
+ nextLessonAria: 'Next lesson',
352
+ firstLessonTitle: 'This is the first lesson',
353
+ lastLessonTitle: 'This is the last lesson',
354
+
355
+ tocLabel: 'Contents',
356
+
357
+ readingProgressLabel: 'Reading progress',
358
+
359
+ locked: 'LOCKED',
360
+ moduleNumberPrefix: 'Module',
361
+ lockedTitle: 'This lesson is still ahead',
362
+ lockedDesc:
363
+ 'The course goes in order — to open this step, finish the previous ones first. Context builds up without gaps that way.',
364
+ attemptedLessonLabel: 'Lesson you tried to open',
365
+ attemptedYouTried: '/ you tried to open',
366
+ continueAction: 'Continue',
367
+ startFromFirst: 'Start from the first lesson',
368
+ openProgram: 'Open outline',
369
+ courseProgress: 'Course progress',
370
+ progress: 'Progress',
371
+ nextStep: 'Next step',
372
+ untilThisLesson: 'Until this lesson',
373
+
374
+ themeLight: 'Light',
375
+ themeDark: 'Dark',
376
+ themePaper: 'Paper',
377
+
378
+ heroTitleLead: 'Kafka',
379
+ heroTitleAccent: 'for people who',
380
+ heroTitleTail: 'write Go',
381
+ continueLessonPrefix: 'Continue · lesson',
382
+ startFromScratch: 'Start over',
383
+ progressSummary: 'Progress summary',
384
+ modulesLabel: 'Modules',
385
+ lessonsLabel: 'Lessons',
386
+ durationLabel: 'Duration',
387
+ stackLabel: 'Stack',
388
+ moduleLockTitle: 'Module unlocks after the previous lessons are complete',
389
+
390
+ lessonInfoLabel: 'Lesson info',
391
+
392
+ notFoundTitle: 'Page not found',
393
+ notFoundDesc: 'There is no such lesson in the course. Head back home.',
394
+ goHome: 'Go home',
395
+
396
+ language: 'Language',
397
+
398
+ translationFallbackTitle: 'Translation in progress',
399
+ translationFallbackBody:
400
+ 'The English version of this lesson is not ready yet. Showing the original Russian text.',
401
+
402
+ startModule: 'Start module',
403
+ continueModulePrefix: 'Continue',
404
+ rereadModule: 'Reread module',
405
+ nextModule: 'Next module',
406
+ moduleProgress: 'Module progress',
407
+ lessonsCount: 'Lessons',
408
+ durationLabelShort: 'Duration',
409
+ stackLabelShort: 'Stack',
410
+ moduleLessonsHeading: 'Lessons',
411
+ lessonLockShort: 'Lesson unlocks after the previous ones are complete',
412
+ lessonHintContinue: '↳ continue from here',
413
+ lessonNeighbourModulesLabel: 'Neighbouring modules',
414
+ prevModule: '← Previous module',
415
+
416
+ moduleMetaKey: 'module',
417
+ readingTimeMetaKey: 'reading time',
418
+ tagsMetaKey: 'tags',
419
+ markUnread: 'Mark as unread',
420
+
421
+ progressBarAriaLabel: 'Course completion progress',
422
+ progressAriaConnector: 'of',
423
+
424
+ ogImageAlt: 'Kafka Cookbook — Apache Kafka course in Go',
425
+
426
+ statusNotStarted: 'not started',
427
+ statusInProgress: 'in progress',
428
+ statusComplete: 'complete',
429
+
430
+ calloutNote: 'Note',
431
+ calloutTip: 'Tip',
432
+ calloutWarning: 'Warning',
433
+ calloutImportant: 'Important',
434
+ calloutCaution: 'Caution',
435
+
436
+ codeBlockCopy: 'copy',
437
+ codeBlockCopied: '✓ copied',
438
+ codeBlockCopyAriaLabel: 'Copy code',
439
+ codeBlockCopiedAriaLabel: 'Copied',
440
+
441
+ settingsLabel: 'Settings',
442
+ settingsEyebrow: '/ config',
443
+ settingsThemeSection: 'Theme',
444
+ readingPrefsProseSection: 'Lesson text',
445
+ readingPrefsCodeSection: 'Code text',
446
+ readingPrefsSize: 'Size',
447
+ readingPrefsFont: 'Font',
448
+ readingPrefsDecrease: 'A−',
449
+ readingPrefsIncrease: 'A+',
450
+ readingPrefsFontSerif: 'Literata',
451
+ readingPrefsFontSans: 'Inter',
452
+ readingPrefsFontSlab: 'Roboto Slab',
453
+ readingPrefsFontJetBrains: 'JetBrains',
454
+ readingPrefsFontFira: 'Fira',
455
+ readingPrefsPreviewProse: 'Pick a typeface and size that make long lessons comfortable to read.',
456
+ readingPrefsPreviewCode: 'consumer.subscribe(topics)',
457
+
458
+ freeReadingSection: 'Progress',
459
+ freeReadingTitle: 'Free reading',
460
+ freeReadingDesc: 'Unlock every lesson and hide progress',
461
+ freeReadingResetWarning: 'Your accumulated progress will be reset.',
462
+ freeReadingResetConfirm: 'Reset',
463
+ freeReadingResetCancel: 'Cancel',
464
+ },
465
+ };
466
+
467
+ /**
468
+ * Server-friendly accessor. Server components receive `lang` via route
469
+ * params; pass it in here. Client components should prefer `useT()` from
470
+ * `web/lib/use-i18n.ts` instead — it reads `lang` from `useParams()`.
471
+ */
472
+ export function getDict(lang: Lang): UIDict {
473
+ return UI_STRINGS[lang];
474
+ }
@@ -0,0 +1,90 @@
1
+ export type Lang = 'ru' | 'en';
2
+
3
+ export const LANGS: readonly Lang[] = ['ru', 'en'] as const;
4
+ export const DEFAULT_LANG: Lang = 'en';
5
+ export const LANG_STORAGE_KEY = 'kafka-cookbook-lang';
6
+
7
+ export const LANG_LABELS: Record<Lang, string> = {
8
+ ru: 'Русский',
9
+ en: 'English',
10
+ };
11
+
12
+ export function isLang(value: unknown): value is Lang {
13
+ return value === 'ru' || value === 'en';
14
+ }
15
+
16
+ export function getBrowserLang(): Lang {
17
+ if (typeof navigator === 'undefined') return DEFAULT_LANG;
18
+ const raw = navigator.language || (navigator.languages && navigator.languages[0]) || '';
19
+ return raw.toLowerCase().startsWith('ru') ? 'ru' : 'en';
20
+ }
21
+
22
+ export function readStoredLang(): Lang | null {
23
+ if (typeof window === 'undefined') return null;
24
+ try {
25
+ const raw = window.localStorage.getItem(LANG_STORAGE_KEY);
26
+ return isLang(raw) ? raw : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function writeStoredLang(lang: Lang): void {
33
+ if (typeof window === 'undefined') return;
34
+ try {
35
+ window.localStorage.setItem(LANG_STORAGE_KEY, lang);
36
+ } catch {
37
+ /* storage may be unavailable (private mode, quota); ignore. */
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Strips a leading `/ru/` or `/en/` segment from a pathname. Used by URL
43
+ * parsers (LessonAwareLink, GateProvider, gate-scripts) so the remaining
44
+ * segments are `[moduleId, slug]` regardless of the active language.
45
+ *
46
+ * Examples:
47
+ * /ru/foo/bar -> { lang: 'ru', rest: '/foo/bar' }
48
+ * /en/ -> { lang: 'en', rest: '/' }
49
+ * /ru -> { lang: 'ru', rest: '/' }
50
+ * /foo/bar -> { lang: null, rest: '/foo/bar' }
51
+ * /enfoo -> { lang: null, rest: '/enfoo' }
52
+ * '' -> { lang: null, rest: '/' }
53
+ */
54
+ export function stripLangFromPath(pathname: string): { lang: Lang | null; rest: string } {
55
+ if (!pathname) return { lang: null, rest: '/' };
56
+ const match = pathname.match(/^\/(ru|en)(\/.*)?$/);
57
+ if (!match) return { lang: null, rest: pathname };
58
+ const lang = match[1] as Lang;
59
+ const rest = match[2] && match[2].length > 0 ? match[2] : '/';
60
+ return { lang, rest };
61
+ }
62
+
63
+ /**
64
+ * Inline script for the root `/page.tsx`. Decides whether to redirect the
65
+ * visitor to `/{lang}/` based on (1) stored preference, (2) navigator.language.
66
+ * If the resolved language equals DEFAULT_LANG we skip the redirect to avoid
67
+ * a flash — the static `/index.html` already serves DEFAULT_LANG content.
68
+ *
69
+ * Side effect: when the language is auto-detected (no stored value yet) we
70
+ * persist the choice so subsequent visits are stable.
71
+ *
72
+ * basePath is derived from `window.location.pathname`: the root index.html
73
+ * sits at `/{basePath}/`, so appending `{lang}/` to that path yields the
74
+ * correct redirect target without needing a build-time substitution.
75
+ */
76
+ export const LANG_INIT_SCRIPT = `(function(){try{var key=${JSON.stringify(
77
+ LANG_STORAGE_KEY,
78
+ )};var def=${JSON.stringify(
79
+ DEFAULT_LANG,
80
+ )};var stored=null;try{stored=window.localStorage.getItem(key);}catch(e){}var lang;if(stored==='ru'||stored==='en'){lang=stored;}else{var nav=(navigator&&(navigator.language||(navigator.languages&&navigator.languages[0])))||'';lang=String(nav).toLowerCase().indexOf('ru')===0?'ru':'en';try{window.localStorage.setItem(key,lang);}catch(e){}}if(lang===def)return;var p=window.location.pathname;if(p.charAt(p.length-1)!=='/')p+='/';window.location.replace(p+lang+'/');}catch(e){}})();`;
81
+
82
+ /**
83
+ * Inline script for `[lang]/layout.tsx`. Syncs `document.documentElement.lang`
84
+ * to the active language so in-browser APIs (Intl, screen readers) see the
85
+ * correct value — the static HTML always carries `<html lang={DEFAULT_LANG}>`
86
+ * because Next.js renders <html> once at the root layout.
87
+ */
88
+ export function LANG_SYNC_SCRIPT(lang: Lang): string {
89
+ return `(function(){try{document.documentElement.lang=${JSON.stringify(lang)};}catch(e){}})();`;
90
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ flattenLessons,
3
+ getLessonIndex,
4
+ type Course,
5
+ type FlatLessonEntry,
6
+ } from './course';
7
+ import { isCompleted, lessonKey, type LessonKey, type ProgressMap } from './progress';
8
+
9
+ /**
10
+ * Resolve the linear index of the user's furthest reached lesson. Returns
11
+ * the max of (a) the index of the stored pointer when it still maps to a
12
+ * real lesson, and (b) the highest index among completed entries that still
13
+ * exist in the course. Combining both sources protects against a cross-tab
14
+ * `bumpFurthest` race that could persist a lower pointer than the user has
15
+ * actually reached: the still-valid `progress` map heals the regression
16
+ * without waiting for another advance write. Returns -1 only when both
17
+ * sources are empty.
18
+ */
19
+ export function resolveFurthestIndex(
20
+ course: Course,
21
+ furthestKey: LessonKey | null,
22
+ progress: ProgressMap,
23
+ ): number {
24
+ const flat = flattenLessons(course);
25
+ let max = -1;
26
+
27
+ if (furthestKey !== null) {
28
+ const idx = flat.findIndex((e) => lessonKey(e.moduleId, e.lesson.slug) === furthestKey);
29
+ if (idx > max) max = idx;
30
+ }
31
+
32
+ for (let i = 0; i < flat.length; i += 1) {
33
+ const key = lessonKey(flat[i].moduleId, flat[i].lesson.slug);
34
+ if (isCompleted(progress, key) && i > max) {
35
+ max = i;
36
+ }
37
+ }
38
+ return max;
39
+ }
40
+
41
+ /**
42
+ * A lesson is unlocked when its index is no further than `furthestIndex + 1`.
43
+ * That extra step is what makes "the next lesson after the furthest" reachable
44
+ * — without it, a fresh user (furthestIndex = -1) could not even open the
45
+ * first lesson (index 0).
46
+ */
47
+ export function isLessonUnlocked(lessonIndex: number, furthestIndex: number): boolean {
48
+ if (lessonIndex < 0) return false;
49
+ return lessonIndex <= furthestIndex + 1;
50
+ }
51
+
52
+ export function isLessonKeyUnlocked(
53
+ course: Course,
54
+ moduleId: string,
55
+ slug: string,
56
+ furthestKey: LessonKey | null,
57
+ progress: ProgressMap,
58
+ ): boolean {
59
+ const idx = getLessonIndex(course, moduleId, slug);
60
+ if (idx === -1) return false;
61
+ return isLessonUnlocked(idx, resolveFurthestIndex(course, furthestKey, progress));
62
+ }
63
+
64
+ /**
65
+ * The "current frontier" lesson — the one a fresh CTA should target. Always
66
+ * the first locked-but-reachable lesson (`furthestIndex + 1`), so the user
67
+ * lands on what they've not yet finished even when checkmarks have been
68
+ * toggled. Returns null when the course is empty or the user has finished it.
69
+ */
70
+ export function getFrontierLesson(
71
+ course: Course,
72
+ furthestIndex: number,
73
+ ): FlatLessonEntry | null {
74
+ const flat = flattenLessons(course);
75
+ if (flat.length === 0) return null;
76
+ const targetIndex = Math.min(furthestIndex + 1, flat.length - 1);
77
+ if (targetIndex < 0) return null;
78
+ return flat[targetIndex];
79
+ }