@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,231 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { Course } from '../course';
|
|
3
|
+
import { DEFAULT_LANG, isLang, type Lang } from '../lang';
|
|
4
|
+
|
|
5
|
+
export interface RemarkLinkRewriteOptions {
|
|
6
|
+
moduleId: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
basePath: string;
|
|
9
|
+
course: Course;
|
|
10
|
+
/**
|
|
11
|
+
* Active route language — the `/<lang>/` prefix stamped on emitted site URLs.
|
|
12
|
+
* Defaults to {@link DEFAULT_LANG}.
|
|
13
|
+
*/
|
|
14
|
+
lang?: Lang;
|
|
15
|
+
/**
|
|
16
|
+
* Language of the source README being processed — used to resolve the
|
|
17
|
+
* `i18n/<sourceLang>/` directory the relative links are anchored to. Defaults
|
|
18
|
+
* to {@link lang}. Decoupled from `lang` so an EN route falling back to the
|
|
19
|
+
* RU README can keep emitting `/en/...` URLs while still anchoring source
|
|
20
|
+
* resolution at `i18n/ru/`.
|
|
21
|
+
*/
|
|
22
|
+
sourceLang?: Lang;
|
|
23
|
+
/**
|
|
24
|
+
* Absolute path to the lectures root (the directory that contains
|
|
25
|
+
* `<moduleId>/<slug>/README.md`). Optional — defaults to the path that
|
|
26
|
+
* `course-loader` uses (`<repo-root>/lectures`). Tests pass a synthetic root.
|
|
27
|
+
*/
|
|
28
|
+
lecturesRoot?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface LinkNode {
|
|
32
|
+
type: 'link' | 'linkReference' | 'definition';
|
|
33
|
+
url?: string;
|
|
34
|
+
data?: {
|
|
35
|
+
hProperties?: Record<string, unknown>;
|
|
36
|
+
} & Record<string, unknown>;
|
|
37
|
+
children?: AstNode[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ParentNode {
|
|
41
|
+
type: string;
|
|
42
|
+
children?: AstNode[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type AstNode = LinkNode | ParentNode;
|
|
46
|
+
|
|
47
|
+
const EXTERNAL_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
48
|
+
const PASS_THROUGH_PROTOCOL_RE = /^(?:mailto:|tel:|data:)/i;
|
|
49
|
+
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|avif|ico)(?:[?#]|$)/i;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pure rewrite logic, exported for unit tests. Returns the new url plus a flag
|
|
53
|
+
* indicating whether the link should be marked external in HAST.
|
|
54
|
+
*/
|
|
55
|
+
export function rewriteLessonLink(
|
|
56
|
+
rawUrl: string,
|
|
57
|
+
options: RemarkLinkRewriteOptions,
|
|
58
|
+
): { url: string; external: boolean } {
|
|
59
|
+
const url = rawUrl.trim();
|
|
60
|
+
|
|
61
|
+
if (url.length === 0) {
|
|
62
|
+
return { url: rawUrl, external: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (url.startsWith('#')) {
|
|
66
|
+
return { url, external: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (EXTERNAL_PROTOCOL_RE.test(url)) {
|
|
70
|
+
return { url, external: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (PASS_THROUGH_PROTOCOL_RE.test(url)) {
|
|
74
|
+
return { url, external: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!url.startsWith('./') && !url.startsWith('../')) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`remark-link-rewrite: link "${rawUrl}" in ${options.moduleId}/${options.slug} ` +
|
|
80
|
+
`is not allowed. Only relative links ("./", "../"), absolute URLs ` +
|
|
81
|
+
`(http(s)://, mailto:, tel:, data:) and "#anchor" are supported.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const [pathPart, hashPart = ''] = splitHash(url);
|
|
86
|
+
const lecturesRoot = options.lecturesRoot ?? '/lectures';
|
|
87
|
+
const lang = options.lang && isLang(options.lang) ? options.lang : DEFAULT_LANG;
|
|
88
|
+
const sourceLang =
|
|
89
|
+
options.sourceLang && isLang(options.sourceLang) ? options.sourceLang : lang;
|
|
90
|
+
const lessonDir = path.posix.join(
|
|
91
|
+
lecturesRoot,
|
|
92
|
+
options.moduleId,
|
|
93
|
+
options.slug,
|
|
94
|
+
'i18n',
|
|
95
|
+
sourceLang,
|
|
96
|
+
);
|
|
97
|
+
const resolved = path.posix.normalize(path.posix.join(lessonDir, pathPart));
|
|
98
|
+
|
|
99
|
+
if (!isInside(resolved, lecturesRoot)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`remark-link-rewrite: link "${rawUrl}" in ${options.moduleId}/${options.slug} ` +
|
|
102
|
+
`escapes the lectures root (resolved to "${resolved}")`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const relative = resolved.slice(lecturesRoot.length).replace(/^\/+/, '');
|
|
107
|
+
const segments = relative.split('/').filter(Boolean);
|
|
108
|
+
|
|
109
|
+
// Image / static asset under another lesson — leave as-is. The image plugin
|
|
110
|
+
// handles its own `image` nodes; <a href="…image">…</a> is a manual link and
|
|
111
|
+
// we don't want to break it.
|
|
112
|
+
if (IMAGE_EXT_RE.test(resolved)) {
|
|
113
|
+
return { url: rawUrl, external: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isMarkdown = resolved.endsWith('.md');
|
|
117
|
+
const looksLikeLessonDir =
|
|
118
|
+
segments.length === 2 && pathPart.endsWith('/');
|
|
119
|
+
const looksLikeLessonReadme =
|
|
120
|
+
segments.length === 5 &&
|
|
121
|
+
segments[2] === 'i18n' &&
|
|
122
|
+
isLang(segments[3]) &&
|
|
123
|
+
segments[4] === 'README.md';
|
|
124
|
+
|
|
125
|
+
if (!isMarkdown && !looksLikeLessonDir) {
|
|
126
|
+
// Non-markdown file (e.g. "../foo.txt") — outside the supported subset.
|
|
127
|
+
throw new Error(
|
|
128
|
+
`remark-link-rewrite: link "${rawUrl}" in ${options.moduleId}/${options.slug} ` +
|
|
129
|
+
`points to a non-markdown, non-lesson resource ("${resolved}"). Only ` +
|
|
130
|
+
`lesson README.md and lesson directory links are supported.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isMarkdown && !looksLikeLessonReadme) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`remark-link-rewrite: link "${rawUrl}" in ${options.moduleId}/${options.slug} ` +
|
|
137
|
+
`points to "${segments.join('/')}". Only ` +
|
|
138
|
+
`"<module>/<slug>/i18n/<ru|en>/README.md" markdown links are supported.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const targetModuleId = segments[0];
|
|
143
|
+
const targetSlug = segments[1];
|
|
144
|
+
|
|
145
|
+
const moduleEntry = options.course.modules.find((m) => m.id === targetModuleId);
|
|
146
|
+
if (!moduleEntry) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`remark-link-rewrite: link "${rawUrl}" in ${options.moduleId}/${options.slug} ` +
|
|
149
|
+
`targets unknown module "${targetModuleId}" (not present in course.yaml)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lessonEntry = moduleEntry.lessons.find((l) => l.slug === targetSlug);
|
|
154
|
+
if (!lessonEntry) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`remark-link-rewrite: link "${rawUrl}" in ${options.moduleId}/${options.slug} ` +
|
|
157
|
+
`targets unknown lesson "${targetModuleId}/${targetSlug}" ` +
|
|
158
|
+
`(not present in course.yaml)`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const basePath = options.basePath.replace(/\/+$/, '');
|
|
163
|
+
const siteUrl = `${basePath}/${lang}/${targetModuleId}/${targetSlug}/${hashPart}`;
|
|
164
|
+
return { url: siteUrl, external: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default function remarkLinkRewrite(options: RemarkLinkRewriteOptions) {
|
|
168
|
+
if (
|
|
169
|
+
!options ||
|
|
170
|
+
!options.moduleId ||
|
|
171
|
+
!options.slug ||
|
|
172
|
+
!options.course ||
|
|
173
|
+
typeof options.basePath !== 'string'
|
|
174
|
+
) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'remark-link-rewrite: options.moduleId, options.slug, options.basePath and options.course are required',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return function transformer(tree: AstNode): void {
|
|
181
|
+
walk(tree, (node) => {
|
|
182
|
+
if (node.type !== 'link' && node.type !== 'definition') return;
|
|
183
|
+
const link = node as LinkNode;
|
|
184
|
+
if (typeof link.url !== 'string') return;
|
|
185
|
+
|
|
186
|
+
// mdast `definition` nodes are shared by image and link references.
|
|
187
|
+
// remark-lesson-images already rewrites image-targeted definitions to
|
|
188
|
+
// absolute asset paths (e.g. `/kafka-cookbook/static/lectures/.../foo.png`);
|
|
189
|
+
// those would fail rewriteLessonLink's relative-only check, so skip them.
|
|
190
|
+
// Link nodes still go through full validation — rewriteLessonLink handles
|
|
191
|
+
// relative image URLs internally and preserves protocol/allowlist checks.
|
|
192
|
+
if (
|
|
193
|
+
node.type === 'definition' &&
|
|
194
|
+
link.url.startsWith('/') &&
|
|
195
|
+
IMAGE_EXT_RE.test(link.url)
|
|
196
|
+
) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { url, external } = rewriteLessonLink(link.url, options);
|
|
201
|
+
link.url = url;
|
|
202
|
+
|
|
203
|
+
if (external) {
|
|
204
|
+
link.data = link.data ?? {};
|
|
205
|
+
link.data.hProperties = link.data.hProperties ?? {};
|
|
206
|
+
link.data.hProperties['data-external'] = 'true';
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function walk(node: AstNode, visitor: (node: AstNode) => void): void {
|
|
213
|
+
visitor(node);
|
|
214
|
+
const children = (node as ParentNode).children;
|
|
215
|
+
if (Array.isArray(children)) {
|
|
216
|
+
for (const child of children) {
|
|
217
|
+
walk(child, visitor);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function splitHash(input: string): [string, string] {
|
|
223
|
+
const i = input.indexOf('#');
|
|
224
|
+
if (i === -1) return [input, ''];
|
|
225
|
+
return [input.slice(0, i), input.slice(i)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isInside(candidate: string, root: string): boolean {
|
|
229
|
+
const r = root.endsWith('/') ? root : `${root}/`;
|
|
230
|
+
return candidate === root || candidate.startsWith(r);
|
|
231
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Shared CWD-based resolution for the consumer's course.yaml / lectures dir.
|
|
5
|
+
//
|
|
6
|
+
// The engine ships inside the consumer's node_modules, so __dirname /
|
|
7
|
+
// import.meta.url point into node_modules and must never be used to locate
|
|
8
|
+
// course data. Resolve from process.cwd() instead: builds, vitest and the CLI
|
|
9
|
+
// helpers all run with CWD=web/ (course data one level up), while coverage/TOC
|
|
10
|
+
// tooling may run with CWD=repo-root (course data alongside). Probe `./` before
|
|
11
|
+
// `../` so a stray manifest in the parent of the repo never wins over the one
|
|
12
|
+
// inside the repo when run from the repo root.
|
|
13
|
+
//
|
|
14
|
+
// `lesson.ts` and `course-loader.ts` both call these so their candidate lists
|
|
15
|
+
// can never drift apart. Evaluated lazily (per call) — never as a module-load
|
|
16
|
+
// constant — so the CWD in effect at call time is honoured.
|
|
17
|
+
function firstExisting(candidates: string[]): string {
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
if (existsSync(candidate)) return candidate;
|
|
20
|
+
}
|
|
21
|
+
return candidates[0];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveCourseYaml(): string {
|
|
25
|
+
return firstExisting([
|
|
26
|
+
path.resolve(process.cwd(), 'course.yaml'), // CWD=repo root
|
|
27
|
+
path.resolve(process.cwd(), '..', 'course.yaml'), // CWD=web/
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveLecturesRoot(): string {
|
|
32
|
+
return firstExisting([
|
|
33
|
+
path.resolve(process.cwd(), 'lectures'), // CWD=repo root
|
|
34
|
+
path.resolve(process.cwd(), '..', 'lectures'), // CWD=web/
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Cross-component channel for opening the program drawer. The drawer state
|
|
2
|
+
// lives in AppShell; descendants dispatch this event to open it.
|
|
3
|
+
export const OPEN_PROGRAM_EVENT = 'kafka-cookbook:open-program';
|
|
4
|
+
|
|
5
|
+
export function openProgramDrawer(): void {
|
|
6
|
+
if (typeof window === 'undefined') return;
|
|
7
|
+
window.dispatchEvent(new CustomEvent(OPEN_PROGRAM_EVENT));
|
|
8
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const PROGRESS_DISABLED_STORAGE_KEY = 'kafka-cookbook-progress-disabled';
|
|
2
|
+
export const PROGRESS_DISABLED_ATTR = 'data-progress-disabled';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads the persisted "free reading" flag. The value is stored raw (as in
|
|
6
|
+
* `theme.ts`): the literal string `'true'` means progress is disabled; the
|
|
7
|
+
* absence of the key — or any other value — means progress is enabled
|
|
8
|
+
* (the backwards-compatible default). Returns `false` on any failure so the
|
|
9
|
+
* course keeps its normal gated behaviour when storage is unavailable.
|
|
10
|
+
*/
|
|
11
|
+
export function readProgressDisabled(): boolean {
|
|
12
|
+
if (typeof window === 'undefined') return false;
|
|
13
|
+
try {
|
|
14
|
+
return window.localStorage.getItem(PROGRESS_DISABLED_STORAGE_KEY) === 'true';
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Persists the flag. Writes the literal `'true'` when disabling progress and
|
|
22
|
+
* removes the key entirely when re-enabling it, so the default state leaves no
|
|
23
|
+
* trace in storage.
|
|
24
|
+
*/
|
|
25
|
+
export function writeProgressDisabled(value: boolean): void {
|
|
26
|
+
if (typeof window === 'undefined') return;
|
|
27
|
+
try {
|
|
28
|
+
if (value) {
|
|
29
|
+
window.localStorage.setItem(PROGRESS_DISABLED_STORAGE_KEY, 'true');
|
|
30
|
+
} else {
|
|
31
|
+
window.localStorage.removeItem(PROGRESS_DISABLED_STORAGE_KEY);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
/* storage may be unavailable (private mode, quota); ignore. */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sets/removes `data-progress-disabled` on <html>. When disabled the value
|
|
40
|
+
* matches the stored value (`'true'`); when enabled the attribute is removed.
|
|
41
|
+
*/
|
|
42
|
+
export function applyProgressDisabled(value: boolean): void {
|
|
43
|
+
if (typeof document === 'undefined') return;
|
|
44
|
+
const root = document.documentElement;
|
|
45
|
+
if (value) {
|
|
46
|
+
root.setAttribute(PROGRESS_DISABLED_ATTR, 'true');
|
|
47
|
+
} else {
|
|
48
|
+
root.removeAttribute(PROGRESS_DISABLED_ATTR);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Inline script string injected into <head> before hydration — and crucially
|
|
54
|
+
* before the gate-init script. Reads the stored flag and sets/removes
|
|
55
|
+
* `data-progress-disabled` on <html> synchronously, so a previously-locked
|
|
56
|
+
* lesson never flashes its locked interstitial in free-reading mode (FOUC-free).
|
|
57
|
+
*/
|
|
58
|
+
export const PROGRESS_MODE_INIT_SCRIPT = `(() => {
|
|
59
|
+
try {
|
|
60
|
+
var key = ${JSON.stringify(PROGRESS_DISABLED_STORAGE_KEY)};
|
|
61
|
+
var stored = null;
|
|
62
|
+
try { stored = window.localStorage.getItem(key); } catch (_) {}
|
|
63
|
+
if (stored === 'true') {
|
|
64
|
+
document.documentElement.setAttribute(${JSON.stringify(PROGRESS_DISABLED_ATTR)}, 'true');
|
|
65
|
+
} else {
|
|
66
|
+
document.documentElement.removeAttribute(${JSON.stringify(PROGRESS_DISABLED_ATTR)});
|
|
67
|
+
}
|
|
68
|
+
} catch (_) {}
|
|
69
|
+
})();`;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Course } from './course';
|
|
2
|
+
import { flattenLessons } from './course';
|
|
3
|
+
|
|
4
|
+
export const PROGRESS_STORAGE_KEY = 'kafka-cookbook-progress';
|
|
5
|
+
export const FURTHEST_STORAGE_KEY = 'kafka-cookbook-furthest';
|
|
6
|
+
export const PROGRESS_CHANGE_EVENT = 'kafka-cookbook:progress-change';
|
|
7
|
+
|
|
8
|
+
export type LessonKey = `${string}/${string}`;
|
|
9
|
+
|
|
10
|
+
export interface ProgressEntry {
|
|
11
|
+
completed: true;
|
|
12
|
+
at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ProgressMap = Record<LessonKey, ProgressEntry>;
|
|
16
|
+
|
|
17
|
+
export function lessonKey(moduleId: string, slug: string): LessonKey {
|
|
18
|
+
return `${moduleId}/${slug}` as LessonKey;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getProgress(): ProgressMap {
|
|
22
|
+
if (typeof window === 'undefined') return {};
|
|
23
|
+
try {
|
|
24
|
+
const raw = window.localStorage.getItem(PROGRESS_STORAGE_KEY);
|
|
25
|
+
if (!raw) return {};
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (!isPlainObject(parsed)) return {};
|
|
28
|
+
const result: ProgressMap = {};
|
|
29
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
30
|
+
if (isProgressEntry(value)) {
|
|
31
|
+
result[key as LessonKey] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeProgress(map: ProgressMap): void {
|
|
41
|
+
if (typeof window === 'undefined') return;
|
|
42
|
+
try {
|
|
43
|
+
window.localStorage.setItem(PROGRESS_STORAGE_KEY, JSON.stringify(map));
|
|
44
|
+
} catch {
|
|
45
|
+
/* storage may be unavailable (private mode, quota); ignore. */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function markCompleted(key: LessonKey, now: () => string = () => new Date().toISOString()): ProgressMap {
|
|
50
|
+
const current = getProgress();
|
|
51
|
+
current[key] = { completed: true, at: now() };
|
|
52
|
+
writeProgress(current);
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function unmarkCompleted(key: LessonKey): ProgressMap {
|
|
57
|
+
const current = getProgress();
|
|
58
|
+
delete current[key];
|
|
59
|
+
writeProgress(current);
|
|
60
|
+
return current;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isCompleted(map: ProgressMap, key: LessonKey): boolean {
|
|
64
|
+
return map[key]?.completed === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getCompletedCount(map: ProgressMap): number {
|
|
68
|
+
return Object.values(map).filter((entry) => entry?.completed === true).length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getCompletedPercent(map: ProgressMap, total: number): number {
|
|
72
|
+
if (total <= 0) return 0;
|
|
73
|
+
const ratio = Math.min(getCompletedCount(map), total) / total;
|
|
74
|
+
return Math.round(ratio * 100);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Furthest-reached lesson — a "sticky" pointer used by the gate to decide which
|
|
79
|
+
* lessons are unlocked. Stored separately from progress so unchecking a lesson
|
|
80
|
+
* does not retract access: once you've gone past a point, that ground stays
|
|
81
|
+
* unlocked. The pointer is a LessonKey rather than a numeric index — the
|
|
82
|
+
* course.yaml ordering can shift across releases, but a stable key survives
|
|
83
|
+
* inserts/removals (and we recompute its index on the fly).
|
|
84
|
+
*/
|
|
85
|
+
export function getFurthestKey(): LessonKey | null {
|
|
86
|
+
if (typeof window === 'undefined') return null;
|
|
87
|
+
try {
|
|
88
|
+
const raw = window.localStorage.getItem(FURTHEST_STORAGE_KEY);
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(raw)) return null;
|
|
91
|
+
return raw as LessonKey;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeFurthestKey(key: LessonKey | null): void {
|
|
98
|
+
if (typeof window === 'undefined') return;
|
|
99
|
+
try {
|
|
100
|
+
if (key === null) {
|
|
101
|
+
window.localStorage.removeItem(FURTHEST_STORAGE_KEY);
|
|
102
|
+
} else {
|
|
103
|
+
window.localStorage.setItem(FURTHEST_STORAGE_KEY, key);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
/* storage may be unavailable; ignore. */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Bump the furthest pointer toward `key` if (and only if) `key` sits past the
|
|
112
|
+
* current furthest in linear course order. Unknown keys (not in `course`) are
|
|
113
|
+
* ignored — we don't want a stale lesson key to clobber a valid one.
|
|
114
|
+
*/
|
|
115
|
+
export function bumpFurthest(course: Course, key: LessonKey): LessonKey | null {
|
|
116
|
+
const flat = flattenLessons(course);
|
|
117
|
+
const newIndex = flat.findIndex((e) => lessonKey(e.moduleId, e.lesson.slug) === key);
|
|
118
|
+
if (newIndex === -1) return getFurthestKey();
|
|
119
|
+
|
|
120
|
+
const currentKey = getFurthestKey();
|
|
121
|
+
if (currentKey === null) {
|
|
122
|
+
writeFurthestKey(key);
|
|
123
|
+
return key;
|
|
124
|
+
}
|
|
125
|
+
const currentIndex = flat.findIndex(
|
|
126
|
+
(e) => lessonKey(e.moduleId, e.lesson.slug) === currentKey,
|
|
127
|
+
);
|
|
128
|
+
if (currentIndex === -1 || newIndex > currentIndex) {
|
|
129
|
+
writeFurthestKey(key);
|
|
130
|
+
return key;
|
|
131
|
+
}
|
|
132
|
+
return currentKey;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mark a lesson complete and advance the furthest pointer in one atomic step,
|
|
137
|
+
* then notify listeners. This is the single entry point UI code should use
|
|
138
|
+
* when the user explicitly finishes a lesson (e.g. clicking "Next lesson") —
|
|
139
|
+
* combining the writes keeps the gate state internally consistent.
|
|
140
|
+
*/
|
|
141
|
+
export function markCompletedAndAdvance(course: Course, key: LessonKey): void {
|
|
142
|
+
markCompleted(key);
|
|
143
|
+
bumpFurthest(course, key);
|
|
144
|
+
if (typeof window !== 'undefined') {
|
|
145
|
+
window.dispatchEvent(new Event(PROGRESS_CHANGE_EVENT));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Wipe all stored progress — both the completion map and the furthest-reached
|
|
151
|
+
* pointer — then notify listeners. This is a destructive reset used by the
|
|
152
|
+
* "free reading" mode toggle: turning that mode on drops accumulated progress
|
|
153
|
+
* so it can't reappear if the mode is later switched off.
|
|
154
|
+
*
|
|
155
|
+
* Dispatches PROGRESS_CHANGE_EVENT synchronously. Callers that also flip
|
|
156
|
+
* free-reading mode must set `data-progress-disabled` BEFORE calling this, so
|
|
157
|
+
* the GateProvider effect takes its cleanup branch instead of repainting the
|
|
158
|
+
* gate (which would flash locked rows). See ProgressModeProvider.setDisabled.
|
|
159
|
+
*/
|
|
160
|
+
export function clearProgress(): void {
|
|
161
|
+
if (typeof window === 'undefined') return;
|
|
162
|
+
try {
|
|
163
|
+
window.localStorage.removeItem(PROGRESS_STORAGE_KEY);
|
|
164
|
+
window.localStorage.removeItem(FURTHEST_STORAGE_KEY);
|
|
165
|
+
window.dispatchEvent(new Event(PROGRESS_CHANGE_EVENT));
|
|
166
|
+
} catch {
|
|
167
|
+
/* storage may be unavailable; ignore. */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
172
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isProgressEntry(value: unknown): value is ProgressEntry {
|
|
176
|
+
return (
|
|
177
|
+
isPlainObject(value) &&
|
|
178
|
+
value.completed === true &&
|
|
179
|
+
typeof value.at === 'string' &&
|
|
180
|
+
value.at.length > 0
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export type SizeStep = 0 | 1 | 2 | 3 | 4 | 5;
|
|
2
|
+
export type ProseFont = 'serif' | 'sans' | 'slab';
|
|
3
|
+
export type CodeFont = 'jetbrains' | 'fira';
|
|
4
|
+
|
|
5
|
+
export interface ReadingPrefs {
|
|
6
|
+
proseSize: SizeStep;
|
|
7
|
+
codeSize: SizeStep;
|
|
8
|
+
proseFont: ProseFont;
|
|
9
|
+
codeFont: CodeFont;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Bumped from v1 → v2 when the prose `lora` option became `slab` (Roboto Slab)
|
|
13
|
+
// and the `plex` code option was retired. Old stored values fall back to defaults.
|
|
14
|
+
export const READING_PREFS_STORAGE_KEY = 'kafka-cookbook-reading-prefs:v2';
|
|
15
|
+
|
|
16
|
+
export const PROSE_FONTS = ['serif', 'sans', 'slab'] as const;
|
|
17
|
+
export const CODE_FONTS = ['jetbrains', 'fira'] as const;
|
|
18
|
+
export const SIZE_STEPS = [0, 1, 2, 3, 4, 5] as const;
|
|
19
|
+
|
|
20
|
+
// Step values match design reference: 14 / 16 / 18 / 20 / 22 / 24 px.
|
|
21
|
+
export const DEFAULT_PREFS: ReadingPrefs = {
|
|
22
|
+
proseSize: 1,
|
|
23
|
+
codeSize: 0,
|
|
24
|
+
proseFont: 'serif',
|
|
25
|
+
codeFont: 'jetbrains',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function isProseFont(value: unknown): value is ProseFont {
|
|
29
|
+
return value === 'serif' || value === 'sans' || value === 'slab';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isCodeFont(value: unknown): value is CodeFont {
|
|
33
|
+
return value === 'jetbrains' || value === 'fira';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isSizeStep(value: unknown): value is SizeStep {
|
|
37
|
+
return (
|
|
38
|
+
value === 0 ||
|
|
39
|
+
value === 1 ||
|
|
40
|
+
value === 2 ||
|
|
41
|
+
value === 3 ||
|
|
42
|
+
value === 4 ||
|
|
43
|
+
value === 5
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readStoredPrefs(): ReadingPrefs {
|
|
48
|
+
if (typeof window === 'undefined') return { ...DEFAULT_PREFS };
|
|
49
|
+
let raw: string | null = null;
|
|
50
|
+
try {
|
|
51
|
+
raw = window.localStorage.getItem(READING_PREFS_STORAGE_KEY);
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...DEFAULT_PREFS };
|
|
54
|
+
}
|
|
55
|
+
if (raw == null) return { ...DEFAULT_PREFS };
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
return { ...DEFAULT_PREFS };
|
|
61
|
+
}
|
|
62
|
+
if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_PREFS };
|
|
63
|
+
const obj = parsed as Record<string, unknown>;
|
|
64
|
+
return {
|
|
65
|
+
proseSize: isSizeStep(obj.proseSize) ? obj.proseSize : DEFAULT_PREFS.proseSize,
|
|
66
|
+
codeSize: isSizeStep(obj.codeSize) ? obj.codeSize : DEFAULT_PREFS.codeSize,
|
|
67
|
+
proseFont: isProseFont(obj.proseFont) ? obj.proseFont : DEFAULT_PREFS.proseFont,
|
|
68
|
+
codeFont: isCodeFont(obj.codeFont) ? obj.codeFont : DEFAULT_PREFS.codeFont,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeStoredPrefs(prefs: ReadingPrefs): void {
|
|
73
|
+
if (typeof window === 'undefined') return;
|
|
74
|
+
try {
|
|
75
|
+
window.localStorage.setItem(READING_PREFS_STORAGE_KEY, JSON.stringify(prefs));
|
|
76
|
+
} catch {
|
|
77
|
+
/* storage may be unavailable (private mode, quota); ignore. */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function applyPrefs(prefs: ReadingPrefs): void {
|
|
82
|
+
if (typeof document === 'undefined') return;
|
|
83
|
+
const ds = document.documentElement.dataset;
|
|
84
|
+
ds.proseSize = String(prefs.proseSize);
|
|
85
|
+
ds.codeSize = String(prefs.codeSize);
|
|
86
|
+
ds.proseFont = prefs.proseFont;
|
|
87
|
+
ds.codeFont = prefs.codeFont;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Inline script injected into <head> before hydration. Reads localStorage,
|
|
92
|
+
* validates per-field, and stamps four data-* attributes on <html>
|
|
93
|
+
* synchronously to avoid FOUC.
|
|
94
|
+
*/
|
|
95
|
+
export const READING_PREFS_INIT_SCRIPT = `(() => {
|
|
96
|
+
var KEY = ${JSON.stringify(READING_PREFS_STORAGE_KEY)};
|
|
97
|
+
var defaults = ${JSON.stringify(DEFAULT_PREFS)};
|
|
98
|
+
var sizes = [0, 1, 2, 3, 4, 5];
|
|
99
|
+
var proseFonts = ['serif', 'sans', 'slab'];
|
|
100
|
+
var codeFonts = ['jetbrains', 'fira'];
|
|
101
|
+
var prefs = {
|
|
102
|
+
proseSize: defaults.proseSize,
|
|
103
|
+
codeSize: defaults.codeSize,
|
|
104
|
+
proseFont: defaults.proseFont,
|
|
105
|
+
codeFont: defaults.codeFont,
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
var raw = null;
|
|
109
|
+
try { raw = window.localStorage.getItem(KEY); } catch (_) {}
|
|
110
|
+
if (raw != null) {
|
|
111
|
+
var parsed = JSON.parse(raw);
|
|
112
|
+
if (parsed && typeof parsed === 'object') {
|
|
113
|
+
if (sizes.indexOf(parsed.proseSize) !== -1) prefs.proseSize = parsed.proseSize;
|
|
114
|
+
if (sizes.indexOf(parsed.codeSize) !== -1) prefs.codeSize = parsed.codeSize;
|
|
115
|
+
if (proseFonts.indexOf(parsed.proseFont) !== -1) prefs.proseFont = parsed.proseFont;
|
|
116
|
+
if (codeFonts.indexOf(parsed.codeFont) !== -1) prefs.codeFont = parsed.codeFont;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (_) {}
|
|
120
|
+
try {
|
|
121
|
+
var ds = document.documentElement.dataset;
|
|
122
|
+
ds.proseSize = String(prefs.proseSize);
|
|
123
|
+
ds.codeSize = String(prefs.codeSize);
|
|
124
|
+
ds.proseFont = prefs.proseFont;
|
|
125
|
+
ds.codeFont = prefs.codeFont;
|
|
126
|
+
} catch (_) {}
|
|
127
|
+
})();`;
|