@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,295 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import Link from 'next/link';
5
+ import { useRouter } from 'next/navigation';
6
+ import { useGate } from '@/components/GateProvider';
7
+ import { LockIcon } from '@/components/ProgramDrawer/LockIcon';
8
+ import {
9
+ flattenLessons,
10
+ getTotalLessons,
11
+ type Course,
12
+ } from '@/lib/course';
13
+ import {
14
+ formatDurationHm,
15
+ formatLessonCount,
16
+ formatModuleCount,
17
+ parseDurationMin,
18
+ } from '@/lib/format';
19
+ import { openProgramDrawer } from '@/lib/program-drawer';
20
+ import { lessonKey } from '@/lib/progress';
21
+ import { navigateToFrontierHref } from '@/lib/frontier-link';
22
+ import { useLang, useT } from '@/lib/use-i18n';
23
+ import styles from './HomePage.module.css';
24
+
25
+ type HomePageProps = {
26
+ course: Course;
27
+ level: string;
28
+ };
29
+
30
+ export function HomePage({ course, level }: HomePageProps) {
31
+ const totalLessons = getTotalLessons(course);
32
+ const router = useRouter();
33
+ const { basePath } = useGate();
34
+ const t = useT();
35
+ const lang = useLang();
36
+ // Hero headline is the most brand-specific surface; prefer course.brand.hero
37
+ // (already resolved to the active lang by parseCourse) and fall back to the
38
+ // i18n defaults so an un-branded course still renders the Kafka headline.
39
+ const hero = course.brand?.hero ?? {
40
+ lead: t.heroTitleLead,
41
+ accent: t.heroTitleAccent,
42
+ tail: t.heroTitleTail,
43
+ };
44
+ const totalDurationMin = useMemo(
45
+ () =>
46
+ course.modules.reduce(
47
+ (sum, mod) =>
48
+ sum + mod.lessons.reduce((s, l) => s + parseDurationMin(l.duration), 0),
49
+ 0,
50
+ ),
51
+ [course],
52
+ );
53
+
54
+ // SSR baseline: render the "fresh user" shape — 0% progress, CTA points at
55
+ // the first lesson, "Начать с первого урока" copy, module rows say "не
56
+ // начато". The inline gate-paint script reads localStorage before first
57
+ // paint and rewrites href + textContent + data-* attributes for the real
58
+ // state, so the user with progress doesn't see this baseline flash.
59
+ const firstEntry = useMemo(() => flattenLessons(course)[0] ?? null, [course]);
60
+ const firstHref = firstEntry
61
+ ? `/${lang}/${firstEntry.moduleId}/${firstEntry.lesson.slug}`
62
+ : '#';
63
+
64
+ return (
65
+ <div className={styles.page}>
66
+ <section className={styles.hero}>
67
+ <div>
68
+ <h1 className={styles.heroTitle}>
69
+ {hero.lead}{' '}
70
+ <span className={styles.heroTitleAccent}>{hero.accent}</span>{' '}
71
+ {hero.tail}
72
+ </h1>
73
+ <p className={styles.heroLead}>{collapseWhitespace(course.description)}</p>
74
+ <div
75
+ className={styles.ctaRow}
76
+ data-cta-frontier="global"
77
+ data-cta-state="not-started"
78
+ suppressHydrationWarning
79
+ >
80
+ {/* Two CTA variants: gate-paint flips data-cta-state and CSS
81
+ shows exactly one. Pre-paint baseline = "not-started" so the
82
+ SSR HTML reads the "start from the first lesson" copy. */}
83
+ <Link
84
+ href={firstHref}
85
+ className={`${styles.btn} ${styles.btnPrimary}`}
86
+ data-cta-variant="not-started"
87
+ >
88
+ {t.startFromFirst}
89
+ <span className={styles.btnArrow}>→</span>
90
+ </Link>
91
+ <Link
92
+ href={firstHref}
93
+ className={`${styles.btn} ${styles.btnPrimary}`}
94
+ data-cta-variant="in-progress"
95
+ data-cta-frontier-link
96
+ suppressHydrationWarning
97
+ onClick={(e) => navigateToFrontierHref(e, router, basePath)}
98
+ >
99
+ {t.continueLessonPrefix}{' '}
100
+ <span data-cta-frontier-num suppressHydrationWarning>
101
+ 1
102
+ </span>
103
+ <span className={styles.btnArrow}>→</span>
104
+ </Link>
105
+ {/* "Start over" only visible in in-progress state. */}
106
+ <Link
107
+ href={firstHref}
108
+ className={`${styles.btn} ${styles.btnSecondary}`}
109
+ data-cta-variant="in-progress"
110
+ >
111
+ {t.startFromScratch}
112
+ </Link>
113
+ <button
114
+ type="button"
115
+ className={`${styles.btn} ${styles.btnGhost}`}
116
+ onClick={openProgramDrawer}
117
+ >
118
+ {t.programCourse}
119
+ </button>
120
+ </div>
121
+ {/* Frontier hint — only visible once gate-paint marks the page as
122
+ having progress. Lesson title is rewritten in-place. */}
123
+ <div
124
+ className={styles.nextHint}
125
+ data-frontier-hint
126
+ data-hint-state="hidden"
127
+ suppressHydrationWarning
128
+ >
129
+ <span className={styles.nextHintArrow}>↳</span>{' '}
130
+ <span
131
+ className={styles.nextHintLesson}
132
+ data-frontier-hint-lesson
133
+ suppressHydrationWarning
134
+ >
135
+ {firstEntry?.lesson.title ?? ''}
136
+ </span>
137
+ </div>
138
+ </div>
139
+
140
+ <aside
141
+ className={styles.statsCard}
142
+ aria-label={t.progressSummary}
143
+ data-progress-scope="global"
144
+ data-progress-state="not-started"
145
+ suppressHydrationWarning
146
+ >
147
+ <div className={styles.statsProgress}>
148
+ <div className={styles.statsProgressRow}>
149
+ <span className={styles.statsPct}>
150
+ <span data-progress-pct suppressHydrationWarning>
151
+ 0
152
+ </span>
153
+ <span className={styles.statsPctUnit}>%</span>
154
+ </span>
155
+ <span className={styles.statsOf}>
156
+ <span data-progress-count suppressHydrationWarning>
157
+ 0
158
+ </span>{' '}
159
+ / {formatLessonCount(totalLessons, lang)}
160
+ </span>
161
+ </div>
162
+ <div className={styles.statsBar} aria-hidden="true">
163
+ <span
164
+ className={styles.statsBarFill}
165
+ data-progress-bar
166
+ style={{ width: '0%' }}
167
+ suppressHydrationWarning
168
+ />
169
+ </div>
170
+ </div>
171
+ <dl className={styles.statsGrid}>
172
+ <div>
173
+ <dt className={styles.statsLabel}>{t.modulesLabel}</dt>
174
+ <dd className={styles.statsValue}>{course.modules.length}</dd>
175
+ </div>
176
+ <div>
177
+ <dt className={styles.statsLabel}>{t.lessonsLabel}</dt>
178
+ <dd className={styles.statsValue}>{totalLessons}</dd>
179
+ </div>
180
+ <div>
181
+ <dt className={styles.statsLabel}>{t.durationLabel}</dt>
182
+ <dd className={styles.statsValue}>{formatDurationHm(totalDurationMin, lang)}</dd>
183
+ </div>
184
+ <div>
185
+ <dt className={styles.statsLabel}>{t.stackLabel}</dt>
186
+ <dd className={styles.statsValue}>{level}</dd>
187
+ </div>
188
+ </dl>
189
+ </aside>
190
+ </section>
191
+
192
+ <header className={styles.sectionHead}>
193
+ <div>
194
+ <div className={styles.sectionEyebrow}>/ contents</div>
195
+ <h2 className={styles.sectionTitle}>{t.programCourse}</h2>
196
+ </div>
197
+ <div className={styles.sectionTools}>
198
+ {formatModuleCount(course.modules.length, lang)} ·{' '}
199
+ {formatLessonCount(totalLessons, lang)}
200
+ </div>
201
+ </header>
202
+
203
+ <ol className={styles.modules}>
204
+ {course.modules.map((mod, mi) => {
205
+ const total = mod.lessons.length;
206
+ const moduleDurationMin = mod.lessons.reduce(
207
+ (s, l) => s + parseDurationMin(l.duration),
208
+ 0,
209
+ );
210
+ // Module is treated as a single gate target via its first lesson.
211
+ // CSV of lesson keys feeds the gate-paint script so it can paint
212
+ // per-module count / percent / state in this row.
213
+ const moduleKey = mod.lessons[0]
214
+ ? lessonKey(mod.id, mod.lessons[0].slug)
215
+ : undefined;
216
+ const moduleKeysCsv = mod.lessons
217
+ .map((l) => lessonKey(mod.id, l.slug))
218
+ .join(',');
219
+
220
+ return (
221
+ <li key={mod.id} className={styles.moduleItem}>
222
+ <Link
223
+ href={`/${lang}/${mod.id}`}
224
+ className={styles.moduleRow}
225
+ data-lesson-key={moduleKey}
226
+ data-progress-scope="module"
227
+ data-progress-keys={moduleKeysCsv}
228
+ data-progress-state="not-started"
229
+ onClick={(e) => {
230
+ if (e.currentTarget.getAttribute('data-locked') === 'true') {
231
+ e.preventDefault();
232
+ }
233
+ }}
234
+ title={t.moduleLockTitle}
235
+ suppressHydrationWarning
236
+ >
237
+ <div className={styles.moduleNum}>
238
+ {String(mi + 1).padStart(2, '0')}
239
+ </div>
240
+ <div className={styles.moduleText}>
241
+ <h3 className={styles.moduleTitle}>{mod.title}</h3>
242
+ <p className={styles.moduleDesc}>{collapseWhitespace(mod.description)}</p>
243
+ </div>
244
+ <div className={styles.moduleProgress} data-module-progress>
245
+ <div className={styles.mpRow}>
246
+ {/* All three labels live in the DOM; CSS shows exactly
247
+ one based on the row's data-progress-state. Gate-paint
248
+ flips that attribute without re-rendering React. */}
249
+ <span className={styles.mpStatus}>
250
+ <span data-status="not-started">{t.statusNotStarted}</span>
251
+ <span data-status="in-progress">{t.statusInProgress}</span>
252
+ <span data-status="complete">{t.statusComplete}</span>
253
+ </span>
254
+ <span className={styles.mpPct}>
255
+ <span data-progress-count suppressHydrationWarning>
256
+ 0
257
+ </span>
258
+ /{total}
259
+ </span>
260
+ </div>
261
+ <div className={styles.mpBar} aria-hidden="true">
262
+ <span
263
+ className={styles.mpFill}
264
+ data-progress-bar
265
+ style={{ width: '0%' }}
266
+ suppressHydrationWarning
267
+ />
268
+ </div>
269
+ </div>
270
+ <div className={styles.moduleMeta}>
271
+ <span className={styles.mmLessons}>
272
+ {formatLessonCount(total, lang)}
273
+ </span>
274
+ <span className={styles.mmDuration}>
275
+ {formatDurationHm(moduleDurationMin, lang)}
276
+ </span>
277
+ </div>
278
+ <div className={styles.arrowCell} aria-hidden="true">
279
+ <span className={styles.arrowOpen}>→</span>
280
+ <span className={styles.arrowLocked}>
281
+ <LockIcon />
282
+ </span>
283
+ </div>
284
+ </Link>
285
+ </li>
286
+ );
287
+ })}
288
+ </ol>
289
+ </div>
290
+ );
291
+ }
292
+
293
+ function collapseWhitespace(text: string): string {
294
+ return text.replace(/\s+/g, ' ').trim();
295
+ }
@@ -0,0 +1 @@
1
+ export { HomePage } from './HomePage';
@@ -0,0 +1,12 @@
1
+ /* Locked-state visuals for cross-lesson MDX links live in globals.css —
2
+ they target plain attribute selectors and don't fit CSS Modules' "must
3
+ contain a local class" rule. This file only keeps the local class for
4
+ the inline lock badge that the global rule reveals. */
5
+
6
+ .lockBadge {
7
+ display: none;
8
+ align-items: center;
9
+ margin-left: 0.25em;
10
+ vertical-align: -0.15em;
11
+ color: currentColor;
12
+ }
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import type { AnchorHTMLAttributes, ReactNode } from 'react';
4
+ import { LockIcon } from '@/components/ProgramDrawer/LockIcon';
5
+ import { stripLangFromPath } from '@/lib/lang';
6
+ import styles from './LessonAwareLink.module.css';
7
+
8
+ type LessonAwareLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
9
+ basePath?: string;
10
+ children?: ReactNode;
11
+ };
12
+
13
+ /**
14
+ * MDX `<a>` replacement that participates in the gate. The rendered shape is
15
+ * always a plain anchor — the inline gate-mark script flips `data-locked` on
16
+ * any link whose `data-lesson-key` points at a still-locked lesson, and CSS
17
+ * dims the row + reveals the inline lock badge from there. Click handler
18
+ * uses the attribute (not React state) so SSR markup and post-hydration
19
+ * markup are identical and there's no flash.
20
+ */
21
+ export function LessonAwareLink({
22
+ href,
23
+ children,
24
+ basePath = '',
25
+ ...rest
26
+ }: LessonAwareLinkProps) {
27
+ const target = href ? matchLessonHref(href, basePath) : null;
28
+ const dataKey = target ? `${target.moduleId}/${target.slug}` : undefined;
29
+
30
+ return (
31
+ <a
32
+ href={href}
33
+ data-lesson-key={dataKey}
34
+ onClick={
35
+ dataKey
36
+ ? (e) => {
37
+ if (e.currentTarget.getAttribute('data-locked') === 'true') {
38
+ e.preventDefault();
39
+ }
40
+ }
41
+ : undefined
42
+ }
43
+ {...rest}
44
+ // Why: the inline gate-mark script (runs before hydration) stamps
45
+ // `data-locked` / `aria-disabled` / `tabindex` on any anchor whose
46
+ // `data-lesson-key` points at a still-locked lesson. React's
47
+ // hydration would otherwise flag those as "extra attributes from
48
+ // the server" because the virtual DOM doesn't include them.
49
+ suppressHydrationWarning
50
+ >
51
+ {children}
52
+ {dataKey && (
53
+ <span
54
+ className={styles.lockBadge}
55
+ data-lock-badge=""
56
+ aria-hidden="true"
57
+ >
58
+ <LockIcon />
59
+ </span>
60
+ )}
61
+ </a>
62
+ );
63
+ }
64
+
65
+ function matchLessonHref(
66
+ href: string,
67
+ basePath: string,
68
+ ): { moduleId: string; slug: string } | null {
69
+ if (href.length === 0) return null;
70
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) return null;
71
+ if (/^(?:mailto:|tel:|data:)/i.test(href)) return null;
72
+ if (href.startsWith('#')) return null;
73
+
74
+ let path = href;
75
+ if (basePath && path.startsWith(`${basePath}/`)) {
76
+ path = path.slice(basePath.length);
77
+ }
78
+ if (!path.startsWith('/')) return null;
79
+
80
+ const withoutHash = path.split('#')[0]?.split('?')[0] ?? '';
81
+ const { rest } = stripLangFromPath(withoutHash);
82
+ const segments = rest.split('/').filter(Boolean);
83
+ if (segments.length < 2) return null;
84
+
85
+ return { moduleId: segments[0], slug: segments[1] };
86
+ }
@@ -0,0 +1 @@
1
+ export { LessonAwareLink } from './LessonAwareLink';
@@ -0,0 +1,35 @@
1
+ .layout {
2
+ display: grid;
3
+ grid-template-columns: minmax(0, var(--layout-content-max));
4
+ gap: var(--space-8);
5
+ padding: var(--space-8) var(--space-6);
6
+ max-width: 1280px;
7
+ margin: 0 auto;
8
+ width: 100%;
9
+ }
10
+
11
+ .content {
12
+ min-width: 0;
13
+ }
14
+
15
+ .title {
16
+ font-size: var(--font-size-3xl);
17
+ line-height: var(--line-height-tight);
18
+ font-weight: var(--font-weight-bold);
19
+ margin: 0;
20
+ color: var(--content-primary);
21
+ letter-spacing: -0.01em;
22
+ }
23
+
24
+ @media (max-width: 1023px) {
25
+ .layout {
26
+ padding: var(--space-6) var(--space-4);
27
+ }
28
+ }
29
+
30
+ @media (max-width: 767px) {
31
+ .layout {
32
+ padding: var(--space-4);
33
+ gap: var(--space-4);
34
+ }
35
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './LessonLayout.module.css';
3
+
4
+ type LessonLayoutProps = {
5
+ children: ReactNode;
6
+ title?: ReactNode;
7
+ };
8
+
9
+ export function LessonLayout({ children, title }: LessonLayoutProps) {
10
+ return (
11
+ <div className={styles.layout}>
12
+ <div className={styles.content}>
13
+ {title && <h1 className={styles.title}>{title}</h1>}
14
+ {children}
15
+ </div>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1 @@
1
+ export { LessonLayout } from './LessonLayout';