@dsbasko/cookbook-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @dsbasko/cookbook-engine might be problematic. Click here for more details.

Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/assets/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 +0 -0
  4. package/assets/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 +0 -0
  5. package/assets/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 +0 -0
  6. package/assets/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 +0 -0
  7. package/assets/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 +0 -0
  8. package/assets/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 +0 -0
  9. package/package.json +92 -0
  10. package/scripts/check-course-coverage.mts +32 -0
  11. package/scripts/fix-static-image-extensions.mjs +78 -0
  12. package/scripts/generate-readme-toc.mts +32 -0
  13. package/scripts/resolve-course-paths.mjs +28 -0
  14. package/scripts/sync-images.mjs +88 -0
  15. package/src/components/AppShell/AppShell.module.css +40 -0
  16. package/src/components/AppShell/AppShell.tsx +135 -0
  17. package/src/components/AppShell/index.ts +1 -0
  18. package/src/components/Callout/Callout.module.css +68 -0
  19. package/src/components/Callout/Callout.tsx +83 -0
  20. package/src/components/Callout/index.ts +1 -0
  21. package/src/components/CodeBlock/CodeBlock.module.css +68 -0
  22. package/src/components/CodeBlock/CodeBlock.tsx +65 -0
  23. package/src/components/CodeBlock/index.ts +1 -0
  24. package/src/components/GateProvider/GateProvider.tsx +207 -0
  25. package/src/components/GateProvider/index.ts +1 -0
  26. package/src/components/Header/Breadcrumbs.tsx +50 -0
  27. package/src/components/Header/Header.module.css +131 -0
  28. package/src/components/Header/Header.tsx +26 -0
  29. package/src/components/Header/HeaderLessonNav.tsx +118 -0
  30. package/src/components/Header/index.ts +1 -0
  31. package/src/components/HomePage/HomePage.module.css +538 -0
  32. package/src/components/HomePage/HomePage.tsx +295 -0
  33. package/src/components/HomePage/index.ts +1 -0
  34. package/src/components/LessonAwareLink/LessonAwareLink.module.css +12 -0
  35. package/src/components/LessonAwareLink/LessonAwareLink.tsx +86 -0
  36. package/src/components/LessonAwareLink/index.ts +1 -0
  37. package/src/components/LessonLayout/LessonLayout.module.css +35 -0
  38. package/src/components/LessonLayout/LessonLayout.tsx +18 -0
  39. package/src/components/LessonLayout/index.ts +1 -0
  40. package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.module.css +367 -0
  41. package/src/components/LessonLockedInterstitial/LessonLockedInterstitial.tsx +256 -0
  42. package/src/components/LessonLockedInterstitial/index.ts +1 -0
  43. package/src/components/LessonNav/LessonNav.module.css +84 -0
  44. package/src/components/LessonNav/LessonNav.tsx +64 -0
  45. package/src/components/LessonNav/index.ts +1 -0
  46. package/src/components/LessonPageLayout/LessonPageLayout.module.css +118 -0
  47. package/src/components/LessonPageLayout/LessonPageLayout.tsx +46 -0
  48. package/src/components/LessonPageLayout/index.ts +1 -0
  49. package/src/components/LessonSideMeta/LessonSideMeta.module.css +68 -0
  50. package/src/components/LessonSideMeta/LessonSideMeta.tsx +87 -0
  51. package/src/components/LessonSideMeta/index.ts +1 -0
  52. package/src/components/ModulePage/ModulePage.module.css +693 -0
  53. package/src/components/ModulePage/ModulePage.tsx +301 -0
  54. package/src/components/ModulePage/index.ts +1 -0
  55. package/src/components/ProgramDrawer/LockIcon.tsx +19 -0
  56. package/src/components/ProgramDrawer/ProgramDrawer.module.css +563 -0
  57. package/src/components/ProgramDrawer/ProgramDrawer.tsx +481 -0
  58. package/src/components/ProgramDrawer/index.ts +1 -0
  59. package/src/components/ProgressBar/ProgressBar.module.css +46 -0
  60. package/src/components/ProgressBar/ProgressBar.tsx +45 -0
  61. package/src/components/ProgressBar/index.ts +1 -0
  62. package/src/components/ProgressModeProvider/ProgressModeProvider.tsx +87 -0
  63. package/src/components/ProgressModeProvider/index.ts +1 -0
  64. package/src/components/ReadingPrefsProvider/ReadingPrefsProvider.tsx +100 -0
  65. package/src/components/ReadingPrefsProvider/index.ts +1 -0
  66. package/src/components/ReadingProgress/ReadingProgress.module.css +19 -0
  67. package/src/components/ReadingProgress/ReadingProgress.tsx +53 -0
  68. package/src/components/ReadingProgress/index.ts +1 -0
  69. package/src/components/SettingsToggle/SettingsToggle.module.css +888 -0
  70. package/src/components/SettingsToggle/SettingsToggle.tsx +688 -0
  71. package/src/components/SettingsToggle/index.ts +1 -0
  72. package/src/components/Sidebar/Sidebar.module.css +157 -0
  73. package/src/components/Sidebar/Sidebar.tsx +63 -0
  74. package/src/components/Sidebar/icons/GitHubIcon.tsx +17 -0
  75. package/src/components/Sidebar/icons/HomeIcon.tsx +22 -0
  76. package/src/components/Sidebar/icons/LanguageIcon.tsx +24 -0
  77. package/src/components/Sidebar/icons/ProgramIcon.tsx +23 -0
  78. package/src/components/Sidebar/icons/SettingsIcon.tsx +26 -0
  79. package/src/components/Sidebar/icons/ThemeIcon.tsx +22 -0
  80. package/src/components/Sidebar/icons/index.ts +6 -0
  81. package/src/components/Sidebar/index.ts +1 -0
  82. package/src/components/ThemeProvider/ThemeProvider.tsx +68 -0
  83. package/src/components/ThemeProvider/index.ts +1 -0
  84. package/src/components/Toc/Toc.module.css +78 -0
  85. package/src/components/Toc/Toc.tsx +92 -0
  86. package/src/components/Toc/index.ts +1 -0
  87. package/src/components/TranslationBanner/TranslationBanner.module.css +32 -0
  88. package/src/components/TranslationBanner/TranslationBanner.tsx +40 -0
  89. package/src/components/TranslationBanner/index.ts +1 -0
  90. package/src/config.d.mts +12 -0
  91. package/src/config.mjs +110 -0
  92. package/src/index.ts +62 -0
  93. package/src/layout/lang.tsx +44 -0
  94. package/src/layout/root.tsx +223 -0
  95. package/src/lib/course-loader.ts +33 -0
  96. package/src/lib/course.ts +429 -0
  97. package/src/lib/coverage.ts +141 -0
  98. package/src/lib/description.ts +43 -0
  99. package/src/lib/extract-toc.ts +59 -0
  100. package/src/lib/format.ts +55 -0
  101. package/src/lib/frontier-link.ts +37 -0
  102. package/src/lib/gate-init-script.ts +40 -0
  103. package/src/lib/gate-mark-script.ts +324 -0
  104. package/src/lib/i18n.ts +474 -0
  105. package/src/lib/lang.ts +90 -0
  106. package/src/lib/lesson-gate.ts +79 -0
  107. package/src/lib/lesson.ts +66 -0
  108. package/src/lib/markdown-components.tsx +51 -0
  109. package/src/lib/markdown.ts +180 -0
  110. package/src/lib/mdx-plugins/rehype-callout.ts +80 -0
  111. package/src/lib/mdx-plugins/remark-lesson-images.ts +109 -0
  112. package/src/lib/mdx-plugins/remark-link-rewrite.ts +231 -0
  113. package/src/lib/paths.ts +36 -0
  114. package/src/lib/program-drawer.ts +8 -0
  115. package/src/lib/progress-mode.ts +69 -0
  116. package/src/lib/progress.ts +182 -0
  117. package/src/lib/reading-prefs.ts +127 -0
  118. package/src/lib/readme-toc.ts +69 -0
  119. package/src/lib/site-url.ts +33 -0
  120. package/src/lib/sitemap.ts +112 -0
  121. package/src/lib/slug.ts +15 -0
  122. package/src/lib/theme.ts +78 -0
  123. package/src/lib/use-i18n.ts +25 -0
  124. package/src/og/icon.tsx +40 -0
  125. package/src/og/opengraph-image.tsx +126 -0
  126. package/src/pages/home.tsx +66 -0
  127. package/src/pages/lesson.tsx +260 -0
  128. package/src/pages/module.tsx +80 -0
  129. package/src/pages/not-found-lang.tsx +51 -0
  130. package/src/pages/not-found-root.tsx +48 -0
  131. package/src/pages/root.tsx +44 -0
  132. package/src/seo/robots.ts +16 -0
  133. package/src/seo/sitemap.ts +10 -0
  134. package/src/styles/globals.css +139 -0
  135. package/src/styles/markdown.css +265 -0
  136. package/src/styles/reset.css +89 -0
  137. package/src/styles/tokens.css +270 -0
