Skip to content

Commit bacda3a

Browse files
committed
레슨 상세 페이지 - 단계별 학습 + 셀 실행(P3.6)
커리큘럼 생성기가 레슨별 전체 섹션(설명·목표·코드)을 static/lessons/<slug>.json으로 방출(472개, gitignore). /learn/<slug> 상세 페이지가 그 JSON을 fetch해 섹션을 렌더하고, 각 섹션의 코드를 상단 공유 pyproc 실행기로 로드해 실행. /learn 카드는 상세로 링크. 직접 URL은 404.html SPA 폴백. 전체 빌드 green(83 routes + 472 레슨 자산).
1 parent 5ea05f0 commit bacda3a

6 files changed

Lines changed: 195 additions & 32 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ landing/build/
5353
landing/.svelte-kit/
5454
landing/static/blog/assets/
5555
landing/static/docs/blog/assets/
56+
landing/static/lessons/
5657

5758
# MkDocs build
5859
site/

‎landing/scripts/generateCurriculum.js‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,36 @@
33
// docsNav/posts 생성기와 같은 패턴. 빌드 prebuild 단계에서 돈다.
44
import { dirname, resolve, relative } from "node:path";
55
import { fileURLToPath } from "node:url";
6-
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
77
import { parse as parseYaml } from "yaml";
88

99
const __dirname = dirname(fileURLToPath(import.meta.url));
1010
const CURRICULA = resolve(__dirname, "../../curricula/python");
1111
const OUT = resolve(__dirname, "../src/lib/generated/curriculum.js");
12+
// 레슨 상세는 static(publicDir)에 JSON으로 써서 /learn/<slug>가 fetch한다(번들 회피).
13+
const LESSON_DIR = resolve(__dirname, "../static/lessons");
14+
15+
function lessonDetail(doc, lesson) {
16+
const sections = Array.isArray(doc.sections) ? doc.sections : [];
17+
return {
18+
id: lesson.id,
19+
title: lesson.title,
20+
emoji: lesson.emoji,
21+
direction: lesson.direction,
22+
tags: lesson.tags,
23+
intro: doc.intro && typeof doc.intro === "object"
24+
? { points: Array.isArray(doc.intro.points) ? doc.intro.points.map(String) : [], benefits: Array.isArray(doc.intro.benefits) ? doc.intro.benefits.map(String) : [] }
25+
: { points: [], benefits: [] },
26+
sections: sections.map((s) => ({
27+
title: s.title ? String(s.title) : "",
28+
subtitle: s.subtitle ? String(s.subtitle) : "",
29+
goal: s.goal ? String(s.goal) : "",
30+
why: s.why ? String(s.why) : "",
31+
explanation: s.explanation ? String(s.explanation) : "",
32+
snippet: typeof s.snippet === "string" ? s.snippet : "",
33+
})).filter((s) => s.title || s.explanation || s.snippet),
34+
};
35+
}
1236

