@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.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 +0 -0
- package/assets/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 +0 -0
- package/package.json +92 -0
- package/scripts/check-course-coverage.mts +32 -0
- package/scripts/fix-static-image-extensions.mjs +78 -0
- package/scripts/generate-readme-toc.mts +32 -0
- package/scripts/resolve-course-paths.mjs +28 -0
- package/scripts/sync-images.mjs +88 -0
- package/src/components/AppShell/AppShell.module.css +40 -0
- package/src/components/AppShell/AppShell.tsx +135 -0
- package/src/components/AppShell/index.ts +1 -0
- package/src/components/Callout/Callout.module.css +68 -0
- package/src/components/Callout/Callout.tsx +83 -0
- package/src/components/Callout/index.ts +1 -0
- package/src/components/CodeBlock/CodeBlock.module.css +68 -0
- package/src/components/CodeBlock/CodeBlock.tsx +65 -0
- package/src/components/CodeBlock/index.ts +1 -0
- package/src/components/GateProvider/GateProvider.tsx +207 -0
- package/src/components/GateProvider/index.ts +1 -0
- package/src/components/Header/Breadcrumbs.tsx +50 -0
- package/src/components/Header/Header.module.css +131 -0
- package/src/components/Header/Header.tsx +26 -0
- package/src/components/Header/HeaderLessonNav.tsx +118 -0
- package/src/components/Header/index.ts +1 -0
- package/src/components/HomePage/HomePage.module.css +538 -0
- package/src/components/HomePage/HomePage.tsx +295 -0
- package/src/components/HomePage/index.ts +1 -0
- package/src/components/LessonAwareLink/LessonAwareLink.module.css +12 -0
- package/src/components/LessonAwareLink/LessonAwareLink.tsx +86 -0
- package/src/components/LessonAwareLink/index.ts +1 -0
- package/src/components/LessonLayout/LessonLayout.module.css +35 -0
- package/src/components/LessonLayout/LessonLayout.tsx +18 -0
- package/src/components/LessonLayout/index.ts +1 -0
- package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.module.css +367 -0
- package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.tsx +256 -0
- package/src/components/LessonLockedInterstitial/index.ts +1 -0
- package/src/components/LessonNav/LessonNav.module.css +84 -0
- package/src/components/LessonNav/LessonNav.tsx +64 -0
- package/src/components/LessonNav/index.ts +1 -0
- package/src/components/LessonPageLayout/LessonPageLayout.module.css +118 -0
- package/src/components/LessonPageLayout/LessonPageLayout.tsx +46 -0
- package/src/components/LessonPageLayout/index.ts +1 -0
- package/src/components/LessonSideMeta/LessonSideMeta.module.css +68 -0
- package/src/components/LessonSideMeta/LessonSideMeta.tsx +87 -0
- package/src/components/LessonSideMeta/index.ts +1 -0
- package/src/components/ModulePage/ModulePage.module.css +693 -0
- package/src/components/ModulePage/ModulePage.tsx +301 -0
- package/src/components/ModulePage/index.ts +1 -0
- package/src/components/ProgramDrawer/LockIcon.tsx +19 -0
- package/src/components/ProgramDrawer/ProgramDrawer.module.css +563 -0
- package/src/components/ProgramDrawer/ProgramDrawer.tsx +481 -0
- package/src/components/ProgramDrawer/index.ts +1 -0
- package/src/components/ProgressBar/ProgressBar.module.css +46 -0
- package/src/components/ProgressBar/ProgressBar.tsx +45 -0
- package/src/components/ProgressBar/index.ts +1 -0
- package/src/components/ProgressModeProvider/ProgressModeProvider.tsx +87 -0
- package/src/components/ProgressModeProvider/index.ts +1 -0
- package/src/components/ReadingPrefsProvider/ReadingPrefsProvider.tsx +100 -0
- package/src/components/ReadingPrefsProvider/index.ts +1 -0
- package/src/components/ReadingProgress/ReadingProgress.module.css +19 -0
- package/src/components/ReadingProgress/ReadingProgress.tsx +53 -0
- package/src/components/ReadingProgress/index.ts +1 -0
- package/src/components/SettingsToggle/SettingsToggle.module.css +888 -0
- package/src/components/SettingsToggle/SettingsToggle.tsx +688 -0
- package/src/components/SettingsToggle/index.ts +1 -0
- package/src/components/Sidebar/Sidebar.module.css +157 -0
- package/src/components/Sidebar/Sidebar.tsx +63 -0
- package/src/components/Sidebar/icons/GitHubIcon.tsx +17 -0
- package/src/components/Sidebar/icons/HomeIcon.tsx +22 -0
- package/src/components/Sidebar/icons/LanguageIcon.tsx +24 -0
- package/src/components/Sidebar/icons/ProgramIcon.tsx +23 -0
- package/src/components/Sidebar/icons/SettingsIcon.tsx +26 -0
- package/src/components/Sidebar/icons/ThemeIcon.tsx +22 -0
- package/src/components/Sidebar/icons/index.ts +6 -0
- package/src/components/Sidebar/index.ts +1 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +68 -0
- package/src/components/ThemeProvider/index.ts +1 -0
- package/src/components/Toc/Toc.module.css +78 -0
- package/src/components/Toc/Toc.tsx +92 -0
- package/src/components/Toc/index.ts +1 -0
- package/src/components/TranslationBanner/TranslationBanner.module.css +32 -0
- package/src/components/TranslationBanner/TranslationBanner.tsx +40 -0
- package/src/components/TranslationBanner/index.ts +1 -0
- package/src/config.d.mts +12 -0
- package/src/config.mjs +110 -0
- package/src/index.ts +62 -0
- package/src/layout/lang.tsx +44 -0
- package/src/layout/root.tsx +223 -0
- package/src/lib/course-loader.ts +33 -0
- package/src/lib/course.ts +429 -0
- package/src/lib/coverage.ts +141 -0
- package/src/lib/description.ts +43 -0
- package/src/lib/extract-toc.ts +59 -0
- package/src/lib/format.ts +55 -0
- package/src/lib/frontier-link.ts +37 -0
- package/src/lib/gate-init-script.ts +40 -0
- package/src/lib/gate-mark-script.ts +324 -0
- package/src/lib/i18n.ts +474 -0
- package/src/lib/lang.ts +90 -0
- package/src/lib/lesson-gate.ts +79 -0
- package/src/lib/lesson.ts +66 -0
- package/src/lib/markdown-components.tsx +51 -0
- package/src/lib/markdown.ts +180 -0
- package/src/lib/mdx-plugins/rehype-callout.ts +80 -0
- package/src/lib/mdx-plugins/remark-lesson-images.ts +109 -0
- package/src/lib/mdx-plugins/remark-link-rewrite.ts +231 -0
- package/src/lib/paths.ts +36 -0
- package/src/lib/program-drawer.ts +8 -0
- package/src/lib/progress-mode.ts +69 -0
- package/src/lib/progress.ts +182 -0
- package/src/lib/reading-prefs.ts +127 -0
- package/src/lib/readme-toc.ts +69 -0
- package/src/lib/site-url.ts +33 -0
- package/src/lib/sitemap.ts +112 -0
- package/src/lib/slug.ts +15 -0
- package/src/lib/theme.ts +78 -0
- package/src/lib/use-i18n.ts +25 -0
- package/src/og/icon.tsx +40 -0
- package/src/og/opengraph-image.tsx +126 -0
- package/src/pages/home.tsx +66 -0
- package/src/pages/lesson.tsx +260 -0
- package/src/pages/module.tsx +80 -0
- package/src/pages/not-found-lang.tsx +51 -0
- package/src/pages/not-found-root.tsx +48 -0
- package/src/pages/root.tsx +44 -0
- package/src/seo/robots.ts +16 -0
- package/src/seo/sitemap.ts +10 -0
- package/src/styles/globals.css +139 -0
- package/src/styles/markdown.css +265 -0
- package/src/styles/reset.css +89 -0
- 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
|
+
}
|