handzon-core 0.12.2 → 0.13.2
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 +2 -1
- package/src/collections.ts +298 -61
- package/src/components/Sidebar.astro +26 -0
- package/src/components/StepHeroMedia.astro +77 -0
- package/src/components/TrackSelector.tsx +130 -0
- package/src/components/ai/ChatButton.tsx +28 -4
- package/src/components/ai/ChatPanel.tsx +10 -0
- package/src/components/home/TutorialCard.astro +77 -3
- package/src/components/mdx/Track.astro +11 -0
- package/src/layouts/BaseLayout.astro +7 -0
- package/src/layouts/TutorialLayout.astro +34 -0
- package/src/lib/ai/context.ts +12 -2
- package/src/lib/ai/prompts.ts +8 -1
- package/src/lib/heroMedia.ts +37 -0
- package/src/lib/mdx-components.ts +2 -0
- package/src/lib/progress/types.ts +1 -0
- package/src/lib/track-scoped.ts +24 -0
- package/src/lib/track-source.ts +10 -0
- package/src/lib/tracks.ts +32 -0
- package/src/pages/TutorialLanding.astro +132 -27
- package/src/server/mcp/startTutorial.ts +70 -12
- package/src/server/mcp/tools.ts +36 -3
- package/src/server/mcp/tracks.ts +43 -0
- package/src/server/mcp/writeTools.ts +34 -4
- package/styles/components/track-selector.css +105 -0
- package/styles/components.css +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"postgres": "^3.4.9",
|
|
58
58
|
"react-markdown": "^9.0.1",
|
|
59
59
|
"remark-gfm": "^4.0.0",
|
|
60
|
+
"simple-icons": "^16.22.0",
|
|
60
61
|
"unist-util-visit": "^5.0.0",
|
|
61
62
|
"zod": "^3.23.8"
|
|
62
63
|
},
|
package/src/collections.ts
CHANGED
|
@@ -19,10 +19,15 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
19
19
|
import { join, relative, resolve } from "node:path";
|
|
20
20
|
import type { Loader } from "astro/loaders";
|
|
21
21
|
import { glob } from "astro/loaders";
|
|
22
|
+
import { createHeroMediaSchema } from "./lib/heroMedia";
|
|
22
23
|
|
|
23
24
|
const TUTORIALS_REL = "src/content/tutorials";
|
|
24
25
|
const INDEX_FILE = "_index.json";
|
|
25
26
|
|
|
27
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
async function readIndexOrder(dir: string): Promise<string[]> {
|
|
27
32
|
try {
|
|
28
33
|
const raw = await readFile(join(dir, INDEX_FILE), "utf8");
|
|
@@ -35,6 +40,114 @@ async function readIndexOrder(dir: string): Promise<string[]> {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
async function readTutorialTrackIds(tutorialSlug: string): Promise<string[]> {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await readFile(join(resolve(TUTORIALS_REL), tutorialSlug, "_meta.json"), "utf8");
|
|
46
|
+
const parsed = JSON.parse(raw) as { tracks?: unknown };
|
|
47
|
+
return Array.isArray(parsed.tracks)
|
|
48
|
+
? parsed.tracks
|
|
49
|
+
.map((track) => (isRecord(track) && typeof track.id === "string" ? track.id : null))
|
|
50
|
+
.filter((id): id is string => !!id)
|
|
51
|
+
: [];
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isStarterSpec(value: unknown): value is StarterSpec {
|
|
58
|
+
return isRecord(value) && typeof value.kind === "string";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isVerifySpec(value: unknown): value is VerifySpec {
|
|
62
|
+
return isRecord(value) && typeof value.id === "string" && Array.isArray(value.checks);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isTrackMap<T>(
|
|
66
|
+
value: TrackScoped<T> | undefined,
|
|
67
|
+
isShared: (v: unknown) => v is T,
|
|
68
|
+
): value is TrackMap<T> {
|
|
69
|
+
return isRecord(value) && !isShared(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateTrackMapCoverage<T>({
|
|
73
|
+
entryId,
|
|
74
|
+
label,
|
|
75
|
+
map,
|
|
76
|
+
trackIds,
|
|
77
|
+
}: {
|
|
78
|
+
entryId: string;
|
|
79
|
+
label: string;
|
|
80
|
+
map: TrackMap<T>;
|
|
81
|
+
trackIds: string[];
|
|
82
|
+
}) {
|
|
83
|
+
if (trackIds.length === 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`[handzon] ${entryId}: ${label} is keyed by track, but the tutorial declares no tracks.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const declared = new Set(trackIds);
|
|
89
|
+
const actual = Object.keys(map);
|
|
90
|
+
const unknown = actual.filter((id) => !declared.has(id));
|
|
91
|
+
const missing = trackIds.filter((id) => !(id in map));
|
|
92
|
+
if (unknown.length > 0 || missing.length > 0) {
|
|
93
|
+
const details = [
|
|
94
|
+
unknown.length > 0 ? `unknown: ${unknown.join(", ")}` : null,
|
|
95
|
+
missing.length > 0 ? `missing: ${missing.join(", ")}` : null,
|
|
96
|
+
]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join("; ");
|
|
99
|
+
throw new Error(`[handzon] ${entryId}: ${label} track coverage mismatch (${details}).`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateTrackIdsInBody({
|
|
104
|
+
body,
|
|
105
|
+
entryId,
|
|
106
|
+
trackIds,
|
|
107
|
+
}: {
|
|
108
|
+
body: string;
|
|
109
|
+
entryId: string;
|
|
110
|
+
trackIds: string[];
|
|
111
|
+
}) {
|
|
112
|
+
const trackRe = /<Track\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/g;
|
|
113
|
+
const declared = new Set(trackIds);
|
|
114
|
+
for (;;) {
|
|
115
|
+
const m = trackRe.exec(body);
|
|
116
|
+
if (m === null) break;
|
|
117
|
+
const id = m[1] ?? m[2] ?? m[3];
|
|
118
|
+
if (!id) continue;
|
|
119
|
+
if (trackIds.length === 0) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[handzon] step ${entryId}: <Track id="${id}"> is used, but the tutorial declares no tracks.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (!declared.has(id)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`[handzon] step ${entryId}: <Track id="${id}"> is not declared in the tutorial's tracks.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateVerifySpecCheckpoint({
|
|
133
|
+
entryId,
|
|
134
|
+
ids,
|
|
135
|
+
trackId,
|
|
136
|
+
verify,
|
|
137
|
+
}: {
|
|
138
|
+
entryId: string;
|
|
139
|
+
ids: Set<string>;
|
|
140
|
+
trackId?: string;
|
|
141
|
+
verify: VerifySpec;
|
|
142
|
+
}) {
|
|
143
|
+
if (!ids.has(verify.id)) {
|
|
144
|
+
const prefix = trackId ? `verify.${trackId}.id` : "verify.id";
|
|
145
|
+
throw new Error(
|
|
146
|
+
`[handzon] step ${entryId}: ${prefix} "${verify.id}" has no matching <Checkpoint id="…"> in the step body. Either add a <Checkpoint id="${verify.id}" …/> or remove the verify block.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
38
151
|
/**
|
|
39
152
|
* Custom loader that scans tutorials/<slug>/_meta.json for each tutorial.
|
|
40
153
|
* Tutorial order is driven by `_index.json` at the tutorials root; folder
|
|
@@ -106,10 +219,18 @@ export function stepsLoader(): Loader {
|
|
|
106
219
|
load: async (args) => {
|
|
107
220
|
await inner.load(args);
|
|
108
221
|
const checkpointRe = /<Checkpoint\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/g;
|
|
222
|
+
const trackIdCache = new Map<string, Promise<string[]>>();
|
|
109
223
|
for (const value of args.store.values()) {
|
|
110
|
-
const
|
|
111
|
-
|
|
224
|
+
const { tutorialSlug } = parseStepCollectionId(value.id);
|
|
225
|
+
const trackIdsPromise =
|
|
226
|
+
trackIdCache.get(tutorialSlug) ?? readTutorialTrackIds(tutorialSlug);
|
|
227
|
+
trackIdCache.set(tutorialSlug, trackIdsPromise);
|
|
228
|
+
const trackIds = await trackIdsPromise;
|
|
112
229
|
const body = value.body ?? "";
|
|
230
|
+
validateTrackIdsInBody({ body, entryId: value.id, trackIds });
|
|
231
|
+
|
|
232
|
+
const verify = (value.data as { verify?: TrackScoped<VerifySpec> } | undefined)?.verify;
|
|
233
|
+
if (!verify) continue;
|
|
113
234
|
const ids = new Set<string>();
|
|
114
235
|
checkpointRe.lastIndex = 0;
|
|
115
236
|
for (;;) {
|
|
@@ -118,10 +239,19 @@ export function stepsLoader(): Loader {
|
|
|
118
239
|
const id = m[1] ?? m[2] ?? m[3];
|
|
119
240
|
if (id) ids.add(id);
|
|
120
241
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
242
|
+
|
|
243
|
+
if (isTrackMap(verify, isVerifySpec)) {
|
|
244
|
+
validateTrackMapCoverage({
|
|
245
|
+
entryId: `step ${value.id}`,
|
|
246
|
+
label: "verify",
|
|
247
|
+
map: verify,
|
|
248
|
+
trackIds,
|
|
249
|
+
});
|
|
250
|
+
for (const [trackId, spec] of Object.entries(verify)) {
|
|
251
|
+
validateVerifySpecCheckpoint({ entryId: value.id, ids, trackId, verify: spec });
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
validateVerifySpecCheckpoint({ entryId: value.id, ids, verify });
|
|
125
255
|
}
|
|
126
256
|
}
|
|
127
257
|
},
|
|
@@ -216,64 +346,171 @@ export const starterSchema = z.discriminatedUnion("kind", [
|
|
|
216
346
|
|
|
217
347
|
export type StarterSpec = z.infer<typeof starterSchema>;
|
|
218
348
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
verify: verifySchema.optional(),
|
|
349
|
+
export const trackSchema = z.object({
|
|
350
|
+
id: z
|
|
351
|
+
.string()
|
|
352
|
+
.min(1)
|
|
353
|
+
.regex(/^[a-z0-9][a-z0-9_-]*$/i, "Use a stable id like py, ts, or rust."),
|
|
354
|
+
label: z.string().min(1),
|
|
226
355
|
});
|
|
227
356
|
|
|
228
|
-
|
|
229
|
-
export
|
|
357
|
+
export type TutorialTrack = z.infer<typeof trackSchema>;
|
|
358
|
+
export type TrackMap<T> = Record<string, T>;
|
|
359
|
+
export type TrackScoped<T> = T | TrackMap<T>;
|
|
360
|
+
|
|
361
|
+
export const verifyByTrackSchema = z.record(verifySchema);
|
|
362
|
+
export const trackScopedVerifySchema = z.union([verifySchema, verifyByTrackSchema]);
|
|
363
|
+
|
|
364
|
+
export const starterByTrackSchema = z.record(starterSchema);
|
|
365
|
+
export const trackScopedStarterSchema = z.union([starterSchema, starterByTrackSchema]);
|
|
366
|
+
|
|
367
|
+
export type TrackScopedVerifySpec = z.infer<typeof trackScopedVerifySchema>;
|
|
368
|
+
export type TrackScopedStarterSpec = z.infer<typeof trackScopedStarterSchema>;
|
|
369
|
+
|
|
370
|
+
export function resolveForTrack<T>(
|
|
371
|
+
value: TrackScoped<T> | undefined,
|
|
372
|
+
trackId: string | undefined,
|
|
373
|
+
isShared: (v: unknown) => v is T,
|
|
374
|
+
): T | undefined {
|
|
375
|
+
if (!value) return undefined;
|
|
376
|
+
if (isShared(value)) return value;
|
|
377
|
+
return trackId ? value[trackId] : undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function resolveStarterForTrack(
|
|
381
|
+
starter: TrackScoped<StarterSpec> | undefined,
|
|
382
|
+
trackId: string | undefined,
|
|
383
|
+
): StarterSpec | undefined {
|
|
384
|
+
return resolveForTrack(starter, trackId, isStarterSpec);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function resolveVerifyForTrack(
|
|
388
|
+
verify: TrackScoped<VerifySpec> | undefined,
|
|
389
|
+
trackId: string | undefined,
|
|
390
|
+
): VerifySpec | undefined {
|
|
391
|
+
return resolveForTrack(verify, trackId, isVerifySpec);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseStepCollectionId(id: string): { tutorialSlug: string; stepFile: string } {
|
|
395
|
+
const slash = id.indexOf("/");
|
|
396
|
+
if (slash < 0) {
|
|
397
|
+
throw new Error(`Unrecognized step id: ${id}`);
|
|
398
|
+
}
|
|
399
|
+
return { tutorialSlug: id.slice(0, slash), stepFile: id.slice(slash + 1) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function refineTrackConfig(
|
|
403
|
+
data: {
|
|
404
|
+
tracks?: TutorialTrack[];
|
|
405
|
+
defaultTrack?: string;
|
|
406
|
+
starter?: TrackScoped<StarterSpec>;
|
|
407
|
+
},
|
|
408
|
+
ctx: z.RefinementCtx,
|
|
409
|
+
) {
|
|
410
|
+
const trackIds = data.tracks?.map((track) => track.id) ?? [];
|
|
411
|
+
const duplicated = trackIds.filter((id, index) => trackIds.indexOf(id) !== index);
|
|
412
|
+
for (const id of new Set(duplicated)) {
|
|
413
|
+
ctx.addIssue({
|
|
414
|
+
code: z.ZodIssueCode.custom,
|
|
415
|
+
path: ["tracks"],
|
|
416
|
+
message: `Duplicate track id "${id}".`,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (data.defaultTrack && !trackIds.includes(data.defaultTrack)) {
|
|
420
|
+
ctx.addIssue({
|
|
421
|
+
code: z.ZodIssueCode.custom,
|
|
422
|
+
path: ["defaultTrack"],
|
|
423
|
+
message: `defaultTrack "${data.defaultTrack}" is not declared in tracks.`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (isTrackMap(data.starter, isStarterSpec)) {
|
|
427
|
+
const actual = Object.keys(data.starter);
|
|
428
|
+
const unknown = actual.filter((id) => !trackIds.includes(id));
|
|
429
|
+
const missing = trackIds.filter((id) => !(id in data.starter!));
|
|
430
|
+
if (trackIds.length === 0) {
|
|
431
|
+
ctx.addIssue({
|
|
432
|
+
code: z.ZodIssueCode.custom,
|
|
433
|
+
path: ["starter"],
|
|
434
|
+
message: "Per-track starter requires declared tracks.",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
for (const id of unknown) {
|
|
438
|
+
ctx.addIssue({
|
|
439
|
+
code: z.ZodIssueCode.custom,
|
|
440
|
+
path: ["starter", id],
|
|
441
|
+
message: `Unknown starter track "${id}".`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (missing.length > 0) {
|
|
445
|
+
ctx.addIssue({
|
|
446
|
+
code: z.ZodIssueCode.custom,
|
|
447
|
+
path: ["starter"],
|
|
448
|
+
message: `Missing starter for track(s): ${missing.join(", ")}.`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Schema for tutorial step entries. Pass through Astro's image() helper. */
|
|
455
|
+
export function stepsSchema({ image }: { image: () => import("astro/zod").ZodType }) {
|
|
230
456
|
return z.object({
|
|
231
457
|
title: z.string(),
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
avatar: image().optional(),
|
|
238
|
-
})
|
|
239
|
-
.optional(),
|
|
240
|
-
publishedAt: z.coerce.date().optional(),
|
|
241
|
-
updatedAt: z.coerce.date().optional(),
|
|
242
|
-
tags: z.array(z.string()).default([]),
|
|
243
|
-
difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
|
|
244
|
-
estimatedDuration: z.string().optional(),
|
|
245
|
-
prerequisites: z.array(z.string()).default([]),
|
|
246
|
-
nextTutorial: z.string().optional(),
|
|
247
|
-
// TODO(handzon): `cover` and `icon` are accepted by the schema for
|
|
248
|
-
// forward compatibility, but no page currently renders them
|
|
249
|
-
// (Home cards, TutorialLanding, OG meta all ignore them). Wire them
|
|
250
|
-
// up in TutorialCard and BaseLayout's OG tags before promoting
|
|
251
|
-
// cover art in author-facing docs and skills.
|
|
252
|
-
cover: image().optional(),
|
|
253
|
-
icon: z.union([z.string(), image()]).optional(),
|
|
254
|
-
steps: z.array(z.string()).optional(),
|
|
255
|
-
gated: z.boolean().default(false),
|
|
256
|
-
showProgress: z.boolean().default(true),
|
|
257
|
-
feedbackUrl: z.string().url().optional(),
|
|
258
|
-
starter: starterSchema.optional(),
|
|
259
|
-
ai: z
|
|
260
|
-
.object({
|
|
261
|
-
enabled: z.boolean().optional(),
|
|
262
|
-
name: z.string().optional(),
|
|
263
|
-
tagline: z.string().optional(),
|
|
264
|
-
greeting: z.string().optional(),
|
|
265
|
-
avatar: image().optional(),
|
|
266
|
-
persona: z.string().optional(),
|
|
267
|
-
tone: z.enum(["socratic", "direct", "encouraging"]).optional(),
|
|
268
|
-
provider: z.string().optional(),
|
|
269
|
-
model: z.string().optional(),
|
|
270
|
-
byok: z.enum(["required", "optional", "disabled"]).optional(),
|
|
271
|
-
references: z.array(z.string()).default([]),
|
|
272
|
-
allowedDomains: z.array(z.string()).default([]),
|
|
273
|
-
disabledSkills: z.array(z.string()).default([]),
|
|
274
|
-
enableSuggestPlaygroundEdit: z.boolean().default(false),
|
|
275
|
-
includeFutureSteps: z.boolean().optional(),
|
|
276
|
-
})
|
|
277
|
-
.optional(),
|
|
458
|
+
duration: z.string().optional(),
|
|
459
|
+
summary: z.string().optional(),
|
|
460
|
+
ai: z.boolean().optional(),
|
|
461
|
+
heroMedia: createHeroMediaSchema(z, image).optional(),
|
|
462
|
+
verify: trackScopedVerifySchema.optional(),
|
|
278
463
|
});
|
|
279
464
|
}
|
|
465
|
+
|
|
466
|
+
/** Schema for tutorial entries. Pass through Astro's image() helper. */
|
|
467
|
+
export function tutorialsSchema({ image }: { image: () => import("astro/zod").ZodType }) {
|
|
468
|
+
return z
|
|
469
|
+
.object({
|
|
470
|
+
title: z.string(),
|
|
471
|
+
description: z.string(),
|
|
472
|
+
author: z
|
|
473
|
+
.object({
|
|
474
|
+
name: z.string(),
|
|
475
|
+
url: z.string().url().optional(),
|
|
476
|
+
avatar: image().optional(),
|
|
477
|
+
})
|
|
478
|
+
.optional(),
|
|
479
|
+
publishedAt: z.coerce.date().optional(),
|
|
480
|
+
updatedAt: z.coerce.date().optional(),
|
|
481
|
+
tags: z.array(z.string()).default([]),
|
|
482
|
+
tracks: z.array(trackSchema).default([]),
|
|
483
|
+
defaultTrack: z.string().min(1).optional(),
|
|
484
|
+
difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
|
|
485
|
+
estimatedDuration: z.string().optional(),
|
|
486
|
+
prerequisites: z.array(z.string()).default([]),
|
|
487
|
+
nextTutorial: z.string().optional(),
|
|
488
|
+
cover: image().optional(),
|
|
489
|
+
icon: z.union([z.string(), image()]).optional(),
|
|
490
|
+
steps: z.array(z.string()).optional(),
|
|
491
|
+
gated: z.boolean().default(false),
|
|
492
|
+
showProgress: z.boolean().default(true),
|
|
493
|
+
feedbackUrl: z.string().url().optional(),
|
|
494
|
+
starter: trackScopedStarterSchema.optional(),
|
|
495
|
+
ai: z
|
|
496
|
+
.object({
|
|
497
|
+
enabled: z.boolean().optional(),
|
|
498
|
+
name: z.string().optional(),
|
|
499
|
+
tagline: z.string().optional(),
|
|
500
|
+
greeting: z.string().optional(),
|
|
501
|
+
avatar: image().optional(),
|
|
502
|
+
persona: z.string().optional(),
|
|
503
|
+
tone: z.enum(["socratic", "direct", "encouraging"]).optional(),
|
|
504
|
+
provider: z.string().optional(),
|
|
505
|
+
model: z.string().optional(),
|
|
506
|
+
byok: z.enum(["required", "optional", "disabled"]).optional(),
|
|
507
|
+
references: z.array(z.string()).default([]),
|
|
508
|
+
allowedDomains: z.array(z.string()).default([]),
|
|
509
|
+
disabledSkills: z.array(z.string()).default([]),
|
|
510
|
+
enableSuggestPlaygroundEdit: z.boolean().default(false),
|
|
511
|
+
includeFutureSteps: z.boolean().optional(),
|
|
512
|
+
})
|
|
513
|
+
.optional(),
|
|
514
|
+
})
|
|
515
|
+
.superRefine(refineTrackConfig);
|
|
516
|
+
}
|
|
@@ -3,6 +3,7 @@ import { withBase } from "../lib/base";
|
|
|
3
3
|
import type { TutorialEntry, StepEntry } from "../lib/content";
|
|
4
4
|
import { parseStepId } from "../lib/content";
|
|
5
5
|
import Progress from "./Progress.tsx";
|
|
6
|
+
import TrackSelector from "./TrackSelector.tsx";
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
tutorial: TutorialEntry;
|
|
@@ -30,6 +31,31 @@ const slug = tutorial.id;
|
|
|
30
31
|
{tutorial.data.showProgress && (
|
|
31
32
|
<Progress tutorialSlug={slug} totalSteps={steps.length} client:load />
|
|
32
33
|
)}
|
|
34
|
+
{tutorial.data.tracks.length > 1 && (
|
|
35
|
+
<div class="track-selector-shell">
|
|
36
|
+
<section class="track-selector" aria-label="Tutorial track" data-track-fallback>
|
|
37
|
+
<div class="track-selector-list">
|
|
38
|
+
{tutorial.data.tracks.map((track: (typeof tutorial.data.tracks)[number]) => (
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
class="track-selector-option"
|
|
42
|
+
data-track-id={track.id}
|
|
43
|
+
data-active={track.id === (tutorial.data.defaultTrack ?? tutorial.data.tracks[0]?.id) ? "true" : "false"}
|
|
44
|
+
disabled
|
|
45
|
+
>
|
|
46
|
+
<span class="track-selector-icon-slot" aria-hidden="true"></span>
|
|
47
|
+
<span>{track.label}</span>
|
|
48
|
+
</button>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
<TrackSelector
|
|
53
|
+
tracks={tutorial.data.tracks}
|
|
54
|
+
defaultTrack={tutorial.data.defaultTrack}
|
|
55
|
+
client:only="react"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
33
59
|
</div>
|
|
34
60
|
|
|
35
61
|
<ol class="sb-steps">
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Image } from "astro:assets";
|
|
3
|
+
import type { ImageMetadata } from "astro";
|
|
4
|
+
import { withBase } from "../lib/base";
|
|
5
|
+
import Embed from "./mdx/Embed.astro";
|
|
6
|
+
|
|
7
|
+
type StepHeroMedia =
|
|
8
|
+
| {
|
|
9
|
+
kind: "image";
|
|
10
|
+
src: string | ImageMetadata;
|
|
11
|
+
alt: string;
|
|
12
|
+
caption?: string;
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
kind: "video";
|
|
16
|
+
src: string;
|
|
17
|
+
title: string;
|
|
18
|
+
aspect?: string;
|
|
19
|
+
type?: "iframe" | "video";
|
|
20
|
+
caption?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
media: StepHeroMedia;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { media } = Astro.props;
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
<figure class="step-hero-media">
|
|
31
|
+
{media.kind === "video" ? (
|
|
32
|
+
<Embed
|
|
33
|
+
src={media.src}
|
|
34
|
+
title={media.title}
|
|
35
|
+
aspect={media.aspect}
|
|
36
|
+
type={media.type}
|
|
37
|
+
/>
|
|
38
|
+
) : typeof media.src !== "string" ? (
|
|
39
|
+
<Image
|
|
40
|
+
src={media.src}
|
|
41
|
+
alt={media.alt}
|
|
42
|
+
class="step-hero-image"
|
|
43
|
+
widths={[720, 960, 1200, 1440]}
|
|
44
|
+
sizes="(min-width: 900px) 720px, 100vw"
|
|
45
|
+
/>
|
|
46
|
+
) : (
|
|
47
|
+
<img
|
|
48
|
+
src={withBase(media.src)}
|
|
49
|
+
alt={media.alt}
|
|
50
|
+
class="step-hero-image"
|
|
51
|
+
loading="eager"
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
{media.caption && <figcaption>{media.caption}</figcaption>}
|
|
55
|
+
</figure>
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
.step-hero-media {
|
|
59
|
+
margin: 0 0 2rem;
|
|
60
|
+
}
|
|
61
|
+
.step-hero-media :global(.embed) {
|
|
62
|
+
margin: 0;
|
|
63
|
+
}
|
|
64
|
+
.step-hero-image {
|
|
65
|
+
display: block;
|
|
66
|
+
width: 100%;
|
|
67
|
+
height: auto;
|
|
68
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
69
|
+
background: var(--color-surface);
|
|
70
|
+
}
|
|
71
|
+
figcaption {
|
|
72
|
+
margin-top: 0.65rem;
|
|
73
|
+
color: var(--color-muted);
|
|
74
|
+
font-size: 0.85em;
|
|
75
|
+
line-height: 1.5;
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type CSSProperties, useEffect, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type SimpleIcon,
|
|
4
|
+
siC,
|
|
5
|
+
siCplusplus,
|
|
6
|
+
siGnubash,
|
|
7
|
+
siGo,
|
|
8
|
+
siJavascript,
|
|
9
|
+
siMysql,
|
|
10
|
+
siPhp,
|
|
11
|
+
siPostgresql,
|
|
12
|
+
siPython,
|
|
13
|
+
siRuby,
|
|
14
|
+
siRust,
|
|
15
|
+
siSqlite,
|
|
16
|
+
siTypescript,
|
|
17
|
+
} from "simple-icons";
|
|
18
|
+
import { useProgress } from "../lib/progress/useProgress";
|
|
19
|
+
import { resolveActiveTrack, type TrackOption, trackStyleText } from "../lib/tracks";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
tracks: TrackOption[];
|
|
23
|
+
defaultTrack?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function applyTrackStyle(trackId: string | undefined) {
|
|
27
|
+
if (typeof document === "undefined" || !trackId) return;
|
|
28
|
+
document.documentElement.dataset.track = trackId;
|
|
29
|
+
let style = document.getElementById("handzon-track-style") as HTMLStyleElement | null;
|
|
30
|
+
if (!style) {
|
|
31
|
+
style = document.createElement("style");
|
|
32
|
+
style.id = "handzon-track-style";
|
|
33
|
+
document.head.appendChild(style);
|
|
34
|
+
}
|
|
35
|
+
style.textContent = trackStyleText(trackId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TRACK_ICONS: Record<string, SimpleIcon> = {
|
|
39
|
+
bash: siGnubash,
|
|
40
|
+
c: siC,
|
|
41
|
+
"c++": siCplusplus,
|
|
42
|
+
cpp: siCplusplus,
|
|
43
|
+
go: siGo,
|
|
44
|
+
js: siJavascript,
|
|
45
|
+
javascript: siJavascript,
|
|
46
|
+
mysql: siMysql,
|
|
47
|
+
php: siPhp,
|
|
48
|
+
postgres: siPostgresql,
|
|
49
|
+
postgresql: siPostgresql,
|
|
50
|
+
py: siPython,
|
|
51
|
+
python: siPython,
|
|
52
|
+
rb: siRuby,
|
|
53
|
+
ruby: siRuby,
|
|
54
|
+
rust: siRust,
|
|
55
|
+
sqlite: siSqlite,
|
|
56
|
+
ts: siTypescript,
|
|
57
|
+
typescript: siTypescript,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function iconForTrack(track: TrackOption): SimpleIcon | undefined {
|
|
61
|
+
const id = track.id.toLowerCase();
|
|
62
|
+
const label = track.label.toLowerCase();
|
|
63
|
+
return TRACK_ICONS[id] ?? TRACK_ICONS[label];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fallbackText(track: TrackOption): string {
|
|
67
|
+
return (track.id || track.label).slice(0, 2).toUpperCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function TrackSelector({ tracks, defaultTrack }: Props) {
|
|
71
|
+
const { state, setPref } = useProgress();
|
|
72
|
+
const activeTrack = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
resolveActiveTrack({
|
|
75
|
+
tracks,
|
|
76
|
+
preferredTrack: state.prefs.track,
|
|
77
|
+
defaultTrack,
|
|
78
|
+
}),
|
|
79
|
+
[tracks, state.prefs.track, defaultTrack],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
applyTrackStyle(activeTrack);
|
|
84
|
+
}, [activeTrack]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
document.querySelectorAll<HTMLElement>("[data-track-fallback]").forEach((el) => {
|
|
88
|
+
el.hidden = true;
|
|
89
|
+
});
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
if (tracks.length < 2 || !activeTrack) return null;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<section className="track-selector" aria-label="Tutorial track">
|
|
96
|
+
<div className="track-selector-list">
|
|
97
|
+
{tracks.map((track) => {
|
|
98
|
+
const selected = track.id === activeTrack;
|
|
99
|
+
const icon = iconForTrack(track);
|
|
100
|
+
return (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
key={track.id}
|
|
104
|
+
className="track-selector-option"
|
|
105
|
+
data-active={selected ? "true" : "false"}
|
|
106
|
+
aria-pressed={selected}
|
|
107
|
+
onClick={() => setPref("track", track.id)}
|
|
108
|
+
>
|
|
109
|
+
{icon ? (
|
|
110
|
+
<svg
|
|
111
|
+
className="track-selector-icon"
|
|
112
|
+
viewBox="0 0 24 24"
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
style={{ "--track-icon-color": `#${icon.hex}` } as CSSProperties}
|
|
115
|
+
>
|
|
116
|
+
<path d={icon.path} />
|
|
117
|
+
</svg>
|
|
118
|
+
) : (
|
|
119
|
+
<span className="track-selector-fallback" aria-hidden="true">
|
|
120
|
+
{fallbackText(track)}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
<span>{track.label}</span>
|
|
124
|
+
</button>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
</section>
|
|
129
|
+
);
|
|
130
|
+
}
|