1337
const DOMAIN_LABELS = {
1438
basics: "파이썬 기초",
@@ -88,6 +112,10 @@ for (const file of files) {
88112
if (!tracks.has(track)) tracks.set(track, []);
89113
tracks.get(track).push(lesson);
90114
lessonCount += 1;
115+
116+
const detailPath = resolve(LESSON_DIR, `${domain}/${track}/${meta.id}.json`);
117+
mkdirSync(dirname(detailPath), { recursive: true });
118+
writeFileSync(detailPath, JSON.stringify(lessonDetail(doc, lesson)));
91119
}
92120

93121
const tree = DOMAIN_ORDER.filter((d) => domains.has(d)).map((domain) => {

‎landing/src/App.jsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { faqEntries } from "./lib/faq.js";
2727
import { tools } from "./lib/tools/registry.js";
2828
import { HomePage } from "./pages/home.jsx";
2929
import { LearnPage } from "./pages/learn.jsx";
30+
import { LessonPage } from "./pages/lessonPage.jsx";
3031

3132
const docsModules = import.meta.glob("./lib/generated/docsPages/*.js");
3233

@@ -369,6 +370,17 @@ function resolveRoute(path) {
369370
element: <LearnPage />,
370371
};
371372
}
373+
if (path.startsWith("/learn/")) {
374+
const slug = path.slice("/learn/".length).replace(/\/$/, "");
375+
return {
376+
meta: {
377+
title: "레슨",
378+
description: "브라우저에서 바로 실행하는 Codaro Python 레슨.",
379+
url: path,
380+
},
381+
element: <LessonPage slug={slug} />,
382+
};
383+
}
372384
if (path === "/docs") return docsIndexRoute();
373385
if (path === "/packs") return packsRoute();
374386
if (path === "/docs/blog") return blogIndexRoute();

‎landing/src/pages/learn.jsx‎

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// learn.jsx - /learn 학습 페이지. astryx 디자인. App.jsx와 prerenderReact.js가 공유하는 SSOT.
22
// 커리큘럼 트리는 빌드시 생성(scripts/generateCurriculum.js). 레슨 실행(브라우저 런타임)은 P3에서 배선.
3-
import { useRef, useState } from "react";
43
import { BookOpen, Download, Globe, Play, Sparkles } from "lucide-react";
54
import { Heading } from "@astryxdesign/core/Heading";
65
import { Text } from "@astryxdesign/core/Text";
@@ -11,19 +10,9 @@ import { brand } from "../lib/brand.js";
1110
import { curriculumTree, curriculumLessonCount } from "../lib/generated/curriculum.js";
1211
import { PythonRunner } from "../components/pythonRunner.jsx";
1312

14-
export function LearnPage() {
15-
const [loaded, setLoaded] = useState(null);
16-
const nonceRef = useRef(0);
17-
18-
function runLesson(lesson) {
19-
if (!lesson || !lesson.code) return;
20-
setLoaded({ code: lesson.code, title: lesson.title, direction: lesson.direction, nonce: (nonceRef.current += 1) });
21-
if (typeof document !== "undefined") {
22-
const el = document.getElementById("py-runner");
23-
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
24-
}
25-
}
13+
const appPath = (path = "/") => brand.appPath(path);
2614

15+
export function LearnPage() {
2716
return (
2817
<main className="learnPage">
2918
<section className="homeWrap homeHero" style={{ paddingBlock: "84px 40px" }}>
@@ -59,20 +48,11 @@ export function LearnPage() {
5948
<Text type="label" color="accent">지금 바로</Text>
6049
<Heading level={2} type="display-3">브라우저에서 Python을 실행해보세요.</Heading>
6150
<Text type="body" color="muted">
62-
설치도 서버도 없습니다. 아래 코드를 고쳐서 실행하거나, 커리큘럼에서 레슨을 눌러 그 코드를 여기로
63-
불러오세요. 이 탭 안에서 진짜 CPython이 돕니다.
51+
설치도 서버도 없습니다. 아래 코드를 고쳐서 실행하거나, 커리큘럼에서 레슨을 열어 단계별로 배우고
52+
실행하세요. 이 탭 안에서 진짜 CPython이 돕니다.
6453
</Text>
6554
</div>
66-
{loaded && loaded.title && (
67-
<div className="learnActiveLesson">
68-
<Badge variant="accent" label="레슨" />
69-
<div>
70-
<Text type="body-sm" weight="600">{loaded.title}</Text>
71-
{loaded.direction && <Text type="body-sm" color="muted">{loaded.direction}</Text>}
72-
</div>
73-
</div>
74-
)}
75-
<PythonRunner load={loaded} />
55+
<PythonRunner />
7656
</section>
7757

7858
<section className="homeWrap homeSection" id="curriculum" style={{ paddingTop: 20 }}>
@@ -96,13 +76,11 @@ export function LearnPage() {
9676
<Heading level={3} type="title">{track.track}</Heading>
9777
<div className="learnLessonGrid">
9878
{track.lessons.map((lesson) => (
99-
<button
79+
<a
10080
key={lesson.slug}
101-
type="button"
10281
className="learnLessonButton"
103-
disabled={!lesson.code}
104-
title={lesson.code ? "이 레슨의 코드를 실행기로 불러오기" : "실행 코드 없음"}
105-
onClick={() => runLesson(lesson)}
82+
href={appPath(`/learn/${lesson.slug}`)}
83+
title={`${lesson.title} 레슨 열기`}
10684
>
10785
<Card padding={4}>
10886
<div className="learnLessonCard">
@@ -120,7 +98,7 @@ export function LearnPage() {
12098
{lesson.code && <Play size={14} className="learnLessonPlay" aria-hidden="true" />}
12199
</div>
122100
</Card>
123-
</button>
101+
</a>
124102
))}
125103
</div>
126104
</div>

‎landing/src/pages/lessonPage.jsx‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// lessonPage.jsx - /learn/<slug> 레슨 상세. 클라이언트 렌더(직접 URL은 404.html SPA 폴백).
2+
// static/lessons/<slug>.json(생성물)을 fetch해 섹션(설명 + 실행 셀)을 렌더. 셀은 상단 공유 실행기로 로드.
3+
import { useEffect, useRef, useState } from "react";
4+
import { ArrowLeft, Play } from "lucide-react";
5+
import { Heading } from "@astryxdesign/core/Heading";
6+
import { Text } from "@astryxdesign/core/Text";
7+
import { Button } from "@astryxdesign/core/Button";
8+
import { Card } from "@astryxdesign/core/Card";
9+
import { brand } from "../lib/brand.js";
10+
import { PythonRunner } from "../components/pythonRunner.jsx";
11+
12+
const appPath = (path = "/") => brand.appPath(path);
13+
14+
export function LessonPage({ slug }) {
15+
const [lesson, setLesson] = useState(null);
16+
const [error, setError] = useState("");
17+
const [loaded, setLoaded] = useState(null);
18+
const nonceRef = useRef(0);
19+
20+
useEffect(() => {
21+
let alive = true;
22+
setLesson(null);
23+
setError("");
24+
fetch(appPath(`/lessons/${slug}.json`))
25+
.then((r) => {
26+
if (!r.ok) throw new Error("레슨을 찾을 수 없습니다");
27+
return r.json();
28+
})
29+
.then((d) => { if (alive) setLesson(d); })
30+
.catch((e) => { if (alive) setError(String((e && e.message) || e)); });
31+
return () => { alive = false; };
32+
}, [slug]);
33+
34+
function runCell(code) {
35+
if (!code) return;
36+
setLoaded({ code, nonce: (nonceRef.current += 1) });
37+
if (typeof document !== "undefined") {
38+
const el = document.getElementById("lesson-runner");
39+
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
40+
}
41+
}
42+
43+
if (error) {
44+
return (
45+
<main className="homeWrap homeSection">
46+
<Heading level={1} type="display-3">{error}</Heading>
47+
<div style={{ marginTop: 18 }}>
48+
<Button as="a" href={appPath("/learn")} variant="secondary" label="학습으로 돌아가기" icon={<ArrowLeft size={15} aria-hidden="true" />} />
49+
</div>
50+
</main>
51+
);
52+
}
53+
54+
if (!lesson) {
55+
return (
56+
<main className="homeWrap homeSection">
57+
<Text type="body" color="muted">레슨을 불러오는 중...</Text>
58+
</main>
59+
);
60+
}
61+
62+
const firstSnippet = lesson.sections.find((s) => s.snippet)?.snippet;
63+
64+
return (
65+
<main className="lessonPage">
66+
<section className="homeWrap homeSection" style={{ paddingBlock: "40px 24px" }}>
67+
<Button as="a" href={appPath("/learn")} variant="ghost" size="sm" label="학습" icon={<ArrowLeft size={15} aria-hidden="true" />} />
68+
<div className="homeSectionHead" style={{ marginTop: 12 }}>
69+
<Text type="label" color="accent">{lesson.emoji ? `${lesson.emoji} ` : ""}레슨</Text>
70+
<Heading level={1} type="display-2">{lesson.title}</Heading>
71+
{lesson.direction && <Text type="body" color="muted">{lesson.direction}</Text>}
72+
</div>
73+
{lesson.intro && lesson.intro.points && lesson.intro.points.length > 0 && (
74+
<ul className="lessonPoints">
75+
{lesson.intro.points.map((p, i) => <li key={i}>{p}</li>)}
76+
</ul>
77+
)}
78+
<div id="lesson-runner" style={{ marginTop: 20 }}>
79+
<PythonRunner load={loaded} initialCode={firstSnippet || "# 이 레슨의 코드를 실행해보세요\nprint('안녕하세요')"} />
80+
</div>
81+
</section>
82+
83+
<section className="homeWrap homeSection" style={{ paddingTop: 8 }}>
84+
<div className="lessonSections">
85+
{lesson.sections.map((s, i) => (
86+
<Card key={i} padding={5}>
87+
<div className="homeCardStack">
88+
{s.title && <Heading level={2} type="title">{s.title}</Heading>}
89+
{s.subtitle && <Text type="body-sm" color="subtle">{s.subtitle}</Text>}
90+
{s.why && <Text type="body-sm" color="muted">{s.why}</Text>}
91+
{s.explanation && <Text type="body" style={{ whiteSpace: "pre-wrap" }}>{s.explanation}</Text>}
92+
{s.snippet && (
93+
<div className="lessonCell">
94+
<pre className="lessonSnippet">{s.snippet}</pre>
95+
<Button variant="primary" size="sm" label="이 코드 실행" icon={<Play size={14} aria-hidden="true" />} clickAction={() => runCell(s.snippet)} />
96+
</div>
97+
)}
98+
</div>
99+
</Card>
100+
))}
101+
</div>
102+
</section>
103+
</main>
104+
);
105+
}

‎landing/src/styles/homeAstryx.css‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,45 @@
241241
background: var(--color-accent-muted, rgba(245, 165, 36, 0.08));
242242
}
243243

244+
/* 레슨 상세 페이지 */
245+
.lessonPoints {
246+
margin: 18px 0 0;
247+
padding-left: 20px;
248+
display: flex;
249+
flex-direction: column;
250+
gap: 6px;
251+
opacity: 0.8;
252+
font-size: 14px;
253+
}
254+
255+
.lessonSections {
256+
display: flex;
257+
flex-direction: column;
258+
gap: 16px;
259+
}
260+
261+
.lessonCell {
262+
margin-top: 6px;
263+
display: flex;
264+
flex-direction: column;
265+
gap: 10px;
266+
align-items: flex-start;
267+
}
268+
269+
.lessonSnippet {
270+
width: 100%;
271+
margin: 0;
272+
padding: 14px 16px;
273+
border-radius: 10px;
274+
border: 1px solid var(--color-border, rgba(120, 120, 130, 0.24));
275+
background: var(--color-background-muted, rgba(120, 120, 130, 0.06));
276+
font-family: ui-monospace, "SFMono-Regular", "Space Grotesk", monospace;
277+
font-size: 13px;
278+
line-height: 1.6;
279+
white-space: pre-wrap;
280+
overflow-x: auto;
281+
}
282+
244283
/* 브라우저 Python 실행기 */
245284
.pyRunner {
246285
border: 1px solid var(--color-border, rgba(120, 120, 130, 0.3));

0 commit comments

Comments
 (0)