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.
Files changed (89) hide show
  1. package/package.json +74 -0
  2. package/src/collections.ts +150 -0
  3. package/src/components/Footer.astro +85 -0
  4. package/src/components/Navbar.astro +74 -0
  5. package/src/components/Progress.tsx +36 -0
  6. package/src/components/Sidebar.astro +162 -0
  7. package/src/components/StepNav.astro +107 -0
  8. package/src/components/ai/ByokSetup.tsx +90 -0
  9. package/src/components/ai/ChatButton.tsx +30 -0
  10. package/src/components/ai/ChatPanel.tsx +244 -0
  11. package/src/components/auth/SignInButton.astro +41 -0
  12. package/src/components/auth/UserMenu.astro +79 -0
  13. package/src/components/auth/UserMenu.tsx +136 -0
  14. package/src/components/home/FilterBar.tsx +152 -0
  15. package/src/components/home/Hero.astro +60 -0
  16. package/src/components/home/Pagination.tsx +89 -0
  17. package/src/components/home/ResumeRail.tsx +50 -0
  18. package/src/components/home/TutorialCard.astro +185 -0
  19. package/src/components/mdx/Callout.astro +77 -0
  20. package/src/components/mdx/Checkpoint.astro +14 -0
  21. package/src/components/mdx/Checkpoint.tsx +49 -0
  22. package/src/components/mdx/Diff.astro +6 -0
  23. package/src/components/mdx/Diff.tsx +100 -0
  24. package/src/components/mdx/Download.astro +37 -0
  25. package/src/components/mdx/Embed.astro +56 -0
  26. package/src/components/mdx/File.astro +28 -0
  27. package/src/components/mdx/FileTree.astro +6 -0
  28. package/src/components/mdx/FileTree.tsx +71 -0
  29. package/src/components/mdx/Hint.astro +51 -0
  30. package/src/components/mdx/Mermaid.astro +6 -0
  31. package/src/components/mdx/Mermaid.tsx +47 -0
  32. package/src/components/mdx/Playground.astro +6 -0
  33. package/src/components/mdx/Playground.tsx +34 -0
  34. package/src/components/mdx/Quiz.astro +6 -0
  35. package/src/components/mdx/Quiz.tsx +102 -0
  36. package/src/components/mdx/Recap.astro +65 -0
  37. package/src/components/mdx/Reveal.astro +7 -0
  38. package/src/components/mdx/Reveal.tsx +25 -0
  39. package/src/components/mdx/Step.astro +12 -0
  40. package/src/components/mdx/Steps.astro +40 -0
  41. package/src/components/mdx/Tab.astro +22 -0
  42. package/src/components/mdx/Tabs.astro +67 -0
  43. package/src/components/mdx/Terminal.astro +6 -0
  44. package/src/components/mdx/Terminal.tsx +47 -0
  45. package/src/index.ts +55 -0
  46. package/src/layouts/BaseLayout.astro +112 -0
  47. package/src/layouts/TutorialLayout.astro +218 -0
  48. package/src/lib/ai/client.ts +92 -0
  49. package/src/lib/ai/context.ts +97 -0
  50. package/src/lib/content.ts +73 -0
  51. package/src/lib/mdx-components.ts +47 -0
  52. package/src/lib/progress/local.ts +89 -0
  53. package/src/lib/progress/remote.ts +199 -0
  54. package/src/lib/progress/types.ts +63 -0
  55. package/src/lib/progress/useProgress.ts +117 -0
  56. package/src/lib/rehype-mermaid-passthrough.ts +31 -0
  57. package/src/pages/Home.astro +408 -0
  58. package/src/pages/TutorialLanding.astro +324 -0
  59. package/src/pages/TutorialStep.astro +67 -0
  60. package/src/pages/paths.ts +36 -0
  61. package/src/server/auth/config.ts +102 -0
  62. package/src/server/auth/schema.ts +66 -0
  63. package/src/server/auth/session.ts +27 -0
  64. package/src/server/auth.ts +127 -0
  65. package/src/server/db/client.ts +14 -0
  66. package/src/server/db/migrate.ts +29 -0
  67. package/src/server/db/schema.ts +65 -0
  68. package/src/server/handlers/healthz.ts +6 -0
  69. package/src/server/handlers/progress.ts +90 -0
  70. package/src/server/handlers/tutorialStats.ts +67 -0
  71. package/src/server/http.ts +33 -0
  72. package/src/types/ai.ts +17 -0
  73. package/styles/base.css +127 -0
  74. package/styles/components/a11y.css +12 -0
  75. package/styles/components/byok.css +50 -0
  76. package/styles/components/chat.css +304 -0
  77. package/styles/components/checkpoint.css +49 -0
  78. package/styles/components/diff.css +44 -0
  79. package/styles/components/expressive-code.css +61 -0
  80. package/styles/components/filetree.css +68 -0
  81. package/styles/components/mermaid.css +19 -0
  82. package/styles/components/modal.css +25 -0
  83. package/styles/components/progress.css +19 -0
  84. package/styles/components/quiz.css +101 -0
  85. package/styles/components/reveal.css +25 -0
  86. package/styles/components/tabs.css +60 -0
  87. package/styles/components/terminal.css +55 -0
  88. package/styles/components.css +28 -0
  89. package/styles/global.css +15 -0
