@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,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';