@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,429 @@
1
+ import yaml from 'js-yaml';
2
+ import { type Lang } from './lang';
3
+ import { isValidSlug } from './slug';
4
+
5
+ export interface Lesson {
6
+ slug: string;
7
+ title: string;
8
+ duration: string;
9
+ tags: string[];
10
+ hasTranslation: boolean;
11
+ }
12
+
13
+ export interface Module {
14
+ id: string;
15
+ title: string;
16
+ description: string;
17
+ lessons: Lesson[];
18
+ }
19
+
20
+ export interface BrandHero {
21
+ lead: string;
22
+ accent: string;
23
+ tail: string;
24
+ }
25
+
26
+ export interface BrandOgImage {
27
+ title?: string;
28
+ subtitle?: string;
29
+ footerTag?: string;
30
+ alt?: string;
31
+ }
32
+
33
+ /**
34
+ * Optional branding for a course. Every field is optional and, when absent,
35
+ * the engine falls back to the historical Kafka Cookbook defaults (see
36
+ * DEFAULT_BRAND_* + the resolve* helpers below) so existing courses keep
37
+ * rendering exactly as before. Localized fields (hero, breadcrumbRoot,
38
+ * ogImage.title/subtitle/alt) are resolved to a single string for the active
39
+ * `lang` at parse time, mirroring how title/description are handled.
40
+ */
41
+ export interface Brand {
42
+ /** Accent colour (hex) — overrides the --accent-main family in light/paper. */
43
+ accent?: string;
44
+ /** Accent colour (hex) for [data-theme=dark]; falls back to `accent`. */
45
+ accentDark?: string;
46
+ /** Path to a logo asset in the course's public/ folder. */
47
+ logo?: string;
48
+ /** Single glyph used by the favicon + og-image badge (default "K"). */
49
+ glyph?: string;
50
+ /** Canonical site origin; sugar over NEXT_PUBLIC_SITE_URL (see config.mjs). */
51
+ siteUrl?: string;
52
+ /** Stack label shown in the stats card (default "Go"). */
53
+ level?: string;
54
+ /** Wordmark used for breadcrumbs/header/og (default "Kafka Cookbook"). */
55
+ breadcrumbRoot?: string;
56
+ /** Three-part hero headline (lead / accent / tail). */
57
+ hero?: BrandHero;
58
+ /** Open Graph image overrides. */
59
+ ogImage?: BrandOgImage;
60
+ }
61
+
62
+ export interface Course {
63
+ title: string;
64
+ description: string;
65
+ basePath: string;
66
+ repoUrl: string;
67
+ modules: Module[];
68
+ brand?: Brand;
69
+ }
70
+
71
+ /** Historical Kafka Cookbook defaults — used when a course omits `brand`. */
72
+ export const DEFAULT_BRAND_NAME = 'Kafka Cookbook';
73
+ export const DEFAULT_BRAND_GLYPH = 'K';
74
+ export const DEFAULT_BRAND_LEVEL = 'Go';
75
+ export const DEFAULT_BRAND_FOOTER_TAG = 'Apache Kafka · Go';
76
+
77
+ export function resolveBrandName(course: Course): string {
78
+ return course.brand?.breadcrumbRoot ?? DEFAULT_BRAND_NAME;
79
+ }
80
+
81
+ export function resolveBrandGlyph(course: Course): string {
82
+ return course.brand?.glyph ?? DEFAULT_BRAND_GLYPH;
83
+ }
84
+
85
+ export function resolveBrandLevel(course: Course): string {
86
+ return course.brand?.level ?? DEFAULT_BRAND_LEVEL;
87
+ }
88
+
89
+ export function resolveOgFooterTag(course: Course): string {
90
+ return course.brand?.ogImage?.footerTag ?? DEFAULT_BRAND_FOOTER_TAG;
91
+ }
92
+
93
+ /**
94
+ * Builds the inline <style> that overrides the --accent-main family from a
95
+ * single brand accent. Returns null when no `brand.accent` is set (the
96
+ * engine then keeps the hand-tuned tokens.css defaults).
97
+ *
98
+ * The selectors use `:root[data-theme=…]` (specificity 0,1,1) so they win the
99
+ * cascade over tokens.css' `[data-theme=…]` (0,1,0) regardless of stylesheet
100
+ * order — verified by tests. Hover/soft shades derive from the base accent via
101
+ * color-mix so a course only declares one colour per theme.
102
+ */
103
+ export function buildBrandAccentCss(brand?: Brand): string | null {
104
+ const light = brand?.accent;
105
+ if (!light) return null;
106
+ const dark = brand.accentDark ?? light;
107
+ return (
108
+ `:root[data-theme='light'],:root[data-theme='paper']{` +
109
+ `--accent-base:${light};` +
110
+ `--accent-main:${light};` +
111
+ `--accent-main-hover:color-mix(in srgb, ${light} 85%, #000);` +
112
+ `--accent-main-soft:color-mix(in srgb, ${light} 12%, transparent);}` +
113
+ `:root[data-theme='dark']{` +
114
+ `--accent-base:${dark};` +
115
+ `--accent-main:${dark};` +
116
+ `--accent-main-hover:color-mix(in srgb, ${dark} 85%, #fff);` +
117
+ `--accent-main-soft:color-mix(in srgb, ${dark} 12%, transparent);}`
118
+ );
119
+ }
120
+
121
+ export interface FlatLessonEntry {
122
+ moduleId: string;
123
+ lesson: Lesson;
124
+ index: number;
125
+ }
126
+
127
+ export function parseCourse(
128
+ source: string,
129
+ lang: Lang = 'ru',
130
+ sourcePath = '<inline>',
131
+ ): Course {
132
+ let parsed: unknown;
133
+ try {
134
+ parsed = yaml.load(source);
135
+ } catch (err) {
136
+ const message = err instanceof Error ? err.message : String(err);
137
+ throw new Error(`course.yaml: invalid YAML in ${sourcePath}: ${message}`);
138
+ }
139
+
140
+ if (!isPlainObject(parsed)) {
141
+ throw new Error(`course.yaml: expected top-level mapping in ${sourcePath}`);
142
+ }
143
+
144
+ const title = requireLocalized(parsed, 'title', lang);
145
+ const description = requireLocalized(parsed, 'description', lang);
146
+ const basePath = requireString(parsed, 'basePath');
147
+ const repoUrl = requireString(parsed, 'repoUrl');
148
+
149
+ const rawModules = parsed.modules;
150
+ if (!Array.isArray(rawModules) || rawModules.length === 0) {
151
+ throw new Error(`course.yaml: "modules" must be a non-empty array`);
152
+ }
153
+
154
+ const seenModuleIds = new Set<string>();
155
+ const modules: Module[] = rawModules.map((value, index) => {
156
+ const where = `modules[${index}]`;
157
+ if (!isPlainObject(value)) {
158
+ throw new Error(`course.yaml: ${where} must be a mapping`);
159
+ }
160
+
161
+ const id = requireString(value, 'id', where);
162
+ if (!isValidSlug(id)) {
163
+ throw new Error(`course.yaml: ${where}.id "${id}" is not a valid slug`);
164
+ }
165
+ if (seenModuleIds.has(id)) {
166
+ throw new Error(`course.yaml: duplicate module id "${id}"`);
167
+ }
168
+ seenModuleIds.add(id);
169
+
170
+ const moduleTitle = requireLocalized(value, 'title', lang, where);
171
+ const moduleDescription = requireLocalized(value, 'description', lang, where);
172
+
173
+ const rawLessons = value.lessons;
174
+ if (!Array.isArray(rawLessons) || rawLessons.length === 0) {
175
+ throw new Error(`course.yaml: ${where}.lessons must be a non-empty array`);
176
+ }
177
+
178
+ const seenSlugs = new Set<string>();
179
+ const lessons: Lesson[] = rawLessons.map((lessonValue, lessonIndex) => {
180
+ const lessonWhere = `${where}.lessons[${lessonIndex}]`;
181
+ if (!isPlainObject(lessonValue)) {
182
+ throw new Error(`course.yaml: ${lessonWhere} must be a mapping`);
183
+ }
184
+ const slug = requireString(lessonValue, 'slug', lessonWhere);
185
+ if (!isValidSlug(slug)) {
186
+ throw new Error(`course.yaml: ${lessonWhere}.slug "${slug}" is not a valid slug`);
187
+ }
188
+ if (seenSlugs.has(slug)) {
189
+ throw new Error(`course.yaml: duplicate lesson slug "${slug}" in module "${id}"`);
190
+ }
191
+ seenSlugs.add(slug);
192
+
193
+ const lessonTitle = requireLocalized(lessonValue, 'title', lang, lessonWhere);
194
+ const duration = requireString(lessonValue, 'duration', lessonWhere);
195
+ const tags = parseTags(lessonValue.tags, lessonWhere);
196
+
197
+ return { slug, title: lessonTitle, duration, tags, hasTranslation: false };
198
+ });
199
+
200
+ return {
201
+ id,
202
+ title: moduleTitle,
203
+ description: moduleDescription,
204
+ lessons,
205
+ };
206
+ });
207
+
208
+ const brand = parseBrand(parsed.brand, lang);
209
+
210
+ return { title, description, basePath, repoUrl, modules, ...(brand ? { brand } : {}) };
211
+ }
212
+
213
+ const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
214
+
215
+ function parseBrand(value: unknown, lang: Lang): Brand | undefined {
216
+ if (value === undefined || value === null) return undefined;
217
+ if (!isPlainObject(value)) {
218
+ throw new Error(`course.yaml: brand must be a mapping`);
219
+ }
220
+
221
+ const brand: Brand = {};
222
+
223
+ if (value.accent !== undefined) {
224
+ brand.accent = requireHexColor(value.accent, 'brand.accent');
225
+ }
226
+ if (value.accentDark !== undefined) {
227
+ brand.accentDark = requireHexColor(value.accentDark, 'brand.accentDark');
228
+ }
229
+ if (value.logo !== undefined) {
230
+ brand.logo = requireScalarString(value.logo, 'brand.logo');
231
+ }
232
+ if (value.glyph !== undefined) {
233
+ brand.glyph = requireScalarString(value.glyph, 'brand.glyph');
234
+ }
235
+ if (value.siteUrl !== undefined) {
236
+ brand.siteUrl = requireHttpUrl(value.siteUrl, 'brand.siteUrl');
237
+ }
238
+ if (value.level !== undefined) {
239
+ brand.level = requireScalarString(value.level, 'brand.level');
240
+ }
241
+ if (value.breadcrumbRoot !== undefined) {
242
+ brand.breadcrumbRoot = requireLocalized(value, 'breadcrumbRoot', lang, 'brand');
243
+ }
244
+
245
+ if (value.hero !== undefined) {
246
+ const hero = value.hero;
247
+ if (!isPlainObject(hero)) {
248
+ throw new Error(`course.yaml: brand.hero must be a mapping`);
249
+ }
250
+ brand.hero = {
251
+ lead: requireLocalized(hero, 'lead', lang, 'brand.hero'),
252
+ accent: requireLocalized(hero, 'accent', lang, 'brand.hero'),
253
+ tail: requireLocalized(hero, 'tail', lang, 'brand.hero'),
254
+ };
255
+ }
256
+
257
+ if (value.ogImage !== undefined) {
258
+ const og = value.ogImage;
259
+ if (!isPlainObject(og)) {
260
+ throw new Error(`course.yaml: brand.ogImage must be a mapping`);
261
+ }
262
+ const ogImage: BrandOgImage = {};
263
+ if (og.title !== undefined) {
264
+ ogImage.title = requireLocalized(og, 'title', lang, 'brand.ogImage');
265
+ }
266
+ if (og.subtitle !== undefined) {
267
+ ogImage.subtitle = requireLocalized(og, 'subtitle', lang, 'brand.ogImage');
268
+ }
269
+ if (og.footerTag !== undefined) {
270
+ ogImage.footerTag = requireScalarString(og.footerTag, 'brand.ogImage.footerTag');
271
+ }
272
+ if (og.alt !== undefined) {
273
+ ogImage.alt = requireLocalized(og, 'alt', lang, 'brand.ogImage');
274
+ }
275
+ brand.ogImage = ogImage;
276
+ }
277
+
278
+ return brand;
279
+ }
280
+
281
+ function requireScalarString(value: unknown, where: string): string {
282
+ if (typeof value !== 'string' || value.trim().length === 0) {
283
+ throw new Error(`course.yaml: ${where} must be a non-empty string`);
284
+ }
285
+ return value.trim();
286
+ }
287
+
288
+ function requireHexColor(value: unknown, where: string): string {
289
+ const str = requireScalarString(value, where);
290
+ if (!HEX_COLOR_RE.test(str)) {
291
+ throw new Error(`course.yaml: ${where} "${str}" must be a hex colour (#rgb or #rrggbb)`);
292
+ }
293
+ return str;
294
+ }
295
+
296
+ function requireHttpUrl(value: unknown, where: string): string {
297
+ const str = requireScalarString(value, where);
298
+ let url: URL;
299
+ try {
300
+ url = new URL(str);
301
+ } catch {
302
+ throw new Error(`course.yaml: ${where} "${str}" must be a valid URL`);
303
+ }
304
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
305
+ throw new Error(`course.yaml: ${where} "${str}" must be an http(s) URL`);
306
+ }
307
+ return str;
308
+ }
309
+
310
+ export function findLesson(
311
+ course: Course,
312
+ moduleId: string,
313
+ lessonSlug: string,
314
+ ): Lesson | null {
315
+ const mod = course.modules.find((m) => m.id === moduleId);
316
+ if (!mod) return null;
317
+ return mod.lessons.find((l) => l.slug === lessonSlug) ?? null;
318
+ }
319
+
320
+ export function getLessonIndex(
321
+ course: Course,
322
+ moduleId: string,
323
+ slug: string,
324
+ ): number {
325
+ const flat = flattenLessons(course);
326
+ return flat.findIndex((e) => e.moduleId === moduleId && e.lesson.slug === slug);
327
+ }
328
+
329
+ export function flattenLessons(course: Course): FlatLessonEntry[] {
330
+ const result: FlatLessonEntry[] = [];
331
+ let index = 0;
332
+ for (const mod of course.modules) {
333
+ for (const lesson of mod.lessons) {
334
+ result.push({ moduleId: mod.id, lesson, index });
335
+ index += 1;
336
+ }
337
+ }
338
+ return result;
339
+ }
340
+
341
+ export function getNextLesson(
342
+ course: Course,
343
+ moduleId: string,
344
+ slug: string,
345
+ ): FlatLessonEntry | null {
346
+ const flat = flattenLessons(course);
347
+ const idx = flat.findIndex((e) => e.moduleId === moduleId && e.lesson.slug === slug);
348
+ if (idx === -1 || idx === flat.length - 1) return null;
349
+ return flat[idx + 1];
350
+ }
351
+
352
+ export function getPrevLesson(
353
+ course: Course,
354
+ moduleId: string,
355
+ slug: string,
356
+ ): FlatLessonEntry | null {
357
+ const flat = flattenLessons(course);
358
+ const idx = flat.findIndex((e) => e.moduleId === moduleId && e.lesson.slug === slug);
359
+ if (idx <= 0) return null;
360
+ return flat[idx - 1];
361
+ }
362
+
363
+ export function getTotalLessons(course: Course): number {
364
+ let total = 0;
365
+ for (const mod of course.modules) total += mod.lessons.length;
366
+ return total;
367
+ }
368
+
369
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
370
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
371
+ }
372
+
373
+ function requireString(
374
+ obj: Record<string, unknown>,
375
+ key: string,
376
+ where = '<root>',
377
+ ): string {
378
+ const value = obj[key];
379
+ if (typeof value !== 'string' || value.trim().length === 0) {
380
+ throw new Error(`course.yaml: ${where}.${key} is required and must be a non-empty string`);
381
+ }
382
+ return value.trim();
383
+ }
384
+
385
+ function requireLocalized(
386
+ obj: Record<string, unknown>,
387
+ key: string,
388
+ lang: Lang,
389
+ where = '<root>',
390
+ ): string {
391
+ const value = obj[key];
392
+ if (typeof value === 'string') {
393
+ if (value.trim().length === 0) {
394
+ throw new Error(
395
+ `course.yaml: ${where}.${key} is required and must be a non-empty string`,
396
+ );
397
+ }
398
+ return value.trim();
399
+ }
400
+ if (isPlainObject(value)) {
401
+ const ruRaw = value.ru;
402
+ if (typeof ruRaw !== 'string' || ruRaw.trim().length === 0) {
403
+ throw new Error(
404
+ `course.yaml: ${where}.${key}.ru is required when ${key} is a locale map`,
405
+ );
406
+ }
407
+ const target = value[lang];
408
+ if (typeof target === 'string' && target.trim().length > 0) {
409
+ return target.trim();
410
+ }
411
+ return ruRaw.trim();
412
+ }
413
+ throw new Error(
414
+ `course.yaml: ${where}.${key} must be a non-empty string or a { ru, en } locale map`,
415
+ );
416
+ }
417
+
418
+ function parseTags(value: unknown, where: string): string[] {
419
+ if (value === undefined || value === null) return [];
420
+ if (!Array.isArray(value)) {
421
+ throw new Error(`course.yaml: ${where}.tags must be an array of strings`);
422
+ }
423
+ return value.map((item, i) => {
424
+ if (typeof item !== 'string' || item.length === 0) {
425
+ throw new Error(`course.yaml: ${where}.tags[${i}] must be a non-empty string`);
426
+ }
427
+ return item;
428
+ });
429
+ }
@@ -0,0 +1,141 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Course } from './course';
4
+ import { LANGS, type Lang } from './lang';
5
+
6
+ export type CoverageStatus = 'OK' | 'MISSING_IN_YAML' | 'MISSING_IN_FS';
7
+
8
+ export interface CoverageRow {
9
+ status: CoverageStatus;
10
+ moduleId: string;
11
+ slug: string;
12
+ translations: Record<Lang, boolean>;
13
+ }
14
+
15
+ export interface CoverageTotals {
16
+ lessons: number;
17
+ mismatches: number;
18
+ translations: Record<Lang, number>;
19
+ }
20
+
21
+ export interface CoverageReport {
22
+ rows: CoverageRow[];
23
+ totals: CoverageTotals;
24
+ }
25
+
26
+ export interface FormattedCoverage {
27
+ ok: string[];
28
+ translationGaps: string[];
29
+ mismatches: string[];
30
+ summary: string;
31
+ }
32
+
33
+ export function discoverFromFs(lecturesRoot: string): Set<string> {
34
+ const out = new Set<string>();
35
+ if (!existsSync(lecturesRoot)) return out;
36
+ for (const moduleEntry of readdirSync(lecturesRoot)) {
37
+ const moduleDir = path.join(lecturesRoot, moduleEntry);
38
+ if (!statSync(moduleDir).isDirectory()) continue;
39
+ if (!/^\d{2}-/.test(moduleEntry)) continue;
40
+
41
+ for (const lessonEntry of readdirSync(moduleDir)) {
42
+ const lessonDir = path.join(moduleDir, lessonEntry);
43
+ if (!statSync(lessonDir).isDirectory()) continue;
44
+ const stub = path.join(lessonDir, 'README.md');
45
+ const ruIndex = path.join(lessonDir, 'i18n', 'ru', 'README.md');
46
+ if (!existsSync(stub) && !existsSync(ruIndex)) continue;
47
+ out.add(`${moduleEntry}/${lessonEntry}`);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function discoverFromCourse(course: Course): Set<string> {
54
+ const out = new Set<string>();
55
+ for (const mod of course.modules) {
56
+ for (const lesson of mod.lessons) {
57
+ out.add(`${mod.id}/${lesson.slug}`);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export function buildCoverageReport(
64
+ course: Course,
65
+ lecturesRoot: string,
66
+ ): CoverageReport {
67
+ const fsSet = discoverFromFs(lecturesRoot);
68
+ const yamlSet = discoverFromCourse(course);
69
+ const allKeys = new Set<string>([...fsSet, ...yamlSet]);
70
+ const rows: CoverageRow[] = [];
71
+
72
+ for (const key of [...allKeys].sort()) {
73
+ const [moduleId, slug] = key.split('/', 2);
74
+ const inFs = fsSet.has(key);
75
+ const inYaml = yamlSet.has(key);
76
+ let status: CoverageStatus;
77
+ if (inFs && inYaml) status = 'OK';
78
+ else if (inFs) status = 'MISSING_IN_YAML';
79
+ else status = 'MISSING_IN_FS';
80
+
81
+ const translations: Record<Lang, boolean> = { ru: false, en: false };
82
+ for (const lang of LANGS) {
83
+ const file = path.join(
84
+ lecturesRoot,
85
+ moduleId,
86
+ slug,
87
+ 'i18n',
88
+ lang,
89
+ 'README.md',
90
+ );
91
+ translations[lang] = existsSync(file);
92
+ }
93
+ rows.push({ status, moduleId, slug, translations });
94
+ }
95
+
96
+ const okRows = rows.filter((r) => r.status === 'OK');
97
+ const translationsTotals: Record<Lang, number> = { ru: 0, en: 0 };
98
+ for (const lang of LANGS) {
99
+ translationsTotals[lang] = okRows.filter((r) => r.translations[lang]).length;
100
+ }
101
+
102
+ return {
103
+ rows,
104
+ totals: {
105
+ lessons: okRows.length,
106
+ mismatches: rows.length - okRows.length,
107
+ translations: translationsTotals,
108
+ },
109
+ };
110
+ }
111
+
112
+ export function formatCoverageReport(report: CoverageReport): FormattedCoverage {
113
+ const ok: string[] = [];
114
+ const translationGaps: string[] = [];
115
+ const mismatches: string[] = [];
116
+
117
+ for (const row of report.rows) {
118
+ const key = `${row.moduleId}/${row.slug}`;
119
+ if (row.status !== 'OK') {
120
+ mismatches.push(`${row.status} ${key}`);
121
+ continue;
122
+ }
123
+ const tags: string[] = [];
124
+ if (!row.translations.ru) tags.push('NO_RU');
125
+ if (!row.translations.en) tags.push('NO_EN');
126
+ if (tags.length > 0) {
127
+ translationGaps.push(`OK ${key} [${tags.join(', ')}]`);
128
+ } else {
129
+ ok.push(`OK ${key}`);
130
+ }
131
+ }
132
+
133
+ const { lessons, mismatches: mismatchCount, translations } = report.totals;
134
+ const summary = `Lessons: ${lessons} | RU: ${translations.ru}/${lessons} | EN: ${translations.en}/${lessons} | mismatches: ${mismatchCount}`;
135
+ return { ok, translationGaps, mismatches, summary };
136
+ }
137
+
138
+ export function isCoverageFailing(report: CoverageReport): boolean {
139
+ if (report.totals.mismatches > 0) return true;
140
+ return report.totals.translations.ru < report.totals.lessons;
141
+ }
@@ -0,0 +1,43 @@
1
+ const MAX_DESCRIPTION_LENGTH = 200;
2
+
3
+ export function extractDescription(markdown: string): string | null {
4
+ const lines = markdown.split('\n');
5
+ let inFence = false;
6
+ const buffer: string[] = [];
7
+
8
+ for (const rawLine of lines) {
9
+ const line = rawLine.trim();
10
+ if (line.startsWith('```')) {
11
+ if (inFence && buffer.length > 0) break;
12
+ inFence = !inFence;
13
+ continue;
14
+ }
15
+ if (inFence) continue;
16
+ if (line.length === 0) {
17
+ if (buffer.length > 0) break;
18
+ continue;
19
+ }
20
+ if (line.startsWith('#')) continue;
21
+ if (line.startsWith('>') || line.startsWith('|') || /^[-*+]\s/.test(line)) continue;
22
+ buffer.push(line);
23
+ }
24
+
25
+ if (buffer.length === 0) return null;
26
+
27
+ let text = buffer.join(' ');
28
+ text = text
29
+ .replace(/`([^`]+)`/g, '$1')
30
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
31
+ .replace(/\*([^*]+)\*/g, '$1')
32
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
33
+ .replace(/\s+/g, ' ')
34
+ .trim();
35
+
36
+ if (text.length > MAX_DESCRIPTION_LENGTH) {
37
+ const cut = text.slice(0, MAX_DESCRIPTION_LENGTH);
38
+ const lastSpace = cut.lastIndexOf(' ');
39
+ text = (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim() + '…';
40
+ }
41
+
42
+ return text;
43
+ }
@@ -0,0 +1,59 @@
1
+ import type { Element, ElementContent, Root as HastRoot } from 'hast';
2
+
3
+ export interface TocEntry {
4
+ depth: 2 | 3;
5
+ slug: string;
6
+ text: string;
7
+ }
8
+
9
+ const HEADING_TAGS: Readonly<Record<string, 2 | 3>> = {
10
+ h2: 2,
11
+ h3: 3,
12
+ };
13
+
14
+ /**
15
+ * Walk a HAST tree and collect h2/h3 headings produced by rehype-slug.
16
+ * Headings without an `id` are skipped (they cannot anchor a TOC link).
17
+ *
18
+ * `rehype-autolink-headings` wraps heading text in an `<a class="heading-anchor">`,
19
+ * so we extract text recursively rather than reading direct children only.
20
+ */
21
+ export function extractToc(tree: HastRoot): TocEntry[] {
22
+ const out: TocEntry[] = [];
23
+ walk(tree, (node) => {
24
+ const depth = HEADING_TAGS[node.tagName];
25
+ if (!depth) return;
26
+ const slug = readId(node);
27
+ if (!slug) return;
28
+ const text = collectText(node).trim();
29
+ if (!text) return;
30
+ out.push({ depth, slug, text });
31
+ });
32
+ return out;
33
+ }
34
+
35
+ function readId(node: Element): string | null {
36
+ const id = node.properties?.id;
37
+ return typeof id === 'string' && id.length > 0 ? id : null;
38
+ }
39
+
40
+ function collectText(node: ElementContent | HastRoot): string {
41
+ if (node.type === 'text') return node.value;
42
+ if (node.type === 'element' || node.type === 'root') {
43
+ let acc = '';
44
+ for (const child of node.children) {
45
+ acc += collectText(child as ElementContent);
46
+ }
47
+ return acc;
48
+ }
49
+ return '';
50
+ }
51
+
52
+ function walk(node: HastRoot | Element, visit: (el: Element) => void) {
53
+ for (const child of node.children) {
54
+ if ((child as Element).type === 'element') {
55
+ visit(child as Element);
56
+ walk(child as Element, visit);
57
+ }
58
+ }
59
+ }