@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,55 @@
|
|
|
1
|
+
import type { Lang } from './lang';
|
|
2
|
+
|
|
3
|
+
export type PluralForms = readonly [one: string, few: string, many: string];
|
|
4
|
+
|
|
5
|
+
export function pluralize(n: number, forms: PluralForms): string {
|
|
6
|
+
const abs = Math.abs(n) % 100;
|
|
7
|
+
const lastDigit = abs % 10;
|
|
8
|
+
if (abs > 10 && abs < 20) return forms[2];
|
|
9
|
+
if (lastDigit > 1 && lastDigit < 5) return forms[1];
|
|
10
|
+
if (lastDigit === 1) return forms[0];
|
|
11
|
+
return forms[2];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const LESSON_FORMS_RU: PluralForms = ['урок', 'урока', 'уроков'];
|
|
15
|
+
const MODULE_FORMS_RU: PluralForms = ['модуль', 'модуля', 'модулей'];
|
|
16
|
+
|
|
17
|
+
export function formatLessonCount(n: number, lang: Lang): string {
|
|
18
|
+
if (lang === 'en') return `${n} ${n === 1 ? 'lesson' : 'lessons'}`;
|
|
19
|
+
return `${n} ${pluralize(n, LESSON_FORMS_RU)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatModuleCount(n: number, lang: Lang): string {
|
|
23
|
+
if (lang === 'en') return `${n} ${n === 1 ? 'module' : 'modules'}`;
|
|
24
|
+
return `${n} ${pluralize(n, MODULE_FORMS_RU)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseDurationMin(input: string): number {
|
|
28
|
+
let total = 0;
|
|
29
|
+
const h = input.match(/(\d+)\s*h/);
|
|
30
|
+
const m = input.match(/(\d+)\s*m/);
|
|
31
|
+
if (h) total += parseInt(h[1], 10) * 60;
|
|
32
|
+
if (m) total += parseInt(m[1], 10);
|
|
33
|
+
return total;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatDurationHm(min: number, lang: Lang): string {
|
|
37
|
+
if (lang === 'en') {
|
|
38
|
+
if (min <= 0) return '0 min';
|
|
39
|
+
const h = Math.floor(min / 60);
|
|
40
|
+
const m = min % 60;
|
|
41
|
+
if (h === 0) return `${m} min`;
|
|
42
|
+
if (m === 0) return `${h} h`;
|
|
43
|
+
return `${h} h ${m} min`;
|
|
44
|
+
}
|
|
45
|
+
if (min <= 0) return '0 мин';
|
|
46
|
+
const h = Math.floor(min / 60);
|
|
47
|
+
const m = min % 60;
|
|
48
|
+
if (h === 0) return `${m} мин`;
|
|
49
|
+
if (m === 0) return `${h} ч`;
|
|
50
|
+
return `${h} ч ${m} мин`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatDurationShort(min: number, lang: Lang): string {
|
|
54
|
+
return lang === 'en' ? `${min}m` : `${min}м`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MouseEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
type AppRouter = {
|
|
4
|
+
push: (href: string) => void;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Click handler for "Continue" CTAs that the gate-paint script rewrites in the
|
|
9
|
+
* DOM. The Next.js `<Link>` keeps its prop `href` (set to a static fallback) so
|
|
10
|
+
* SSR is stable, but at click time we must navigate to whatever the inline
|
|
11
|
+
* gate-paint script wrote into the anchor's `href` attribute — otherwise the
|
|
12
|
+
* label says "lesson N" while the prop-driven router.push lands on the
|
|
13
|
+
* fallback. Behaves like a normal anchor for modifier / non-primary clicks so
|
|
14
|
+
* cmd-click etc. still open the painted href in a new tab.
|
|
15
|
+
*/
|
|
16
|
+
export function navigateToFrontierHref(
|
|
17
|
+
event: MouseEvent<HTMLAnchorElement>,
|
|
18
|
+
router: AppRouter,
|
|
19
|
+
basePath: string,
|
|
20
|
+
): void {
|
|
21
|
+
if (
|
|
22
|
+
event.defaultPrevented ||
|
|
23
|
+
event.button !== 0 ||
|
|
24
|
+
event.metaKey ||
|
|
25
|
+
event.ctrlKey ||
|
|
26
|
+
event.shiftKey ||
|
|
27
|
+
event.altKey
|
|
28
|
+
) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const href = event.currentTarget.getAttribute('href');
|
|
32
|
+
if (!href || href === '#') return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
const stripped =
|
|
35
|
+
basePath && href.startsWith(`${basePath}/`) ? href.slice(basePath.length) : href;
|
|
36
|
+
router.push(stripped);
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { flattenLessons, type Course } from './course';
|
|
2
|
+
import { FURTHEST_STORAGE_KEY, PROGRESS_STORAGE_KEY, lessonKey } from './progress';
|
|
3
|
+
|
|
4
|
+
export const GATE_LOCKED_ATTR = 'data-lesson-locked';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Inline script that runs synchronously in <head>, before <body> is parsed,
|
|
8
|
+
* to decide whether the current URL points at a locked lesson. If so it
|
|
9
|
+
* stamps `data-lesson-locked="true"` on <html>; CSS then hides the lesson
|
|
10
|
+
* body and reveals the interstitial — no flash of content while React
|
|
11
|
+
* hydrates. After hydration GateProvider keeps the attribute in sync.
|
|
12
|
+
*
|
|
13
|
+
* The script is intentionally pre-compiled rather than emitted via JSX so
|
|
14
|
+
* the linear lesson order from course.yaml is baked into the bundle at
|
|
15
|
+
* build time (no runtime fetch of the same data we already know).
|
|
16
|
+
*/
|
|
17
|
+
export function buildGateInitScript(course: Course, basePath: string): string {
|
|
18
|
+
const linearKeys = flattenLessons(course).map((e) => lessonKey(e.moduleId, e.lesson.slug));
|
|
19
|
+
const data = JSON.stringify({
|
|
20
|
+
keys: linearKeys,
|
|
21
|
+
basePath: basePath ?? '',
|
|
22
|
+
progressKey: PROGRESS_STORAGE_KEY,
|
|
23
|
+
furthestKey: FURTHEST_STORAGE_KEY,
|
|
24
|
+
attr: GATE_LOCKED_ATTR,
|
|
25
|
+
}).replace(/</g, '\\u003c');
|
|
26
|
+
// Inline lang-strip mirrors `stripLangFromPath` from lib/lang.ts: removes a
|
|
27
|
+
// leading `/ru/` or `/en/` segment so the parser sees `[moduleId, slug]` as
|
|
28
|
+
// its first two segments regardless of the active language route. basePath
|
|
29
|
+
// strip is segment-aware (mirrors LessonAwareLink) so `/foo` does not match
|
|
30
|
+
// `/foobar/...`. The frontier index merges the stored `furthest` pointer
|
|
31
|
+
// and the highest completed entry in `progress` (mirrors `resolveFurthestIndex`
|
|
32
|
+
// in lesson-gate.ts); without merging both, a cross-tab race where progress
|
|
33
|
+
// overtakes the pointer would flash the locked interstitial before hydration.
|
|
34
|
+
// Free-reading mode short-circuit: progress-mode-init runs earlier in <head>
|
|
35
|
+
// and may have stamped data-progress-disabled on <html>. When it has, no
|
|
36
|
+
// lesson is ever locked, so skip the gate computation entirely (and never set
|
|
37
|
+
// data-lesson-locked) — keeps the interstitial from flashing for a lesson the
|
|
38
|
+
// user has chosen to read freely.
|
|
39
|
+
return `(function(){if(document.documentElement.getAttribute('data-progress-disabled')==='true')return;try{var D=${data};var p=window.location.pathname;var base=D.basePath;if(base&&(p===base||p.indexOf(base+'/')===0)){p=p.slice(base.length)||'/'}var lm=p.match(/^\\/(ru|en)(\\/.*)?$/);if(lm){p=lm[2]&&lm[2].length>0?lm[2]:'/'}var m=p.replace(/^\\/+|\\/+$/g,'').split('/');if(m.length<2)return;var key=m[0]+'/'+m[1];var idx=D.keys.indexOf(key);if(idx<0)return;var fkey=null;try{fkey=window.localStorage.getItem(D.furthestKey)}catch(e){}var fidx=fkey?D.keys.indexOf(fkey):-1;try{var raw=window.localStorage.getItem(D.progressKey);if(raw){var pr=JSON.parse(raw);for(var i=0;i<D.keys.length;i++){var v=pr[D.keys[i]];if(v&&v.completed===true&&i>fidx){fidx=i}}}}catch(e){}if(idx>fidx+1){document.documentElement.setAttribute(D.attr,'true')}}catch(e){}})();`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { flattenLessons, type Course } from './course';
|
|
2
|
+
import { getDict } from './i18n';
|
|
3
|
+
import { LANGS, type Lang } from './lang';
|
|
4
|
+
import { FURTHEST_STORAGE_KEY, PROGRESS_STORAGE_KEY, lessonKey, type LessonKey } from './progress';
|
|
5
|
+
|
|
6
|
+
export const GATE_ITEM_LOCKED_ATTR = 'data-locked';
|
|
7
|
+
export const GATE_ITEM_KEY_ATTR = 'data-lesson-key';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Single inline script that "paints" the SSR-rendered page against the user's
|
|
11
|
+
* actual progress *synchronously, before first paint*. JSX outputs a stable
|
|
12
|
+
* pre-hydrated baseline (everything 0%, nothing completed, default CTA copy);
|
|
13
|
+
* this script reads localStorage, computes furthest/completed/percent, and
|
|
14
|
+
* mutates the DOM directly — setting attributes on lesson rows and rewriting
|
|
15
|
+
* text/href in dedicated slots. Avoids the React-driven re-render flash that
|
|
16
|
+
* useEffect-based hydration causes.
|
|
17
|
+
*
|
|
18
|
+
* The same logic is mirrored in {@link applyGatePainting} for post-hydration
|
|
19
|
+
* updates (SPA route changes, cross-tab progress sync, drawer expand). React
|
|
20
|
+
* never sees these attributes in JSX, so reconciliation can't clobber them.
|
|
21
|
+
*/
|
|
22
|
+
export function buildGateMarkScript(
|
|
23
|
+
coursesByLang: Record<Lang, Course>,
|
|
24
|
+
basePath: string,
|
|
25
|
+
defaultLang: Lang,
|
|
26
|
+
): string {
|
|
27
|
+
// Lesson order is the same across languages (slugs/ids don't change), so use
|
|
28
|
+
// any course as the structural reference. Titles vary per lang and are sent
|
|
29
|
+
// as parallel arrays indexed by lang.
|
|
30
|
+
const referenceCourse = coursesByLang[defaultLang];
|
|
31
|
+
const flat = flattenLessons(referenceCourse);
|
|
32
|
+
const titlesByLang: Partial<Record<Lang, string[]>> = {};
|
|
33
|
+
const moduleTitlesByLang: Partial<Record<Lang, string[]>> = {};
|
|
34
|
+
for (const lang of LANGS) {
|
|
35
|
+
const langFlat = flattenLessons(coursesByLang[lang]);
|
|
36
|
+
titlesByLang[lang] = langFlat.map((e) => e.lesson.title);
|
|
37
|
+
moduleTitlesByLang[lang] = langFlat.map((e) => {
|
|
38
|
+
const mod = coursesByLang[lang].modules.find((m) => m.id === e.moduleId);
|
|
39
|
+
return mod?.title ?? '';
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const data = JSON.stringify({
|
|
43
|
+
keys: flat.map((e) => lessonKey(e.moduleId, e.lesson.slug)),
|
|
44
|
+
titlesByLang,
|
|
45
|
+
moduleTitlesByLang,
|
|
46
|
+
basePath: basePath ?? '',
|
|
47
|
+
progressKey: PROGRESS_STORAGE_KEY,
|
|
48
|
+
furthestKey: FURTHEST_STORAGE_KEY,
|
|
49
|
+
defaultLang,
|
|
50
|
+
// Per-lang connector word for aria-valuetext ("X из Y" / "X of Y").
|
|
51
|
+
// Lookup at runtime by the lang derived from URL prefix.
|
|
52
|
+
progressConnector: {
|
|
53
|
+
ru: getDict('ru').progressAriaConnector,
|
|
54
|
+
en: getDict('en').progressAriaConnector,
|
|
55
|
+
},
|
|
56
|
+
// Escape `<` so a maintainer-supplied title containing `</script>` cannot
|
|
57
|
+
// prematurely close the inline <script> tag during HTML parsing.
|
|
58
|
+
}).replace(/</g, '\\u003c');
|
|
59
|
+
// Body kept in one IIFE so it can ship as inline <script> verbatim.
|
|
60
|
+
// Free-reading mode short-circuit: progress-mode-init (in <head>) has already
|
|
61
|
+
// run by the time gate-mark executes at the end of <body>, so if it stamped
|
|
62
|
+
// data-progress-disabled on <html> we paint nothing — no data-locked, no
|
|
63
|
+
// percentages — leaving the SSR baseline untouched in free-reading mode.
|
|
64
|
+
return `(function(){if(document.documentElement.getAttribute('data-progress-disabled')==='true')return;try{var D=${data};${GATE_PAINT_BODY}}catch(e){}})();`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Plain JS body. Extracted as a constant so the imperative `applyGatePainting`
|
|
68
|
+
// stays in lockstep with the inline script — same algorithm, same attribute
|
|
69
|
+
// names, same edge cases.
|
|
70
|
+
const GATE_PAINT_BODY = `
|
|
71
|
+
// Derive active lang from URL so the inline CTA href stays in /<lang>/.../ —
|
|
72
|
+
// the root layout (which renders this script) has no lang context, but the
|
|
73
|
+
// browser does. Mirrors stripLangFromPath in lib/lang.ts.
|
|
74
|
+
var __p=window.location.pathname;if(D.basePath&&(__p===D.basePath||__p.indexOf(D.basePath+'/')===0))__p=__p.slice(D.basePath.length)||'/';var __lm=__p.match(/^\\/(ru|en)(\\/.*)?$/);var lang=__lm?__lm[1]:D.defaultLang;var connector=(D.progressConnector&&D.progressConnector[lang])||'of';var titles=(D.titlesByLang&&D.titlesByLang[lang])||D.titlesByLang[D.defaultLang]||[];var moduleTitles=(D.moduleTitlesByLang&&D.moduleTitlesByLang[lang])||D.moduleTitlesByLang[D.defaultLang]||[];
|
|
75
|
+
var fkey=null;try{fkey=window.localStorage.getItem(D.furthestKey)}catch(e){}
|
|
76
|
+
var fidx=fkey?D.keys.indexOf(fkey):-1;
|
|
77
|
+
var completed={};var completedCount=0;
|
|
78
|
+
try{var raw=window.localStorage.getItem(D.progressKey);if(raw){var pr=JSON.parse(raw);for(var i=0;i<D.keys.length;i++){var k=D.keys[i];if(pr[k]&&pr[k].completed===true){completed[k]=true;completedCount++;if(i>fidx)fidx=i}}}}catch(e){}
|
|
79
|
+
var hasProgress=completedCount>0||!!fkey;
|
|
80
|
+
var frontierIdx=Math.max(0,Math.min(fidx+1,D.keys.length-1));
|
|
81
|
+
|
|
82
|
+
// 1. Lesson rows: data-locked + data-completed.
|
|
83
|
+
var rows=document.querySelectorAll('[data-lesson-key]');
|
|
84
|
+
for(var j=0;j<rows.length;j++){var el=rows[j];var k2=el.getAttribute('data-lesson-key');var idx=D.keys.indexOf(k2);el.removeAttribute('data-locked');el.removeAttribute('data-completed');el.removeAttribute('data-next');el.removeAttribute('aria-disabled');if(idx>=0&&idx>fidx+1){el.setAttribute('data-locked','true');el.setAttribute('aria-disabled','true');el.setAttribute('tabindex','-1')}else{el.removeAttribute('tabindex')}if(completed[k2]){el.setAttribute('data-completed','true')}}
|
|
85
|
+
|
|
86
|
+
// 2. "Next" marker within each group (first non-completed in DOM order).
|
|
87
|
+
var groups=document.querySelectorAll('[data-lesson-group]');
|
|
88
|
+
for(var g=0;g<groups.length;g++){var gr=groups[g].querySelectorAll('[data-lesson-key]');for(var x=0;x<gr.length;x++){gr[x].removeAttribute('data-next')}for(var x2=0;x2<gr.length;x2++){if(!gr[x2].hasAttribute('data-completed')){gr[x2].setAttribute('data-next','true');break}}}
|
|
89
|
+
|
|
90
|
+
// 3. Progress slots: numbers, percent, bar fill. Scope = 'global' uses the
|
|
91
|
+
// whole course; scope = 'module' uses data-progress-keys (csv) for in-page
|
|
92
|
+
// per-module cards.
|
|
93
|
+
var slots=document.querySelectorAll('[data-progress-scope]');
|
|
94
|
+
for(var p=0;p<slots.length;p++){var s=slots[p];var scope=s.getAttribute('data-progress-scope');var sKeys;if(scope==='module'){var csv=s.getAttribute('data-progress-keys')||'';sKeys=csv?csv.split(','):[]}else{sKeys=D.keys}var sDone=0;for(var i2=0;i2<sKeys.length;i2++){if(completed[sKeys[i2]])sDone++}var sTotal=sKeys.length;var sPct=sTotal>0?Math.round((sDone/sTotal)*100):0;var qN=s.querySelectorAll('[data-progress-count]');for(var i3=0;i3<qN.length;i3++)qN[i3].textContent=sDone;var qP=s.querySelectorAll('[data-progress-pct]');for(var i4=0;i4<qP.length;i4++)qP[i4].textContent=sPct;var qB=s.querySelectorAll('[data-progress-bar]');for(var i5=0;i5<qB.length;i5++)qB[i5].style.width=sPct+'%';var state=sTotal===0?'empty':sDone===0?'not-started':sDone===sTotal?'complete':'in-progress';s.setAttribute('data-progress-state',state);if(s.getAttribute('role')==='progressbar'){s.setAttribute('aria-valuenow',String(sDone));s.setAttribute('aria-valuemax',String(sTotal));s.setAttribute('aria-valuetext',sDone+' '+connector+' '+sTotal+' ('+sPct+'%)')}}
|
|
95
|
+
|
|
96
|
+
// 4. Module rows on HomePage: each row carries data-progress-keys + slot for
|
|
97
|
+
// its own count/pct/state. Re-uses the same logic above by being matched
|
|
98
|
+
// via data-progress-scope='module'.
|
|
99
|
+
|
|
100
|
+
// 5. CTA frontier rows: scope = 'global' picks the global frontier; scope =
|
|
101
|
+
// 'module' picks the first non-completed lesson in the module, falling
|
|
102
|
+
// back to the global frontier if that's locked. The row carries variants
|
|
103
|
+
// (not-started, in-progress, complete) and we set data-cta-state on it
|
|
104
|
+
// so CSS hides the unused variants.
|
|
105
|
+
var ctas=document.querySelectorAll('[data-cta-frontier]');
|
|
106
|
+
for(var c=0;c<ctas.length;c++){var row=ctas[c];var ctaScope=row.getAttribute('data-cta-frontier');var targetIdx=-1;var ctaState='not-started';if(ctaScope==='module'){var mcsv=row.getAttribute('data-progress-keys')||'';var mKeys=mcsv?mcsv.split(','):[];var mDone=0;var mNext=null;for(var i6=0;i6<mKeys.length;i6++){if(completed[mKeys[i6]]){mDone++}else if(mNext===null){mNext=mKeys[i6]}}var mTotal=mKeys.length;if(mTotal>0&&mDone===mTotal){ctaState='complete';targetIdx=D.keys.indexOf(mKeys[0])}else if(mNext!==null){var nextIdx=D.keys.indexOf(mNext);if(nextIdx>fidx+1){targetIdx=frontierIdx}else{targetIdx=nextIdx}ctaState=mDone>0?'in-progress':hasProgress?'in-progress':'not-started'}}else{targetIdx=frontierIdx;ctaState=hasProgress?'in-progress':'not-started'}if(targetIdx<0||targetIdx>=D.keys.length){row.setAttribute('data-cta-state',ctaState);continue}var tKey=D.keys[targetIdx];var tTitle=titles[targetIdx];var hrefVal=(D.basePath||'')+'/'+lang+'/'+tKey+'/';var links=row.querySelectorAll('[data-cta-frontier-link]');for(var i7=0;i7<links.length;i7++)links[i7].setAttribute('href',hrefVal);var tTitleEls=row.querySelectorAll('[data-cta-frontier-title]');for(var i8=0;i8<tTitleEls.length;i8++)tTitleEls[i8].textContent=tTitle;var tNumEls=row.querySelectorAll('[data-cta-frontier-num]');for(var i9=0;i9<tNumEls.length;i9++)tNumEls[i9].textContent=String(targetIdx+1);row.setAttribute('data-cta-state',ctaState)}
|
|
107
|
+
|
|
108
|
+
// 6. Continue hints (HomePage frontier line under CTA, interstitial side card).
|
|
109
|
+
var hints=document.querySelectorAll('[data-frontier-hint]');
|
|
110
|
+
if(hints.length>0){var hKey=D.keys[frontierIdx];var hTitle=titles[frontierIdx];var hMod=moduleTitles[frontierIdx];for(var i10=0;i10<hints.length;i10++){var h=hints[i10];var hLes=h.querySelectorAll('[data-frontier-hint-lesson]');var hMo=h.querySelectorAll('[data-frontier-hint-module]');if(hKey){h.setAttribute('data-hint-state',hasProgress?'visible':'hidden')}else{h.setAttribute('data-hint-state','hidden')}for(var i11=0;i11<hLes.length;i11++)hLes[i11].textContent=hTitle;for(var i12=0;i12<hMo.length;i12++)hMo[i12].textContent=hMod}}
|
|
111
|
+
|
|
112
|
+
// 7. Steps-until counters (interstitial "до этого урока N шагов"). The slot
|
|
113
|
+
// carries data-steps-target-index = linear index of the locked target;
|
|
114
|
+
// the slot text becomes max(0, target - frontier) and data-steps-state
|
|
115
|
+
// is flipped so CSS hides the row when there's effectively no gap.
|
|
116
|
+
var steps=document.querySelectorAll('[data-steps-until]');
|
|
117
|
+
for(var sx=0;sx<steps.length;sx++){var sel=steps[sx];var tgt=parseInt(sel.getAttribute('data-steps-target-index')||'-1',10);var diff=tgt>=0?Math.max(0,tgt-frontierIdx):0;sel.textContent=String(diff);sel.setAttribute('data-steps-state',diff>0?'visible':'hidden')}
|
|
118
|
+
|
|
119
|
+
document.documentElement.setAttribute('data-has-progress',hasProgress?'true':'false');
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
export function applyGatePainting(
|
|
123
|
+
course: Course,
|
|
124
|
+
furthestIndex: number,
|
|
125
|
+
basePath: string,
|
|
126
|
+
lang: Lang,
|
|
127
|
+
): void {
|
|
128
|
+
if (typeof document === 'undefined') return;
|
|
129
|
+
const flat = flattenLessons(course);
|
|
130
|
+
const keys = flat.map((e) => lessonKey(e.moduleId, e.lesson.slug));
|
|
131
|
+
const titles = flat.map((e) => e.lesson.title);
|
|
132
|
+
|
|
133
|
+
// Pull live progress from localStorage so this mirrors what the inline
|
|
134
|
+
// script saw. Using the same source on both paths (initial paint and
|
|
135
|
+
// subsequent updates) keeps state consistent.
|
|
136
|
+
const moduleTitleByIndex: string[] = [];
|
|
137
|
+
for (const entry of flat) {
|
|
138
|
+
const mod = course.modules.find((m) => m.id === entry.moduleId);
|
|
139
|
+
moduleTitleByIndex.push(mod?.title ?? '');
|
|
140
|
+
}
|
|
141
|
+
const completed: Record<string, boolean> = {};
|
|
142
|
+
let completedCount = 0;
|
|
143
|
+
let fidx = furthestIndex;
|
|
144
|
+
try {
|
|
145
|
+
const raw = window.localStorage.getItem(PROGRESS_STORAGE_KEY);
|
|
146
|
+
if (raw) {
|
|
147
|
+
const pr = JSON.parse(raw) as Record<string, { completed?: boolean } | undefined>;
|
|
148
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
149
|
+
const k = keys[i];
|
|
150
|
+
if (pr[k]?.completed === true) {
|
|
151
|
+
completed[k] = true;
|
|
152
|
+
completedCount += 1;
|
|
153
|
+
if (i > fidx) fidx = i;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore — leave completed empty */
|
|
159
|
+
}
|
|
160
|
+
let furthestKeyExists: string | null = null;
|
|
161
|
+
try {
|
|
162
|
+
furthestKeyExists = window.localStorage.getItem(FURTHEST_STORAGE_KEY);
|
|
163
|
+
} catch {
|
|
164
|
+
/* ignore */
|
|
165
|
+
}
|
|
166
|
+
const hasProgress = completedCount > 0 || !!furthestKeyExists;
|
|
167
|
+
const frontierIdx = Math.max(0, Math.min(fidx + 1, keys.length - 1));
|
|
168
|
+
|
|
169
|
+
// 1. Lesson rows.
|
|
170
|
+
document.querySelectorAll<HTMLElement>('[data-lesson-key]').forEach((el) => {
|
|
171
|
+
const k = (el.getAttribute('data-lesson-key') ?? '') as LessonKey;
|
|
172
|
+
const idx = keys.indexOf(k);
|
|
173
|
+
el.removeAttribute('data-locked');
|
|
174
|
+
el.removeAttribute('data-completed');
|
|
175
|
+
el.removeAttribute('data-next');
|
|
176
|
+
el.removeAttribute('aria-disabled');
|
|
177
|
+
if (idx >= 0 && idx > fidx + 1) {
|
|
178
|
+
el.setAttribute('data-locked', 'true');
|
|
179
|
+
el.setAttribute('aria-disabled', 'true');
|
|
180
|
+
el.setAttribute('tabindex', '-1');
|
|
181
|
+
} else {
|
|
182
|
+
el.removeAttribute('tabindex');
|
|
183
|
+
}
|
|
184
|
+
if (completed[k]) {
|
|
185
|
+
el.setAttribute('data-completed', 'true');
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 2. Next markers per group.
|
|
190
|
+
document.querySelectorAll<HTMLElement>('[data-lesson-group]').forEach((group) => {
|
|
191
|
+
const rows = group.querySelectorAll<HTMLElement>('[data-lesson-key]');
|
|
192
|
+
rows.forEach((r) => r.removeAttribute('data-next'));
|
|
193
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
194
|
+
if (!rows[i].hasAttribute('data-completed')) {
|
|
195
|
+
rows[i].setAttribute('data-next', 'true');
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 3. Progress slots.
|
|
202
|
+
document.querySelectorAll<HTMLElement>('[data-progress-scope]').forEach((slot) => {
|
|
203
|
+
const scope = slot.getAttribute('data-progress-scope');
|
|
204
|
+
let slotKeys: string[];
|
|
205
|
+
if (scope === 'module') {
|
|
206
|
+
const csv = slot.getAttribute('data-progress-keys') ?? '';
|
|
207
|
+
slotKeys = csv ? csv.split(',') : [];
|
|
208
|
+
} else {
|
|
209
|
+
slotKeys = keys;
|
|
210
|
+
}
|
|
211
|
+
let slotDone = 0;
|
|
212
|
+
for (const k of slotKeys) if (completed[k]) slotDone += 1;
|
|
213
|
+
const slotTotal = slotKeys.length;
|
|
214
|
+
const slotPct = slotTotal > 0 ? Math.round((slotDone / slotTotal) * 100) : 0;
|
|
215
|
+
slot.querySelectorAll<HTMLElement>('[data-progress-count]').forEach((n) => {
|
|
216
|
+
n.textContent = String(slotDone);
|
|
217
|
+
});
|
|
218
|
+
slot.querySelectorAll<HTMLElement>('[data-progress-pct]').forEach((n) => {
|
|
219
|
+
n.textContent = String(slotPct);
|
|
220
|
+
});
|
|
221
|
+
slot.querySelectorAll<HTMLElement>('[data-progress-bar]').forEach((bar) => {
|
|
222
|
+
bar.style.width = `${slotPct}%`;
|
|
223
|
+
});
|
|
224
|
+
const state =
|
|
225
|
+
slotTotal === 0
|
|
226
|
+
? 'empty'
|
|
227
|
+
: slotDone === 0
|
|
228
|
+
? 'not-started'
|
|
229
|
+
: slotDone === slotTotal
|
|
230
|
+
? 'complete'
|
|
231
|
+
: 'in-progress';
|
|
232
|
+
slot.setAttribute('data-progress-state', state);
|
|
233
|
+
// Mirror values onto ARIA attributes when this slot is also a
|
|
234
|
+
// progressbar (Header ProgressBar uses role=progressbar). Other slots
|
|
235
|
+
// (HomePage stats card, ModulePage side card) carry the same data but
|
|
236
|
+
// present it as plain text — no role, so we don't pollute them with
|
|
237
|
+
// misleading ARIA values.
|
|
238
|
+
if (slot.getAttribute('role') === 'progressbar') {
|
|
239
|
+
slot.setAttribute('aria-valuenow', String(slotDone));
|
|
240
|
+
slot.setAttribute('aria-valuemax', String(slotTotal));
|
|
241
|
+
slot.setAttribute(
|
|
242
|
+
'aria-valuetext',
|
|
243
|
+
`${slotDone} ${getDict(lang).progressAriaConnector} ${slotTotal} (${slotPct}%)`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// 4. CTA frontier.
|
|
249
|
+
document.querySelectorAll<HTMLElement>('[data-cta-frontier]').forEach((row) => {
|
|
250
|
+
const ctaScope = row.getAttribute('data-cta-frontier');
|
|
251
|
+
let targetIdx = -1;
|
|
252
|
+
let ctaState = 'not-started';
|
|
253
|
+
if (ctaScope === 'module') {
|
|
254
|
+
const csv = row.getAttribute('data-progress-keys') ?? '';
|
|
255
|
+
const mKeys = csv ? csv.split(',') : [];
|
|
256
|
+
let mDone = 0;
|
|
257
|
+
let mNext: string | null = null;
|
|
258
|
+
for (const k of mKeys) {
|
|
259
|
+
if (completed[k]) {
|
|
260
|
+
mDone += 1;
|
|
261
|
+
} else if (mNext === null) {
|
|
262
|
+
mNext = k;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const mTotal = mKeys.length;
|
|
266
|
+
if (mTotal > 0 && mDone === mTotal) {
|
|
267
|
+
ctaState = 'complete';
|
|
268
|
+
targetIdx = keys.indexOf(mKeys[0] as LessonKey);
|
|
269
|
+
} else if (mNext !== null) {
|
|
270
|
+
const nextIdx = keys.indexOf(mNext as LessonKey);
|
|
271
|
+
targetIdx = nextIdx > fidx + 1 ? frontierIdx : nextIdx;
|
|
272
|
+
ctaState = mDone > 0 || hasProgress ? 'in-progress' : 'not-started';
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
targetIdx = frontierIdx;
|
|
276
|
+
ctaState = hasProgress ? 'in-progress' : 'not-started';
|
|
277
|
+
}
|
|
278
|
+
if (targetIdx >= 0 && targetIdx < keys.length) {
|
|
279
|
+
const tKey = keys[targetIdx];
|
|
280
|
+
const tTitle = titles[targetIdx];
|
|
281
|
+
const hrefVal = `${basePath || ''}/${lang}/${tKey}/`;
|
|
282
|
+
row.querySelectorAll<HTMLElement>('[data-cta-frontier-link]').forEach((link) => {
|
|
283
|
+
link.setAttribute('href', hrefVal);
|
|
284
|
+
});
|
|
285
|
+
row.querySelectorAll<HTMLElement>('[data-cta-frontier-title]').forEach((n) => {
|
|
286
|
+
n.textContent = tTitle;
|
|
287
|
+
});
|
|
288
|
+
row.querySelectorAll<HTMLElement>('[data-cta-frontier-num]').forEach((n) => {
|
|
289
|
+
n.textContent = String(targetIdx + 1);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
row.setAttribute('data-cta-state', ctaState);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// 5. Frontier hint (HomePage line + interstitial side card).
|
|
296
|
+
document.querySelectorAll<HTMLElement>('[data-frontier-hint]').forEach((h) => {
|
|
297
|
+
const tTitle = titles[frontierIdx] ?? '';
|
|
298
|
+
const tModule = moduleTitleByIndex[frontierIdx] ?? '';
|
|
299
|
+
h.querySelectorAll<HTMLElement>('[data-frontier-hint-lesson]').forEach((n) => {
|
|
300
|
+
n.textContent = tTitle;
|
|
301
|
+
});
|
|
302
|
+
h.querySelectorAll<HTMLElement>('[data-frontier-hint-module]').forEach((n) => {
|
|
303
|
+
n.textContent = tModule;
|
|
304
|
+
});
|
|
305
|
+
h.setAttribute('data-hint-state', hasProgress ? 'visible' : 'hidden');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// 6. Steps-until counters.
|
|
309
|
+
document.querySelectorAll<HTMLElement>('[data-steps-until]').forEach((slot) => {
|
|
310
|
+
const tgt = Number.parseInt(
|
|
311
|
+
slot.getAttribute('data-steps-target-index') ?? '-1',
|
|
312
|
+
10,
|
|
313
|
+
);
|
|
314
|
+
const diff = tgt >= 0 ? Math.max(0, tgt - frontierIdx) : 0;
|
|
315
|
+
slot.textContent = String(diff);
|
|
316
|
+
slot.setAttribute('data-steps-state', diff > 0 ? 'visible' : 'hidden');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
document.documentElement.setAttribute(
|
|
320
|
+
'data-has-progress',
|
|
321
|
+
hasProgress ? 'true' : 'false',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|