@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,260 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { LessonLockedInterstitial } from '@/components/LessonLockedInterstitial';
4
+ import { LessonNav, type LessonNavLink } from '@/components/LessonNav';
5
+ import { LessonPageLayout } from '@/components/LessonPageLayout';
6
+ import { LessonSideMeta } from '@/components/LessonSideMeta';
7
+ import { ReadingProgress } from '@/components/ReadingProgress';
8
+ import { Toc } from '@/components/Toc';
9
+ import { TranslationBanner } from '@/components/TranslationBanner';
10
+ import {
11
+ findLesson,
12
+ flattenLessons,
13
+ getNextLesson,
14
+ getPrevLesson,
15
+ resolveBrandName,
16
+ type FlatLessonEntry,
17
+ } from '@/lib/course';
18
+ import { loadCourse } from '@/lib/course-loader';
19
+ import { getDict } from '@/lib/i18n';
20
+ import { isLang, LANGS, type Lang } from '@/lib/lang';
21
+ import { getLessonContent } from '@/lib/lesson';
22
+ import { renderLessonMarkdown } from '@/lib/markdown';
23
+ import { extractDescription } from '@/lib/description';
24
+ import { buildLessonLangMap } from '@/lib/sitemap';
25
+ import { buildAssetUrl, buildSiteUrl, getRuntimeBasePath } from '@/lib/site-url';
26
+
27
+ type LessonPageProps = {
28
+ params: { lang: string; module: string; lesson: string };
29
+ };
30
+
31
+ export function generateStaticParams(): Array<{
32
+ lang: Lang;
33
+ module: string;
34
+ lesson: string;
35
+ }> {
36
+ // Pages are emitted for every (lang × module × lesson) triple — even when
37
+ // the EN translation is missing. The page renders with a fallback banner
38
+ // and noindex metadata in that case (see generateMetadata + render below).
39
+ const course = loadCourse('ru');
40
+ const lessonPairs = flattenLessons(course).map((entry) => ({
41
+ module: entry.moduleId,
42
+ lesson: entry.lesson.slug,
43
+ }));
44
+ return LANGS.flatMap((lang) =>
45
+ lessonPairs.map((pair) => ({ lang, ...pair })),
46
+ );
47
+ }
48
+
49
+ export async function generateMetadata({
50
+ params,
51
+ }: LessonPageProps): Promise<Metadata> {
52
+ if (!isLang(params.lang)) return {};
53
+ const lang = params.lang as Lang;
54
+ const t = getDict(lang);
55
+ const course = loadCourse(lang);
56
+ const lesson = findLesson(course, params.module, params.lesson);
57
+ if (!lesson) {
58
+ return { title: `${t.notFoundTitle} · ${resolveBrandName(course)}` };
59
+ }
60
+
61
+ const courseDescription = course.description.replace(/\s+/g, ' ').trim();
62
+ let description = courseDescription;
63
+ let fallbackUsed = false;
64
+ try {
65
+ const content = await getLessonContent(params.module, params.lesson, lang);
66
+ fallbackUsed = content.fallbackUsed;
67
+ // When EN falls back to the RU README, the markdown is Russian — extracting
68
+ // a description from it would leak RU text into the EN page's <meta>. Keep
69
+ // the EN course-level description instead.
70
+ if (!fallbackUsed) {
71
+ description = extractDescription(content.markdown) ?? courseDescription;
72
+ }
73
+ } catch {
74
+ // metadata is best-effort; the page render will surface the real failure.
75
+ }
76
+
77
+ const title = `${lesson.title} · ${course.title}`;
78
+ const canonicalUrl = buildSiteUrl(course.basePath, [
79
+ lang,
80
+ params.module,
81
+ params.lesson,
82
+ ]);
83
+ const ogImage = {
84
+ url: buildAssetUrl(course.basePath, '/opengraph-image'),
85
+ width: 1200,
86
+ height: 630,
87
+ alt: course.brand?.ogImage?.alt ?? t.ogImageAlt,
88
+ };
89
+
90
+ // Determine EN translation presence independently of the route lang:
91
+ // `course.hasTranslation` only reflects whichever language the course was
92
+ // loaded with, but the hreflang map must always say whether the EN copy
93
+ // exists. On EN we already loaded it; on RU we re-resolve via loadCourse.
94
+ let hasEn: boolean;
95
+ if (lang === 'en') {
96
+ hasEn = !fallbackUsed;
97
+ } else {
98
+ const enCourse = loadCourse('en');
99
+ const enLesson = findLesson(enCourse, params.module, params.lesson);
100
+ hasEn = enLesson?.hasTranslation ?? false;
101
+ }
102
+ const languages = buildLessonLangMap(
103
+ course.basePath,
104
+ params.module,
105
+ params.lesson,
106
+ hasEn,
107
+ );
108
+
109
+ // EN page falling back to RU content — withhold from search and point the
110
+ // canonical at the actual RU URL so search engines don't index a duplicate
111
+ // under the wrong language. Still emit a full openGraph block: without it
112
+ // social-share unfurls inherit root-layout defaults (og:url=/en/,
113
+ // og:type=website), which gives misleading previews even though crawlers
114
+ // honour the noindex.
115
+ if (lang === 'en' && fallbackUsed) {
116
+ const ruCanonical = buildSiteUrl(course.basePath, [
117
+ 'ru',
118
+ params.module,
119
+ params.lesson,
120
+ ]);
121
+ return {
122
+ title,
123
+ description,
124
+ alternates: { canonical: ruCanonical, languages },
125
+ robots: { index: false, follow: true },
126
+ openGraph: {
127
+ type: 'article',
128
+ siteName: course.title,
129
+ title,
130
+ description,
131
+ url: ruCanonical,
132
+ locale: 'ru_RU',
133
+ images: [ogImage],
134
+ },
135
+ twitter: {
136
+ card: 'summary_large_image',
137
+ title,
138
+ description,
139
+ images: [ogImage.url],
140
+ },
141
+ };
142
+ }
143
+
144
+ return {
145
+ title,
146
+ description,
147
+ alternates: { canonical: canonicalUrl, languages },
148
+ openGraph: {
149
+ type: 'article',
150
+ siteName: course.title,
151
+ title,
152
+ description,
153
+ url: canonicalUrl,
154
+ locale: lang === 'ru' ? 'ru_RU' : 'en_US',
155
+ images: [ogImage],
156
+ },
157
+ twitter: {
158
+ card: 'summary_large_image',
159
+ title,
160
+ description,
161
+ images: [ogImage.url],
162
+ },
163
+ };
164
+ }
165
+
166
+ export default async function LessonPage({ params }: LessonPageProps) {
167
+ if (!isLang(params.lang)) notFound();
168
+ const lang = params.lang as Lang;
169
+ const course = loadCourse(lang);
170
+ const lesson = findLesson(course, params.module, params.lesson);
171
+ if (!lesson) {
172
+ notFound();
173
+ }
174
+
175
+ const { markdown, lang: contentLang, fallbackUsed } = await getLessonContent(
176
+ params.module,
177
+ params.lesson,
178
+ lang,
179
+ );
180
+ const { content, toc } = await renderLessonMarkdown(markdown, {
181
+ moduleId: params.module,
182
+ slug: params.lesson,
183
+ basePath: getRuntimeBasePath(course.basePath),
184
+ course,
185
+ // Keep emitting `/<route-lang>/` URLs so an EN reader who clicks a sibling
186
+ // link inside fallback RU content stays on EN routes (and gets the same
187
+ // fallback banner there). `sourceLang` follows the README we actually
188
+ // loaded so relative paths are anchored at `i18n/<source>/`.
189
+ lang,
190
+ sourceLang: contentLang,
191
+ });
192
+
193
+ const prev = toNavLink(getPrevLesson(course, params.module, params.lesson));
194
+ const next = toNavLink(getNextLesson(course, params.module, params.lesson));
195
+
196
+ const currentModule = course.modules.find((m) => m.id === params.module);
197
+ const moduleIndex = currentModule
198
+ ? course.modules.findIndex((m) => m.id === currentModule.id) + 1
199
+ : 0;
200
+
201
+ // Both branches render side-by-side. CSS toggles their visibility via the
202
+ // `data-lesson-locked` attribute on <html>, which the inline gate-init
203
+ // script stamps synchronously before <body> is parsed (no flash) and which
204
+ // GateProvider keeps in sync on the client (route changes, cross-tab
205
+ // localStorage updates).
206
+ return (
207
+ <>
208
+ <div data-lesson-body>
209
+ <ReadingProgress />
210
+ <LessonPageLayout
211
+ lang={lang}
212
+ title={lesson.title}
213
+ tocSlot={<Toc entries={toc} />}
214
+ sideMetaSlot={
215
+ currentModule ? (
216
+ <LessonSideMeta
217
+ moduleId={currentModule.id}
218
+ moduleTitle={currentModule.title}
219
+ moduleIndex={moduleIndex}
220
+ slug={params.lesson}
221
+ duration={lesson.duration}
222
+ tags={lesson.tags}
223
+ />
224
+ ) : null
225
+ }
226
+ footer={
227
+ <LessonNav
228
+ prev={prev}
229
+ next={next}
230
+ currentModuleId={params.module}
231
+ currentSlug={params.lesson}
232
+ />
233
+ }
234
+ >
235
+ <article className="markdown" data-translation-fallback={fallbackUsed ? 'true' : 'false'}>
236
+ {lang === 'en' && fallbackUsed ? (
237
+ <TranslationBanner lang={lang} />
238
+ ) : null}
239
+ {content}
240
+ </article>
241
+ </LessonPageLayout>
242
+ </div>
243
+ <div data-lesson-gate>
244
+ <LessonLockedInterstitial
245
+ attemptedModuleId={params.module}
246
+ attemptedSlug={params.lesson}
247
+ />
248
+ </div>
249
+ </>
250
+ );
251
+ }
252
+
253
+ function toNavLink(entry: FlatLessonEntry | null): LessonNavLink | null {
254
+ if (!entry) return null;
255
+ return {
256
+ moduleId: entry.moduleId,
257
+ slug: entry.lesson.slug,
258
+ title: entry.lesson.title,
259
+ };
260
+ }
@@ -0,0 +1,80 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { ModulePage } from '@/components/ModulePage';
4
+ import { resolveBrandLevel, resolveBrandName } from '@/lib/course';
5
+ import { loadCourse } from '@/lib/course-loader';
6
+ import { getDict } from '@/lib/i18n';
7
+ import { isLang, LANGS, type Lang } from '@/lib/lang';
8
+ import { buildLangMap } from '@/lib/sitemap';
9
+ import { buildAssetUrl, buildSiteUrl } from '@/lib/site-url';
10
+
11
+ type ModulePageProps = {
12
+ params: { lang: string; module: string };
13
+ };
14
+
15
+ export function generateStaticParams(): Array<{ lang: Lang; module: string }> {
16
+ // Module set is shared across languages (titles differ, ids don't), so
17
+ // re-loading per lang is wasteful — parse once and fan out.
18
+ const course = loadCourse('ru');
19
+ const moduleIds = course.modules.map((m) => m.id);
20
+ return LANGS.flatMap((lang) =>
21
+ moduleIds.map((module) => ({ lang, module })),
22
+ );
23
+ }
24
+
25
+ export function generateMetadata({ params }: ModulePageProps): Metadata {
26
+ if (!isLang(params.lang)) return {};
27
+ const lang = params.lang as Lang;
28
+ const t = getDict(lang);
29
+ const course = loadCourse(lang);
30
+ const mod = course.modules.find((m) => m.id === params.module);
31
+ if (!mod) {
32
+ return { title: `${t.notFoundTitle} · ${resolveBrandName(course)}` };
33
+ }
34
+ const description = collapseWhitespace(mod.description);
35
+ const title = `${mod.title} · ${course.title}`;
36
+ const url = buildSiteUrl(course.basePath, [lang, mod.id]);
37
+ const ogImage = {
38
+ url: buildAssetUrl(course.basePath, '/opengraph-image'),
39
+ width: 1200,
40
+ height: 630,
41
+ alt: course.brand?.ogImage?.alt ?? t.ogImageAlt,
42
+ };
43
+ // See note in `home.tsx`: `alternates` from the root layout is
44
+ // overridden, not merged, so we have to re-emit the hreflang map.
45
+ const languages = buildLangMap(course.basePath, [mod.id]);
46
+ return {
47
+ title,
48
+ description,
49
+ alternates: { canonical: url, languages },
50
+ openGraph: {
51
+ type: 'article',
52
+ siteName: course.title,
53
+ title,
54
+ description,
55
+ url,
56
+ locale: lang === 'ru' ? 'ru_RU' : 'en_US',
57
+ images: [ogImage],
58
+ },
59
+ twitter: {
60
+ card: 'summary_large_image',
61
+ title,
62
+ description,
63
+ images: [ogImage.url],
64
+ },
65
+ };
66
+ }
67
+
68
+ export default function ModuleRoute({ params }: ModulePageProps) {
69
+ if (!isLang(params.lang)) notFound();
70
+ const course = loadCourse(params.lang as Lang);
71
+ const mod = course.modules.find((m) => m.id === params.module);
72
+ if (!mod) {
73
+ notFound();
74
+ }
75
+ return <ModulePage course={course} module={mod} level={resolveBrandLevel(course)} />;
76
+ }
77
+
78
+ function collapseWhitespace(text: string): string {
79
+ return text.replace(/\s+/g, ' ').trim();
80
+ }
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useLang, useT } from '@/lib/use-i18n';
5
+
6
+ export default function NotFound() {
7
+ const t = useT();
8
+ const lang = useLang();
9
+ return (
10
+ <main
11
+ style={{
12
+ maxWidth: 'var(--layout-content-max)',
13
+ margin: '0 auto',
14
+ padding: 'var(--space-12) var(--space-6)',
15
+ textAlign: 'center',
16
+ }}
17
+ >
18
+ <h1
19
+ style={{
20
+ fontSize: 'var(--font-size-3xl)',
21
+ fontWeight: 'var(--font-weight-bold)',
22
+ marginBottom: 'var(--space-4)',
23
+ }}
24
+ >
25
+ {t.notFoundTitle}
26
+ </h1>
27
+ <p
28
+ style={{
29
+ fontSize: 'var(--font-size-lg)',
30
+ color: 'var(--content-secondary)',
31
+ marginBottom: 'var(--space-6)',
32
+ }}
33
+ >
34
+ {t.notFoundDesc}
35
+ </p>
36
+ <Link
37
+ href={`/${lang}`}
38
+ style={{
39
+ display: 'inline-block',
40
+ padding: 'var(--space-3) var(--space-6)',
41
+ borderRadius: 'var(--radius-md)',
42
+ backgroundColor: 'var(--accent-main)',
43
+ color: 'var(--content-inverse)',
44
+ fontWeight: 'var(--font-weight-semibold)',
45
+ }}
46
+ >
47
+ {t.goHome}
48
+ </Link>
49
+ </main>
50
+ );
51
+ }
@@ -0,0 +1,48 @@
1
+ import Link from 'next/link';
2
+
3
+ // Root not-found is the fallback used when no lang segment matches. Always
4
+ // rendered in DEFAULT_LANG; per-lang copies live at `not-found-lang.tsx`.
5
+ export default function NotFound() {
6
+ return (
7
+ <main
8
+ style={{
9
+ maxWidth: 'var(--layout-content-max)',
10
+ margin: '0 auto',
11
+ padding: 'var(--space-12) var(--space-6)',
12
+ textAlign: 'center',
13
+ }}
14
+ >
15
+ <h1
16
+ style={{
17
+ fontSize: 'var(--font-size-3xl)',
18
+ fontWeight: 'var(--font-weight-bold)',
19
+ marginBottom: 'var(--space-4)',
20
+ }}
21
+ >
22
+ Page not found
23
+ </h1>
24
+ <p
25
+ style={{
26
+ fontSize: 'var(--font-size-lg)',
27
+ color: 'var(--content-secondary)',
28
+ marginBottom: 'var(--space-6)',
29
+ }}
30
+ >
31
+ That lesson is not part of the course. Head back to the home page.
32
+ </p>
33
+ <Link
34
+ href="/"
35
+ style={{
36
+ display: 'inline-block',
37
+ padding: 'var(--space-3) var(--space-6)',
38
+ borderRadius: 'var(--radius-md)',
39
+ backgroundColor: 'var(--accent-main)',
40
+ color: 'var(--content-inverse)',
41
+ fontWeight: 'var(--font-weight-semibold)',
42
+ }}
43
+ >
44
+ Go home
45
+ </Link>
46
+ </main>
47
+ );
48
+ }
@@ -0,0 +1,44 @@
1
+ import { AppShell } from '@/components/AppShell';
2
+ import { GateProvider } from '@/components/GateProvider';
3
+ import { HomePage } from '@/components/HomePage';
4
+ import { resolveBrandLevel } from '@/lib/course';
5
+ import { loadCourse } from '@/lib/course-loader';
6
+ import { DEFAULT_LANG, LANG_INIT_SCRIPT, LANG_LABELS, LANGS } from '@/lib/lang';
7
+ import { buildSiteUrl, getRuntimeBasePath } from '@/lib/site-url';
8
+
9
+ export default function RootPage() {
10
+ // Static `/index.html` always serves the default-language HomePage. Visitors
11
+ // whose stored or browser language differs are redirected client-side by
12
+ // LANG_INIT_SCRIPT to `/{lang}/`. <noscript> ships explicit per-lang links so
13
+ // users without JS can still pick a language.
14
+ const course = loadCourse(DEFAULT_LANG);
15
+ const basePath = getRuntimeBasePath(course.basePath);
16
+
17
+ return (
18
+ <>
19
+ <script
20
+ id="lang-init"
21
+ // Renders as the first body child. Before the rest of the body paints,
22
+ // it redirects to `/{lang}/` when the resolved language differs from
23
+ // DEFAULT_LANG — at worst the user sees a brief background flash before
24
+ // the new page loads.
25
+ dangerouslySetInnerHTML={{ __html: LANG_INIT_SCRIPT }}
26
+ />
27
+ <GateProvider course={course} basePath={basePath}>
28
+ <AppShell course={course}>
29
+ <HomePage course={course} level={resolveBrandLevel(course)} />
30
+ </AppShell>
31
+ </GateProvider>
32
+ <noscript>
33
+ <p>Choose your language / Выберите язык:</p>
34
+ <ul>
35
+ {LANGS.map((lang) => (
36
+ <li key={lang}>
37
+ <a href={buildSiteUrl(course.basePath, [lang])}>{LANG_LABELS[lang]}</a>
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ </noscript>
42
+ </>
43
+ );
44
+ }
@@ -0,0 +1,16 @@
1
+ import type { MetadataRoute } from 'next';
2
+ import { loadCourse } from '@/lib/course-loader';
3
+ import { buildSiteUrl } from '@/lib/site-url';
4
+
5
+ export default function robots(): MetadataRoute.Robots {
6
+ const course = loadCourse('ru');
7
+ return {
8
+ rules: [
9
+ {
10
+ userAgent: '*',
11
+ allow: '/',
12
+ },
13
+ ],
14
+ sitemap: `${buildSiteUrl(course.basePath)}sitemap.xml`,
15
+ };
16
+ }
@@ -0,0 +1,10 @@
1
+ import type { MetadataRoute } from 'next';
2
+ import { loadCourse } from '@/lib/course-loader';
3
+ import { buildSitemap } from '@/lib/sitemap';
4
+
5
+ // Load with `'en'` so `lesson.hasTranslation` reflects the EN README — the
6
+ // sitemap uses that flag to skip /en/<m>/<l> entries for untranslated lessons.
7
+ // Titles aren't needed here, so RU vs EN resolution of strings doesn't matter.
8
+ export default function sitemap(): MetadataRoute.Sitemap {
9
+ return buildSitemap(loadCourse('en'));
10
+ }
@@ -0,0 +1,139 @@
1
+ @import './tokens.css';
2
+ @import './reset.css';
3
+ @import './markdown.css';
4
+
5
+ html {
6
+ font-family: var(--font-ui), -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui,
7
+ sans-serif;
8
+ font-size: var(--font-size-md);
9
+ color-scheme: light dark;
10
+ /* iOS Safari paints the area above the layout viewport (under the URL bar
11
+ chip and inside the rubber-band overscroll) with the <html> background,
12
+ not <body>. Without this, that area falls back to a default dark colour
13
+ and the FAB halos appear to clip at the top edge of the page. */
14
+ background-color: var(--bg-default);
15
+ }
16
+
17
+ body {
18
+ background-color: var(--bg-default);
19
+ color: var(--content-primary);
20
+ font-size: var(--font-size-md);
21
+ line-height: var(--line-height-normal);
22
+ transition:
23
+ background-color 120ms ease,
24
+ color 120ms ease;
25
+ }
26
+
27
+ a {
28
+ color: var(--content-link);
29
+ text-decoration: none;
30
+ transition: color 120ms ease;
31
+ }
32
+
33
+ a:hover {
34
+ color: var(--content-link-hover);
35
+ }
36
+
37
+ a:focus-visible,
38
+ button:focus-visible,
39
+ [tabindex]:focus-visible {
40
+ outline: 2px solid var(--accent-main);
41
+ outline-offset: 2px;
42
+ border-radius: var(--radius-sm);
43
+ }
44
+
45
+ ::selection {
46
+ background-color: var(--accent-main-soft);
47
+ color: var(--content-primary);
48
+ }
49
+
50
+ code,
51
+ kbd,
52
+ samp,
53
+ pre {
54
+ font-family: var(--font-mono), ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
55
+ }
56
+
57
+ /* Lesson gate: the lesson page renders both the lesson body and the locked
58
+ interstitial side-by-side; visibility is toggled via the data attribute
59
+ on <html>. The attribute is set synchronously by the gate-init inline
60
+ script before <body> parses (no flash on direct navigation) and kept in
61
+ sync on the client by GateProvider (SPA route changes, cross-tab
62
+ localStorage updates). Default state hides the gate. */
63
+ [data-lesson-gate] {
64
+ display: none;
65
+ }
66
+
67
+ html[data-lesson-locked='true'] [data-lesson-body] {
68
+ display: none;
69
+ }
70
+
71
+ html[data-lesson-locked='true'] [data-lesson-gate] {
72
+ display: block;
73
+ }
74
+
75
+ /* Cross-lesson links inside MDX prose: the inline gate-mark script flips
76
+ `data-locked` on any `<a>` whose `data-lesson-key` points at a still-
77
+ locked lesson. Visual is global because the selector targets a bare
78
+ attribute pattern that CSS Modules forbids; the trailing lock badge is
79
+ a local class on the link (see LessonAwareLink.module.css).
80
+ `cursor: not-allowed` signals the disabled state on hover; actual click
81
+ blocking is handled by the JS preventDefault in LessonAwareLink and
82
+ `tabindex=-1` / `aria-disabled` stamped by the gate-mark script. */
83
+ a[data-lesson-key][data-locked='true'],
84
+ .markdown a[data-lesson-key][data-locked='true'] {
85
+ color: var(--content-tertiary);
86
+ font-weight: var(--font-weight-semibold);
87
+ text-decoration: underline dotted;
88
+ text-decoration-color: var(--content-tertiary);
89
+ text-underline-offset: 3px;
90
+ text-decoration-thickness: 1px;
91
+ cursor: not-allowed;
92
+ }
93
+
94
+ a[data-lesson-key][data-locked='true']:hover {
95
+ color: var(--content-tertiary);
96
+ }
97
+
98
+ a[data-lesson-key][data-locked='true'] [data-lock-badge] {
99
+ display: inline-flex;
100
+ }
101
+
102
+ /* Free-reading mode: the progress-mode-init inline script stamps
103
+ `data-progress-disabled="true"` on <html> synchronously (before first paint),
104
+ so these rules hide every progress-gamification widget without a flash. We key
105
+ on the stable `data-progress-*` hooks rather than hashed CSS-module class names.
106
+
107
+ Scoping caveats (verified against the real components):
108
+ - The header course bar is `[data-progress-scope][role='progressbar']`. We must
109
+ NOT hide bare `[role='progressbar']` — ReadingProgress (scroll position) reuses
110
+ that role and stays visible in free-reading mode.
111
+ - Standalone stats cards are `aside[data-progress-scope]` (HomePage hero aside,
112
+ ModulePage side card). We must NOT hide bare `[data-progress-scope]` — HomePage
113
+ module rows are `<a data-progress-scope="module">` navigation and must stay.
114
+ - The per-module progress meter inside those navigation rows is wrapped in
115
+ `[data-module-progress]`, so we hide that whole block (status label, count,
116
+ and bar together) while the row itself stays navigable. Hiding only the
117
+ granular count/bar slots would leave the bare "/N" text, the "not started"
118
+ label, and the empty bar track visible.
119
+ - The granular `[data-progress-count]`/`[data-progress-pct]`/`[data-progress-bar]`
120
+ slots are still hidden directly for progress widgets that live outside a
121
+ module row (e.g. the header bar).
122
+ - The ProgramDrawer per-module done/total pill is `[data-module-badge]`. It is
123
+ a React-rendered "N/M" counter (not a paint-script slot), so it needs its own
124
+ hook to stay hidden in free-reading mode.
125
+ - CTA frontier rows are left alone: without gate-paint they keep their SSR
126
+ `data-cta-state="not-started"` baseline, so only the plain "start" variant shows
127
+ (the "continue/reread" progress variants stay hidden) and the Program button stays.
128
+ - The locked interstitial needs no rule here — it's already hidden by the gate CSS
129
+ above once `data-lesson-locked` is cleared. */
130
+ html[data-progress-disabled='true'] [data-progress-scope][role='progressbar'],
131
+ html[data-progress-disabled='true'] aside[data-progress-scope],
132
+ html[data-progress-disabled='true'] [data-module-progress],
133
+ html[data-progress-disabled='true'] [data-frontier-hint],
134
+ html[data-progress-disabled='true'] [data-progress-count],
135
+ html[data-progress-disabled='true'] [data-progress-pct],
136
+ html[data-progress-disabled='true'] [data-progress-bar],
137
+ html[data-progress-disabled='true'] [data-module-badge] {
138
+ display: none;
139
+ }