@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,135 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import { Sidebar } from '@/components/Sidebar';
6
+ import { Header } from '@/components/Header';
7
+ import { Breadcrumbs } from '@/components/Header/Breadcrumbs';
8
+ import { HeaderLessonNav } from '@/components/Header/HeaderLessonNav';
9
+ import { ProgramDrawer } from '@/components/ProgramDrawer';
10
+ import { ProgressBar } from '@/components/ProgressBar';
11
+ import {
12
+ type Course,
13
+ getNextLesson,
14
+ getPrevLesson,
15
+ getTotalLessons,
16
+ resolveBrandName,
17
+ } from '@/lib/course';
18
+ import { DEFAULT_LANG, stripLangFromPath } from '@/lib/lang';
19
+ import { OPEN_PROGRAM_EVENT } from '@/lib/program-drawer';
20
+ import styles from './AppShell.module.css';
21
+
22
+ type AppShellProps = {
23
+ children: ReactNode;
24
+ course: Course;
25
+ };
26
+
27
+ export function AppShell({ children, course }: AppShellProps) {
28
+ const pathname = usePathname() ?? '/';
29
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
30
+
31
+ // Allow descendant components (Hero CTA on the home page) to ask for the
32
+ // program drawer without lifting state up — the AppShell stays the single
33
+ // owner of drawer state.
34
+ useEffect(() => {
35
+ const handler = () => setIsDrawerOpen(true);
36
+ window.addEventListener(OPEN_PROGRAM_EVENT, handler);
37
+ return () => window.removeEventListener(OPEN_PROGRAM_EVENT, handler);
38
+ }, []);
39
+
40
+ // After the i18n restructure every route lives under `/<lang>/...`.
41
+ // Strip the lang segment so module/slug detection sees the same shape
42
+ // it did pre-i18n; the AppShell stays language-agnostic for routing
43
+ // but forwards the active lang to the server-rendered Header (which
44
+ // needs it for aria-labels via getDict).
45
+ const { lang: parsedLang, rest } = stripLangFromPath(pathname);
46
+ const lang = parsedLang ?? DEFAULT_LANG;
47
+ const segments = rest.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
48
+ const moduleId = segments[0];
49
+ const lessonSlug = segments[1];
50
+
51
+ const currentModule = moduleId
52
+ ? course.modules.find((m) => m.id === moduleId)
53
+ : undefined;
54
+ const currentLesson =
55
+ currentModule && lessonSlug
56
+ ? currentModule.lessons.find((l) => l.slug === lessonSlug)
57
+ : undefined;
58
+
59
+ const prev =
60
+ currentModule && currentLesson
61
+ ? getPrevLesson(course, currentModule.id, currentLesson.slug)
62
+ : null;
63
+ const next =
64
+ currentModule && currentLesson
65
+ ? getNextLesson(course, currentModule.id, currentLesson.slug)
66
+ : null;
67
+
68
+ const brandRoot = resolveBrandName(course);
69
+
70
+ const breadcrumbs = (
71
+ <Breadcrumbs
72
+ lang={lang}
73
+ moduleId={currentModule?.id}
74
+ moduleTitle={currentModule?.title}
75
+ lessonTitle={currentLesson?.title}
76
+ brandRoot={brandRoot}
77
+ />
78
+ );
79
+
80
+ const actions = (
81
+ <>
82
+ <ProgressBar total={getTotalLessons(course)} lang={lang} />
83
+ {currentModule && currentLesson ? (
84
+ <HeaderLessonNav
85
+ prev={prev}
86
+ next={next}
87
+ currentModuleId={currentModule.id}
88
+ currentSlug={currentLesson.slug}
89
+ />
90
+ ) : null}
91
+ </>
92
+ );
93
+
94
+ // Home and module index pages own their own hero (eyebrow + breadcrumbs +
95
+ // progress card) — the global header would duplicate that chrome. Lesson
96
+ // pages keep the header because they need lesson nav + reading progress.
97
+ const isHome = rest === '/' || segments.length === 0;
98
+ const isModuleIndex = !!moduleId && !lessonSlug;
99
+ const hideHeader = isHome || isModuleIndex;
100
+
101
+ return (
102
+ <div className={styles.shell}>
103
+ <Sidebar
104
+ onProgramClick={() => setIsDrawerOpen((prev) => !prev)}
105
+ isProgramOpen={isDrawerOpen}
106
+ repoUrl={course.repoUrl}
107
+ />
108
+ <ProgramDrawer
109
+ course={course}
110
+ currentModuleId={currentModule?.id}
111
+ currentSlug={currentLesson?.slug}
112
+ currentLessonTitle={currentLesson?.title}
113
+ currentLessonIndex={
114
+ currentModule && currentLesson
115
+ ? currentModule.lessons.findIndex((l) => l.slug === currentLesson.slug) + 1
116
+ : undefined
117
+ }
118
+ currentModuleTitle={currentModule?.title}
119
+ isOpen={isDrawerOpen}
120
+ onClose={() => setIsDrawerOpen(false)}
121
+ prev={prev}
122
+ next={next}
123
+ repoUrl={course.repoUrl}
124
+ lang={lang}
125
+ totalLessons={getTotalLessons(course)}
126
+ />
127
+ <div className={styles.body}>
128
+ {hideHeader ? null : (
129
+ <Header lang={lang} breadcrumbs={breadcrumbs} actions={actions} brandRoot={brandRoot} />
130
+ )}
131
+ <main className={styles.main}>{children}</main>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1 @@
1
+ export { AppShell } from './AppShell';
@@ -0,0 +1,68 @@
1
+ .callout {
2
+ margin: var(--space-6) 0;
3
+ padding: var(--space-4) var(--space-5);
4
+ border-left: 3px solid var(--callout-accent, var(--accent-main));
5
+ border-radius: var(--radius-md);
6
+ background-color: var(--callout-bg, var(--accent-main-soft));
7
+ color: var(--content-primary);
8
+ }
9
+
10
+ .callout[data-callout-type='note'] {
11
+ --callout-accent: var(--accent-main);
12
+ --callout-bg: var(--accent-main-soft);
13
+ }
14
+
15
+ .callout[data-callout-type='tip'] {
16
+ --callout-accent: var(--accent-success);
17
+ --callout-bg: var(--accent-success-soft);
18
+ }
19
+
20
+ .callout[data-callout-type='warning'] {
21
+ --callout-accent: var(--accent-notice);
22
+ --callout-bg: var(--accent-notice-soft);
23
+ }
24
+
25
+ .callout[data-callout-type='important'] {
26
+ --callout-accent: var(--accent-main);
27
+ --callout-bg: var(--accent-main-soft);
28
+ }
29
+
30
+ .callout[data-callout-type='caution'] {
31
+ --callout-accent: var(--accent-critical);
32
+ --callout-bg: var(--accent-critical-soft);
33
+ }
34
+
35
+ .header {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: var(--space-2);
39
+ margin-bottom: var(--space-2);
40
+ color: var(--callout-accent);
41
+ font-weight: var(--font-weight-semibold);
42
+ }
43
+
44
+ .icon {
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ .title {
49
+ font-size: var(--font-size-sm);
50
+ text-transform: uppercase;
51
+ letter-spacing: 0.04em;
52
+ }
53
+
54
+ .body {
55
+ color: var(--content-primary);
56
+ }
57
+
58
+ .body > * + * {
59
+ margin-top: var(--space-3);
60
+ }
61
+
62
+ .body > :first-child {
63
+ margin-top: 0;
64
+ }
65
+
66
+ .body > :last-child {
67
+ margin-bottom: 0;
68
+ }
@@ -0,0 +1,83 @@
1
+ import type { ReactNode, SVGProps } from 'react';
2
+ import { DEFAULT_LANG, type Lang } from '@/lib/lang';
3
+ import { getDict } from '@/lib/i18n';
4
+ import styles from './Callout.module.css';
5
+
6
+ export type CalloutType = 'note' | 'tip' | 'warning' | 'important' | 'caution';
7
+
8
+ type CalloutProps = {
9
+ type: CalloutType;
10
+ lang?: Lang;
11
+ children?: ReactNode;
12
+ };
13
+
14
+ export function Callout({ type, lang = DEFAULT_LANG, children }: CalloutProps) {
15
+ const Icon = ICONS[type];
16
+ const t = getDict(lang);
17
+ const titles: Readonly<Record<CalloutType, string>> = {
18
+ note: t.calloutNote,
19
+ tip: t.calloutTip,
20
+ warning: t.calloutWarning,
21
+ important: t.calloutImportant,
22
+ caution: t.calloutCaution,
23
+ };
24
+ return (
25
+ <aside className={styles.callout} data-callout-type={type} role="note">
26
+ <header className={styles.header}>
27
+ <Icon className={styles.icon} aria-hidden="true" />
28
+ <span className={styles.title}>{titles[type]}</span>
29
+ </header>
30
+ <div className={styles.body}>{children}</div>
31
+ </aside>
32
+ );
33
+ }
34
+
35
+ export function isCalloutType(value: unknown): value is CalloutType {
36
+ return (
37
+ typeof value === 'string' &&
38
+ (value === 'note' ||
39
+ value === 'tip' ||
40
+ value === 'warning' ||
41
+ value === 'important' ||
42
+ value === 'caution')
43
+ );
44
+ }
45
+
46
+ type IconComponent = (props: SVGProps<SVGSVGElement>) => JSX.Element;
47
+
48
+ const baseSvg = (path: string): IconComponent => {
49
+ function Icon(props: SVGProps<SVGSVGElement>) {
50
+ return (
51
+ <svg
52
+ viewBox="0 0 16 16"
53
+ width="16"
54
+ height="16"
55
+ fill="currentColor"
56
+ xmlns="http://www.w3.org/2000/svg"
57
+ {...props}
58
+ >
59
+ <path d={path} />
60
+ </svg>
61
+ );
62
+ }
63
+ return Icon;
64
+ };
65
+
66
+ /* GitHub Octicon paths — same set used by remark-github-blockquote-alert. */
67
+ const ICONS: Readonly<Record<CalloutType, IconComponent>> = {
68
+ note: baseSvg(
69
+ 'M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z',
70
+ ),
71
+ tip: baseSvg(
72
+ 'M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z',
73
+ ),
74
+ important: baseSvg(
75
+ 'M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
76
+ ),
77
+ warning: baseSvg(
78
+ 'M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
79
+ ),
80
+ caution: baseSvg(
81
+ 'M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z',
82
+ ),
83
+ };
@@ -0,0 +1 @@
1
+ export { Callout, isCalloutType, type CalloutType } from './Callout';
@@ -0,0 +1,68 @@
1
+ .figure {
2
+ margin: 0;
3
+ border: 1px solid var(--bg-stroke);
4
+ border-radius: var(--radius-md);
5
+ background-color: var(--bg-surface);
6
+ overflow: hidden;
7
+ }
8
+
9
+ [data-theme='dark'] .figure {
10
+ background-color: var(--bg-muted);
11
+ }
12
+
13
+ .header {
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: space-between;
17
+ gap: var(--space-3);
18
+ padding: var(--space-2) var(--space-4);
19
+ border-bottom: 1px solid var(--bg-stroke);
20
+ background-color: var(--bg-subtle);
21
+ }
22
+
23
+ .language {
24
+ font-family: var(--font-mono), ui-monospace, monospace;
25
+ font-size: var(--font-size-xs);
26
+ font-weight: var(--font-weight-medium);
27
+ text-transform: uppercase;
28
+ letter-spacing: 0.06em;
29
+ color: var(--content-tertiary);
30
+ }
31
+
32
+ .copy {
33
+ font-family: var(--font-mono), ui-monospace, monospace;
34
+ font-size: var(--font-size-xs);
35
+ font-weight: var(--font-weight-medium);
36
+ color: var(--content-secondary);
37
+ background-color: transparent;
38
+ border: 1px solid var(--bg-stroke);
39
+ border-radius: var(--radius-sm);
40
+ padding: 3px 10px;
41
+ cursor: pointer;
42
+ text-transform: uppercase;
43
+ letter-spacing: 0.06em;
44
+ transition:
45
+ color 120ms ease,
46
+ border-color 120ms ease,
47
+ background-color 120ms ease;
48
+ }
49
+
50
+ .copy:hover {
51
+ color: var(--content-primary);
52
+ border-color: var(--bg-stroke-strong);
53
+ background-color: var(--bg-default);
54
+ }
55
+
56
+ .copy:focus-visible {
57
+ outline: 2px solid var(--accent-main);
58
+ outline-offset: 2px;
59
+ }
60
+
61
+ .copy[data-copied='true'] {
62
+ color: var(--accent-success);
63
+ border-color: var(--accent-success);
64
+ }
65
+
66
+ .body {
67
+ display: block;
68
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
4
+ import { DEFAULT_LANG, type Lang } from '@/lib/lang';
5
+ import { getDict } from '@/lib/i18n';
6
+ import styles from './CodeBlock.module.css';
7
+
8
+ const COPY_RESET_MS = 1500;
9
+
10
+ type CodeBlockProps = {
11
+ language: string;
12
+ lang?: Lang;
13
+ children: ReactNode;
14
+ };
15
+
16
+ export function CodeBlock({ language, lang = DEFAULT_LANG, children }: CodeBlockProps) {
17
+ const t = getDict(lang);
18
+ const figureRef = useRef<HTMLElement>(null);
19
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20
+ const [copied, setCopied] = useState(false);
21
+
22
+ useEffect(() => {
23
+ return () => {
24
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
25
+ };
26
+ }, []);
27
+
28
+ const handleCopy = async () => {
29
+ const code = figureRef.current?.querySelector('pre code');
30
+ if (!code) return;
31
+ const text = code.textContent ?? '';
32
+ if (!text) return;
33
+ try {
34
+ await navigator.clipboard.writeText(text);
35
+ } catch {
36
+ return;
37
+ }
38
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
39
+ setCopied(true);
40
+ timeoutRef.current = setTimeout(() => setCopied(false), COPY_RESET_MS);
41
+ };
42
+
43
+ return (
44
+ <figure
45
+ ref={figureRef}
46
+ className={styles.figure}
47
+ data-rehype-pretty-code-figure=""
48
+ data-language={language}
49
+ >
50
+ <header className={styles.header}>
51
+ <span className={styles.language}>{language}</span>
52
+ <button
53
+ type="button"
54
+ className={styles.copy}
55
+ data-copied={copied ? 'true' : 'false'}
56
+ onClick={handleCopy}
57
+ aria-label={copied ? t.codeBlockCopiedAriaLabel : t.codeBlockCopyAriaLabel}
58
+ >
59
+ {copied ? t.codeBlockCopied : t.codeBlockCopy}
60
+ </button>
61
+ </header>
62
+ <div className={styles.body}>{children}</div>
63
+ </figure>
64
+ );
65
+ }
@@ -0,0 +1 @@
1
+ export { CodeBlock } from './CodeBlock';
@@ -0,0 +1,207 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { usePathname } from 'next/navigation';
12
+ import type { Course, FlatLessonEntry } from '@/lib/course';
13
+ import { GATE_LOCKED_ATTR } from '@/lib/gate-init-script';
14
+ import {
15
+ applyGatePainting,
16
+ GATE_ITEM_KEY_ATTR,
17
+ GATE_ITEM_LOCKED_ATTR,
18
+ } from '@/lib/gate-mark-script';
19
+ import { DEFAULT_LANG, stripLangFromPath } from '@/lib/lang';
20
+ import { useProgressMode } from '@/components/ProgressModeProvider';
21
+ import {
22
+ getFrontierLesson,
23
+ isLessonKeyUnlocked,
24
+ resolveFurthestIndex,
25
+ } from '@/lib/lesson-gate';
26
+ import {
27
+ FURTHEST_STORAGE_KEY,
28
+ getFurthestKey,
29
+ getProgress,
30
+ PROGRESS_CHANGE_EVENT,
31
+ PROGRESS_STORAGE_KEY,
32
+ type LessonKey,
33
+ type ProgressMap,
34
+ } from '@/lib/progress';
35
+
36
+ export type GateContextValue = {
37
+ course: Course;
38
+ basePath: string;
39
+ hydrated: boolean;
40
+ progress: ProgressMap;
41
+ furthestKey: LessonKey | null;
42
+ furthestIndex: number;
43
+ isLessonUnlocked(moduleId: string, slug: string): boolean;
44
+ getFrontier(): FlatLessonEntry | null;
45
+ };
46
+
47
+ export const GateContext = createContext<GateContextValue | null>(null);
48
+
49
+ type GateProviderProps = {
50
+ course: Course;
51
+ basePath: string;
52
+ children: ReactNode;
53
+ };
54
+
55
+ export function GateProvider({ course, basePath, children }: GateProviderProps) {
56
+ const [hydrated, setHydrated] = useState(false);
57
+ const [progress, setProgress] = useState<ProgressMap>({});
58
+ const [furthestKey, setFurthestKeyState] = useState<LessonKey | null>(null);
59
+ const pathname = usePathname();
60
+ const { disabled } = useProgressMode();
61
+
62
+ useEffect(() => {
63
+ setProgress(getProgress());
64
+ setFurthestKeyState(getFurthestKey());
65
+ setHydrated(true);
66
+
67
+ function refresh() {
68
+ setProgress(getProgress());
69
+ setFurthestKeyState(getFurthestKey());
70
+ }
71
+ function syncStorage(e: StorageEvent) {
72
+ if (e.key !== PROGRESS_STORAGE_KEY && e.key !== FURTHEST_STORAGE_KEY) return;
73
+ refresh();
74
+ }
75
+ window.addEventListener(PROGRESS_CHANGE_EVENT, refresh);
76
+ window.addEventListener('storage', syncStorage);
77
+ return () => {
78
+ window.removeEventListener(PROGRESS_CHANGE_EVENT, refresh);
79
+ window.removeEventListener('storage', syncStorage);
80
+ };
81
+ }, []);
82
+
83
+ // The inline LANG_SYNC_SCRIPT in [lang]/layout fixes <html lang> for the
84
+ // very first paint, but client navigation between sibling /ru/ and /en/
85
+ // routes only re-renders the script element — browsers do not execute
86
+ // <script> tags inserted via DOM mutation, so the static `<html lang>`
87
+ // from the root layout would stay wrong for screen readers and Intl APIs
88
+ // after a language switch. Mirror the route lang from a client effect.
89
+ useEffect(() => {
90
+ if (typeof document === 'undefined') return;
91
+ const { lang } = stripLangFromPath(pathname ?? '/');
92
+ document.documentElement.lang = lang ?? DEFAULT_LANG;
93
+ }, [pathname]);
94
+
95
+ // Keep gate markers in sync with current state. Two attributes are
96
+ // managed here from a single source of truth (resolveFurthestIndex):
97
+ // • `data-lesson-locked` on <html> — controls the lesson page gate.
98
+ // The inline init script handles the very first paint on direct
99
+ // navigation; this effect covers SPA route changes and cross-tab
100
+ // localStorage updates.
101
+ // • `data-locked` on every `[data-lesson-key]` element — controls
102
+ // locked styling in lists (HomePage modules, ModulePage lessons,
103
+ // ProgramDrawer, MDX cross-lesson links). The inline gate-mark
104
+ // script sets these before first paint; this effect re-applies them
105
+ // after React updates the DOM (e.g. drawer opening, route change
106
+ // introducing new rows, cross-tab progress sync).
107
+ useEffect(() => {
108
+ if (!hydrated) return;
109
+ if (typeof document === 'undefined') return;
110
+ const root = document.documentElement;
111
+
112
+ // Free-reading mode: every lesson is reachable, so there is nothing to
113
+ // gate. Drop the page-level lock and skip painting entirely. We must also
114
+ // actively strip any residual markers gate-mark may have painted before the
115
+ // flag flipped (e.g. cross-tab sync or a route change). Besides the lock
116
+ // markers (whose styling the CSS hide-list doesn't cover for MDX cross-
117
+ // lesson links or ProgramDrawer rows), we also clear the completion markers
118
+ // `data-completed`/`data-next`: progress was just reset, and the CSS hide-
119
+ // list doesn't cover `[data-completed]`, so the "done" affordances
120
+ // (checkmark, strikethrough, ModulePage dimming, the "mark unread" button)
121
+ // would otherwise linger on already-painted rows.
122
+ if (disabled) {
123
+ root.removeAttribute(GATE_LOCKED_ATTR);
124
+ // `data-has-progress` is stamped on <html> by gate-mark; clear it so no
125
+ // residual progress-state lingers on the document root.
126
+ root.removeAttribute('data-has-progress');
127
+ document
128
+ .querySelectorAll<HTMLElement>(`[${GATE_ITEM_KEY_ATTR}]`)
129
+ .forEach((el) => {
130
+ el.removeAttribute(GATE_ITEM_LOCKED_ATTR);
131
+ el.removeAttribute('data-completed');
132
+ el.removeAttribute('data-next');
133
+ el.removeAttribute('aria-disabled');
134
+ el.removeAttribute('tabindex');
135
+ });
136
+ // CTA frontier rows are intentionally NOT hidden by the free-reading CSS
137
+ // (the plain "start" variant must stay visible). But gate-mark may have
138
+ // already painted `data-cta-state="in-progress"/"complete"` before the
139
+ // flag flipped (in-tab toggle or cross-tab sync), and nothing else resets
140
+ // it — so reset them to the SSR baseline so the right CTA variant shows
141
+ // without a reload.
142
+ document
143
+ .querySelectorAll<HTMLElement>('[data-cta-frontier]')
144
+ .forEach((el) => {
145
+ el.setAttribute('data-cta-state', 'not-started');
146
+ });
147
+ return;
148
+ }
149
+
150
+ const furthestIndex = resolveFurthestIndex(course, furthestKey, progress);
151
+
152
+ const { lang: parsedLang, rest } = stripLangFromPath(pathname ?? '/');
153
+ const segments = rest
154
+ .replace(/^\/+|\/+$/g, '')
155
+ .split('/')
156
+ .filter(Boolean);
157
+ if (segments.length < 2) {
158
+ root.removeAttribute(GATE_LOCKED_ATTR);
159
+ } else {
160
+ const [moduleId, slug] = segments;
161
+ const locked = !isLessonKeyUnlocked(course, moduleId, slug, furthestKey, progress);
162
+ if (locked) {
163
+ root.setAttribute(GATE_LOCKED_ATTR, 'true');
164
+ } else {
165
+ root.removeAttribute(GATE_LOCKED_ATTR);
166
+ }
167
+ }
168
+
169
+ applyGatePainting(course, furthestIndex, basePath, parsedLang ?? DEFAULT_LANG);
170
+ }, [hydrated, pathname, course, basePath, furthestKey, progress, disabled]);
171
+
172
+ const value = useMemo<GateContextValue>(() => {
173
+ const furthestIndex = resolveFurthestIndex(course, furthestKey, progress);
174
+ return {
175
+ course,
176
+ basePath,
177
+ hydrated,
178
+ progress,
179
+ furthestKey,
180
+ furthestIndex,
181
+ isLessonUnlocked(moduleId, slug) {
182
+ // Free-reading mode unlocks everything. Checked before `!hydrated`
183
+ // because `disabled` is seeded synchronously from the <html> attribute,
184
+ // so it is already correct during SSR/pre-hydration.
185
+ if (disabled) return true;
186
+ // Pre-hydration we cannot know what the user has unlocked — render
187
+ // everything as reachable so SSR matches and the user never sees a
188
+ // flash of locked items that immediately unlock on hydration.
189
+ if (!hydrated) return true;
190
+ return isLessonKeyUnlocked(course, moduleId, slug, furthestKey, progress);
191
+ },
192
+ getFrontier() {
193
+ return getFrontierLesson(course, furthestIndex);
194
+ },
195
+ };
196
+ }, [course, basePath, hydrated, progress, furthestKey, disabled]);
197
+
198
+ return <GateContext.Provider value={value}>{children}</GateContext.Provider>;
199
+ }
200
+
201
+ export function useGate(): GateContextValue {
202
+ const value = useContext(GateContext);
203
+ if (!value) {
204
+ throw new Error('useGate must be used within <GateProvider>');
205
+ }
206
+ return value;
207
+ }
@@ -0,0 +1 @@
1
+ export { GateContext, GateProvider, useGate, type GateContextValue } from './GateProvider';
@@ -0,0 +1,50 @@
1
+ import Link from 'next/link';
2
+ import { DEFAULT_BRAND_NAME } from '@/lib/course';
3
+ import type { Lang } from '@/lib/lang';
4
+ import styles from './Header.module.css';
5
+
6
+ type BreadcrumbsProps = {
7
+ lang: Lang;
8
+ moduleId?: string;
9
+ moduleTitle?: string;
10
+ lessonTitle?: string;
11
+ brandRoot?: string;
12
+ };
13
+
14
+ export function Breadcrumbs({
15
+ lang,
16
+ moduleId,
17
+ moduleTitle,
18
+ lessonTitle,
19
+ brandRoot = DEFAULT_BRAND_NAME,
20
+ }: BreadcrumbsProps) {
21
+ if (!moduleId || !moduleTitle) {
22
+ return <span className={styles.breadcrumbRoot}>{brandRoot}</span>;
23
+ }
24
+
25
+ return (
26
+ <>
27
+ <Link href={`/${lang}`} className={styles.breadcrumbLink}>
28
+ {brandRoot}
29
+ </Link>
30
+ <span className={styles.breadcrumbSeparator} aria-hidden="true">
31
+ /
32
+ </span>
33
+ {lessonTitle ? (
34
+ <Link href={`/${lang}/${moduleId}`} className={styles.breadcrumbLink}>
35
+ {moduleTitle}
36
+ </Link>
37
+ ) : (
38
+ <span className={styles.breadcrumbCurrent}>{moduleTitle}</span>
39
+ )}
40
+ {lessonTitle && (
41
+ <>
42
+ <span className={styles.breadcrumbSeparator} aria-hidden="true">
43
+ /
44
+ </span>
45
+ <span className={styles.breadcrumbCurrent}>{lessonTitle}</span>
46
+ </>
47
+ )}
48
+ </>
49
+ );
50
+ }