@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,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
|
+
}
|