@@ -0,0 +1,31 @@
1
+ import type { Element, Root, Text } from "hast";
2
+ import { visit } from "unist-util-visit";
3
+
4
+ /**
5
+ * Rewrite <pre><code class="language-mermaid">…</code></pre> into
6
+ * <pre class="mermaid">…</pre> so the client-side mermaid loader in
7
+ * BaseLayout can render it. Zero build-time deps (no playwright).
8
+ */
9
+ export default function rehypeMermaidPassthrough() {
10
+ return (tree: Root) => {
11
+ visit(tree, "element", (node: Element, index, parent) => {
12
+ if (node.tagName !== "pre" || !parent || index === undefined) return;
13
+ const code = node.children.find(
14
+ (c): c is Element => c.type === "element" && c.tagName === "code",
15
+ );
16
+ if (!code) return;
17
+ const classes = (code.properties?.className as string[] | undefined) ?? [];
18
+ if (!classes.includes("language-mermaid")) return;
19
+ const source = code.children
20
+ .filter((c): c is Text => c.type === "text")
21
+ .map((c) => c.value)
22
+ .join("");
23
+ (parent.children as Element[])[index] = {
24
+ type: "element",
25
+ tagName: "pre",
26
+ properties: { className: ["mermaid"] },
27
+ children: [{ type: "text", value: source }],
28
+ };
29
+ });
30
+ };
31
+ }
@@ -0,0 +1,408 @@
1
+ ---
2
+ import BaseLayout from "../layouts/BaseLayout.astro";
3
+ import Hero from "../components/home/Hero.astro";
4
+ import TutorialCard from "../components/home/TutorialCard.astro";
5
+ import FilterBar from "../components/home/FilterBar.tsx";
6
+ import Pagination from "../components/home/Pagination.tsx";
7
+ import ResumeRail from "../components/home/ResumeRail.tsx";
8
+ import { getTutorials, getStepsForTutorial, sumDurations } from "../lib/content.ts";
9
+
10
+ interface Props {
11
+ siteName?: string;
12
+ tagline?: string;
13
+ hero?: { title?: string; subtitle?: string };
14
+ logoUrl?: string;
15
+ faviconUrl?: string;
16
+ repoUrl?: string;
17
+ showResumeRail?: boolean;
18
+ emptyStateCommand?: string;
19
+ /** Tutorials per page on the grid. */
20
+ pageSize?: number;
21
+ }
22
+
23
+ const {
24
+ siteName = "Handzon",
25
+ tagline = "Hands-on tutorials",
26
+ hero,
27
+ logoUrl,
28
+ faviconUrl,
29
+ repoUrl,
30
+ showResumeRail = true,
31
+ emptyStateCommand = "pnpm handzon:new",
32
+ pageSize = 9,
33
+ } = Astro.props;
34
+
35
+ const tutorials = await getTutorials();
36
+ const stepsByTutorial = new Map(
37
+ await Promise.all(
38
+ tutorials.map(async (t) => [t.id, await getStepsForTutorial(t.id)] as const),
39
+ ),
40
+ );
41
+ const difficulties = ["beginner", "intermediate", "advanced"];
42
+ const tags = Array.from(new Set(tutorials.flatMap((t) => t.data.tags))).sort();
43
+ const compactTutorials = tutorials.map((t) => ({ slug: t.id, title: t.data.title }));
44
+ ---
45
+ <BaseLayout
46
+ siteName={siteName}
47
+ tagline={tagline}
48
+ logoUrl={logoUrl}
49
+ faviconUrl={faviconUrl}
50
+ repoUrl={repoUrl}
51
+ nav="userMenu"
52
+ >
53
+ <div class="home">
54
+ <Hero title={hero?.title} subtitle={hero?.subtitle} logoUrl={logoUrl} />
55
+
56
+ {showResumeRail && (
57
+ <ResumeRail client:load tutorials={compactTutorials} />
58
+ )}
59
+
60
+ <FilterBar client:load difficulties={difficulties} tags={tags} />
61
+
62
+ <section class="grid">
63
+ {tutorials.map((tut) => {
64
+ const steps = stepsByTutorial.get(tut.id) ?? [];
65
+ const dur = tut.data.estimatedDuration ?? sumDurations(steps);
66
+ return <TutorialCard tutorial={tut} duration={dur} />;
67
+ })}
68
+ </section>
69
+
70
+ <Pagination client:load pageSize={pageSize} />
71
+
72
+ <div class="empty" data-empty-state hidden>
73
+ <p>No tutorials match the current filters.</p>
74
+ </div>
75
+
76
+ {tutorials.length === 0 && (
77
+ <div class="empty">
78
+ <p>
79
+ No tutorials yet. Run <code>{emptyStateCommand}</code> to create your first one.
80
+ </p>
81
+ </div>
82
+ )}
83
+ </div>
84
+
85
+ <script>
86
+ // Hydrate per-card progress mini-bars from localStorage. Use a subpath
87
+ // import to avoid pulling the server-only barrel into the client bundle.
88
+ import { getStore } from "../lib/progress/local.ts";
89
+ function refresh() {
90
+ const state = getStore().get();
91
+ const totals = new Map<string, number>();
92
+ const completed = new Map<string, number>();
93
+ for (const [key, value] of Object.entries(state.steps)) {
94
+ const slug = key.split("/")[0];
95
+ if (!slug) continue;
96
+ totals.set(slug, (totals.get(slug) ?? 0) + 1);
97
+ if (value === "complete") completed.set(slug, (completed.get(slug) ?? 0) + 1);
98
+ }
99
+ document.querySelectorAll<HTMLElement>("[data-tutorial-slug]").forEach((el) => {
100
+ const slug = el.dataset.tutorialSlug!;
101
+ const done = completed.get(slug) ?? 0;
102
+ const total = totals.get(slug) ?? 0;
103
+ if (total === 0) return;
104
+ const pct = Math.round((done / total) * 100);
105
+ el.innerHTML = `<div class="mini-bar"><div style="width:${pct}%"></div></div><span>${done}/${total} steps</span>`;
106
+ });
107
+ }
108
+ refresh();
109
+ getStore().subscribe(refresh);
110
+ </script>
111
+
112
+ <script>
113
+ // Hydrate cross-learner popularity numbers and the `data-popularity`
114
+ // attribute used by the FilterBar's "Popular" sort. Single fetch on
115
+ // mount; Cache-Control: max-age=60 on the response handles SPA-like
116
+ // navigations. Failures are silent — cards just show no numbers,
117
+ // which is the desired state on Tier 1 anyway.
118
+ type Stat = { slug: string; started: number; completed: number };
119
+ function fmt(n: number): string {
120
+ if (n < 1000) return String(n);
121
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
122
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
123
+ return `${(n / 1_000_000).toFixed(1)}M`;
124
+ }
125
+ function popularityScore(stat: Stat): number {
126
+ // Weight completion higher than start. Completed learners are a
127
+ // much stronger signal than visitors who bounced after step 1.
128
+ return stat.completed * 3 + stat.started;
129
+ }
130
+ async function hydrateStats() {
131
+ try {
132
+ const res = await fetch("/api/tutorials/stats", { credentials: "same-origin" });
133
+ if (!res.ok) return;
134
+ const data = (await res.json()) as { stats: Stat[] };
135
+ for (const stat of data.stats) {
136
+ const card = document.querySelector<HTMLElement>(
137
+ `[data-card-slug="${CSS.escape(stat.slug)}"]`,
138
+ );
139
+ if (!card) continue;
140
+ card.dataset.popularity = String(popularityScore(stat));
141
+ const slot = card.querySelector<HTMLElement>(
142
+ `[data-tutorial-stats-slug="${CSS.escape(stat.slug)}"]`,
143
+ );
144
+ if (!slot) continue;
145
+ if (stat.started === 0 && stat.completed === 0) continue;
146
+ slot.innerHTML =
147
+ `<span class="stat" title="Unique learners started">▶ ${fmt(stat.started)} started</span>` +
148
+ `<span class="stat-divider">·</span>` +
149
+ `<span class="stat" title="Unique learners completed">✓ ${fmt(stat.completed)} finished</span>`;
150
+ }
151
+ window.dispatchEvent(new CustomEvent("hz:stats-loaded"));
152
+ } catch {
153
+ // ignore
154
+ }
155
+ }
156
+ hydrateStats();
157
+ </script>
158
+
159
+ <script>
160
+ // Popularity sort: re-orders cards in place by writing CSS `order`
161
+ // from `data-popularity`. Composes with the existing filter/page
162
+ // hide attributes because order is a flex/grid layout concern, not
163
+ // a visibility concern.
164
+ function applySort(mode: string) {
165
+ const cards = document.querySelectorAll<HTMLElement>("[data-card-slug]");
166
+ if (mode === "popular") {
167
+ const sorted = Array.from(cards).sort((a, b) => {
168
+ return Number(b.dataset.popularity ?? 0) - Number(a.dataset.popularity ?? 0);
169
+ });
170
+ sorted.forEach((card, idx) => {
171
+ card.style.order = String(idx);
172
+ });
173
+ } else {
174
+ cards.forEach((card) => {
175
+ card.style.removeProperty("order");
176
+ });
177
+ }
178
+ }
179
+ function syncFromUrl() {
180
+ const url = new URL(window.location.href);
181
+ applySort(url.searchParams.get("sort") ?? "");
182
+ }
183
+ syncFromUrl();
184
+ window.addEventListener("hz:sort-changed", (e) => {
185
+ applySort((e as CustomEvent<{ sort: string }>).detail.sort);
186
+ });
187
+ window.addEventListener("hz:stats-loaded", syncFromUrl);
188
+ </script>
189
+ </BaseLayout>
190
+
191
+ <style>
192
+ .home {
193
+ max-width: 80rem;
194
+ margin: 0 auto;
195
+ padding: 0 clamp(1rem, 4vw, 2rem) 4rem;
196
+ }
197
+ .grid {
198
+ display: grid;
199
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
200
+ gap: 1rem;
201
+ margin-top: 1.5rem;
202
+ }
203
+ .empty {
204
+ margin: 2rem 0;
205
+ padding: 2rem;
206
+ border: var(--border-default, 2px) solid var(--color-border);
207
+ text-align: center;
208
+ color: var(--color-muted);
209
+ }
210
+ </style>
211
+
212
+ <style is:global>
213
+ /* ---------- Resume rail ---------- */
214
+ /* One coherent link, not a stack of three competing styles. Border
215
+ * lights up on hover; the trailing → is the accent. Everything else
216
+ * sits on a single muted/foreground baseline. */
217
+ .resume-rail {
218
+ display: flex;
219
+ align-items: baseline;
220
+ gap: 0.5rem;
221
+ margin-top: 1.5rem;
222
+ padding: 0.7rem 0.95rem;
223
+ border: 1px solid var(--color-border);
224
+ color: var(--color-fg);
225
+ font-size: 0.95em;
226
+ text-decoration: none;
227
+ transition: border-color 0.12s ease, background 0.12s ease;
228
+ }
229
+ .resume-rail:hover {
230
+ border-color: var(--color-accent);
231
+ background: color-mix(in oklab, var(--color-accent) 6%, transparent);
232
+ }
233
+ .rr-prefix {
234
+ font-family: var(--font-mono);
235
+ font-size: 0.78em;
236
+ text-transform: uppercase;
237
+ letter-spacing: 0.08em;
238
+ color: var(--color-muted);
239
+ }
240
+ .rr-title { font-weight: 600; }
241
+ .rr-step {
242
+ color: var(--color-muted);
243
+ overflow: hidden;
244
+ text-overflow: ellipsis;
245
+ white-space: nowrap;
246
+ }
247
+ .rr-arrow {
248
+ margin-left: auto;
249
+ color: var(--color-accent);
250
+ font-family: var(--font-mono);
251
+ transition: transform 0.12s ease;
252
+ }
253
+ .resume-rail:hover .rr-arrow { transform: translateX(3px); }
254
+
255
+ /* ---------- Filter bar ---------- */
256
+ /* Two rows: search + level + clear on top, tag pills on a wrapping
257
+ * second row. Replaces the previous one-line flex-wrap that crammed
258
+ * 20+ pills into the same row as the input. */
259
+ /* Consistent vertical rhythm on the home page:
260
+ * - Every block (resume rail, filterbar, grid) gets 1.5rem above.
261
+ * - Inside the filterbar, the search row → tags row gap is 1rem.
262
+ * The previous design also had padding-bottom + border-bottom on
263
+ * the filterbar, which doubled the spacing below the tags
264
+ * relative to the 1.5rem above the search bar — that visible
265
+ * imbalance is gone now. */
266
+ .filterbar {
267
+ display: flex;
268
+ flex-direction: column;
269
+ gap: 1.5rem;
270
+ margin-top: 1.5rem;
271
+ }
272
+ .fb-row-primary {
273
+ display: flex;
274
+ gap: 0.75rem;
275
+ align-items: stretch;
276
+ flex-wrap: wrap;
277
+ }
278
+ /* Stretch the pills group itself so each pill can flex to row height */
279
+ .fb-row-primary .pills {
280
+ align-items: stretch;
281
+ }
282
+ .filterbar .search {
283
+ flex: 1 1 22rem;
284
+ display: inline-flex;
285
+ align-items: center;
286
+ gap: 0.5rem;
287
+ padding: 0.45rem 0.7rem;
288
+ border: 1px solid var(--color-border);
289
+ color: var(--color-muted);
290
+ transition: border-color 0.12s ease, color 0.12s ease;
291
+ }
292
+ .filterbar .search:focus-within {
293
+ border-color: var(--color-accent);
294
+ color: var(--color-fg);
295
+ }
296
+ .filterbar input {
297
+ background: transparent;
298
+ border: 0;
299
+ color: var(--color-fg);
300
+ font: inherit;
301
+ outline: none;
302
+ width: 100%;
303
+ }
304
+ .pills {
305
+ display: inline-flex;
306
+ flex-wrap: wrap;
307
+ gap: 0.3rem;
308
+ align-items: center;
309
+ }
310
+ .pill {
311
+ padding: 0.3rem 0.65rem;
312
+ border: 1px solid var(--color-border);
313
+ background: transparent;
314
+ color: var(--color-muted);
315
+ font-family: var(--font-mono);
316
+ font-size: 0.75em;
317
+ cursor: pointer;
318
+ text-transform: lowercase;
319
+ transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
320
+ }
321
+ /* Pills in the primary row stretch to match the search input's
322
+ * height so the whole row reads as one strip. align-items on the
323
+ * inner flex re-centers the text within each grown pill. Tag pills
324
+ * stay compact on the secondary row. */
325
+ .fb-row-primary .pill,
326
+ .fb-row-primary .clear {
327
+ padding: 0 0.85rem;
328
+ align-items: center;
329
+ }
330
+ .pill:hover {
331
+ border-color: var(--color-accent);
332
+ color: var(--color-fg);
333
+ }
334
+ .pill.is-active {
335
+ background: var(--color-accent);
336
+ color: var(--color-accent-fg);
337
+ border-color: var(--color-accent);
338
+ }
339
+ .clear {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ gap: 0.3rem;
343
+ padding: 0.3rem 0.6rem;
344
+ background: transparent;
345
+ border: 1px solid var(--color-border);
346
+ color: var(--color-muted);
347
+ font-family: var(--font-mono);
348
+ font-size: 0.75em;
349
+ cursor: pointer;
350
+ transition: color 0.12s ease, border-color 0.12s ease;
351
+ }
352
+ .clear:hover { color: var(--color-fg); border-color: var(--color-fg); }
353
+
354
+ /* Cards hidden by filter and/or pagination. Both layers set their
355
+ * own data-attribute so they compose without stomping each other. */
356
+ [data-filter-hidden],
357
+ [data-page-hidden] {
358
+ display: none !important;
359
+ }
360
+
361
+ /* ---------- Pagination ---------- */
362
+ .pagination {
363
+ margin-top: 1.5rem;
364
+ display: flex;
365
+ justify-content: center;
366
+ align-items: center;
367
+ gap: 1rem;
368
+ font-family: var(--font-mono);
369
+ font-size: 0.85em;
370
+ color: var(--color-muted);
371
+ }
372
+ .pg-btn {
373
+ padding: 0.45rem 0.85rem;
374
+ background: transparent;
375
+ border: 1px solid var(--color-border);
376
+ color: var(--color-fg);
377
+ font: inherit;
378
+ cursor: pointer;
379
+ transition: border-color 0.12s ease, color 0.12s ease;
380
+ }
381
+ .pg-btn:hover:not(:disabled) {
382
+ border-color: var(--color-accent);
383
+ color: var(--color-accent);
384
+ }
385
+ .pg-btn:disabled {
386
+ opacity: 0.4;
387
+ cursor: not-allowed;
388
+ }
389
+ .pg-status strong { color: var(--color-fg); }
390
+
391
+ /* ---------- Per-card mini progress bar ---------- */
392
+ [data-tutorial-slug] {
393
+ display: flex;
394
+ align-items: center;
395
+ gap: 0.5rem;
396
+ margin-top: 0.75rem;
397
+ font-family: var(--font-mono);
398
+ font-size: 0.7em;
399
+ color: var(--color-muted);
400
+ }
401
+ .mini-bar {
402
+ flex: 1;
403
+ height: 3px;
404
+ background: var(--color-surface);
405
+ overflow: hidden;
406
+ }
407
+ .mini-bar div { background: var(--color-accent); height: 100%; }
408
+ </style>