@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitriy Basenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,232 @@
1
+ # @dsbasko/cookbook-engine
2
+
3
+ Переиспользуемый движок учебников на Next.js 14 (static export). Один движок —
4
+ много курсов: новый учебник = `course.yaml` + `lectures/` + `public/` + тонкая
5
+ обёртка из ре-экспортов. Вся логика, UI и SEO живут в пакете; репозиторий курса
6
+ держит только данные и бренд.
7
+
8
+ `kafka-cookbook` — первый потребитель пакета и его живой валидатор.
9
+
10
+ ## Как завести новый курс
11
+
12
+ Новый курс — это репозиторий **только с данными**: ноль TS-логики, всё
13
+ поведение приходит из пакета. Нужны четыре вещи.
14
+
15
+ ### 1. Данные курса
16
+
17
+ ```
18
+ my-course/
19
+ ├── course.yaml # манифест: модули, уроки, i18n-заголовки, basePath, brand
20
+ ├── lectures/ # markdown-контент
21
+ │ └── <module>/<slug>/i18n/<lang>/README.md
22
+ ├── web/ # тонкая обёртка-потребитель (см. ниже)
23
+ └── public/ # favicon, logo, og-картинки курса (специфичны)
24
+ ```
25
+
26
+ `course.yaml` описывает структуру (см. формат полей ниже); контент уроков лежит
27
+ в `lectures/<module>/<slug>/i18n/<lang>/README.md`. Если перевода для языка нет,
28
+ движок рендерит fallback-баннер и отдаёт `noindex` на этой странице.
29
+
30
+ ### 2. Тонкая обёртка `web/`
31
+
32
+ Все файлы роутов — **голые ре-экспорты** entry-points пакета. Next требует,
33
+ чтобы `default`/`generateStaticParams`/`generateMetadata` были именованными
34
+ экспортами самого файла роута, поэтому ре-экспорт — единственная форма (данные
35
+ приходят из `cwd` курса через FS, параметризовать пути не нужно).
36
+
37
+ ```
38
+ web/
39
+ ├── next.config.mjs # export default createCookbookConfig()
40
+ ├── package.json # зависит от @dsbasko/cookbook-engine
41
+ ├── tsconfig.json # paths: @/* → engine/src/*
42
+ └── app/
43
+ ├── layout.tsx # → /layout/root
44
+ ├── page.tsx # → /pages/root
45
+ ├── not-found.tsx # → /pages/not-found-root
46
+ ├── icon.tsx # → /og/icon
47
+ ├── opengraph-image.tsx # → /og/opengraph-image
48
+ ├── robots.ts # → /seo/robots
49
+ ├── sitemap.ts # → /seo/sitemap
50
+ └── [lang]/
51
+ ├── layout.tsx # → /layout/lang
52
+ ├── page.tsx # → /pages/home
53
+ ├── not-found.tsx # → /pages/not-found-lang
54
+ └── [module]/
55
+ ├── page.tsx # → /pages/module
56
+ └── [lesson]/page.tsx # → /pages/lesson
57
+ ```
58
+
59
+ `web/next.config.mjs` целиком:
60
+
61
+ ```js
62
+ import { createCookbookConfig } from '@dsbasko/cookbook-engine/config';
63
+ export default createCookbookConfig();
64
+ ```
65
+
66
+ Пример ре-экспорта (`web/app/[lang]/[module]/[lesson]/page.tsx`):
67
+
68
+ ```ts
69
+ export {
70
+ default,
71
+ generateStaticParams,
72
+ generateMetadata,
73
+ } from '@dsbasko/cookbook-engine/pages/lesson';
74
+ ```
75
+
76
+ Полный готовый комплект обёртки лежит в [`examples/web/`](./examples/web) — его
77
+ можно копировать как шаблон 1-в-1.
78
+
79
+ ### 3. tsconfig обёртки
80
+
81
+ Внутренний код движка использует TS-алиас `@/*`. Чтобы `tsc` потребителя его
82
+ видел, добавьте в `web/tsconfig.json`:
83
+
84
+ ```json
85
+ {
86
+ "compilerOptions": {
87
+ "baseUrl": ".",
88
+ "paths": { "@/*": ["./node_modules/@dsbasko/cookbook-engine/src/*"] }
89
+ }
90
+ }
91
+ ```
92
+
93
+ Webpack-алиас `@` → `engine/src` инлайнится автоматически из
94
+ `createCookbookConfig` — для сборки руками ничего настраивать не нужно.
95
+
96
+ ### 4. Сборка
97
+
98
+ ```bash
99
+ pnpm install
100
+ pnpm --filter web build # next build, output:'export' → web/out/
101
+ ```
102
+
103
+ `prebuild`/`postbuild`-хуки курса зовут хелперы пакета (`cookbook-sync-images`,
104
+ `cookbook-fix-static-images`) — они синкают картинки уроков и чинят расширения
105
+ статических изображений; оба резолвят пути через `process.cwd()`, так что
106
+ запускаются из `web/`.
107
+
108
+ ## Формат `course.yaml`
109
+
110
+ Базовые поля (обязательные) — как у любого курса: `title`, `description`
111
+ (`{ru,en}`), `basePath`, `repoUrl`, `modules[]`. Брендирование — опциональная
112
+ секция `brand`; при её отсутствии движок берёт исторические дефолты Kafka
113
+ Cookbook, поэтому существующие курсы рендерятся без изменений.
114
+
115
+ ```yaml
116
+ brand:
117
+ accent: "#7c3aed" # hex; переопределяет семейство --accent-main (light/paper)
118
+ accentDark: "#a78bfa" # hex; для [data-theme=dark]; fallback → accent
119
+ glyph: "D" # один символ для favicon + бейджа og-картинки (дефолт "K")
120
+ logo: /logo.svg # путь в public/ курса
121
+ siteUrl: https://my.dev # canonical origin; сахар над NEXT_PUBLIC_SITE_URL
122
+ level: "Demo" # подпись стека в карточке статистики (дефолт "Go")
123
+ breadcrumbRoot: { ru: Demo Cookbook, en: Demo Cookbook } # лейбл breadcrumb/header
124
+ hero: # трёхчастный заголовок главной (lead / accent / tail)
125
+ lead: { ru: Демонстрационный, en: A demo }
126
+ accent: { ru: Cookbook, en: Cookbook }
127
+ tail: { ru: движок на практике, en: engine in practice }
128
+ ogImage:
129
+ title: { ru: Demo Cookbook, en: Demo Cookbook }
130
+ subtitle: { ru: Харнесс движка, en: The engine harness } # fallback → truncated description
131
+ footerTag: "cookbook-engine · demo" # дефолт "Apache Kafka · Go"
132
+ alt: { ru: Demo Cookbook …, en: Demo Cookbook … } # fallback → i18n-дефолт
133
+ ```
134
+
135
+ Правила валидации (`parseCourse`/`parseBrand`):
136
+
137
+ - `accent`/`accentDark` — валидный hex, иначе сборка падает с ошибкой парсинга;
138
+ - `siteUrl` — `http(s)`-URL;
139
+ - скалярные поля (`glyph`, `level`, `footerTag`) — непустые строки;
140
+ - per-lang поля (`breadcrumbRoot`, `hero.*`, `ogImage.title/subtitle/alt`)
141
+ резолвятся в одну строку для активного языка на этапе парсинга, как
142
+ `title`/`description`.
143
+
144
+ Акцент-цвет: `brand.accent` схлопывает light+paper в один цвет и деривит
145
+ hover/soft через `color-mix`. Это переопределяет семейство `--accent-main` через
146
+ инлайн `<style>` с селекторами `:root[data-theme=…]` (специфичность выигрывает
147
+ у `tokens.css`). Если курсу нужны hand-tuned per-theme палитры (как у Kafka —
148
+ три отдельных акцента light/paper/dark), `brand.accent` опускают и правят
149
+ `tokens.css` в движке.
150
+
151
+ Меняется бренд **только** правкой `course.yaml` + логотип в `public/` — кода
152
+ трогать не нужно.
153
+
154
+ ## Публичный API (entry-points через `package.json#exports`)
155
+
156
+ | Импорт | Назначение |
157
+ | --------------------------------------------------- | ------------------------------------------------ |
158
+ | `@dsbasko/cookbook-engine` | Барелл: компоненты + ключевые lib-функции |
159
+ | `@dsbasko/cookbook-engine/config` | `createCookbookConfig(overrides?)` |
160
+ | `@dsbasko/cookbook-engine/layout/root` | Root layout: `default` + `generateMetadata` + `viewport` |
161
+ | `@dsbasko/cookbook-engine/layout/lang` | `[lang]` layout: `default` + `generateStaticParams` + `generateMetadata` |
162
+ | `@dsbasko/cookbook-engine/pages/root` | Индекс `/` (lang-redirect) |
163
+ | `@dsbasko/cookbook-engine/pages/home` | Главная `[lang]` |
164
+ | `@dsbasko/cookbook-engine/pages/module` | Страница модуля |
165
+ | `@dsbasko/cookbook-engine/pages/lesson` | Страница урока |
166
+ | `@dsbasko/cookbook-engine/pages/not-found-root` | 404 (статический EN) |
167
+ | `@dsbasko/cookbook-engine/pages/not-found-lang` | 404 (клиентский i18n) |
168
+ | `@dsbasko/cookbook-engine/og/icon` | favicon |
169
+ | `@dsbasko/cookbook-engine/og/opengraph-image` | og-картинка |
170
+ | `@dsbasko/cookbook-engine/seo/sitemap` | sitemap.xml |
171
+ | `@dsbasko/cookbook-engine/seo/robots` | robots.txt |
172
+ | `@dsbasko/cookbook-engine/styles/*.css` | Глобальные стили (`reset`/`tokens`/`globals`/`markdown`/…) |
173
+
174
+ `createCookbookConfig(overrides?)` читает `course.yaml` через `process.cwd()`,
175
+ берёт `basePath`, ставит `output:'export'`, `trailingSlash`,
176
+ `images.unoptimized`, prod `basePath`/`assetPrefix`,
177
+ `transpilePackages:['@dsbasko/cookbook-engine']`, инлайнит webpack-алиас
178
+ `@`→`engine/src` и прокидывает `brand.siteUrl`→`env.NEXT_PUBLIC_SITE_URL`.
179
+ `overrides` мёржатся поверх (shallow, с сохранением `experimental`-дефолтов).
180
+
181
+ ## Состав пакета
182
+
183
+ - `src/components/**` — UI-компоненты (AppShell, Header, Sidebar, Toc, CodeBlock,
184
+ LessonNav, провайдеры темы/прогресса/reading-prefs/gating, …).
185
+ - `src/lib/**` — course-loader, markdown, i18n, gating, progress, SEO-хелперы и
186
+ `mdx-plugins/**`. Юнит-тесты (`*.test.ts`, vitest) лежат рядом и являются
187
+ контрактом публичного поведения.
188
+ - `src/styles/**` — `reset`/`tokens`/`globals`/`markdown`/`slab`.
189
+ - `src/layout/**`, `src/pages/**`, `src/og/**`, `src/seo/**` — entry-points для
190
+ роутов курса.
191
+ - `src/config.mjs` — `createCookbookConfig()`.
192
+ - `assets/fonts/**` — woff2 (JetBrains Mono), общие для всех курсов.
193
+ - `scripts/**` — хелперы сборки (`cookbook-sync-images`,
194
+ `cookbook-fix-static-images`, coverage/TOC).
195
+
196
+ Формат пакета — **нетранспилированный ESM** (source as-is: `.tsx`/`.ts`/`.css`):
197
+ потребитель включает `transpilePackages` (вшито в `createCookbookConfig`),
198
+ сохраняются `'use client'`, `next/font`, server/client-компоненты.
199
+
200
+ ## Политика версионирования (SemVer)
201
+
202
+ Публичный контракт — это `package.json#exports`, сигнатуры entry-points и формат
203
+ `course.yaml` (включая секцию `brand`). Версия — контракт:
204
+
205
+ - **major** — несовместимое изменение: удалён/переименован entry-point,
206
+ изменилась сигнатура экспорта роута, удалено/переименовано поле `course.yaml`,
207
+ ужесточена валидация так, что ранее валидный курс перестаёт собираться, либо
208
+ смена peer-диапазона next/react.
209
+ - **minor** — новый entry-point, новое **опциональное** поле `brand`/`course.yaml`,
210
+ новый компонент в барелле, новая фича без поломки существующих курсов.
211
+ - **patch** — багфиксы, правки стилей/контента, внутренний рефактор без изменения
212
+ контракта.
213
+
214
+ Потребители (`kafka-cookbook` и др.) пинят major-диапазон и получают
215
+ авто-обновления движка через `pnpm update`. Изменения, требующие правок в
216
+ курсах, идут только в major с заметкой в changelog.
217
+
218
+ ## Разработка
219
+
220
+ Основной полигон — мини-курс `examples/` (двуязычный демо-курс с полной секцией
221
+ `brand`):
222
+
223
+ ```bash
224
+ pnpm install
225
+ pnpm typecheck # tsc --noEmit
226
+ pnpm test # vitest run — контракт публичного поведения
227
+ pnpm lint # eslint .
228
+ pnpm examples:dev # поднять examples/web на localhost:3000
229
+ pnpm smoke # next build examples/web (output:'export') — e2e-smoke для SSG
230
+ ```
231
+
232
+ Требуется Node `>=20` и pnpm `9`.
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "@dsbasko/cookbook-engine",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Reusable Next.js engine for cookbook-style courses driven by course.yaml + markdown lectures.",
6
+ "author": "Dmitriy Basenko",
7
+ "license": "MIT",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "packageManager": "pnpm@9.15.0",
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "assets",
18
+ "scripts",
19
+ "README.md",
20
+ "LICENSE",
21
+ "!**/*.test.ts",
22
+ "!**/*.test.tsx",
23
+ "!**/__snapshots__"
24
+ ],
25
+ "bin": {
26
+ "cookbook-sync-images": "./scripts/sync-images.mjs",
27
+ "cookbook-fix-static-images": "./scripts/fix-static-image-extensions.mjs"
28
+ },
29
+ "exports": {
30
+ ".": "./src/index.ts",
31
+ "./config": "./src/config.mjs",
32
+ "./layout/root": "./src/layout/root.tsx",
33
+ "./layout/lang": "./src/layout/lang.tsx",
34
+ "./pages/root": "./src/pages/root.tsx",
35
+ "./pages/home": "./src/pages/home.tsx",
36
+ "./pages/module": "./src/pages/module.tsx",
37
+ "./pages/lesson": "./src/pages/lesson.tsx",
38
+ "./pages/not-found-root": "./src/pages/not-found-root.tsx",
39
+ "./pages/not-found-lang": "./src/pages/not-found-lang.tsx",
40
+ "./og/icon": "./src/og/icon.tsx",
41
+ "./og/opengraph-image": "./src/og/opengraph-image.tsx",
42
+ "./seo/sitemap": "./src/seo/sitemap.ts",
43
+ "./seo/robots": "./src/seo/robots.ts",
44
+ "./styles/*.css": "./src/styles/*.css"
45
+ },
46
+ "scripts": {
47
+ "lint": "eslint .",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "examples:dev": "pnpm -C examples/web dev",
52
+ "smoke": "pnpm -C examples/web build"
53
+ },
54
+ "peerDependencies": {
55
+ "next": "^14.2.18",
56
+ "react": "^18.3.1",
57
+ "react-dom": "^18.3.1"
58
+ },
59
+ "dependencies": {
60
+ "hast-util-to-jsx-runtime": "2.3.6",
61
+ "js-yaml": "4.1.0",
62
+ "rehype-autolink-headings": "7.1.0",
63
+ "rehype-pretty-code": "0.13.2",
64
+ "rehype-slug": "6.0.0",
65
+ "remark-gfm": "4.0.1",
66
+ "remark-github-blockquote-alert": "^2.1.0",
67
+ "remark-parse": "11.0.0",
68
+ "remark-rehype": "11.1.2",
69
+ "shiki": "1.29.2",
70
+ "unified": "11.0.5"
71
+ },
72
+ "devDependencies": {
73
+ "@types/hast": "3.0.4",
74
+ "@types/js-yaml": "4.0.9",
75
+ "@types/node": "20.17.9",
76
+ "@types/react": "18.3.12",
77
+ "@types/react-dom": "18.3.1",
78
+ "@vitejs/plugin-react": "4.3.4",
79
+ "@vitest/ui": "2.1.8",
80
+ "eslint": "8.57.1",
81
+ "eslint-config-next": "14.2.18",
82
+ "eslint-config-prettier": "9.1.0",
83
+ "jsdom": "25.0.1",
84
+ "next": "14.2.18",
85
+ "prettier": "3.4.2",
86
+ "react": "18.3.1",
87
+ "react-dom": "18.3.1",
88
+ "tsx": "4.19.2",
89
+ "typescript": "5.7.2",
90
+ "vitest": "2.1.8"
91
+ }
92
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env tsx
2
+ // Engine-side coverage check. Verifies course.yaml ↔ lectures/ parity plus
3
+ // RU/EN translation coverage. Resolves course data via process.cwd() (run it
4
+ // from the consumer's web/), and imports the engine's lib by relative path so
5
+ // it works straight off the package source under tsx.
6
+ import {
7
+ buildCoverageReport,
8
+ formatCoverageReport,
9
+ isCoverageFailing,
10
+ } from '../src/lib/coverage.ts';
11
+ import { loadCourse } from '../src/lib/course-loader.ts';
12
+ import { resolveCourseYaml, resolveLecturesRoot } from './resolve-course-paths.mjs';
13
+
14
+ function main(): void {
15
+ const courseYaml = resolveCourseYaml();
16
+ const lecturesRoot = resolveLecturesRoot();
17
+ const course = loadCourse('ru', {
18
+ filePath: courseYaml,
19
+ lecturesRoot,
20
+ });
21
+ const report = buildCoverageReport(course, lecturesRoot);
22
+ const formatted = formatCoverageReport(report);
23
+
24
+ for (const line of formatted.ok) console.log(line);
25
+ for (const line of formatted.translationGaps) console.log(line);
26
+ for (const line of formatted.mismatches) console.error(line);
27
+ console.error(`\n${formatted.summary}`);
28
+
29
+ process.exit(isCoverageFailing(report) ? 1 : 0);
30
+ }
31
+
32
+ main();
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // Engine-side postbuild helper. Next.js App Router materializes
3
+ // app/opengraph-image.tsx and app/icon.tsx as extension-less files
4
+ // (out/opengraph-image, out/icon). GitHub Pages serves such files as
5
+ // application/octet-stream, which breaks social-card crawlers and the favicon.
6
+ // Rename the files to .png and patch every reference in the generated HTML.
7
+ //
8
+ // Parameterized via process.cwd() (NOT import.meta.url): the consumer runs it
9
+ // from web/ after `next build`, so the export dir is cwd/out.
10
+ import { promises as fs, existsSync } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const OUT_DIR = path.resolve(process.cwd(), 'out');
14
+
15
+ const RENAMES = [
16
+ { from: 'opengraph-image', to: 'opengraph-image.png' },
17
+ { from: 'icon', to: 'icon.png' },
18
+ ];
19
+
20
+ async function* walkHtml(dir) {
21
+ const entries = await fs.readdir(dir, { withFileTypes: true });
22
+ for (const entry of entries) {
23
+ const abs = path.join(dir, entry.name);
24
+ if (entry.isDirectory()) {
25
+ yield* walkHtml(abs);
26
+ } else if (entry.isFile() && entry.name.endsWith('.html')) {
27
+ yield abs;
28
+ }
29
+ }
30
+ }
31
+
32
+ function patchContent(content) {
33
+ let next = content;
34
+ for (const { from, to } of RENAMES) {
35
+ // Match /<name> when followed by ?, ", or backslash (for JSON-escaped
36
+ // strings inside <script>). Lookahead keeps existing query/fragment.
37
+ const re = new RegExp(`/${from}(?=[?"\\\\])`, 'g');
38
+ next = next.replace(re, `/${to}`);
39
+ }
40
+ return next;
41
+ }
42
+
43
+ async function main() {
44
+ if (!existsSync(OUT_DIR)) {
45
+ console.error(`fix-static-image-extensions: ${OUT_DIR} not found`);
46
+ process.exit(1);
47
+ }
48
+
49
+ for (const { from, to } of RENAMES) {
50
+ const src = path.join(OUT_DIR, from);
51
+ const dst = path.join(OUT_DIR, to);
52
+ if (!existsSync(src)) {
53
+ console.error(`fix-static-image-extensions: missing ${src}`);
54
+ process.exit(1);
55
+ }
56
+ await fs.rename(src, dst);
57
+ }
58
+
59
+ let patched = 0;
60
+ for await (const file of walkHtml(OUT_DIR)) {
61
+ const original = await fs.readFile(file, 'utf8');
62
+ const updated = patchContent(original);
63
+ if (updated !== original) {
64
+ await fs.writeFile(file, updated);
65
+ patched += 1;
66
+ }
67
+ }
68
+
69
+ console.log(
70
+ `fix-static-image-extensions: renamed ${RENAMES.length} files, patched ${patched} html files`,
71
+ );
72
+ }
73
+
74
+ main().catch((err) => {
75
+ console.error('fix-static-image-extensions: failed');
76
+ console.error(err);
77
+ process.exit(1);
78
+ });
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env tsx
2
+ // Engine-side README TOC generator. Prints the markdown table of contents for
3
+ // the course root README. Resolves course data via process.cwd() (run it from
4
+ // the consumer's web/), imports engine lib by relative path under tsx.
5
+ import { loadCourse } from '../src/lib/course-loader.ts';
6
+ import { isLang, type Lang } from '../src/lib/lang.ts';
7
+ import { generateReadmeToc } from '../src/lib/readme-toc.ts';
8
+ import { resolveCourseYaml, resolveLecturesRoot } from './resolve-course-paths.mjs';
9
+
10
+ function parseLang(): Lang {
11
+ const arg = process.argv.slice(2).find((a) => a.startsWith('--lang='));
12
+ if (!arg) return 'en';
13
+ const value = arg.slice('--lang='.length);
14
+ if (!isLang(value)) {
15
+ console.error(`Invalid --lang value: ${value}. Expected 'ru' or 'en'.`);
16
+ process.exit(2);
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function main(): void {
22
+ const lang = parseLang();
23
+ const courseYaml = resolveCourseYaml();
24
+ const lecturesRoot = resolveLecturesRoot();
25
+ const course = loadCourse(lang, {
26
+ filePath: courseYaml,
27
+ lecturesRoot,
28
+ });
29
+ process.stdout.write(generateReadmeToc(course, { lang, lecturesRoot }));
30
+ }
31
+
32
+ main();
@@ -0,0 +1,28 @@
1
+ // Shared cwd-based resolver for the engine CLI helpers. Mirrors the candidate
2
+ // lists in lib/course-loader.ts: probe CWD=repo-root (`./course.yaml`,
3
+ // `./lectures`) first, then CWD=web/ (`../course.yaml`, `../lectures`). NEVER
4
+ // resolves via import.meta.url — the package lives in node_modules, so the
5
+ // course data must always come from the consumer's process.cwd().
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ function firstExisting(candidates) {
10
+ for (const candidate of candidates) {
11
+ if (existsSync(candidate)) return candidate;
12
+ }
13
+ return candidates[0];
14
+ }
15
+
16
+ export function resolveCourseYaml() {
17
+ return firstExisting([
18
+ path.resolve(process.cwd(), 'course.yaml'),
19
+ path.resolve(process.cwd(), '..', 'course.yaml'),
20
+ ]);
21
+ }
22
+
23
+ export function resolveLecturesRoot() {
24
+ return firstExisting([
25
+ path.resolve(process.cwd(), 'lectures'),
26
+ path.resolve(process.cwd(), '..', 'lectures'),
27
+ ]);
28
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Engine-side prebuild helper. Copies lecture images from <repoRoot>/lectures
3
+ // into the consumer web app's public/ tree so Next can serve them as static
4
+ // assets. Parameterized via process.cwd() (NOT import.meta.url) so the same
5
+ // file works from any course repo: the consumer runs it from web/, so the
6
+ // course repo root is one level up and the public dir is cwd/public.
7
+ import { promises as fs, existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const WEB_DIR = process.cwd();
11
+ const REPO_ROOT = path.resolve(WEB_DIR, '..');
12
+ const LECTURES_DIR = path.join(REPO_ROOT, 'lectures');
13
+ const PUBLIC_DIR = path.join(WEB_DIR, 'public', 'static', 'lectures');
14
+
15
+ const ALLOWED_EXT = new Set([
16
+ '.png',
17
+ '.jpg',
18
+ '.jpeg',
19
+ '.svg',
20
+ '.webp',
21
+ '.gif',
22
+ ]);
23
+
24
+ async function* walkImages(dir, relativeFromRoot = '') {
25
+ const entries = await fs.readdir(dir, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const abs = path.join(dir, entry.name);
28
+ const rel = path.join(relativeFromRoot, entry.name);
29
+ if (entry.isDirectory()) {
30
+ yield* walkImages(abs, rel);
31
+ } else if (entry.isFile()) {
32
+ const ext = path.extname(entry.name).toLowerCase();
33
+ if (ALLOWED_EXT.has(ext)) {
34
+ yield { abs, rel };
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ async function main() {
41
+ if (!existsSync(LECTURES_DIR)) {
42
+ console.error(`sync-images: lectures dir not found at ${LECTURES_DIR}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ await fs.rm(PUBLIC_DIR, { recursive: true, force: true });
47
+ await fs.mkdir(PUBLIC_DIR, { recursive: true });
48
+
49
+ let copied = 0;
50
+ const moduleEntries = await fs.readdir(LECTURES_DIR, { withFileTypes: true });
51
+
52
+ for (const moduleEntry of moduleEntries) {
53
+ if (!moduleEntry.isDirectory()) continue;
54
+ if (!/^\d{2}-/.test(moduleEntry.name)) continue;
55
+
56
+ const moduleDir = path.join(LECTURES_DIR, moduleEntry.name);
57
+ const lessonEntries = await fs.readdir(moduleDir, { withFileTypes: true });
58
+
59
+ for (const lessonEntry of lessonEntries) {
60
+ if (!lessonEntry.isDirectory()) continue;
61
+
62
+ const imagesDir = path.join(moduleDir, lessonEntry.name, 'images');
63
+ if (!existsSync(imagesDir)) continue;
64
+
65
+ const destDir = path.join(
66
+ PUBLIC_DIR,
67
+ moduleEntry.name,
68
+ lessonEntry.name,
69
+ 'images',
70
+ );
71
+
72
+ for await (const file of walkImages(imagesDir)) {
73
+ const dest = path.join(destDir, file.rel);
74
+ await fs.mkdir(path.dirname(dest), { recursive: true });
75
+ await fs.copyFile(file.abs, dest);
76
+ copied += 1;
77
+ }
78
+ }
79
+ }
80
+
81
+ console.log(`sync-images: copied ${copied} image${copied === 1 ? '' : 's'}`);
82
+ }
83
+
84
+ main().catch((err) => {
85
+ console.error('sync-images: failed');
86
+ console.error(err);
87
+ process.exit(1);
88
+ });
@@ -0,0 +1,40 @@
1
+ .shell {
2
+ display: flex;
3
+ flex-direction: row;
4
+ min-height: 100dvh;
5
+ background-color: var(--bg-default);
6
+ color: var(--content-primary);
7
+ }
8
+
9
+ .body {
10
+ display: flex;
11
+ flex-direction: column;
12
+ flex: 1 1 auto;
13
+ min-width: 0;
14
+ }
15
+
16
+ .main {
17
+ flex: 1 1 auto;
18
+ display: flex;
19
+ flex-direction: column;
20
+ min-width: 0;
21
+ }
22
+
23
+ @media (max-width: 1023px) {
24
+ .shell {
25
+ flex-direction: column;
26
+ }
27
+
28
+ /* Sidebar collapses into two FABs at the top of the viewport (see
29
+ Sidebar.module.css). The body reclaims full width; the main column adds
30
+ a top clearance so heroes/hl titles don't render under the gradient
31
+ band that the FABs sit on. */
32
+ .body {
33
+ width: 100%;
34
+ }
35
+
36
+ .main {
37
+ padding-top: calc(env(safe-area-inset-top, 0px) + 56px);
38
+ padding-bottom: env(safe-area-inset-bottom);
39
+ }
40
+ }