@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
package/src/config.d.mts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { NextConfig } from 'next';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the Next.js config for a cookbook course consumer. Reads `course.yaml`
|
|
5
|
+
* (via process.cwd()) for `basePath`, enables the static export pipeline, wires
|
|
6
|
+
* `transpilePackages` for the engine, and injects `course.brand.siteUrl` into
|
|
7
|
+
* `env.NEXT_PUBLIC_SITE_URL` at build time when present.
|
|
8
|
+
*/
|
|
9
|
+
export function createCookbookConfig(overrides?: NextConfig): NextConfig;
|
|
10
|
+
|
|
11
|
+
declare const _default: typeof createCookbookConfig;
|
|
12
|
+
export default _default;
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
|
|
6
|
+
const PACKAGE_NAME = '@dsbasko/cookbook-engine';
|
|
7
|
+
|
|
8
|
+
// Absolute path to the engine's own `src/` dir. This file lives at
|
|
9
|
+
// `<engineRoot>/src/config.mjs`, so dirname(import.meta.url) IS that dir even
|
|
10
|
+
// when the engine is installed under the consumer's node_modules. Note the
|
|
11
|
+
// asymmetry with course.yaml resolution: course.yaml MUST come from the
|
|
12
|
+
// consumer (process.cwd()), but the engine's source is resolved relative to the
|
|
13
|
+
// engine itself — that is exactly what import.meta.url gives here.
|
|
14
|
+
const ENGINE_SRC = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
// Resolve the consumer's course.yaml relative to process.cwd() — NEVER via
|
|
17
|
+
// import.meta.url/__dirname. The engine lives in node_modules, so a __dirname
|
|
18
|
+
// probe would point inside the package and the build would crash at startup
|
|
19
|
+
// (see plan Task 0b verdict). The consumer runs `next build` from web/, so we
|
|
20
|
+
// probe both CWD=repo-root (`./course.yaml`) and CWD=web/ (`../course.yaml`),
|
|
21
|
+
// matching the candidate lists in lib/course-loader.ts.
|
|
22
|
+
function resolveCourseYamlPath() {
|
|
23
|
+
const candidates = [
|
|
24
|
+
path.resolve(process.cwd(), 'course.yaml'),
|
|
25
|
+
path.resolve(process.cwd(), '..', 'course.yaml'),
|
|
26
|
+
];
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
if (existsSync(candidate)) return candidate;
|
|
29
|
+
}
|
|
30
|
+
return candidates[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readCourse() {
|
|
34
|
+
const courseYamlPath = resolveCourseYamlPath();
|
|
35
|
+
const course = yaml.load(readFileSync(courseYamlPath, 'utf8'));
|
|
36
|
+
if (!course || typeof course.basePath !== 'string' || !course.basePath.startsWith('/')) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`createCookbookConfig: course.yaml at ${courseYamlPath} must define a basePath string starting with '/'`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return course;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Builds the Next.js config for a cookbook course consumer.
|
|
46
|
+
*
|
|
47
|
+
* Reads `course.yaml` (via process.cwd()) for `basePath`, enables the static
|
|
48
|
+
* export pipeline, wires `transpilePackages` for the engine, and — when
|
|
49
|
+
* `course.brand.siteUrl` is set — injects it into `env.NEXT_PUBLIC_SITE_URL`
|
|
50
|
+
* at build time so the client-only `getSiteUrl()` stays env-based.
|
|
51
|
+
*
|
|
52
|
+
* @param {import('next').NextConfig} [overrides] shallow-merged over the base config.
|
|
53
|
+
* @returns {import('next').NextConfig}
|
|
54
|
+
*/
|
|
55
|
+
export function createCookbookConfig(overrides = {}) {
|
|
56
|
+
const course = readCourse();
|
|
57
|
+
const coursePath = course.basePath;
|
|
58
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
59
|
+
|
|
60
|
+
// brand.siteUrl is sugar over NEXT_PUBLIC_SITE_URL: surface it at build time
|
|
61
|
+
// so the client bundle inlines it. An explicit env var still wins.
|
|
62
|
+
const siteUrl =
|
|
63
|
+
course.brand && typeof course.brand.siteUrl === 'string' && course.brand.siteUrl.trim().length > 0
|
|
64
|
+
? course.brand.siteUrl.trim()
|
|
65
|
+
: undefined;
|
|
66
|
+
const env = {
|
|
67
|
+
...(siteUrl && !process.env.NEXT_PUBLIC_SITE_URL ? { NEXT_PUBLIC_SITE_URL: siteUrl } : {}),
|
|
68
|
+
...(overrides.env ?? {}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The engine source uses the `@/*` path alias internally (TS-only). Under
|
|
72
|
+
// transpilePackages the consumer's webpack compiles those files, so it must
|
|
73
|
+
// resolve `@/foo` to the engine's own src — otherwise every engine import
|
|
74
|
+
// fails with "Module not found". The consumer apps are thin re-export
|
|
75
|
+
// wrappers with no `@/` imports of their own, so this global alias is safe.
|
|
76
|
+
const userWebpack = typeof overrides.webpack === 'function' ? overrides.webpack : undefined;
|
|
77
|
+
/** @type {NonNullable<import('next').NextConfig['webpack']>} */
|
|
78
|
+
const webpack = (config, context) => {
|
|
79
|
+
config.resolve = config.resolve ?? {};
|
|
80
|
+
config.resolve.alias = { ...(config.resolve.alias ?? {}), '@': ENGINE_SRC };
|
|
81
|
+
return userWebpack ? userWebpack(config, context) : config;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** @type {import('next').NextConfig} */
|
|
85
|
+
const base = {
|
|
86
|
+
output: 'export',
|
|
87
|
+
basePath: isProd ? coursePath : '',
|
|
88
|
+
assetPrefix: isProd ? `${coursePath}/` : undefined,
|
|
89
|
+
trailingSlash: true,
|
|
90
|
+
images: { unoptimized: true },
|
|
91
|
+
reactStrictMode: true,
|
|
92
|
+
transpilePackages: [PACKAGE_NAME],
|
|
93
|
+
webpack,
|
|
94
|
+
experimental: {
|
|
95
|
+
typedRoutes: true,
|
|
96
|
+
...(overrides.experimental ?? {}),
|
|
97
|
+
},
|
|
98
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const {
|
|
102
|
+
experimental: _experimental,
|
|
103
|
+
env: _env,
|
|
104
|
+
webpack: _webpack,
|
|
105
|
+
...restOverrides
|
|
106
|
+
} = overrides;
|
|
107
|
+
return { ...base, ...restOverrides };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default createCookbookConfig;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Public barrel for @dsbasko/cookbook-engine.
|
|
2
|
+
//
|
|
3
|
+
// Course consumers normally use the dedicated entry-points
|
|
4
|
+
// (`/config`, `/layout/*`, `/pages/*`, `/og/*`, `/seo/*`, `/styles/*`). This
|
|
5
|
+
// barrel is the escape hatch for advanced cases: composing custom layouts or
|
|
6
|
+
// pages on top of the engine's components and lib helpers.
|
|
7
|
+
|
|
8
|
+
export const ENGINE_PACKAGE_NAME = '@dsbasko/cookbook-engine';
|
|
9
|
+
|
|
10
|
+
// --- lib: course model + loading -------------------------------------------
|
|
11
|
+
export * from './lib/course';
|
|
12
|
+
export * from './lib/course-loader';
|
|
13
|
+
export * from './lib/lesson';
|
|
14
|
+
export * from './lib/lang';
|
|
15
|
+
export * from './lib/use-i18n';
|
|
16
|
+
export * from './lib/i18n';
|
|
17
|
+
|
|
18
|
+
// --- lib: content rendering -------------------------------------------------
|
|
19
|
+
export * from './lib/markdown';
|
|
20
|
+
export * from './lib/extract-toc';
|
|
21
|
+
export * from './lib/readme-toc';
|
|
22
|
+
export * from './lib/slug';
|
|
23
|
+
export * from './lib/format';
|
|
24
|
+
export * from './lib/description';
|
|
25
|
+
export * from './lib/frontier-link';
|
|
26
|
+
|
|
27
|
+
// --- lib: client state (theme / progress / reading prefs / gating) ----------
|
|
28
|
+
export * from './lib/theme';
|
|
29
|
+
export * from './lib/progress';
|
|
30
|
+
export * from './lib/progress-mode';
|
|
31
|
+
export * from './lib/reading-prefs';
|
|
32
|
+
export * from './lib/lesson-gate';
|
|
33
|
+
export * from './lib/program-drawer';
|
|
34
|
+
|
|
35
|
+
// --- lib: SEO ---------------------------------------------------------------
|
|
36
|
+
export * from './lib/site-url';
|
|
37
|
+
export * from './lib/sitemap';
|
|
38
|
+
|
|
39
|
+
// --- components -------------------------------------------------------------
|
|
40
|
+
export { AppShell } from './components/AppShell';
|
|
41
|
+
export { Header } from './components/Header';
|
|
42
|
+
export { Sidebar } from './components/Sidebar';
|
|
43
|
+
export { HomePage } from './components/HomePage';
|
|
44
|
+
export { ModulePage } from './components/ModulePage';
|
|
45
|
+
export { Toc } from './components/Toc';
|
|
46
|
+
export { CodeBlock } from './components/CodeBlock';
|
|
47
|
+
export { Callout } from './components/Callout';
|
|
48
|
+
export { LessonNav } from './components/LessonNav';
|
|
49
|
+
export { LessonLayout } from './components/LessonLayout';
|
|
50
|
+
export { LessonPageLayout } from './components/LessonPageLayout';
|
|
51
|
+
export { LessonSideMeta } from './components/LessonSideMeta';
|
|
52
|
+
export { LessonAwareLink } from './components/LessonAwareLink';
|
|
53
|
+
export { LessonLockedInterstitial } from './components/LessonLockedInterstitial';
|
|
54
|
+
export { ProgramDrawer } from './components/ProgramDrawer';
|
|
55
|
+
export { ProgressBar } from './components/ProgressBar';
|
|
56
|
+
export { ReadingProgress } from './components/ReadingProgress';
|
|
57
|
+
export { SettingsToggle } from './components/SettingsToggle';
|
|
58
|
+
export { TranslationBanner } from './components/TranslationBanner';
|
|
59
|
+
export { GateProvider } from './components/GateProvider';
|
|
60
|
+
export { ThemeProvider } from './components/ThemeProvider';
|
|
61
|
+
export { ProgressModeProvider } from './components/ProgressModeProvider';
|
|
62
|
+
export { ReadingPrefsProvider } from './components/ReadingPrefsProvider';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { AppShell } from '@/components/AppShell';
|
|
4
|
+
import { GateProvider } from '@/components/GateProvider';
|
|
5
|
+
import { loadCourse } from '@/lib/course-loader';
|
|
6
|
+
import { isLang, LANG_SYNC_SCRIPT, LANGS, type Lang } from '@/lib/lang';
|
|
7
|
+
import { getRuntimeBasePath } from '@/lib/site-url';
|
|
8
|
+
|
|
9
|
+
type LangLayoutProps = {
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
params: { lang: string };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function generateStaticParams(): Array<{ lang: Lang }> {
|
|
15
|
+
return LANGS.map((lang) => ({ lang }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateMetadata({ params }: LangLayoutProps): Metadata {
|
|
19
|
+
if (!isLang(params.lang)) return {};
|
|
20
|
+
// The static HTML on disk always carries `<html lang={DEFAULT_LANG}>` because
|
|
21
|
+
// Next renders <html> once at the root layout. `content-language` is the
|
|
22
|
+
// per-lang signal crawlers read instead.
|
|
23
|
+
return { other: { 'content-language': params.lang } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function LangLayout({ children, params }: LangLayoutProps) {
|
|
27
|
+
if (!isLang(params.lang)) notFound();
|
|
28
|
+
const lang = params.lang as Lang;
|
|
29
|
+
const course = loadCourse(lang);
|
|
30
|
+
const basePath = getRuntimeBasePath(course.basePath);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<GateProvider course={course} basePath={basePath}>
|
|
34
|
+
<AppShell course={course}>{children}</AppShell>
|
|
35
|
+
<script
|
|
36
|
+
// Static HTML always carries `<html lang={DEFAULT_LANG}>`. Sync it to
|
|
37
|
+
// the active route so in-browser APIs (Intl, screen readers) see the
|
|
38
|
+
// correct value the moment this subtree mounts.
|
|
39
|
+
id={`lang-sync-${lang}`}
|
|
40
|
+
dangerouslySetInnerHTML={{ __html: LANG_SYNC_SCRIPT(lang) }}
|
|
41
|
+
/>
|
|
42
|
+
</GateProvider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Metadata, Viewport } from 'next';
|
|
2
|
+
import { Fira_Code, Inter, Literata, Manrope, Roboto_Slab } from 'next/font/google';
|
|
3
|
+
import localFont from 'next/font/local';
|
|
4
|
+
import { ProgressModeProvider } from '@/components/ProgressModeProvider';
|
|
5
|
+
import { ReadingPrefsProvider } from '@/components/ReadingPrefsProvider';
|
|
6
|
+
import { ThemeProvider } from '@/components/ThemeProvider';
|
|
7
|
+
import { buildBrandAccentCss, type Course } from '@/lib/course';
|
|
8
|
+
import { loadCourse } from '@/lib/course-loader';
|
|
9
|
+
import { buildGateInitScript } from '@/lib/gate-init-script';
|
|
10
|
+
import { buildGateMarkScript } from '@/lib/gate-mark-script';
|
|
11
|
+
import { DEFAULT_LANG, LANGS, type Lang } from '@/lib/lang';
|
|
12
|
+
import { PROGRESS_MODE_INIT_SCRIPT } from '@/lib/progress-mode';
|
|
13
|
+
import { READING_PREFS_INIT_SCRIPT } from '@/lib/reading-prefs';
|
|
14
|
+
import { buildSiteUrl, getRuntimeBasePath, getSiteUrl } from '@/lib/site-url';
|
|
15
|
+
import { THEME_INIT_SCRIPT } from '@/lib/theme';
|
|
16
|
+
import '@/styles/globals.css';
|
|
17
|
+
|
|
18
|
+
const manrope = Manrope({
|
|
19
|
+
subsets: ['latin', 'cyrillic'],
|
|
20
|
+
display: 'swap',
|
|
21
|
+
variable: '--font-ui',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Self-hosted JetBrains Mono with full glyph coverage (incl. Box Drawing U+2500–U+257F),
|
|
25
|
+
// avoids Google Fonts' subset split that breaks ASCII-art alignment.
|
|
26
|
+
// Fonts ship inside the package (assets/fonts/); relative paths resolve from
|
|
27
|
+
// node_modules via the consumer's transpilePackages (verified in spike 0a).
|
|
28
|
+
const jetbrains = localFont({
|
|
29
|
+
variable: '--font-mono',
|
|
30
|
+
display: 'swap',
|
|
31
|
+
src: [
|
|
32
|
+
{ path: '../../assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2', weight: '400', style: 'normal' },
|
|
33
|
+
{ path: '../../assets/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2', weight: '400', style: 'italic' },
|
|
34
|
+
{ path: '../../assets/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2', weight: '500', style: 'normal' },
|
|
35
|
+
{ path: '../../assets/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2', weight: '600', style: 'normal' },
|
|
36
|
+
{ path: '../../assets/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2', weight: '700', style: 'normal' },
|
|
37
|
+
{ path: '../../assets/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2', weight: '700', style: 'italic' },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Literata is the default prose face — designed by TypeTogether for Google Play
|
|
42
|
+
// Books, tuned for long-form on-screen reading (low stroke contrast, sturdy
|
|
43
|
+
// serifs, near-upright italic that holds the pixel grid).
|
|
44
|
+
const literataProse = Literata({
|
|
45
|
+
subsets: ['latin', 'cyrillic'],
|
|
46
|
+
weight: ['400', '500', '600', '700'],
|
|
47
|
+
style: ['normal', 'italic'],
|
|
48
|
+
display: 'swap',
|
|
49
|
+
variable: '--font-serif',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Optional reading-prefs fonts. Weights are pinned explicitly so next/font does
|
|
53
|
+
// not pull the full axis (cyrillic subsets balloon otherwise). Only the CSS
|
|
54
|
+
// variable is exposed; the actual font kicks in once the user picks the
|
|
55
|
+
// matching prose/code option in <SettingsToggle>.
|
|
56
|
+
const interProse = Inter({
|
|
57
|
+
subsets: ['latin', 'cyrillic'],
|
|
58
|
+
weight: ['400', '500', '600', '700'],
|
|
59
|
+
style: ['normal'],
|
|
60
|
+
display: 'swap',
|
|
61
|
+
variable: '--font-prose-inter',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Roboto Slab provides the "slab" universe — third prose option. It has no
|
|
65
|
+
// true italic, so reserve it for accent/short-form blocks (see slab.css).
|
|
66
|
+
const robotoSlab = Roboto_Slab({
|
|
67
|
+
subsets: ['latin', 'cyrillic'],
|
|
68
|
+
weight: ['400', '500', '600', '700'],
|
|
69
|
+
style: ['normal'],
|
|
70
|
+
display: 'swap',
|
|
71
|
+
variable: '--font-prose-slab',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const firaCode = Fira_Code({
|
|
75
|
+
subsets: ['latin', 'cyrillic'],
|
|
76
|
+
weight: ['400', '500', '700'],
|
|
77
|
+
style: ['normal'],
|
|
78
|
+
display: 'swap',
|
|
79
|
+
variable: '--font-code-fira',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export function generateMetadata(): Metadata {
|
|
83
|
+
// The root layout is the SEO entry point. Default-language content lives at
|
|
84
|
+
// `/` (this layout's child), with `/{ru,en}/` mirrors under [lang]/. The
|
|
85
|
+
// canonical points at the default-lang URL and `alternates.languages` lets
|
|
86
|
+
// crawlers discover the per-lang copies.
|
|
87
|
+
const course = loadCourse(DEFAULT_LANG);
|
|
88
|
+
// course.yaml owns the per-lang description; loadCourse(DEFAULT_LANG) has
|
|
89
|
+
// already resolved the right side of the `{ ru, en }` map. Normalize
|
|
90
|
+
// whitespace so multi-line YAML scalars render as a single sentence.
|
|
91
|
+
const description = course.description.replace(/\s+/g, ' ').trim();
|
|
92
|
+
const canonical = buildSiteUrl(course.basePath, [DEFAULT_LANG]);
|
|
93
|
+
const languages: Record<string, string> = {
|
|
94
|
+
'x-default': canonical,
|
|
95
|
+
};
|
|
96
|
+
for (const lang of LANGS) {
|
|
97
|
+
languages[lang] = buildSiteUrl(course.basePath, [lang]);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
metadataBase: new URL(getSiteUrl()),
|
|
101
|
+
title: course.title,
|
|
102
|
+
description,
|
|
103
|
+
alternates: { canonical, languages },
|
|
104
|
+
openGraph: {
|
|
105
|
+
type: 'website',
|
|
106
|
+
siteName: course.title,
|
|
107
|
+
title: course.title,
|
|
108
|
+
description,
|
|
109
|
+
url: canonical,
|
|
110
|
+
locale: DEFAULT_LANG === 'ru' ? 'ru_RU' : 'en_US',
|
|
111
|
+
},
|
|
112
|
+
twitter: {
|
|
113
|
+
card: 'summary_large_image',
|
|
114
|
+
title: course.title,
|
|
115
|
+
description,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `viewportFit: 'cover'` extends the layout viewport across the entire
|
|
121
|
+
// physical screen on iOS (under the notch / status bar / home indicator)
|
|
122
|
+
// instead of clipping at the safe-area edges. Two side-effects we need:
|
|
123
|
+
// 1. env(safe-area-inset-*) returns real pixel values (≈47px top on
|
|
124
|
+
// notched devices) so the sidebar padding pushes FABs below the notch.
|
|
125
|
+
// 2. box-shadow on `position: fixed; top: 0` elements can finally render
|
|
126
|
+
// ABOVE the FAB into the now-paintable status-bar zone instead of
|
|
127
|
+
// hitting a hard clip at the layout-viewport edge.
|
|
128
|
+
// `themeColor` then matches Safari chrome (status bar + URL chip) to
|
|
129
|
+
// --bg-default so the chrome reads as part of the page. Paper mode is a
|
|
130
|
+
// manual user choice and falls under the light branch — close enough in hue.
|
|
131
|
+
export const viewport: Viewport = {
|
|
132
|
+
width: 'device-width',
|
|
133
|
+
initialScale: 1,
|
|
134
|
+
viewportFit: 'cover',
|
|
135
|
+
themeColor: [
|
|
136
|
+
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
|
137
|
+
{ media: '(prefers-color-scheme: dark)', color: '#16140f' },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
142
|
+
// gate-init operates on lesson keys + linear order (language-agnostic), so
|
|
143
|
+
// the default-lang course is enough. gate-mark, however, writes localized
|
|
144
|
+
// lesson/module titles into CTA/hint slots — it must ship per-lang title
|
|
145
|
+
// tables and pick the right one at runtime based on the URL prefix; without
|
|
146
|
+
// this, RU pages flash EN titles into CTAs between SSR and hydration.
|
|
147
|
+
const coursesByLang: Record<Lang, Course> = {
|
|
148
|
+
ru: loadCourse('ru'),
|
|
149
|
+
en: loadCourse('en'),
|
|
150
|
+
};
|
|
151
|
+
const course = coursesByLang[DEFAULT_LANG];
|
|
152
|
+
const basePath = getRuntimeBasePath(course.basePath);
|
|
153
|
+
const gateInitScript = buildGateInitScript(course, basePath);
|
|
154
|
+
const gateMarkScript = buildGateMarkScript(coursesByLang, basePath, DEFAULT_LANG);
|
|
155
|
+
// When the course declares brand.accent, override the --accent-main family
|
|
156
|
+
// for both themes. The injected selectors out-specify tokens.css so the
|
|
157
|
+
// brand colour wins the cascade regardless of stylesheet order.
|
|
158
|
+
const brandAccentCss = buildBrandAccentCss(course.brand);
|
|
159
|
+
return (
|
|
160
|
+
<html
|
|
161
|
+
lang={DEFAULT_LANG}
|
|
162
|
+
data-theme="light"
|
|
163
|
+
className={`${manrope.variable} ${jetbrains.variable} ${literataProse.variable} ${interProse.variable} ${robotoSlab.variable} ${firaCode.variable}`}
|
|
164
|
+
suppressHydrationWarning
|
|
165
|
+
>
|
|
166
|
+
<head>
|
|
167
|
+
<script
|
|
168
|
+
id="theme-init"
|
|
169
|
+
// FOUC-free: applies stored/system theme to <html data-theme> before hydration.
|
|
170
|
+
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
|
|
171
|
+
/>
|
|
172
|
+
<script
|
|
173
|
+
id="reading-prefs-init"
|
|
174
|
+
// FOUC-free: stamps four data-prose-*/data-code-* attributes on <html>
|
|
175
|
+
// from localStorage before hydration. Sits between theme-init and
|
|
176
|
+
// gate-init so personal-preferences scripts group together.
|
|
177
|
+
dangerouslySetInnerHTML={{ __html: READING_PREFS_INIT_SCRIPT }}
|
|
178
|
+
/>
|
|
179
|
+
<script
|
|
180
|
+
id="progress-mode-init"
|
|
181
|
+
// FOUC-free: stamps data-progress-disabled on <html> from localStorage
|
|
182
|
+
// before hydration. MUST run before gate-init so that gate-init's early
|
|
183
|
+
// exit (free-reading mode) sees the attribute and never locks a lesson —
|
|
184
|
+
// otherwise a previously-locked lesson would flash its interstitial.
|
|
185
|
+
dangerouslySetInnerHTML={{ __html: PROGRESS_MODE_INIT_SCRIPT }}
|
|
186
|
+
/>
|
|
187
|
+
<script
|
|
188
|
+
id="gate-init"
|
|
189
|
+
// Pre-hydration gate: stamps data-lesson-locked on <html> when the
|
|
190
|
+
// current URL targets a locked lesson, so CSS can hide the content
|
|
191
|
+
// before React mounts and there is no flash of "open" lesson body.
|
|
192
|
+
// The script strips the `/{ru,en}/` prefix internally (see
|
|
193
|
+
// gate-init-script.ts) so it stays lang-agnostic.
|
|
194
|
+
dangerouslySetInnerHTML={{ __html: gateInitScript }}
|
|
195
|
+
/>
|
|
196
|
+
{brandAccentCss ? (
|
|
197
|
+
<style
|
|
198
|
+
id="brand-accent"
|
|
199
|
+
// Brand accent override from course.yaml.brand.accent. Rendered
|
|
200
|
+
// last in <head> and selector-weighted to beat the tokens.css
|
|
201
|
+
// defaults; absent entirely when no brand accent is configured.
|
|
202
|
+
dangerouslySetInnerHTML={{ __html: brandAccentCss }}
|
|
203
|
+
/>
|
|
204
|
+
) : null}
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<ProgressModeProvider>
|
|
208
|
+
<ThemeProvider>
|
|
209
|
+
<ReadingPrefsProvider>{children}</ReadingPrefsProvider>
|
|
210
|
+
</ThemeProvider>
|
|
211
|
+
</ProgressModeProvider>
|
|
212
|
+
{/* Runs as the last body child — by that point every [data-lesson-key]
|
|
213
|
+
element from server-rendered lists is in the DOM, so we can stamp
|
|
214
|
+
data-locked before the browser paints. Stops the flash where rows
|
|
215
|
+
momentarily appear unlocked before React's hydration cycle. */}
|
|
216
|
+
<script
|
|
217
|
+
id="gate-mark"
|
|
218
|
+
dangerouslySetInnerHTML={{ __html: gateMarkScript }}
|
|
219
|
+
/>
|
|
220
|
+
</body>
|
|
221
|
+
</html>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseCourse, type Course } from './course';
|
|
4
|
+
import { type Lang } from './lang';
|
|
5
|
+
import { resolveCourseYaml, resolveLecturesRoot } from './paths';
|
|
6
|
+
|
|
7
|
+
export interface LoadCourseOptions {
|
|
8
|
+
filePath?: string;
|
|
9
|
+
lecturesRoot?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadCourse(lang: Lang, options: LoadCourseOptions = {}): Course {
|
|
13
|
+
const filePath = options.filePath ?? resolveCourseYaml();
|
|
14
|
+
const lecturesRoot = options.lecturesRoot ?? resolveLecturesRoot();
|
|
15
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
16
|
+
const course = parseCourse(raw, lang, filePath);
|
|
17
|
+
|
|
18
|
+
for (const mod of course.modules) {
|
|
19
|
+
for (const lesson of mod.lessons) {
|
|
20
|
+
const translationPath = path.join(
|
|
21
|
+
lecturesRoot,
|
|
22
|
+
mod.id,
|
|
23
|
+
lesson.slug,
|
|
24
|
+
'i18n',
|
|
25
|
+
lang,
|
|
26
|
+
'README.md',
|
|
27
|
+
);
|
|
28
|
+
lesson.hasTranslation = existsSync(translationPath);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return course;
|
|
33
|
+
}
|