@@ -0,0 +1,66 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DEFAULT_LANG, type Lang } from './lang';
4
+ import { resolveLecturesRoot } from './paths';
5
+
6
+ export interface LessonContent {
7
+ markdown: string;
8
+ /**
9
+ * The language actually loaded. May differ from the requested lang when the
10
+ * requested translation is missing and the loader falls back (currently to
11
+ * Russian) — callers use this to decide whether to render a fallback banner.
12
+ */
13
+ lang: Lang;
14
+ /**
15
+ * True when the requested language was not available and the loader fell
16
+ * back to a different one. Always false when `lang === requested`.
17
+ */
18
+ fallbackUsed: boolean;
19
+ }
20
+
21
+ export interface GetLessonContentOptions {
22
+ lecturesRoot?: string;
23
+ }
24
+
25
+ export async function getLessonContent(
26
+ moduleId: string,
27
+ slug: string,
28
+ lang: Lang = DEFAULT_LANG,
29
+ options: GetLessonContentOptions = {},
30
+ ): Promise<LessonContent> {
31
+ const root = options.lecturesRoot ?? resolveLecturesRoot();
32
+ const primaryPath = getLessonReadmePath(moduleId, slug, lang, options);
33
+
34
+ try {
35
+ const markdown = await fs.readFile(primaryPath, 'utf8');
36
+ return { markdown, lang, fallbackUsed: false };
37
+ } catch (err) {
38
+ const code = (err as NodeJS.ErrnoException).code;
39
+ if (code !== 'ENOENT') throw err;
40
+ }
41
+
42
+ if (lang !== 'ru') {
43
+ const fallbackPath = getLessonReadmePath(moduleId, slug, 'ru', { lecturesRoot: root });
44
+ try {
45
+ const markdown = await fs.readFile(fallbackPath, 'utf8');
46
+ return { markdown, lang: 'ru', fallbackUsed: true };
47
+ } catch (err) {
48
+ const code = (err as NodeJS.ErrnoException).code;
49
+ if (code !== 'ENOENT') throw err;
50
+ }
51
+ }
52
+
53
+ throw new Error(
54
+ `lesson README not found: ${moduleId}/${slug} (expected at ${primaryPath})`,
55
+ );
56
+ }
57
+
58
+ export function getLessonReadmePath(
59
+ moduleId: string,
60
+ slug: string,
61
+ lang: Lang = DEFAULT_LANG,
62
+ options: GetLessonContentOptions = {},
63
+ ): string {
64
+ const root = options.lecturesRoot ?? resolveLecturesRoot();
65
+ return path.join(root, moduleId, slug, 'i18n', lang, 'README.md');
66
+ }
@@ -0,0 +1,51 @@
1
+ import type { ReactNode } from 'react';
2
+ import { CodeBlock } from '@/components/CodeBlock';
3
+ import { Callout, isCalloutType } from '@/components/Callout';
4
+ import { DEFAULT_LANG, type Lang } from '@/lib/lang';
5
+
6
+ type FigureProps = {
7
+ children?: ReactNode;
8
+ } & Record<string, unknown>;
9
+
10
+ export function makeMarkdownFigure(lang: Lang) {
11
+ return function MarkdownFigure(props: FigureProps) {
12
+ if (!('data-rehype-pretty-code-figure' in props)) {
13
+ const { children, ...rest } = props;
14
+ return <figure {...(rest as Record<string, string>)}>{children}</figure>;
15
+ }
16
+ const language = readLanguage(props);
17
+ return (
18
+ <CodeBlock language={language} lang={lang}>
19
+ {props.children}
20
+ </CodeBlock>
21
+ );
22
+ };
23
+ }
24
+
25
+ export const MarkdownFigure = makeMarkdownFigure(DEFAULT_LANG);
26
+
27
+ type AsideProps = {
28
+ children?: ReactNode;
29
+ } & Record<string, unknown>;
30
+
31
+ export function makeMarkdownAside(lang: Lang) {
32
+ return function MarkdownAside(props: AsideProps) {
33
+ const calloutType = props['data-callout-type'];
34
+ if (isCalloutType(calloutType)) {
35
+ return (
36
+ <Callout type={calloutType} lang={lang}>
37
+ {props.children}
38
+ </Callout>
39
+ );
40
+ }
41
+ const { children, ...rest } = props;
42
+ return <aside {...(rest as Record<string, string>)}>{children}</aside>;
43
+ };
44
+ }
45
+
46
+ export const MarkdownAside = makeMarkdownAside(DEFAULT_LANG);
47
+
48
+ function readLanguage(props: Record<string, unknown>): string {
49
+ const v = props['data-language'];
50
+ return typeof v === 'string' && v.length > 0 ? v : 'plaintext';
51
+ }
@@ -0,0 +1,180 @@
1
+ import { createElement, type ReactElement } from 'react';
2
+ import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
3
+ import { unified, type Plugin } from 'unified';
4
+ import remarkParse from 'remark-parse';
5
+ import remarkGfm from 'remark-gfm';
6
+ import remarkRehype from 'remark-rehype';
7
+ import rehypeSlug from 'rehype-slug';
8
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
9
+ import rehypePrettyCode from 'rehype-pretty-code';
10
+ import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
11
+ import type { Element, Root as HastRoot } from 'hast';
12
+ import { remarkAlert } from 'remark-github-blockquote-alert';
13
+ import remarkLessonImages from './mdx-plugins/remark-lesson-images';
14
+ import remarkLinkRewrite from './mdx-plugins/remark-link-rewrite';
15
+ import rehypeCallout from './mdx-plugins/rehype-callout';
16
+ import type { Course } from './course';
17
+ import { DEFAULT_LANG, type Lang } from './lang';
18
+ import { LessonAwareLink } from '@/components/LessonAwareLink';
19
+ import { makeMarkdownAside, makeMarkdownFigure } from './markdown-components';
20
+ import { extractToc, type TocEntry } from './extract-toc';
21
+
22
+ export interface RenderLessonMarkdownOptions {
23
+ moduleId: string;
24
+ slug: string;
25
+ basePath: string;
26
+ course: Course;
27
+ /**
28
+ * Active route language. Determines the `/<lang>/` prefix the rewriter
29
+ * stamps on emitted site URLs. Defaults to {@link DEFAULT_LANG}.
30
+ */
31
+ lang?: Lang;
32
+ /**
33
+ * Language of the README being rendered. When an EN route falls back to the
34
+ * RU README this differs from {@link lang}: the URL prefix stays `/en/` but
35
+ * relative sibling links inside the body anchor at `i18n/ru/`. Defaults to
36
+ * {@link lang}.
37
+ */
38
+ sourceLang?: Lang;
39
+ }
40
+
41
+ export interface RenderLessonMarkdownResult {
42
+ content: ReactElement;
43
+ toc: TocEntry[];
44
+ }
45
+
46
+ const PRETTY_CODE_OPTIONS = {
47
+ theme: { light: 'github-light', dark: 'night-owl' },
48
+ keepBackground: false,
49
+ defaultLang: 'plaintext',
50
+ } as const;
51
+
52
+ /**
53
+ * The lesson page already renders the `course.yaml` title as the page-level
54
+ * `<h1>` via `LessonLayout`. Lecture READMEs also start with their own `# Title`
55
+ * heading, which would produce a second `<h1>` in the article body — bad for
56
+ * the document outline, screen-reader navigation and SEO. Strip the leading
57
+ * `<h1>` from the lesson-content tree so the remaining `h2`/`h3` headings sit
58
+ * beneath the page H1 cleanly.
59
+ */
60
+ const rehypeStripLeadingH1: Plugin<[], HastRoot> = () => {
61
+ return (tree) => {
62
+ for (let i = 0; i < tree.children.length; i += 1) {
63
+ const child = tree.children[i];
64
+ if (child.type !== 'element') continue;
65
+ if (child.tagName === 'h1') {
66
+ tree.children.splice(i, 1);
67
+ }
68
+ return;
69
+ }
70
+ };
71
+ };
72
+
73
+ /**
74
+ * rehype-pretty-code stamps `data-language` on the inner <pre>/<code>, but our
75
+ * CodeBlock wrapper renders at the <figure> level. Lift the language up so the
76
+ * figure component can show it without inspecting children.
77
+ */
78
+ const rehypeLiftCodeBlockLanguage: Plugin<[], HastRoot> = () => {
79
+ return (tree) => {
80
+ walk(tree, (node) => {
81
+ if (node.tagName !== 'figure') return;
82
+ const props = node.properties;
83
+ if (!props || !('data-rehype-pretty-code-figure' in props)) return;
84
+ for (const child of node.children) {
85
+ if (child.type !== 'element' || child.tagName !== 'pre') continue;
86
+ const lang = (child.properties as Record<string, unknown> | undefined)?.['data-language'];
87
+ if (typeof lang === 'string') {
88
+ (props as Record<string, unknown>)['data-language'] = lang;
89
+ }
90
+ return;
91
+ }
92
+ });
93
+ };
94
+ };
95
+
96
+ function walk(node: HastRoot | Element, visit: (el: Element) => void) {
97
+ for (const child of node.children) {
98
+ if ((child as Element).type === 'element') {
99
+ visit(child as Element);
100
+ walk(child as Element, visit);
101
+ }
102
+ }
103
+ }
104
+
105
+ function hasAnchorDescendant(node: Element): boolean {
106
+ for (const child of node.children) {
107
+ if (child.type !== 'element') continue;
108
+ if (child.tagName === 'a') return true;
109
+ if (hasAnchorDescendant(child)) return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ export async function renderLessonMarkdown(
115
+ source: string,
116
+ options: RenderLessonMarkdownOptions,
117
+ ): Promise<RenderLessonMarkdownResult> {
118
+ const processor = unified()
119
+ .use(remarkParse)
120
+ .use(remarkGfm)
121
+ .use(remarkAlert)
122
+ .use(remarkLessonImages, {
123
+ moduleId: options.moduleId,
124
+ slug: options.slug,
125
+ basePath: options.basePath,
126
+ })
127
+ .use(remarkLinkRewrite, {
128
+ moduleId: options.moduleId,
129
+ slug: options.slug,
130
+ basePath: options.basePath,
131
+ course: options.course,
132
+ lang: options.lang ?? DEFAULT_LANG,
133
+ sourceLang: options.sourceLang ?? options.lang ?? DEFAULT_LANG,
134
+ })
135
+ .use(remarkRehype, { allowDangerousHtml: false })
136
+ .use(rehypeStripLeadingH1)
137
+ .use(rehypeCallout)
138
+ .use(rehypeSlug)
139
+ .use(rehypeAutolinkHeadings, {
140
+ behavior: 'wrap',
141
+ properties: { className: ['heading-anchor'] },
142
+ // Skip headings that already contain an <a> (markdown links inside the
143
+ // heading text). Wrapping such a heading would emit invalid HTML
144
+ // (<a><h2>...<a>...</a>...</h2></a>) and trigger a hydration failure.
145
+ test: (node) => !hasAnchorDescendant(node),
146
+ })
147
+ .use(rehypePrettyCode, PRETTY_CODE_OPTIONS)
148
+ .use(rehypeLiftCodeBlockLanguage);
149
+
150
+ const mdast = processor.parse(source);
151
+ const hast = (await processor.run(mdast)) as HastRoot;
152
+
153
+ const toc = extractToc(hast);
154
+
155
+ // Wrap LessonAwareLink in a closure so it learns the build's basePath at
156
+ // render time without needing a React context (renderLessonMarkdown runs
157
+ // server-side; GateContext is client-only). The wrapper passes basePath
158
+ // down so the link can derive data-lesson-key from href on the server,
159
+ // which is what the inline gate-mark script needs to find at first paint.
160
+ const basePath = options.basePath;
161
+ const lang = options.lang ?? DEFAULT_LANG;
162
+ const LessonLink = (props: Record<string, unknown>) =>
163
+ createElement(LessonAwareLink, {
164
+ basePath,
165
+ ...(props as Record<string, unknown>),
166
+ } as never);
167
+
168
+ const content = toJsxRuntime(hast, {
169
+ Fragment,
170
+ jsx: jsx as never,
171
+ jsxs: jsxs as never,
172
+ components: {
173
+ a: LessonLink as never,
174
+ figure: makeMarkdownFigure(lang) as never,
175
+ aside: makeMarkdownAside(lang) as never,
176
+ },
177
+ }) as ReactElement;
178
+
179
+ return { content, toc };
180
+ }
@@ -0,0 +1,80 @@
1
+ import type { Plugin } from 'unified';
2
+ import type { Element, Root as HastRoot } from 'hast';
3
+
4
+ export type CalloutType = 'note' | 'tip' | 'warning' | 'important' | 'caution';
5
+
6
+ const CLASS_PREFIX = 'markdown-alert-';
7
+ const TITLE_CLASS = 'markdown-alert-title';
8
+
9
+ const CALLOUT_TYPES: ReadonlySet<CalloutType> = new Set([
10
+ 'note',
11
+ 'tip',
12
+ 'warning',
13
+ 'important',
14
+ 'caution',
15
+ ]);
16
+
17
+ /**
18
+ * Normalises GitHub-style alert markup produced by `remark-github-blockquote-alert`
19
+ * into our own React-friendly shape: `<aside data-callout-type="note">…</aside>`.
20
+ *
21
+ * The upstream plugin emits `<div class="markdown-alert markdown-alert-note">`
22
+ * with a leading title paragraph (`<p class="markdown-alert-title"><svg/>NOTE</p>`).
23
+ * We strip the auto-generated title and let the React `<Callout>` render its own
24
+ * header (with Russian label and icon), keeping the body content intact.
25
+ */
26
+ const rehypeCallout: Plugin<[], HastRoot> = () => {
27
+ return (tree) => {
28
+ walk(tree, (node) => {
29
+ const type = readCalloutType(node);
30
+ if (!type) return;
31
+
32
+ node.tagName = 'aside';
33
+ const props = node.properties ?? {};
34
+ delete props.className;
35
+ (props as Record<string, unknown>)['data-callout-type'] = type;
36
+ node.properties = props;
37
+
38
+ node.children = node.children.filter((child) => {
39
+ if (child.type !== 'element') return true;
40
+ return !hasClass(child, TITLE_CLASS);
41
+ });
42
+ });
43
+ };
44
+ };
45
+
46
+ export default rehypeCallout;
47
+
48
+ function readCalloutType(node: Element): CalloutType | null {
49
+ const classes = readClassList(node);
50
+ if (!classes.includes('markdown-alert')) return null;
51
+ for (const cls of classes) {
52
+ if (!cls.startsWith(CLASS_PREFIX)) continue;
53
+ const candidate = cls.slice(CLASS_PREFIX.length);
54
+ if (CALLOUT_TYPES.has(candidate as CalloutType)) {
55
+ return candidate as CalloutType;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function hasClass(node: Element, target: string): boolean {
62
+ return readClassList(node).includes(target);
63
+ }
64
+
65
+ function readClassList(node: Element): string[] {
66
+ const raw = node.properties?.className;
67
+ if (!raw) return [];
68
+ if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string');
69
+ if (typeof raw === 'string') return raw.split(/\s+/).filter(Boolean);
70
+ return [];
71
+ }
72
+
73
+ function walk(node: HastRoot | Element, visit: (el: Element) => void) {
74
+ for (const child of node.children) {
75
+ if ((child as Element).type === 'element') {
76
+ visit(child as Element);
77
+ walk(child as Element, visit);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,109 @@
1
+ export interface RemarkLessonImagesOptions {
2
+ moduleId: string;
3
+ slug: string;
4
+ basePath: string;
5
+ }
6
+
7
+ interface ImageLikeNode {
8
+ type: 'image' | 'definition';
9
+ url: string;
10
+ alt?: string | null;
11
+ identifier?: string;
12
+ }
13
+
14
+ interface ParentNode {
15
+ type: string;
16
+ children?: AstNode[];
17
+ }
18
+
19
+ type AstNode = ImageLikeNode | ParentNode;
20
+
21
+ const PROTOCOL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|data:|mailto:|tel:|#)/i;
22
+
23
+ const IMAGE_PREFIXES = ['./images/', '../../images/'] as const;
24
+
25
+ function matchImagePrefix(url: string): string | null {
26
+ for (const prefix of IMAGE_PREFIXES) {
27
+ if (url.startsWith(prefix)) {
28
+ return prefix;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ export function rewriteLessonImageUrl(
35
+ url: string,
36
+ options: RemarkLessonImagesOptions,
37
+ context: { nodeKind: 'image' | 'definition' },
38
+ ): string {
39
+ if (PROTOCOL_RE.test(url)) {
40
+ return url;
41
+ }
42
+
43
+ const prefix = matchImagePrefix(url);
44
+ if (prefix === null) {
45
+ throw new Error(
46
+ `remark-lesson-images: ${context.nodeKind} url "${url}" in ` +
47
+ `${options.moduleId}/${options.slug} is not allowed. ` +
48
+ `Only "./images/<file>" (legacy) or "../../images/<file>" (post-migration) ` +
49
+ `relative paths and absolute URLs (http(s)://, data:, mailto:, tel:) are supported.`,
50
+ );
51
+ }
52
+
53
+ const relative = url.slice(prefix.length);
54
+ if (relative.length === 0) {
55
+ throw new Error(
56
+ `remark-lesson-images: empty image filename in ${options.moduleId}/${options.slug}`,
57
+ );
58
+ }
59
+ if (relative.includes('..')) {
60
+ throw new Error(
61
+ `remark-lesson-images: image url "${url}" in ${options.moduleId}/${options.slug} ` +
62
+ `must not contain ".." segments after the images/ prefix`,
63
+ );
64
+ }
65
+
66
+ const basePath = options.basePath.replace(/\/+$/, '');
67
+ return `${basePath}/static/lectures/${options.moduleId}/${options.slug}/images/${relative}`;
68
+ }
69
+
70
+ export default function remarkLessonImages(options: RemarkLessonImagesOptions) {
71
+ if (!options || !options.moduleId || !options.slug) {
72
+ throw new Error(
73
+ 'remark-lesson-images: options.moduleId and options.slug are required',
74
+ );
75
+ }
76
+
77
+ return function transformer(tree: AstNode): void {
78
+ walk(tree, (node) => {
79
+ if (node.type === 'image') {
80
+ const img = node as ImageLikeNode;
81
+ img.url = rewriteLessonImageUrl(img.url, options, {
82
+ nodeKind: 'image',
83
+ });
84
+ return;
85
+ }
86
+ // mdast `definition` nodes are shared between image and link references.
87
+ // Only claim ones that clearly point to a lesson image; let
88
+ // remark-link-rewrite handle the rest.
89
+ if (node.type === 'definition') {
90
+ const def = node as ImageLikeNode;
91
+ if (typeof def.url === 'string' && matchImagePrefix(def.url) !== null) {
92
+ def.url = rewriteLessonImageUrl(def.url, options, {
93
+ nodeKind: 'definition',
94
+ });
95
+ }
96
+ }
97
+ });
98
+ };
99
+ }
100
+
101
+ function walk(node: AstNode, visitor: (node: AstNode) => void): void {
102
+ visitor(node);
103
+ const children = (node as ParentNode).children;
104
+ if (Array.isArray(children)) {
105
+ for (const child of children) {
106
+ walk(child, visitor);
107
+ }
108
+ }
109
+ }