@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,157 @@
|
|
|
1
|
+
.sidebar {
|
|
2
|
+
width: var(--layout-sidebar-width);
|
|
3
|
+
/* Fixed viewport height so the flex parent doesn't stretch the column to
|
|
4
|
+
document height — otherwise the footer icons end up far below the fold. */
|
|
5
|
+
height: 100dvh;
|
|
6
|
+
align-self: flex-start;
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: var(--space-2);
|
|
11
|
+
padding: var(--space-4) 0;
|
|
12
|
+
background-color: var(--bg-default);
|
|
13
|
+
border-right: 1px solid var(--bg-stroke);
|
|
14
|
+
position: sticky;
|
|
15
|
+
top: 0;
|
|
16
|
+
z-index: 10;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.nav {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
gap: var(--space-2);
|
|
23
|
+
width: 100%;
|
|
24
|
+
align-items: center;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.footer {
|
|
28
|
+
margin-top: auto;
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
gap: var(--space-2);
|
|
32
|
+
width: 100%;
|
|
33
|
+
align-items: center;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.button {
|
|
37
|
+
width: 36px;
|
|
38
|
+
height: 36px;
|
|
39
|
+
display: grid;
|
|
40
|
+
place-items: center;
|
|
41
|
+
border: 1px solid transparent;
|
|
42
|
+
background: transparent;
|
|
43
|
+
color: var(--content-secondary);
|
|
44
|
+
border-radius: var(--radius-sm);
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
transition:
|
|
47
|
+
color 120ms ease,
|
|
48
|
+
background-color 120ms ease,
|
|
49
|
+
border-color 120ms ease;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.button svg {
|
|
53
|
+
width: 18px;
|
|
54
|
+
height: 18px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.button:hover {
|
|
58
|
+
background-color: var(--bg-subtle);
|
|
59
|
+
color: var(--content-primary);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.button:focus-visible {
|
|
63
|
+
outline: 2px solid var(--accent-main);
|
|
64
|
+
outline-offset: 2px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.button[aria-current='page'],
|
|
68
|
+
.button[aria-expanded='true'] {
|
|
69
|
+
color: var(--content-primary);
|
|
70
|
+
background-color: var(--bg-subtle);
|
|
71
|
+
border-color: var(--bg-stroke);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Mobile: the sidebar becomes a transparent top-of-viewport band carrying
|
|
75
|
+
two floating action buttons — settings on the left, program on the right.
|
|
76
|
+
Home and GitHub are demoted to inside the ProgramDrawer (they used to sit
|
|
77
|
+
in the bottom dock that this file replaces). A vertical gradient fades
|
|
78
|
+
the default background into transparency so the icons stay legible over
|
|
79
|
+
any prose without painting a solid stripe across the top. */
|
|
80
|
+
@media (max-width: 1023px) {
|
|
81
|
+
.sidebar {
|
|
82
|
+
position: fixed;
|
|
83
|
+
inset: 0 0 auto 0;
|
|
84
|
+
top: 0;
|
|
85
|
+
left: 0;
|
|
86
|
+
right: 0;
|
|
87
|
+
bottom: auto;
|
|
88
|
+
transform: none;
|
|
89
|
+
width: auto;
|
|
90
|
+
height: auto;
|
|
91
|
+
min-height: 0;
|
|
92
|
+
padding: max(env(safe-area-inset-top), var(--space-2)) var(--space-4) var(--space-6);
|
|
93
|
+
flex-direction: row;
|
|
94
|
+
justify-content: space-between;
|
|
95
|
+
align-items: flex-start;
|
|
96
|
+
gap: 0;
|
|
97
|
+
background: transparent;
|
|
98
|
+
border: 0;
|
|
99
|
+
border-radius: 0;
|
|
100
|
+
box-shadow: none;
|
|
101
|
+
backdrop-filter: none;
|
|
102
|
+
-webkit-backdrop-filter: none;
|
|
103
|
+
z-index: var(--z-header);
|
|
104
|
+
pointer-events: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.nav,
|
|
108
|
+
.footer {
|
|
109
|
+
flex-direction: row;
|
|
110
|
+
width: auto;
|
|
111
|
+
gap: var(--space-2);
|
|
112
|
+
margin: 0;
|
|
113
|
+
pointer-events: auto;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Settings (left) is the first visual slot, Program (right) the second.
|
|
117
|
+
Flex order flips .footer ahead of .nav without changing the JSX so
|
|
118
|
+
ARIA/focus order continues to read Home → Program → Settings → GitHub
|
|
119
|
+
on desktop. */
|
|
120
|
+
.footer {
|
|
121
|
+
order: 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.nav {
|
|
125
|
+
order: 2;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Hide Home (Link) and GitHub (anchor) on mobile — their destinations
|
|
129
|
+
live inside the program overlay now. .button is the shared FAB class,
|
|
130
|
+
and only Links/anchors carry it on these spots; the program <button>
|
|
131
|
+
stays visible. */
|
|
132
|
+
.nav > a.button,
|
|
133
|
+
.footer > a.button {
|
|
134
|
+
display: none;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.button {
|
|
138
|
+
width: 44px;
|
|
139
|
+
height: 44px;
|
|
140
|
+
border-radius: 999px;
|
|
141
|
+
background-color: var(--bg-surface);
|
|
142
|
+
border: 1px solid var(--bg-stroke);
|
|
143
|
+
box-shadow: var(--shadow-sm);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.button svg {
|
|
147
|
+
width: 20px;
|
|
148
|
+
height: 20px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.button[aria-current='page'],
|
|
152
|
+
.button[aria-expanded='true'] {
|
|
153
|
+
background-color: var(--content-primary);
|
|
154
|
+
color: var(--bg-default);
|
|
155
|
+
border-color: var(--content-primary);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { SettingsToggle } from '@/components/SettingsToggle';
|
|
6
|
+
import { stripLangFromPath } from '@/lib/lang';
|
|
7
|
+
import { useLang, useT } from '@/lib/use-i18n';
|
|
8
|
+
import { HomeIcon, ProgramIcon, GitHubIcon } from './icons';
|
|
9
|
+
import styles from './Sidebar.module.css';
|
|
10
|
+
|
|
11
|
+
type SidebarProps = {
|
|
12
|
+
onProgramClick: () => void;
|
|
13
|
+
isProgramOpen: boolean;
|
|
14
|
+
repoUrl: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function Sidebar({ onProgramClick, isProgramOpen, repoUrl }: SidebarProps) {
|
|
18
|
+
const pathname = usePathname() ?? '/';
|
|
19
|
+
const lang = useLang();
|
|
20
|
+
const { rest } = stripLangFromPath(pathname);
|
|
21
|
+
const isHome = rest === '/' || pathname === '/';
|
|
22
|
+
const t = useT();
|
|
23
|
+
return (
|
|
24
|
+
<aside className={styles.sidebar} aria-label={t.sidebarLabel}>
|
|
25
|
+
<nav className={styles.nav} aria-label={t.navMainLabel}>
|
|
26
|
+
<Link
|
|
27
|
+
href={`/${lang}`}
|
|
28
|
+
className={styles.button}
|
|
29
|
+
aria-label={t.home}
|
|
30
|
+
title={t.home}
|
|
31
|
+
aria-current={isHome ? 'page' : undefined}
|
|
32
|
+
>
|
|
33
|
+
<HomeIcon />
|
|
34
|
+
</Link>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
className={styles.button}
|
|
38
|
+
aria-label={t.programCourse}
|
|
39
|
+
title={t.programCourse}
|
|
40
|
+
aria-haspopup="dialog"
|
|
41
|
+
aria-expanded={isProgramOpen}
|
|
42
|
+
onClick={onProgramClick}
|
|
43
|
+
>
|
|
44
|
+
<ProgramIcon />
|
|
45
|
+
</button>
|
|
46
|
+
</nav>
|
|
47
|
+
|
|
48
|
+
<div className={styles.footer}>
|
|
49
|
+
<SettingsToggle repoUrl={repoUrl} />
|
|
50
|
+
<a
|
|
51
|
+
className={styles.button}
|
|
52
|
+
href={repoUrl}
|
|
53
|
+
target="_blank"
|
|
54
|
+
rel="noreferrer noopener"
|
|
55
|
+
aria-label={t.githubRepo}
|
|
56
|
+
title={t.githubRepo}
|
|
57
|
+
>
|
|
58
|
+
<GitHubIcon />
|
|
59
|
+
</a>
|
|
60
|
+
</div>
|
|
61
|
+
</aside>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
width="24"
|
|
7
|
+
height="24"
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
fill="currentColor"
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
focusable="false"
|
|
12
|
+
{...props}
|
|
13
|
+
>
|
|
14
|
+
<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2z" />
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
export function HomeIcon(props: SVGProps<SVGSVGElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
width="24"
|
|
7
|
+
height="24"
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
fill="none"
|
|
10
|
+
stroke="currentColor"
|
|
11
|
+
strokeWidth="1.6"
|
|
12
|
+
strokeLinecap="round"
|
|
13
|
+
strokeLinejoin="round"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
focusable="false"
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<path d="M3 12 12 4l9 8" />
|
|
19
|
+
<path d="M5 10v10h14V10" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
export function LanguageIcon(props: SVGProps<SVGSVGElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
width="24"
|
|
7
|
+
height="24"
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
fill="none"
|
|
10
|
+
stroke="currentColor"
|
|
11
|
+
strokeWidth="1.6"
|
|
12
|
+
strokeLinecap="round"
|
|
13
|
+
strokeLinejoin="round"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
focusable="false"
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<circle cx="12" cy="12" r="9" />
|
|
19
|
+
<path d="M3 12h18" />
|
|
20
|
+
<path d="M12 3a14 14 0 0 1 0 18" />
|
|
21
|
+
<path d="M12 3a14 14 0 0 0 0 18" />
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
export function ProgramIcon(props: SVGProps<SVGSVGElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
width="24"
|
|
7
|
+
height="24"
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
fill="none"
|
|
10
|
+
stroke="currentColor"
|
|
11
|
+
strokeWidth="1.6"
|
|
12
|
+
strokeLinecap="round"
|
|
13
|
+
strokeLinejoin="round"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
focusable="false"
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<path d="M4 6h16" />
|
|
19
|
+
<path d="M4 12h16" />
|
|
20
|
+
<path d="M4 18h10" />
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
export function SettingsIcon(props: SVGProps<SVGSVGElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
width="18"
|
|
7
|
+
height="18"
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
fill="none"
|
|
10
|
+
stroke="currentColor"
|
|
11
|
+
strokeWidth="1.6"
|
|
12
|
+
strokeLinecap="round"
|
|
13
|
+
strokeLinejoin="round"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
focusable="false"
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<path d="M4 6h7M15 6h5" />
|
|
19
|
+
<path d="M4 12h3M11 12h9" />
|
|
20
|
+
<path d="M4 18h12M20 18h0" />
|
|
21
|
+
<circle cx="13" cy="6" r="2" />
|
|
22
|
+
<circle cx="9" cy="12" r="2" />
|
|
23
|
+
<circle cx="18" cy="18" r="2" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
|
|
3
|
+
export function ThemeIcon(props: SVGProps<SVGSVGElement>) {
|
|
4
|
+
return (
|
|
5
|
+
<svg
|
|
6
|
+
width="24"
|
|
7
|
+
height="24"
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
fill="none"
|
|
10
|
+
stroke="currentColor"
|
|
11
|
+
strokeWidth="1.6"
|
|
12
|
+
strokeLinecap="round"
|
|
13
|
+
strokeLinejoin="round"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
focusable="false"
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<circle cx="12" cy="12" r="4" />
|
|
19
|
+
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { HomeIcon } from './HomeIcon';
|
|
2
|
+
export { ProgramIcon } from './ProgramIcon';
|
|
3
|
+
export { ThemeIcon } from './ThemeIcon';
|
|
4
|
+
export { GitHubIcon } from './GitHubIcon';
|
|
5
|
+
export { LanguageIcon } from './LanguageIcon';
|
|
6
|
+
export { SettingsIcon } from './SettingsIcon';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Sidebar } from './Sidebar';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import {
|
|
12
|
+
applyResolvedTheme,
|
|
13
|
+
readStoredPreference,
|
|
14
|
+
THEME_STORAGE_KEY,
|
|
15
|
+
type ResolvedTheme,
|
|
16
|
+
type ThemePreference,
|
|
17
|
+
} from '@/lib/theme';
|
|
18
|
+
|
|
19
|
+
type ThemeContextValue = {
|
|
20
|
+
preference: ThemePreference;
|
|
21
|
+
resolvedTheme: ResolvedTheme;
|
|
22
|
+
setPreference: (next: ThemePreference) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
28
|
+
const [preference, setPreferenceState] = useState<ThemePreference>('light');
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setPreferenceState(readStoredPreference());
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
function handleStorage(event: StorageEvent) {
|
|
36
|
+
if (event.key !== THEME_STORAGE_KEY) return;
|
|
37
|
+
const next = readStoredPreference();
|
|
38
|
+
setPreferenceState(next);
|
|
39
|
+
applyResolvedTheme(next);
|
|
40
|
+
}
|
|
41
|
+
window.addEventListener('storage', handleStorage);
|
|
42
|
+
return () => window.removeEventListener('storage', handleStorage);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const setPreference = useCallback((next: ThemePreference) => {
|
|
46
|
+
setPreferenceState(next);
|
|
47
|
+
try {
|
|
48
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, next);
|
|
49
|
+
} catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
applyResolvedTheme(next);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<ThemeContext.Provider value={{ preference, resolvedTheme: preference, setPreference }}>
|
|
57
|
+
{children}
|
|
58
|
+
</ThemeContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useTheme(): ThemeContextValue {
|
|
63
|
+
const ctx = useContext(ThemeContext);
|
|
64
|
+
if (!ctx) {
|
|
65
|
+
throw new Error('useTheme must be used inside <ThemeProvider>');
|
|
66
|
+
}
|
|
67
|
+
return ctx;
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ThemeProvider, useTheme } from './ThemeProvider';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
.toc {
|
|
2
|
+
font-size: 13px;
|
|
3
|
+
line-height: var(--line-height-snug);
|
|
4
|
+
color: var(--content-tertiary);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.heading {
|
|
8
|
+
margin: 0 0 var(--space-3) 0;
|
|
9
|
+
font-family: var(--font-mono), ui-monospace, monospace;
|
|
10
|
+
font-size: 12px;
|
|
11
|
+
font-weight: var(--font-weight-regular);
|
|
12
|
+
letter-spacing: 0.06em;
|
|
13
|
+
text-transform: uppercase;
|
|
14
|
+
color: var(--content-tertiary);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.list {
|
|
18
|
+
list-style: none;
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 0;
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
gap: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.item {
|
|
27
|
+
margin: 0;
|
|
28
|
+
padding: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.item[data-depth='3'] {
|
|
32
|
+
padding-left: var(--space-3);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.link {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-template-columns: 16px 1fr;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: var(--space-2);
|
|
40
|
+
padding: 6px 0;
|
|
41
|
+
text-decoration: none;
|
|
42
|
+
color: var(--content-tertiary);
|
|
43
|
+
font-size: 13px;
|
|
44
|
+
line-height: 1.35;
|
|
45
|
+
transition: color 120ms ease;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.link:hover {
|
|
49
|
+
color: var(--content-secondary);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.marker {
|
|
53
|
+
width: 8px;
|
|
54
|
+
height: 1px;
|
|
55
|
+
background: var(--bg-stroke-strong);
|
|
56
|
+
transition: width 180ms ease, height 180ms ease, background-color 180ms ease;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.label {
|
|
60
|
+
min-width: 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.item[data-active='true'] > .link {
|
|
64
|
+
color: var(--content-primary);
|
|
65
|
+
font-weight: var(--font-weight-semibold);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.item[data-active='true'] .marker {
|
|
69
|
+
width: 14px;
|
|
70
|
+
height: 2px;
|
|
71
|
+
background: var(--accent-main);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.link:focus-visible {
|
|
75
|
+
outline: 2px solid var(--accent-main);
|
|
76
|
+
outline-offset: 2px;
|
|
77
|
+
border-radius: var(--radius-sm);
|
|
78
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { TocEntry } from '@/lib/extract-toc';
|
|
5
|
+
import { useT } from '@/lib/use-i18n';
|
|
6
|
+
import styles from './Toc.module.css';
|
|
7
|
+
|
|
8
|
+
type TocProps = {
|
|
9
|
+
entries: TocEntry[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const OBSERVER_OPTIONS: IntersectionObserverInit = {
|
|
13
|
+
// Trigger when a heading enters the top quarter of the viewport.
|
|
14
|
+
rootMargin: '-80px 0px -70% 0px',
|
|
15
|
+
threshold: [0, 1],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function Toc({ entries }: TocProps) {
|
|
19
|
+
const t = useT();
|
|
20
|
+
const [activeSlug, setActiveSlug] = useState<string | null>(
|
|
21
|
+
entries.length > 0 ? entries[0].slug : null,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (entries.length === 0) return;
|
|
26
|
+
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const elements = entries
|
|
31
|
+
.map((entry) => document.getElementById(entry.slug))
|
|
32
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
33
|
+
|
|
34
|
+
if (elements.length === 0) return;
|
|
35
|
+
|
|
36
|
+
const visibleSlugs = new Set<string>();
|
|
37
|
+
const observer = new IntersectionObserver((records) => {
|
|
38
|
+
for (const r of records) {
|
|
39
|
+
if (r.isIntersecting) visibleSlugs.add(r.target.id);
|
|
40
|
+
else visibleSlugs.delete(r.target.id);
|
|
41
|
+
}
|
|
42
|
+
const firstVisible = entries.find((e) => visibleSlugs.has(e.slug));
|
|
43
|
+
if (firstVisible) {
|
|
44
|
+
setActiveSlug(firstVisible.slug);
|
|
45
|
+
}
|
|
46
|
+
}, OBSERVER_OPTIONS);
|
|
47
|
+
|
|
48
|
+
for (const el of elements) observer.observe(el);
|
|
49
|
+
return () => observer.disconnect();
|
|
50
|
+
}, [entries]);
|
|
51
|
+
|
|
52
|
+
if (entries.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
const handleClick = (slug: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
55
|
+
const target = document.getElementById(slug);
|
|
56
|
+
if (!target) return;
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
59
|
+
if (typeof window !== 'undefined' && window.history?.replaceState) {
|
|
60
|
+
window.history.replaceState(null, '', `#${slug}`);
|
|
61
|
+
}
|
|
62
|
+
setActiveSlug(slug);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<nav className={styles.toc} aria-label={t.tocLabel}>
|
|
67
|
+
<p className={styles.heading}>/ contents</p>
|
|
68
|
+
<ol className={styles.list}>
|
|
69
|
+
{entries.map((entry) => {
|
|
70
|
+
const isActive = entry.slug === activeSlug;
|
|
71
|
+
return (
|
|
72
|
+
<li
|
|
73
|
+
key={entry.slug}
|
|
74
|
+
className={styles.item}
|
|
75
|
+
data-depth={entry.depth}
|
|
76
|
+
data-active={isActive ? 'true' : 'false'}
|
|
77
|
+
>
|
|
78
|
+
<a
|
|
79
|
+
href={`#${entry.slug}`}
|
|
80
|
+
className={styles.link}
|
|
81
|
+
onClick={handleClick(entry.slug)}
|
|
82
|
+
>
|
|
83
|
+
<span className={styles.marker} aria-hidden="true" />
|
|
84
|
+
<span className={styles.label}>{entry.text}</span>
|
|
85
|
+
</a>
|
|
86
|
+
</li>
|
|
87
|
+
);
|
|
88
|
+
})}
|
|
89
|
+
</ol>
|
|
90
|
+
</nav>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Toc } from './Toc';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.banner {
|
|
2
|
+
margin: 0 0 var(--space-6);
|
|
3
|
+
padding: var(--space-4) var(--space-5);
|
|
4
|
+
border-left: 3px solid var(--accent-notice);
|
|
5
|
+
border-radius: var(--radius-md);
|
|
6
|
+
background-color: var(--accent-notice-soft);
|
|
7
|
+
color: var(--content-primary);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: var(--space-2);
|
|
14
|
+
margin-bottom: var(--space-2);
|
|
15
|
+
color: var(--accent-notice);
|
|
16
|
+
font-weight: var(--font-weight-semibold);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.icon {
|
|
20
|
+
flex-shrink: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.title {
|
|
24
|
+
font-size: var(--font-size-sm);
|
|
25
|
+
text-transform: uppercase;
|
|
26
|
+
letter-spacing: 0.04em;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.body {
|
|
30
|
+
margin: 0;
|
|
31
|
+
color: var(--content-primary);
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getDict } from '@/lib/i18n';
|
|
2
|
+
import type { Lang } from '@/lib/lang';
|
|
3
|
+
import styles from './TranslationBanner.module.css';
|
|
4
|
+
|
|
5
|
+
type TranslationBannerProps = {
|
|
6
|
+
lang: Lang;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function TranslationBanner({ lang }: TranslationBannerProps) {
|
|
10
|
+
const t = getDict(lang);
|
|
11
|
+
return (
|
|
12
|
+
<aside
|
|
13
|
+
className={styles.banner}
|
|
14
|
+
data-translation-banner
|
|
15
|
+
role="note"
|
|
16
|
+
aria-label={t.translationFallbackTitle}
|
|
17
|
+
>
|
|
18
|
+
<header className={styles.header}>
|
|
19
|
+
<GlobeIcon className={styles.icon} aria-hidden="true" />
|
|
20
|
+
<span className={styles.title}>{t.translationFallbackTitle}</span>
|
|
21
|
+
</header>
|
|
22
|
+
<p className={styles.body}>{t.translationFallbackBody}</p>
|
|
23
|
+
</aside>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
28
|
+
return (
|
|
29
|
+
<svg
|
|
30
|
+
viewBox="0 0 16 16"
|
|
31
|
+
width="16"
|
|
32
|
+
height="16"
|
|
33
|
+
fill="currentColor"
|
|
34
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0Zm5.93 7.25h-2.486a13.18 13.18 0 0 0-.66-3.61 6.52 6.52 0 0 1 3.146 3.61ZM8 1.5c.78 0 1.86 1.5 2.32 4.25H5.68C6.14 3 7.22 1.5 8 1.5ZM1.55 8.75H4.05c.06 1.23.28 2.44.65 3.61A6.52 6.52 0 0 1 1.55 8.75Zm0-1.5a6.52 6.52 0 0 1 3.15-3.61c-.37 1.17-.59 2.38-.65 3.61H1.55Zm4.13 1.5h4.64C9.86 11.5 8.78 13 8 13s-1.86-1.5-2.32-3.25Zm0-1.5c.46-2.75 1.54-4.25 2.32-4.25s1.86 1.5 2.32 4.25H5.68Zm5.76 4.86c.37-1.17.59-2.38.66-3.61h2.49a6.52 6.52 0 0 1-3.15 3.61Z" />
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TranslationBanner } from './TranslationBanner';
|