handzon-core 0.6.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.
- package/package.json +74 -0
- package/src/collections.ts +150 -0
- package/src/components/Footer.astro +85 -0
- package/src/components/Navbar.astro +74 -0
- package/src/components/Progress.tsx +36 -0
- package/src/components/Sidebar.astro +162 -0
- package/src/components/StepNav.astro +107 -0
- package/src/components/ai/ByokSetup.tsx +90 -0
- package/src/components/ai/ChatButton.tsx +30 -0
- package/src/components/ai/ChatPanel.tsx +244 -0
- package/src/components/auth/SignInButton.astro +41 -0
- package/src/components/auth/UserMenu.astro +79 -0
- package/src/components/auth/UserMenu.tsx +136 -0
- package/src/components/home/FilterBar.tsx +152 -0
- package/src/components/home/Hero.astro +60 -0
- package/src/components/home/Pagination.tsx +89 -0
- package/src/components/home/ResumeRail.tsx +50 -0
- package/src/components/home/TutorialCard.astro +185 -0
- package/src/components/mdx/Callout.astro +77 -0
- package/src/components/mdx/Checkpoint.astro +14 -0
- package/src/components/mdx/Checkpoint.tsx +49 -0
- package/src/components/mdx/Diff.astro +6 -0
- package/src/components/mdx/Diff.tsx +100 -0
- package/src/components/mdx/Download.astro +37 -0
- package/src/components/mdx/Embed.astro +56 -0
- package/src/components/mdx/File.astro +28 -0
- package/src/components/mdx/FileTree.astro +6 -0
- package/src/components/mdx/FileTree.tsx +71 -0
- package/src/components/mdx/Hint.astro +51 -0
- package/src/components/mdx/Mermaid.astro +6 -0
- package/src/components/mdx/Mermaid.tsx +47 -0
- package/src/components/mdx/Playground.astro +6 -0
- package/src/components/mdx/Playground.tsx +34 -0
- package/src/components/mdx/Quiz.astro +6 -0
- package/src/components/mdx/Quiz.tsx +102 -0
- package/src/components/mdx/Recap.astro +65 -0
- package/src/components/mdx/Reveal.astro +7 -0
- package/src/components/mdx/Reveal.tsx +25 -0
- package/src/components/mdx/Step.astro +12 -0
- package/src/components/mdx/Steps.astro +40 -0
- package/src/components/mdx/Tab.astro +22 -0
- package/src/components/mdx/Tabs.astro +67 -0
- package/src/components/mdx/Terminal.astro +6 -0
- package/src/components/mdx/Terminal.tsx +47 -0
- package/src/index.ts +55 -0
- package/src/layouts/BaseLayout.astro +112 -0
- package/src/layouts/TutorialLayout.astro +218 -0
- package/src/lib/ai/client.ts +92 -0
- package/src/lib/ai/context.ts +97 -0
- package/src/lib/content.ts +73 -0
- package/src/lib/mdx-components.ts +47 -0
- package/src/lib/progress/local.ts +89 -0
- package/src/lib/progress/remote.ts +199 -0
- package/src/lib/progress/types.ts +63 -0
- package/src/lib/progress/useProgress.ts +117 -0
- package/src/lib/rehype-mermaid-passthrough.ts +31 -0
- package/src/pages/Home.astro +408 -0
- package/src/pages/TutorialLanding.astro +324 -0
- package/src/pages/TutorialStep.astro +67 -0
- package/src/pages/paths.ts +36 -0
- package/src/server/auth/config.ts +102 -0
- package/src/server/auth/schema.ts +66 -0
- package/src/server/auth/session.ts +27 -0
- package/src/server/auth.ts +127 -0
- package/src/server/db/client.ts +14 -0
- package/src/server/db/migrate.ts +29 -0
- package/src/server/db/schema.ts +65 -0
- package/src/server/handlers/healthz.ts +6 -0
- package/src/server/handlers/progress.ts +90 -0
- package/src/server/handlers/tutorialStats.ts +67 -0
- package/src/server/http.ts +33 -0
- package/src/types/ai.ts +17 -0
- package/styles/base.css +127 -0
- package/styles/components/a11y.css +12 -0
- package/styles/components/byok.css +50 -0
- package/styles/components/chat.css +304 -0
- package/styles/components/checkpoint.css +49 -0
- package/styles/components/diff.css +44 -0
- package/styles/components/expressive-code.css +61 -0
- package/styles/components/filetree.css +68 -0
- package/styles/components/mermaid.css +19 -0
- package/styles/components/modal.css +25 -0
- package/styles/components/progress.css +19 -0
- package/styles/components/quiz.css +101 -0
- package/styles/components/reveal.css +25 -0
- package/styles/components/tabs.css +60 -0
- package/styles/components/terminal.css +55 -0
- package/styles/components.css +28 -0
- package/styles/global.css +15 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
3
|
+
import { getStepsForTutorial, parseStepId, sumDurations } from "../lib/content.ts";
|
|
4
|
+
import type { TutorialEntry } from "../lib/content.ts";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
tutorial: TutorialEntry;
|
|
8
|
+
siteName?: string;
|
|
9
|
+
tagline?: string;
|
|
10
|
+
logoUrl?: string;
|
|
11
|
+
faviconUrl?: string;
|
|
12
|
+
repoUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { tutorial, siteName, tagline, logoUrl, faviconUrl, repoUrl } = Astro.props;
|
|
16
|
+
const steps = await getStepsForTutorial(tutorial.id);
|
|
17
|
+
const duration = tutorial.data.estimatedDuration ?? sumDurations(steps) ?? "";
|
|
18
|
+
const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
19
|
+
---
|
|
20
|
+
<BaseLayout
|
|
21
|
+
title={tutorial.data.title}
|
|
22
|
+
description={tutorial.data.description}
|
|
23
|
+
siteName={siteName}
|
|
24
|
+
tagline={tagline}
|
|
25
|
+
logoUrl={logoUrl}
|
|
26
|
+
faviconUrl={faviconUrl}
|
|
27
|
+
repoUrl={repoUrl}
|
|
28
|
+
>
|
|
29
|
+
<div class="landing">
|
|
30
|
+
<a class="back" href="/">← All tutorials</a>
|
|
31
|
+
|
|
32
|
+
<header class="hero">
|
|
33
|
+
<div class="hero-meta">
|
|
34
|
+
<span class={`pill pill-${tutorial.data.difficulty}`}>{tutorial.data.difficulty}</span>
|
|
35
|
+
{duration && <span class="pill pill-neutral">⏱ {duration}</span>}
|
|
36
|
+
<span class="pill pill-neutral">{steps.length} {steps.length === 1 ? "step" : "steps"}</span>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<h1>{tutorial.data.title}</h1>
|
|
40
|
+
<p class="desc">{tutorial.data.description}</p>
|
|
41
|
+
|
|
42
|
+
<div class="hero-actions">
|
|
43
|
+
{firstStepSlug && (
|
|
44
|
+
<a class="cta" href={`/${tutorial.id}/${firstStepSlug}`} data-tutorial-slug={tutorial.id}>
|
|
45
|
+
<span class="cta-label">Start tutorial</span>
|
|
46
|
+
<span class="cta-arrow" aria-hidden="true">→</span>
|
|
47
|
+
</a>
|
|
48
|
+
)}
|
|
49
|
+
{tutorial.data.author && (
|
|
50
|
+
<div class="author">
|
|
51
|
+
By <strong>{tutorial.data.author.name}</strong>
|
|
52
|
+
{tutorial.data.publishedAt && (
|
|
53
|
+
<> · {new Date(tutorial.data.publishedAt).toLocaleDateString()}</>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{tutorial.data.tags.length > 0 && (
|
|
60
|
+
<div class="hero-tags" aria-label="Topics">
|
|
61
|
+
{tutorial.data.tags.map((tag) => (
|
|
62
|
+
<a class="hero-tag" href={`/?tag=${encodeURIComponent(tag)}`}>#{tag}</a>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</header>
|
|
67
|
+
|
|
68
|
+
{tutorial.data.prerequisites.length > 0 && (
|
|
69
|
+
<section class="block">
|
|
70
|
+
<h2>Prerequisites</h2>
|
|
71
|
+
<ul class="prereqs">{tutorial.data.prerequisites.map((p) => <li>{p}</li>)}</ul>
|
|
72
|
+
</section>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<section class="block">
|
|
76
|
+
<h2>Steps</h2>
|
|
77
|
+
<ol class="step-list">
|
|
78
|
+
{steps.map((s, i) => {
|
|
79
|
+
const { stepSlug } = parseStepId(s.id);
|
|
80
|
+
return (
|
|
81
|
+
<li>
|
|
82
|
+
<a href={`/${tutorial.id}/${stepSlug}`}>
|
|
83
|
+
<span class="step-num">{String(i + 1).padStart(2, "0")}</span>
|
|
84
|
+
<span class="step-body">
|
|
85
|
+
<strong>{s.data.title}</strong>
|
|
86
|
+
{s.data.summary && <span class="step-summary">{s.data.summary}</span>}
|
|
87
|
+
</span>
|
|
88
|
+
{s.data.duration && <span class="step-dur">{s.data.duration}</span>}
|
|
89
|
+
<span class="step-chevron" aria-hidden="true">→</span>
|
|
90
|
+
</a>
|
|
91
|
+
</li>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</ol>
|
|
95
|
+
</section>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<script>
|
|
99
|
+
// Resume from last visited if we have one.
|
|
100
|
+
import { getStore } from "../lib/progress/local.ts";
|
|
101
|
+
const cta = document.querySelector<HTMLAnchorElement>("[data-tutorial-slug]");
|
|
102
|
+
if (cta) {
|
|
103
|
+
const slug = cta.dataset.tutorialSlug!;
|
|
104
|
+
const last = getStore().get().lastVisited[slug];
|
|
105
|
+
const step = typeof last === "string" ? last : last?.step;
|
|
106
|
+
if (step) {
|
|
107
|
+
cta.textContent = `Continue from "${step}" →`;
|
|
108
|
+
cta.setAttribute("href", `/${slug}/${step}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
</script>
|
|
112
|
+
</BaseLayout>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.landing {
|
|
116
|
+
max-width: 56rem;
|
|
117
|
+
margin: 0 auto;
|
|
118
|
+
padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
|
|
119
|
+
}
|
|
120
|
+
.back {
|
|
121
|
+
display: inline-block;
|
|
122
|
+
font-family: var(--font-mono);
|
|
123
|
+
font-size: 0.75em;
|
|
124
|
+
text-transform: uppercase;
|
|
125
|
+
letter-spacing: 0.06em;
|
|
126
|
+
color: var(--color-muted);
|
|
127
|
+
text-decoration: none;
|
|
128
|
+
margin-bottom: 1.5rem;
|
|
129
|
+
}
|
|
130
|
+
.back:hover { color: var(--color-accent); }
|
|
131
|
+
.hero {
|
|
132
|
+
position: relative;
|
|
133
|
+
padding: 2rem 2rem 2.25rem;
|
|
134
|
+
background: linear-gradient(
|
|
135
|
+
180deg,
|
|
136
|
+
color-mix(in oklab, var(--color-accent) 8%, var(--color-bg)),
|
|
137
|
+
var(--color-bg) 80%
|
|
138
|
+
);
|
|
139
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
140
|
+
border-left-width: var(--border-thick, 4px);
|
|
141
|
+
border-left-color: var(--color-accent);
|
|
142
|
+
margin-bottom: 3rem;
|
|
143
|
+
}
|
|
144
|
+
.hero-meta {
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-wrap: wrap;
|
|
147
|
+
gap: 0.4rem;
|
|
148
|
+
margin-bottom: 1.1rem;
|
|
149
|
+
}
|
|
150
|
+
.pill {
|
|
151
|
+
display: inline-flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: 0.25rem;
|
|
154
|
+
padding: 0.22rem 0.55rem;
|
|
155
|
+
font-family: var(--font-mono);
|
|
156
|
+
font-size: 0.7em;
|
|
157
|
+
text-transform: uppercase;
|
|
158
|
+
letter-spacing: 0.06em;
|
|
159
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
160
|
+
color: var(--color-muted);
|
|
161
|
+
background: var(--color-bg);
|
|
162
|
+
}
|
|
163
|
+
.pill-soft {
|
|
164
|
+
border-color: transparent;
|
|
165
|
+
background: var(--color-surface);
|
|
166
|
+
}
|
|
167
|
+
.pill-beginner { color: var(--color-success); border-color: var(--color-success); }
|
|
168
|
+
.pill-intermediate { color: var(--color-warn); border-color: var(--color-warn); }
|
|
169
|
+
.pill-advanced { color: var(--color-danger); border-color: var(--color-danger); }
|
|
170
|
+
|
|
171
|
+
/* Topics — quiet, hash-prefixed, sitting below the CTA. No pill
|
|
172
|
+
* boxes (which fought with the difficulty/duration row); just
|
|
173
|
+
* monospace muted text, clickable to filter the homepage. */
|
|
174
|
+
.hero-tags {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-wrap: wrap;
|
|
177
|
+
gap: 0.65rem 1rem;
|
|
178
|
+
margin-top: 1.75rem;
|
|
179
|
+
padding-top: 1.25rem;
|
|
180
|
+
border-top: 1px solid var(--color-border);
|
|
181
|
+
}
|
|
182
|
+
.hero-tag {
|
|
183
|
+
font-family: var(--font-mono);
|
|
184
|
+
font-size: 0.75em;
|
|
185
|
+
color: var(--color-muted);
|
|
186
|
+
text-decoration: none;
|
|
187
|
+
transition: color 0.12s ease;
|
|
188
|
+
}
|
|
189
|
+
.hero-tag:hover { color: var(--color-accent); }
|
|
190
|
+
h1 {
|
|
191
|
+
font-size: clamp(2.1rem, 4.5vw, 3rem);
|
|
192
|
+
font-weight: 800;
|
|
193
|
+
margin: 0 0 0.75rem;
|
|
194
|
+
letter-spacing: -0.025em;
|
|
195
|
+
line-height: 1.05;
|
|
196
|
+
}
|
|
197
|
+
.desc {
|
|
198
|
+
font-size: clamp(1rem, 1.5vw, 1.15rem);
|
|
199
|
+
line-height: 1.55;
|
|
200
|
+
color: var(--color-muted);
|
|
201
|
+
margin: 0 0 1.75rem;
|
|
202
|
+
max-width: 50ch;
|
|
203
|
+
}
|
|
204
|
+
.hero-actions {
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-wrap: wrap;
|
|
207
|
+
align-items: center;
|
|
208
|
+
gap: 1.25rem;
|
|
209
|
+
}
|
|
210
|
+
.author { color: var(--color-muted); font-size: 0.85em; }
|
|
211
|
+
.cta {
|
|
212
|
+
display: inline-flex;
|
|
213
|
+
align-items: center;
|
|
214
|
+
gap: 0.65rem;
|
|
215
|
+
padding: 0.85rem 1.4rem;
|
|
216
|
+
background: color-mix(in oklab, var(--color-accent) 10%, transparent);
|
|
217
|
+
color: var(--color-accent);
|
|
218
|
+
border: 1px solid var(--color-accent);
|
|
219
|
+
font-weight: 700;
|
|
220
|
+
font-size: 0.95em;
|
|
221
|
+
text-decoration: none;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
transition: background 0.12s ease, color 0.12s ease;
|
|
224
|
+
}
|
|
225
|
+
.cta:hover {
|
|
226
|
+
background: var(--color-accent);
|
|
227
|
+
color: var(--color-accent-fg);
|
|
228
|
+
}
|
|
229
|
+
.cta:hover .cta-arrow { transform: translateX(3px); }
|
|
230
|
+
.cta-arrow {
|
|
231
|
+
display: inline-block;
|
|
232
|
+
transition: transform 0.12s ease;
|
|
233
|
+
font-family: var(--font-mono);
|
|
234
|
+
}
|
|
235
|
+
.block { margin-top: 3rem; }
|
|
236
|
+
.block h2 {
|
|
237
|
+
font-size: 0.85rem;
|
|
238
|
+
text-transform: uppercase;
|
|
239
|
+
letter-spacing: 0.1em;
|
|
240
|
+
color: var(--color-accent);
|
|
241
|
+
margin: 0 0 1rem;
|
|
242
|
+
font-weight: 700;
|
|
243
|
+
}
|
|
244
|
+
.prereqs {
|
|
245
|
+
list-style: none;
|
|
246
|
+
padding: 0;
|
|
247
|
+
margin: 0;
|
|
248
|
+
display: grid;
|
|
249
|
+
gap: 0.4rem;
|
|
250
|
+
}
|
|
251
|
+
.prereqs li {
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
gap: 0.7rem;
|
|
255
|
+
padding: 0.55rem 0.85rem;
|
|
256
|
+
background: var(--color-surface);
|
|
257
|
+
border: 1px solid var(--color-border);
|
|
258
|
+
color: var(--color-fg);
|
|
259
|
+
font-size: 0.95em;
|
|
260
|
+
line-height: 1.4;
|
|
261
|
+
}
|
|
262
|
+
.prereqs li::before {
|
|
263
|
+
content: "✓";
|
|
264
|
+
display: inline-grid;
|
|
265
|
+
place-items: center;
|
|
266
|
+
width: 1.1rem;
|
|
267
|
+
height: 1.1rem;
|
|
268
|
+
border: 1px solid color-mix(in oklab, var(--color-accent) 40%, var(--color-border));
|
|
269
|
+
background: color-mix(in oklab, var(--color-accent) 12%, var(--color-bg));
|
|
270
|
+
color: var(--color-accent);
|
|
271
|
+
font-family: var(--font-mono);
|
|
272
|
+
font-size: 0.7em;
|
|
273
|
+
font-weight: 700;
|
|
274
|
+
flex-shrink: 0;
|
|
275
|
+
}
|
|
276
|
+
.step-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
|
|
277
|
+
.step-list li { border: 1px solid var(--color-border); background: var(--color-bg); }
|
|
278
|
+
.step-list a {
|
|
279
|
+
display: grid;
|
|
280
|
+
grid-template-columns: auto 1fr auto auto;
|
|
281
|
+
gap: 1rem;
|
|
282
|
+
align-items: center;
|
|
283
|
+
padding: 0.9rem 1rem;
|
|
284
|
+
text-decoration: none;
|
|
285
|
+
color: var(--color-fg);
|
|
286
|
+
transition: background 0.12s ease, border-color 0.12s ease;
|
|
287
|
+
}
|
|
288
|
+
.step-list li:hover { border-color: var(--color-accent); }
|
|
289
|
+
.step-list a:hover { background: var(--color-surface); }
|
|
290
|
+
.step-list a:hover .step-chevron { color: var(--color-accent); transform: translateX(3px); }
|
|
291
|
+
.step-num {
|
|
292
|
+
display: inline-grid;
|
|
293
|
+
place-items: center;
|
|
294
|
+
min-width: 2.25rem;
|
|
295
|
+
height: 2.25rem;
|
|
296
|
+
padding: 0 0.5rem;
|
|
297
|
+
font-family: var(--font-mono);
|
|
298
|
+
font-size: 0.8em;
|
|
299
|
+
font-weight: 700;
|
|
300
|
+
color: var(--color-accent);
|
|
301
|
+
background: color-mix(in oklab, var(--color-accent) 10%, var(--color-bg));
|
|
302
|
+
border: 1px solid color-mix(in oklab, var(--color-accent) 35%, var(--color-border));
|
|
303
|
+
}
|
|
304
|
+
.step-body { display: grid; gap: 0.15rem; min-width: 0; }
|
|
305
|
+
.step-body strong { font-weight: 600; font-size: 1em; letter-spacing: -0.005em; }
|
|
306
|
+
.step-summary { color: var(--color-muted); font-size: 0.88em; }
|
|
307
|
+
.step-dur {
|
|
308
|
+
font-family: var(--font-mono);
|
|
309
|
+
font-size: 0.75em;
|
|
310
|
+
color: var(--color-muted);
|
|
311
|
+
padding: 0.2rem 0.5rem;
|
|
312
|
+
border: 1px solid var(--color-border);
|
|
313
|
+
}
|
|
314
|
+
.step-chevron {
|
|
315
|
+
font-family: var(--font-mono);
|
|
316
|
+
color: var(--color-muted);
|
|
317
|
+
transition: transform 0.12s ease, color 0.12s ease;
|
|
318
|
+
}
|
|
319
|
+
@media (max-width: 640px) {
|
|
320
|
+
.hero { padding: 1.5rem 1.25rem 1.75rem; }
|
|
321
|
+
.step-list a { grid-template-columns: auto 1fr auto; padding: 0.75rem; }
|
|
322
|
+
.step-chevron { display: none; }
|
|
323
|
+
}
|
|
324
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { render } from "astro:content";
|
|
3
|
+
import TutorialLayout from "../layouts/TutorialLayout.astro";
|
|
4
|
+
import ChatButton from "../components/ai/ChatButton.tsx";
|
|
5
|
+
import { parseStepId } from "../lib/content.ts";
|
|
6
|
+
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
7
|
+
import { mdxComponents } from "../lib/mdx-components.ts";
|
|
8
|
+
import { buildContext } from "../lib/ai/context.ts";
|
|
9
|
+
import { emptyState } from "../lib/progress/types.ts";
|
|
10
|
+
import type { AiConfig } from "../types/ai.ts";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
tutorial: TutorialEntry;
|
|
14
|
+
steps: StepEntry[];
|
|
15
|
+
currentStep: StepEntry;
|
|
16
|
+
aiDefaults: AiConfig;
|
|
17
|
+
siteName?: string;
|
|
18
|
+
tagline?: string;
|
|
19
|
+
logoUrl?: string;
|
|
20
|
+
faviconUrl?: string;
|
|
21
|
+
repoUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
tutorial,
|
|
26
|
+
steps,
|
|
27
|
+
currentStep,
|
|
28
|
+
aiDefaults,
|
|
29
|
+
siteName,
|
|
30
|
+
tagline,
|
|
31
|
+
logoUrl,
|
|
32
|
+
faviconUrl,
|
|
33
|
+
repoUrl,
|
|
34
|
+
} = Astro.props;
|
|
35
|
+
const { stepSlug } = parseStepId(currentStep.id);
|
|
36
|
+
const { Content } = await render(currentStep);
|
|
37
|
+
|
|
38
|
+
const components = mdxComponents();
|
|
39
|
+
const hasCheckpoint = currentStep.body?.includes("<Checkpoint") ?? false;
|
|
40
|
+
|
|
41
|
+
const aiConfig: AiConfig = { ...aiDefaults, ...(tutorial.data.ai ?? {}) };
|
|
42
|
+
const initialContext = buildContext({
|
|
43
|
+
tutorial,
|
|
44
|
+
steps,
|
|
45
|
+
currentStep,
|
|
46
|
+
progress: emptyState(),
|
|
47
|
+
references: [],
|
|
48
|
+
includeFutureSteps: aiConfig.includeFutureSteps,
|
|
49
|
+
});
|
|
50
|
+
---
|
|
51
|
+
<TutorialLayout
|
|
52
|
+
tutorial={tutorial}
|
|
53
|
+
steps={steps}
|
|
54
|
+
currentStep={currentStep}
|
|
55
|
+
currentStepSlug={stepSlug}
|
|
56
|
+
hasCheckpoint={hasCheckpoint}
|
|
57
|
+
siteName={siteName}
|
|
58
|
+
tagline={tagline}
|
|
59
|
+
logoUrl={logoUrl}
|
|
60
|
+
faviconUrl={faviconUrl}
|
|
61
|
+
repoUrl={repoUrl}
|
|
62
|
+
>
|
|
63
|
+
<Content components={components} />
|
|
64
|
+
{aiConfig.enabled && (
|
|
65
|
+
<ChatButton client:idle config={aiConfig} context={initialContext} />
|
|
66
|
+
)}
|
|
67
|
+
</TutorialLayout>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-path helpers for handzon scaffolds. Use these as the
|
|
3
|
+
* `getStaticPaths` export of a route file:
|
|
4
|
+
*
|
|
5
|
+
* export const getStaticPaths = getTutorialLandingPaths;
|
|
6
|
+
* export const getStaticPaths = getTutorialStepPaths;
|
|
7
|
+
*/
|
|
8
|
+
import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
|
|
9
|
+
import type { StepEntry, TutorialEntry } from "../lib/content.ts";
|
|
10
|
+
|
|
11
|
+
export async function getTutorialLandingPaths() {
|
|
12
|
+
const tutorials = await getTutorials();
|
|
13
|
+
return tutorials.map((tut) => ({
|
|
14
|
+
params: { tutorial: tut.id },
|
|
15
|
+
props: { tutorial: tut },
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getTutorialStepPaths() {
|
|
20
|
+
const tutorials = await getTutorials();
|
|
21
|
+
const paths: Array<{
|
|
22
|
+
params: { tutorial: string; step: string };
|
|
23
|
+
props: { tutorial: TutorialEntry; steps: StepEntry[]; currentStep: StepEntry };
|
|
24
|
+
}> = [];
|
|
25
|
+
for (const tut of tutorials) {
|
|
26
|
+
const steps = await getStepsForTutorial(tut.id);
|
|
27
|
+
for (const step of steps) {
|
|
28
|
+
const { stepSlug } = parseStepId(step.id);
|
|
29
|
+
paths.push({
|
|
30
|
+
params: { tutorial: tut.id, step: stepSlug },
|
|
31
|
+
props: { tutorial: tut, steps, currentStep: step },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return paths;
|
|
36
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth.js config factory consumed by the scaffold's `auth.config.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Usage (scaffold side):
|
|
5
|
+
*
|
|
6
|
+
* import { createAuthConfig } from "handzon-core/server/auth/config";
|
|
7
|
+
* import { getDb } from "handzon-core/server/db/client";
|
|
8
|
+
* export default createAuthConfig({ db: getDb() });
|
|
9
|
+
*
|
|
10
|
+
* GitHub is the only provider in 0.2; email/password and others are
|
|
11
|
+
* out of scope (see plan: github-auth_d52529d5).
|
|
12
|
+
*/
|
|
13
|
+
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
|
14
|
+
import GitHub from "@auth/core/providers/github";
|
|
15
|
+
import { defineConfig } from "auth-astro";
|
|
16
|
+
import { accounts, sessions, users, verificationTokens } from "./schema.ts";
|
|
17
|
+
|
|
18
|
+
interface AuthConfigOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Postgres drizzle client, or `null` when DATABASE_URL isn't
|
|
21
|
+
* available at build time. When `null`, we return a stub config so
|
|
22
|
+
* auth-astro's integration loads but does nothing — useful for Tier 1
|
|
23
|
+
* builds and for `pnpm build` on a fresh checkout without a database.
|
|
24
|
+
*/
|
|
25
|
+
db: Parameters<typeof DrizzleAdapter>[0] | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalises whatever the operator gave us into a full URL that
|
|
30
|
+
* Auth.js + GitHub OAuth callbacks can use. Priority:
|
|
31
|
+
*
|
|
32
|
+
* 1. `AUTH_URL` — operator override; supports either a
|
|
33
|
+
* full URL or a bare hostname (in which
|
|
34
|
+
* case we append `.onrender.com`, the
|
|
35
|
+
* same shape used by ALLOWED_ORIGIN and
|
|
36
|
+
* PUBLIC_AI_SERVICE_URL).
|
|
37
|
+
* 2. `RENDER_EXTERNAL_URL` — Render auto-injects this on every web
|
|
38
|
+
* service (e.g. `https://foo.onrender.com`).
|
|
39
|
+
* 3. `RENDER_EXTERNAL_HOSTNAME` — bare hostname, also auto-injected;
|
|
40
|
+
* we prefix with `https://`.
|
|
41
|
+
*
|
|
42
|
+
* Returns `undefined` when none are set (local dev with no override —
|
|
43
|
+
* Auth.js falls back to the request's Origin header).
|
|
44
|
+
*/
|
|
45
|
+
function resolveAuthUrl(): string | undefined {
|
|
46
|
+
const explicit = process.env.AUTH_URL?.trim();
|
|
47
|
+
if (explicit) {
|
|
48
|
+
const trimmed = explicit.replace(/\/$/, "");
|
|
49
|
+
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}.onrender.com`;
|
|
50
|
+
}
|
|
51
|
+
const renderUrl = process.env.RENDER_EXTERNAL_URL?.trim();
|
|
52
|
+
if (renderUrl) return renderUrl.replace(/\/$/, "");
|
|
53
|
+
const renderHost = process.env.RENDER_EXTERNAL_HOSTNAME?.trim();
|
|
54
|
+
if (renderHost) return `https://${renderHost}`;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createAuthConfig({ db }: AuthConfigOptions) {
|
|
59
|
+
// Auth.js v5 reads `AUTH_URL` from process.env on each request, so
|
|
60
|
+
// we resolve once and write it back. Idempotent: if AUTH_URL was
|
|
61
|
+
// already a full URL, this is a no-op aside from the trailing-slash
|
|
62
|
+
// trim.
|
|
63
|
+
const resolvedUrl = resolveAuthUrl();
|
|
64
|
+
if (resolvedUrl && resolvedUrl !== process.env.AUTH_URL) {
|
|
65
|
+
process.env.AUTH_URL = resolvedUrl;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!db) {
|
|
69
|
+
// No database → no adapter → no providers. auth-astro logs a warning
|
|
70
|
+
// and any sign-in attempt fails gracefully instead of crashing the
|
|
71
|
+
// build.
|
|
72
|
+
return defineConfig({ providers: [] });
|
|
73
|
+
}
|
|
74
|
+
return defineConfig({
|
|
75
|
+
adapter: DrizzleAdapter(db, {
|
|
76
|
+
usersTable: users,
|
|
77
|
+
accountsTable: accounts,
|
|
78
|
+
sessionsTable: sessions,
|
|
79
|
+
verificationTokensTable: verificationTokens,
|
|
80
|
+
}),
|
|
81
|
+
providers: [
|
|
82
|
+
GitHub({
|
|
83
|
+
clientId: process.env.GITHUB_CLIENT_ID,
|
|
84
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
85
|
+
}),
|
|
86
|
+
],
|
|
87
|
+
// JWT session strategy keeps the cookie self-describing — no extra
|
|
88
|
+
// DB hit on every request. The `users` table still holds the
|
|
89
|
+
// canonical record; we just don't store the live session there.
|
|
90
|
+
session: { strategy: "jwt" },
|
|
91
|
+
callbacks: {
|
|
92
|
+
// Surface the `users.id` UUID on `session.user.id` so server
|
|
93
|
+
// code (getOrCreateLearner) can match it without re-resolving.
|
|
94
|
+
async session({ session, token }) {
|
|
95
|
+
if (session.user && token.sub) {
|
|
96
|
+
(session.user as { id?: string }).id = token.sub;
|
|
97
|
+
}
|
|
98
|
+
return session;
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth.js (NextAuth) drizzle schema for Postgres. Tables shape matches
|
|
3
|
+
* the canonical reference in `@auth/drizzle-adapter` docs. Drizzle
|
|
4
|
+
* passes these to `DrizzleAdapter(db)` via the adapter's auto-detection.
|
|
5
|
+
*
|
|
6
|
+
* The `learners` row that maps a signed-in user to local progress lives
|
|
7
|
+
* in `../db/schema.ts` — it adds a nullable `user_id` FK to `users` here.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
integer,
|
|
11
|
+
pgTable,
|
|
12
|
+
primaryKey,
|
|
13
|
+
text,
|
|
14
|
+
timestamp,
|
|
15
|
+
uuid,
|
|
16
|
+
} from "drizzle-orm/pg-core";
|
|
17
|
+
|
|
18
|
+
export const users = pgTable("users", {
|
|
19
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
20
|
+
name: text("name"),
|
|
21
|
+
email: text("email").notNull().unique(),
|
|
22
|
+
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
|
23
|
+
image: text("image"),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const accounts = pgTable(
|
|
27
|
+
"accounts",
|
|
28
|
+
{
|
|
29
|
+
userId: uuid("user_id")
|
|
30
|
+
.notNull()
|
|
31
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
32
|
+
type: text("type").notNull(),
|
|
33
|
+
provider: text("provider").notNull(),
|
|
34
|
+
providerAccountId: text("provider_account_id").notNull(),
|
|
35
|
+
refresh_token: text("refresh_token"),
|
|
36
|
+
access_token: text("access_token"),
|
|
37
|
+
expires_at: integer("expires_at"),
|
|
38
|
+
token_type: text("token_type"),
|
|
39
|
+
scope: text("scope"),
|
|
40
|
+
id_token: text("id_token"),
|
|
41
|
+
session_state: text("session_state"),
|
|
42
|
+
},
|
|
43
|
+
(account) => ({
|
|
44
|
+
pk: primaryKey({ columns: [account.provider, account.providerAccountId] }),
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export const sessions = pgTable("sessions", {
|
|
49
|
+
sessionToken: text("session_token").primaryKey(),
|
|
50
|
+
userId: uuid("user_id")
|
|
51
|
+
.notNull()
|
|
52
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
53
|
+
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const verificationTokens = pgTable(
|
|
57
|
+
"verification_tokens",
|
|
58
|
+
{
|
|
59
|
+
identifier: text("identifier").notNull(),
|
|
60
|
+
token: text("token").notNull(),
|
|
61
|
+
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
|
62
|
+
},
|
|
63
|
+
(vt) => ({
|
|
64
|
+
pk: primaryKey({ columns: [vt.identifier, vt.token] }),
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around `auth-astro/server`'s `getSession` that flattens
|
|
3
|
+
* the shape into something boring and stable. Callers don't need to
|
|
4
|
+
* know about Auth.js's JWT vs database session details.
|
|
5
|
+
*/
|
|
6
|
+
import { getSession } from "auth-astro/server";
|
|
7
|
+
|
|
8
|
+
export interface AuthedUser {
|
|
9
|
+
userId: string;
|
|
10
|
+
email: string | null;
|
|
11
|
+
name: string | null;
|
|
12
|
+
image: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function getAuthedUser(request: Request): Promise<AuthedUser | null> {
|
|
16
|
+
const session = await getSession(request);
|
|
17
|
+
const user = session?.user as
|
|
18
|
+
| { id?: string; email?: string | null; name?: string | null; image?: string | null }
|
|
19
|
+
| undefined;
|
|
20
|
+
if (!user?.id) return null;
|
|
21
|
+
return {
|
|
22
|
+
userId: user.id,
|
|
23
|
+
email: user.email ?? null,
|
|
24
|
+
name: user.name ?? null,
|
|
25
|
+
image: user.image ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|