@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,69 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Course, Module, Lesson } from './course';
|
|
4
|
+
import { type Lang } from './lang';
|
|
5
|
+
|
|
6
|
+
export interface GenerateTocOptions {
|
|
7
|
+
/** Active language. EN tries to link to `i18n/en/README.md` and falls back to RU with a marker. */
|
|
8
|
+
lang: Lang;
|
|
9
|
+
/** Path prefix used in markdown link targets (relative to the README that will embed the TOC). Default: 'lectures'. */
|
|
10
|
+
linkPrefix?: string;
|
|
11
|
+
/** Filesystem root used to probe which translations exist. Default: same as linkPrefix. */
|
|
12
|
+
lecturesRoot?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RU_ONLY_MARKER = ' *(RU only)*';
|
|
16
|
+
|
|
17
|
+
export function generateReadmeToc(course: Course, options: GenerateTocOptions): string {
|
|
18
|
+
const linkPrefix = options.linkPrefix ?? 'lectures';
|
|
19
|
+
const fsRoot = options.lecturesRoot ?? linkPrefix;
|
|
20
|
+
const sections = course.modules.map((mod) =>
|
|
21
|
+
renderModule(mod, options.lang, linkPrefix, fsRoot),
|
|
22
|
+
);
|
|
23
|
+
return sections.join('\n\n').replace(/\s+$/, '') + '\n';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderModule(mod: Module, lang: Lang, linkPrefix: string, fsRoot: string): string {
|
|
27
|
+
const moduleNumber = leadingNumber(mod.id);
|
|
28
|
+
const heading = `### ${moduleNumber} — ${mod.title}`;
|
|
29
|
+
const description = mod.description.trim();
|
|
30
|
+
const bullets = mod.lessons
|
|
31
|
+
.map((lesson) => renderLesson(mod.id, moduleNumber, lesson, lang, linkPrefix, fsRoot))
|
|
32
|
+
.join('\n');
|
|
33
|
+
return [heading, '', description, '', bullets].join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderLesson(
|
|
37
|
+
moduleId: string,
|
|
38
|
+
moduleNumber: string,
|
|
39
|
+
lesson: Lesson,
|
|
40
|
+
lang: Lang,
|
|
41
|
+
linkPrefix: string,
|
|
42
|
+
fsRoot: string,
|
|
43
|
+
): string {
|
|
44
|
+
const enFile = path.join(fsRoot, moduleId, lesson.slug, 'i18n', 'en', 'README.md');
|
|
45
|
+
const hasEn = existsSync(enFile);
|
|
46
|
+
const targetLang: Lang = lang === 'en' && !hasEn ? 'ru' : lang;
|
|
47
|
+
const linkTarget = `${linkPrefix}/${moduleId}/${lesson.slug}/i18n/${targetLang}/README.md`;
|
|
48
|
+
const label = buildLessonLabel(moduleNumber, lesson);
|
|
49
|
+
const marker = lang === 'en' && !hasEn ? RU_ONLY_MARKER : '';
|
|
50
|
+
return `- [${label}](${linkTarget})${marker}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildLessonLabel(moduleNumber: string, lesson: Lesson): string {
|
|
54
|
+
const lessonNum = leadingLessonNumber(lesson.slug);
|
|
55
|
+
if (!lessonNum) return lesson.title;
|
|
56
|
+
return `${moduleNumber}-${lessonNum} — ${lesson.title}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function leadingNumber(id: string): string {
|
|
60
|
+
const m = id.match(/^(\d{2})/);
|
|
61
|
+
return m ? m[1] : id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function leadingLessonNumber(slug: string): string | null {
|
|
65
|
+
const nested = slug.match(/^\d{2}-(\d{2})-/);
|
|
66
|
+
if (nested) return nested[1];
|
|
67
|
+
const flat = slug.match(/^(\d{2})-/);
|
|
68
|
+
return flat ? flat[1] : null;
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const DEFAULT_SITE_URL = 'https://dsbasko.github.io';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the basePath that matches Next.js routing in the current runtime.
|
|
5
|
+
* In production (and any non-development NODE_ENV) this is the configured
|
|
6
|
+
* `course.basePath` (e.g. `/kafka-cookbook`). In dev `next.config.mjs`
|
|
7
|
+
* disables basePath, so internal links and assets must point at the root —
|
|
8
|
+
* otherwise rewritten markdown URLs 404 against the dev server.
|
|
9
|
+
*/
|
|
10
|
+
export function getRuntimeBasePath(coursePath: string): string {
|
|
11
|
+
return process.env.NODE_ENV === 'development' ? '' : coursePath;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getSiteUrl(): string {
|
|
15
|
+
const raw = process.env.NEXT_PUBLIC_SITE_URL;
|
|
16
|
+
const value = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_SITE_URL;
|
|
17
|
+
return value.replace(/\/+$/, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildSiteUrl(basePath: string, segments: string[] = []): string {
|
|
21
|
+
const root = getSiteUrl();
|
|
22
|
+
const normalizedBase = basePath.replace(/\/+$/, '');
|
|
23
|
+
const path = segments.filter(Boolean).join('/');
|
|
24
|
+
const tail = path.length > 0 ? `/${path}/` : '/';
|
|
25
|
+
return `${root}${normalizedBase}${tail}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildAssetUrl(basePath: string, asset: string): string {
|
|
29
|
+
const root = getSiteUrl();
|
|
30
|
+
const normalizedBase = basePath.replace(/\/+$/, '');
|
|
31
|
+
const normalizedAsset = asset.startsWith('/') ? asset : `/${asset}`;
|
|
32
|
+
return `${root}${normalizedBase}${normalizedAsset}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
import { flattenLessons, type Course } from './course';
|
|
3
|
+
import { DEFAULT_LANG, LANGS } from './lang';
|
|
4
|
+
import { buildSiteUrl } from './site-url';
|
|
5
|
+
|
|
6
|
+
export interface BuildSitemapOptions {
|
|
7
|
+
now?: Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type SitemapEntry = MetadataRoute.Sitemap[number];
|
|
11
|
+
type LanguageMap = NonNullable<NonNullable<SitemapEntry['alternates']>['languages']>;
|
|
12
|
+
|
|
13
|
+
// `lesson.hasTranslation` must reflect the EN README (load the course via
|
|
14
|
+
// `loadCourse('en')`). RU READMEs are guaranteed by the i18n migration, so
|
|
15
|
+
// every RU URL is always emitted. Missing EN lesson pages still render with
|
|
16
|
+
// noindex metadata — they just stay out of the sitemap.
|
|
17
|
+
export function buildSitemap(
|
|
18
|
+
course: Course,
|
|
19
|
+
options: BuildSitemapOptions = {},
|
|
20
|
+
): MetadataRoute.Sitemap {
|
|
21
|
+
const now = options.now ?? new Date();
|
|
22
|
+
const entries: MetadataRoute.Sitemap = [];
|
|
23
|
+
|
|
24
|
+
const homeLangs = buildLangMap(course.basePath, []);
|
|
25
|
+
for (const lang of LANGS) {
|
|
26
|
+
entries.push({
|
|
27
|
+
url: buildSiteUrl(course.basePath, [lang]),
|
|
28
|
+
lastModified: now,
|
|
29
|
+
changeFrequency: 'weekly',
|
|
30
|
+
priority: 1,
|
|
31
|
+
alternates: { languages: { ...homeLangs } },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const mod of course.modules) {
|
|
36
|
+
const moduleLangs = buildLangMap(course.basePath, [mod.id]);
|
|
37
|
+
for (const lang of LANGS) {
|
|
38
|
+
entries.push({
|
|
39
|
+
url: buildSiteUrl(course.basePath, [lang, mod.id]),
|
|
40
|
+
lastModified: now,
|
|
41
|
+
changeFrequency: 'monthly',
|
|
42
|
+
priority: 0.8,
|
|
43
|
+
alternates: { languages: { ...moduleLangs } },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const entry of flattenLessons(course)) {
|
|
49
|
+
const lessonLangs = buildLessonLangMap(
|
|
50
|
+
course.basePath,
|
|
51
|
+
entry.moduleId,
|
|
52
|
+
entry.lesson.slug,
|
|
53
|
+
entry.lesson.hasTranslation,
|
|
54
|
+
);
|
|
55
|
+
const ruUrl = buildSiteUrl(course.basePath, ['ru', entry.moduleId, entry.lesson.slug]);
|
|
56
|
+
const enUrl = buildSiteUrl(course.basePath, ['en', entry.moduleId, entry.lesson.slug]);
|
|
57
|
+
|
|
58
|
+
entries.push({
|
|
59
|
+
url: ruUrl,
|
|
60
|
+
lastModified: now,
|
|
61
|
+
changeFrequency: 'monthly',
|
|
62
|
+
priority: 0.6,
|
|
63
|
+
alternates: { languages: { ...lessonLangs } },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (entry.lesson.hasTranslation) {
|
|
67
|
+
entries.push({
|
|
68
|
+
url: enUrl,
|
|
69
|
+
lastModified: now,
|
|
70
|
+
changeFrequency: 'monthly',
|
|
71
|
+
priority: 0.6,
|
|
72
|
+
alternates: { languages: { ...lessonLangs } },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build the hreflang `languages` map for a "simple" route (home `/[lang]/`
|
|
81
|
+
// or module `/[lang]/[module]/`) — both language URLs always exist there, so
|
|
82
|
+
// `x-default` points at the DEFAULT_LANG variant.
|
|
83
|
+
export function buildLangMap(basePath: string, tail: string[]): LanguageMap {
|
|
84
|
+
const map: LanguageMap = {};
|
|
85
|
+
for (const lang of LANGS) {
|
|
86
|
+
map[lang] = buildSiteUrl(basePath, [lang, ...tail]);
|
|
87
|
+
}
|
|
88
|
+
map['x-default'] = buildSiteUrl(basePath, [DEFAULT_LANG, ...tail]);
|
|
89
|
+
return map;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build the hreflang `languages` map for a lesson route. When EN README is
|
|
93
|
+
// missing the EN page renders a noindex fallback (RU content + banner); we
|
|
94
|
+
// match `buildSitemap`'s rule and omit it from `languages` so crawlers don't
|
|
95
|
+
// see a non-reciprocal alternate.
|
|
96
|
+
export function buildLessonLangMap(
|
|
97
|
+
basePath: string,
|
|
98
|
+
moduleId: string,
|
|
99
|
+
slug: string,
|
|
100
|
+
hasEn: boolean,
|
|
101
|
+
): LanguageMap {
|
|
102
|
+
const ruUrl = buildSiteUrl(basePath, ['ru', moduleId, slug]);
|
|
103
|
+
const map: LanguageMap = { ru: ruUrl };
|
|
104
|
+
if (hasEn) {
|
|
105
|
+
const enUrl = buildSiteUrl(basePath, ['en', moduleId, slug]);
|
|
106
|
+
map.en = enUrl;
|
|
107
|
+
map['x-default'] = enUrl;
|
|
108
|
+
} else {
|
|
109
|
+
map['x-default'] = ruUrl;
|
|
110
|
+
}
|
|
111
|
+
return map;
|
|
112
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2
|
+
|
|
3
|
+
export function normalizeSlug(input: string): string {
|
|
4
|
+
return input
|
|
5
|
+
.trim()
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[_\s]+/g, '-')
|
|
8
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
9
|
+
.replace(/-+/g, '-')
|
|
10
|
+
.replace(/^-+|-+$/g, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isValidSlug(input: string): boolean {
|
|
14
|
+
return typeof input === 'string' && input.length > 0 && SLUG_PATTERN.test(input);
|
|
15
|
+
}
|
package/src/lib/theme.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type ThemePreference = 'light' | 'dark' | 'paper';
|
|
2
|
+
export type ResolvedTheme = ThemePreference;
|
|
3
|
+
|
|
4
|
+
export const THEME_STORAGE_KEY = 'kafka-cookbook-theme';
|
|
5
|
+
export const THEME_PREFERENCES = ['light', 'paper', 'dark'] as const;
|
|
6
|
+
|
|
7
|
+
export function isThemePreference(value: unknown): value is ThemePreference {
|
|
8
|
+
return value === 'light' || value === 'dark' || value === 'paper';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getSystemTheme(): 'light' | 'dark' {
|
|
12
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
13
|
+
return 'light';
|
|
14
|
+
}
|
|
15
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveTheme(preference: ThemePreference): ResolvedTheme {
|
|
19
|
+
return preference;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the stored preference or — when nothing valid is stored —
|
|
24
|
+
* silently picks light/dark from `prefers-color-scheme`. This preserves the
|
|
25
|
+
* old "system" auto-detect for first-time visitors and for users migrating
|
|
26
|
+
* from previously-stored `'system'` without exposing it as a UI choice.
|
|
27
|
+
*/
|
|
28
|
+
export function readStoredPreference(): ThemePreference {
|
|
29
|
+
if (typeof window === 'undefined') return 'light';
|
|
30
|
+
try {
|
|
31
|
+
const raw = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
32
|
+
if (isThemePreference(raw)) return raw;
|
|
33
|
+
return getSystemTheme();
|
|
34
|
+
} catch {
|
|
35
|
+
return 'light';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeStoredPreference(preference: ThemePreference): void {
|
|
40
|
+
if (typeof window === 'undefined') return;
|
|
41
|
+
try {
|
|
42
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, preference);
|
|
43
|
+
} catch {
|
|
44
|
+
/* storage may be unavailable (private mode, quota); ignore. */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function applyResolvedTheme(theme: ResolvedTheme): void {
|
|
49
|
+
if (typeof document === 'undefined') return;
|
|
50
|
+
document.documentElement.dataset.theme = theme;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applyPreference(preference: ThemePreference): ResolvedTheme {
|
|
54
|
+
const resolved = resolveTheme(preference);
|
|
55
|
+
applyResolvedTheme(resolved);
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Inline script string injected into <head> before hydration.
|
|
61
|
+
* Reads stored preference (or system query) and sets `data-theme` on <html>
|
|
62
|
+
* synchronously, eliminating FOUC.
|
|
63
|
+
*/
|
|
64
|
+
export const THEME_INIT_SCRIPT = `(() => {
|
|
65
|
+
try {
|
|
66
|
+
var key = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
67
|
+
var stored = null;
|
|
68
|
+
try { stored = window.localStorage.getItem(key); } catch (_) {}
|
|
69
|
+
var resolved = (stored === 'light' || stored === 'dark' || stored === 'paper') ? stored : null;
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
72
|
+
resolved = mq && mq.matches ? 'dark' : 'light';
|
|
73
|
+
}
|
|
74
|
+
document.documentElement.dataset.theme = resolved;
|
|
75
|
+
} catch (_) {
|
|
76
|
+
document.documentElement.dataset.theme = 'light';
|
|
77
|
+
}
|
|
78
|
+
})();`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useParams } from 'next/navigation';
|
|
4
|
+
import { DEFAULT_LANG, isLang, type Lang } from './lang';
|
|
5
|
+
import { getDict, type UIDict } from './i18n';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads the active language from the `[lang]` segment of the URL. Falls
|
|
9
|
+
* back to DEFAULT_LANG if the param is missing or invalid — happens on
|
|
10
|
+
* the root layout before the language-prefixed route mounts.
|
|
11
|
+
*/
|
|
12
|
+
export function useLang(): Lang {
|
|
13
|
+
const params = useParams();
|
|
14
|
+
const raw = params && (params as Record<string, string | string[] | undefined>).lang;
|
|
15
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
16
|
+
return isLang(value) ? value : DEFAULT_LANG;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Client-side translation accessor. Returns the full dictionary for the
|
|
21
|
+
* active language so component code reads `t.something` directly.
|
|
22
|
+
*/
|
|
23
|
+
export function useT(): UIDict {
|
|
24
|
+
return getDict(useLang());
|
|
25
|
+
}
|
package/src/og/icon.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ImageResponse } from 'next/og';
|
|
2
|
+
import { resolveBrandGlyph } from '@/lib/course';
|
|
3
|
+
import { loadCourse } from '@/lib/course-loader';
|
|
4
|
+
import { DEFAULT_LANG } from '@/lib/lang';
|
|
5
|
+
|
|
6
|
+
export const size = { width: 32, height: 32 };
|
|
7
|
+
export const contentType = 'image/png';
|
|
8
|
+
|
|
9
|
+
export function generateStaticParams() {
|
|
10
|
+
return [{ __metadata_id__: [] }];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function Icon() {
|
|
14
|
+
const glyph = resolveBrandGlyph(loadCourse(DEFAULT_LANG));
|
|
15
|
+
return new ImageResponse(
|
|
16
|
+
(
|
|
17
|
+
<div
|
|
18
|
+
style={{
|
|
19
|
+
width: '100%',
|
|
20
|
+
height: '100%',
|
|
21
|
+
display: 'flex',
|
|
22
|
+
alignItems: 'center',
|
|
23
|
+
justifyContent: 'center',
|
|
24
|
+
background: '#1a1a1a',
|
|
25
|
+
color: '#faf7f2',
|
|
26
|
+
fontSize: 22,
|
|
27
|
+
fontWeight: 700,
|
|
28
|
+
fontFamily: 'sans-serif',
|
|
29
|
+
letterSpacing: -1,
|
|
30
|
+
borderRadius: 6,
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{glyph}
|
|
34
|
+
</div>
|
|
35
|
+
),
|
|
36
|
+
{
|
|
37
|
+
...size,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ImageResponse } from 'next/og';
|
|
2
|
+
import {
|
|
3
|
+
getTotalLessons,
|
|
4
|
+
resolveBrandGlyph,
|
|
5
|
+
resolveBrandName,
|
|
6
|
+
resolveOgFooterTag,
|
|
7
|
+
} from '@/lib/course';
|
|
8
|
+
import { loadCourse } from '@/lib/course-loader';
|
|
9
|
+
import { formatLessonCount, formatModuleCount } from '@/lib/format';
|
|
10
|
+
import { getDict } from '@/lib/i18n';
|
|
11
|
+
import { DEFAULT_LANG } from '@/lib/lang';
|
|
12
|
+
|
|
13
|
+
export const alt =
|
|
14
|
+
loadCourse(DEFAULT_LANG).brand?.ogImage?.alt ?? getDict(DEFAULT_LANG).ogImageAlt;
|
|
15
|
+
export const size = { width: 1200, height: 630 };
|
|
16
|
+
export const contentType = 'image/png';
|
|
17
|
+
|
|
18
|
+
export function generateStaticParams() {
|
|
19
|
+
return [{ __metadata_id__: [] }];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function OpengraphImage() {
|
|
23
|
+
const course = loadCourse(DEFAULT_LANG);
|
|
24
|
+
const description = course.description.replace(/\s+/g, ' ').trim();
|
|
25
|
+
const truncated =
|
|
26
|
+
description.length > 180 ? `${description.slice(0, 179).trimEnd()}…` : description;
|
|
27
|
+
const moduleCount = course.modules.length;
|
|
28
|
+
const lessonCount = getTotalLessons(course);
|
|
29
|
+
const stats = `${formatModuleCount(moduleCount, DEFAULT_LANG)} · ${formatLessonCount(lessonCount, DEFAULT_LANG)}`;
|
|
30
|
+
const glyph = resolveBrandGlyph(course);
|
|
31
|
+
const wordmark = resolveBrandName(course);
|
|
32
|
+
const title = course.brand?.ogImage?.title ?? course.title;
|
|
33
|
+
const subtitle = course.brand?.ogImage?.subtitle ?? truncated;
|
|
34
|
+
const footerTag = resolveOgFooterTag(course);
|
|
35
|
+
|
|
36
|
+
return new ImageResponse(
|
|
37
|
+
(
|
|
38
|
+
<div
|
|
39
|
+
style={{
|
|
40
|
+
width: '100%',
|
|
41
|
+
height: '100%',
|
|
42
|
+
display: 'flex',
|
|
43
|
+
flexDirection: 'column',
|
|
44
|
+
justifyContent: 'space-between',
|
|
45
|
+
padding: '72px 80px',
|
|
46
|
+
background: 'linear-gradient(135deg, #faf7f2 0%, #f3efe7 100%)',
|
|
47
|
+
fontFamily: 'sans-serif',
|
|
48
|
+
color: '#1a1a1a',
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
|
|
52
|
+
<div
|
|
53
|
+
style={{
|
|
54
|
+
width: 96,
|
|
55
|
+
height: 96,
|
|
56
|
+
borderRadius: 48,
|
|
57
|
+
background: '#1a1a1a',
|
|
58
|
+
display: 'flex',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
justifyContent: 'center',
|
|
61
|
+
color: '#faf7f2',
|
|
62
|
+
fontSize: 60,
|
|
63
|
+
fontWeight: 700,
|
|
64
|
+
letterSpacing: -2,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{glyph}
|
|
68
|
+
</div>
|
|
69
|
+
<span
|
|
70
|
+
style={{
|
|
71
|
+
fontSize: 32,
|
|
72
|
+
fontWeight: 500,
|
|
73
|
+
color: '#5b5750',
|
|
74
|
+
letterSpacing: -0.5,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{wordmark}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
|
82
|
+
<div
|
|
83
|
+
style={{
|
|
84
|
+
fontSize: 96,
|
|
85
|
+
fontWeight: 700,
|
|
86
|
+
lineHeight: 1.05,
|
|
87
|
+
letterSpacing: -3,
|
|
88
|
+
color: '#1a1a1a',
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
{title}
|
|
92
|
+
</div>
|
|
93
|
+
<div
|
|
94
|
+
style={{
|
|
95
|
+
fontSize: 32,
|
|
96
|
+
fontWeight: 400,
|
|
97
|
+
lineHeight: 1.35,
|
|
98
|
+
color: '#5b5750',
|
|
99
|
+
maxWidth: 980,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{subtitle}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
display: 'flex',
|
|
109
|
+
justifyContent: 'space-between',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
paddingTop: 32,
|
|
112
|
+
borderTop: '2px solid #e6e0d3',
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<span style={{ fontSize: 28, color: '#8a857b' }}>{stats}</span>
|
|
116
|
+
<span style={{ fontSize: 28, color: '#2a6fdb', fontWeight: 600 }}>
|
|
117
|
+
{footerTag}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
),
|
|
122
|
+
{
|
|
123
|
+
...size,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { HomePage } from '@/components/HomePage';
|
|
4
|
+
import { resolveBrandLevel } 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 LangPageProps = {
|
|
12
|
+
params: { lang: string };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function generateStaticParams(): Array<{ lang: Lang }> {
|
|
16
|
+
return LANGS.map((lang) => ({ lang }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function generateMetadata({ params }: LangPageProps): Metadata {
|
|
20
|
+
if (!isLang(params.lang)) return {};
|
|
21
|
+
// Root layout hardcodes DEFAULT_LANG values for canonical/og:url/og:locale/
|
|
22
|
+
// description; without an override the /ru/ home would point crawlers at
|
|
23
|
+
// /en/ as canonical and ship an EN description. Build a per-lang block
|
|
24
|
+
// that matches the actual route.
|
|
25
|
+
const lang = params.lang as Lang;
|
|
26
|
+
const t = getDict(lang);
|
|
27
|
+
const course = loadCourse(lang);
|
|
28
|
+
const description = course.description.replace(/\s+/g, ' ').trim();
|
|
29
|
+
const url = buildSiteUrl(course.basePath, [lang]);
|
|
30
|
+
const ogImage = {
|
|
31
|
+
url: buildAssetUrl(course.basePath, '/opengraph-image'),
|
|
32
|
+
width: 1200,
|
|
33
|
+
height: 630,
|
|
34
|
+
alt: course.brand?.ogImage?.alt ?? t.ogImageAlt,
|
|
35
|
+
};
|
|
36
|
+
// Next.js' metadata resolution overrides `alternates` wholesale rather than
|
|
37
|
+
// merging — without re-specifying `languages` here, the root layout's
|
|
38
|
+
// hreflang map is dropped from /ru/ and /en/ pages.
|
|
39
|
+
const languages = buildLangMap(course.basePath, []);
|
|
40
|
+
return {
|
|
41
|
+
title: course.title,
|
|
42
|
+
description,
|
|
43
|
+
alternates: { canonical: url, languages },
|
|
44
|
+
openGraph: {
|
|
45
|
+
type: 'website',
|
|
46
|
+
siteName: course.title,
|
|
47
|
+
title: course.title,
|
|
48
|
+
description,
|
|
49
|
+
url,
|
|
50
|
+
locale: lang === 'ru' ? 'ru_RU' : 'en_US',
|
|
51
|
+
images: [ogImage],
|
|
52
|
+
},
|
|
53
|
+
twitter: {
|
|
54
|
+
card: 'summary_large_image',
|
|
55
|
+
title: course.title,
|
|
56
|
+
description,
|
|
57
|
+
images: [ogImage.url],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function Page({ params }: LangPageProps) {
|
|
63
|
+
if (!isLang(params.lang)) notFound();
|
|
64
|
+
const course = loadCourse(params.lang as Lang);
|
|
65
|
+
return <HomePage course={course} level={resolveBrandLevel(course)} />;
|
|
66
|
+
}
|