handzon-core 0.13.3 → 0.14.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
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"
@@ -20,6 +20,7 @@ import { join, relative, resolve } from "node:path";
20
20
  import type { Loader } from "astro/loaders";
21
21
  import { glob } from "astro/loaders";
22
22
  import { createHeroMediaSchema } from "./lib/heroMedia";
23
+ import { createTutorialIconSchema } from "./lib/tutorialIcon";
23
24
 
24
25
  const TUTORIALS_REL = "src/content/tutorials";
25
26
  const INDEX_FILE = "_index.json";
@@ -479,6 +480,8 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
479
480
  publishedAt: z.coerce.date().optional(),
480
481
  updatedAt: z.coerce.date().optional(),
481
482
  tags: z.array(z.string()).default([]),
483
+ published: z.boolean().default(true),
484
+ hidden: z.boolean().default(false),
482
485
  tracks: z.array(trackSchema).default([]),
483
486
  defaultTrack: z.string().min(1).optional(),
484
487
  difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
@@ -486,7 +489,7 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
486
489
  prerequisites: z.array(z.string()).default([]),
487
490
  nextTutorial: z.string().optional(),
488
491
  cover: image().optional(),
489
- icon: z.union([z.string(), image()]).optional(),
492
+ icon: createTutorialIconSchema(z, image).optional(),
490
493
  steps: z.array(z.string()).optional(),
491
494
  gated: z.boolean().default(false),
492
495
  showProgress: z.boolean().default(true),
@@ -2,6 +2,7 @@
2
2
  import { withBase } from "../lib/base";
3
3
  import type { TutorialEntry, StepEntry } from "../lib/content";
4
4
  import { parseStepId } from "../lib/content";
5
+ import { fallbackTextForTrack, iconForTrack } from "../lib/track-icons";
5
6
  import Progress from "./Progress.tsx";
6
7
  import TrackSelector from "./TrackSelector.tsx";
7
8
 
@@ -35,18 +36,29 @@ const slug = tutorial.id;
35
36
  <div class="track-selector-shell">
36
37
  <section class="track-selector" aria-label="Tutorial track" data-track-fallback>
37
38
  <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
- ))}
39
+ {tutorial.data.tracks.map((track: (typeof tutorial.data.tracks)[number]) => {
40
+ const icon = iconForTrack(track);
41
+ return (
42
+ <button
43
+ type="button"
44
+ class="track-selector-option"
45
+ data-track-id={track.id}
46
+ data-active={track.id === (tutorial.data.defaultTrack ?? tutorial.data.tracks[0]?.id) ? "true" : "false"}
47
+ disabled
48
+ >
49
+ {icon ? (
50
+ <svg class="track-selector-icon" viewBox="0 0 24 24" aria-hidden="true">
51
+ <path d={icon.path} />
52
+ </svg>
53
+ ) : (
54
+ <span class="track-selector-fallback" aria-hidden="true">
55
+ {fallbackTextForTrack(track)}
56
+ </span>
57
+ )}
58
+ <span>{track.label}</span>
59
+ </button>
60
+ );
61
+ })}
50
62
  </div>
51
63
  </section>
52
64
  <TrackSelector
@@ -1,21 +1,6 @@
1
1
  import { 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
2
  import { useProgress } from "../lib/progress/useProgress";
3
+ import { fallbackTextForTrack, iconForTrack } from "../lib/track-icons";
19
4
  import { resolveActiveTrack, type TrackOption, trackStyleText } from "../lib/tracks";
20
5
 
21
6
  interface Props {
@@ -35,38 +20,6 @@ function applyTrackStyle(trackId: string | undefined) {
35
20
  style.textContent = trackStyleText(trackId);
36
21
  }
37
22
 
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
23
  export default function TrackSelector({ tracks, defaultTrack }: Props) {
71
24
  const { state, setPref } = useProgress();
72
25
  const activeTrack = useMemo(
@@ -112,7 +65,7 @@ export default function TrackSelector({ tracks, defaultTrack }: Props) {
112
65
  </svg>
113
66
  ) : (
114
67
  <span className="track-selector-fallback" aria-hidden="true">
115
- {fallbackText(track)}
68
+ {fallbackTextForTrack(track)}
116
69
  </span>
117
70
  )}
118
71
  <span>{track.label}</span>
@@ -99,6 +99,12 @@ const GITHUB_ICON_PATH =
99
99
  .um-btn-icon {
100
100
  padding: 0.3rem 0.45rem;
101
101
  }
102
+ .um-mcp-btn {
103
+ border-color: color-mix(in oklab, var(--color-accent) 45%, var(--color-border));
104
+ background: color-mix(in oklab, var(--color-accent) 8%, transparent);
105
+ color: var(--color-accent);
106
+ font-weight: 700;
107
+ }
102
108
  .um-gh {
103
109
  flex-shrink: 0;
104
110
  }
@@ -107,9 +107,9 @@ export default function UserMenu() {
107
107
  {displayName}
108
108
  </span>
109
109
  <a
110
- className="um-btn um-btn-icon"
110
+ className="um-btn um-mcp-btn"
111
111
  href={withBase("/settings/tokens")}
112
- title="Access tokens for editor MCP"
112
+ title="Create an access token to connect your editor over MCP"
113
113
  >
114
114
  <svg
115
115
  viewBox="0 0 24 24"
@@ -125,7 +125,7 @@ export default function UserMenu() {
125
125
  <circle cx="7.5" cy="15.5" r="4.5" />
126
126
  <path d="M11 12L20 3l1.5 1.5L20 6l1.5 1.5L19 9l-1.5-1.5L16 9" />
127
127
  </svg>
128
- <span className="sr-only">Access tokens</span>
128
+ <span>MCP setup</span>
129
129
  </a>
130
130
  <form method="post" action={withBase("/api/auth/signout")}>
131
131
  <input type="hidden" name="csrfToken" value={csrfToken} />
@@ -2,6 +2,7 @@
2
2
  import { Image } from "astro:assets";
3
3
  import { withBase } from "../../lib/base";
4
4
  import type { TutorialEntry } from "../../lib/content";
5
+ import { getDefaultCoverMeta } from "../../lib/defaultCover";
5
6
 
6
7
  interface Props {
7
8
  tutorial: TutorialEntry;
@@ -17,8 +18,17 @@ const { tutorial, duration, stepCount } = Astro.props;
17
18
  const data = tutorial.data;
18
19
  const slug = tutorial.id;
19
20
  const iconText = typeof data.icon === "string" ? data.icon : undefined;
20
- const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undefined;
21
+ const iconImage =
22
+ data.icon && typeof data.icon !== "string" ? data.icon : undefined;
23
+ const defaultCover = getDefaultCoverMeta({
24
+ slug,
25
+ title: data.title,
26
+ difficulty: data.difficulty,
27
+ tags: data.tags,
28
+ icon: data.icon,
29
+ });
21
30
  ---
31
+
22
32
  <article
23
33
  class="card"
24
34
  data-difficulty={data.difficulty}
@@ -28,71 +38,110 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
28
38
  data-popularity="0"
29
39
  >
30
40
  <a href={withBase(`/${slug}`)} class="card-link">
31
- {data.cover && (
32
- <div class="card-media">
33
- <Image
34
- src={data.cover}
35
- alt=""
36
- class="card-cover"
37
- loading="lazy"
38
- widths={[360, 540, 720]}
39
- sizes="(min-width: 960px) 33vw, (min-width: 640px) 50vw, 100vw"
40
- />
41
- </div>
42
- )}
43
- <div class="card-body">
44
- <div class="card-title-row">
45
- {iconImage && (
41
+ <div class="card-media">
42
+ {
43
+ data.cover ? (
46
44
  <Image
47
- src={iconImage}
45
+ src={data.cover}
48
46
  alt=""
49
- class="card-icon card-icon-image"
47
+ class="card-cover"
50
48
  loading="lazy"
51
- width={32}
52
- height={32}
49
+ widths={[360, 540, 720]}
50
+ sizes="(min-width: 960px) 33vw, (min-width: 640px) 50vw, 100vw"
53
51
  />
54
- )}
55
- {iconText && <span class="card-icon card-icon-text" aria-hidden="true">{iconText}</span>}
52
+ ) : (
53
+ <div
54
+ class={`card-cover-fallback pattern-${defaultCover.pattern} scale-${defaultCover.scale} glow-${defaultCover.glow} variant-${defaultCover.variant} difficulty-${defaultCover.difficulty}`}
55
+ aria-hidden="true"
56
+ >
57
+ <span class="fallback-keywords">
58
+ {defaultCover.keywords.map((keyword) => (
59
+ <span>{keyword}</span>
60
+ ))}
61
+ </span>
62
+ {defaultCover.icon ? (
63
+ <span class="fallback-watermark">
64
+ <svg
65
+ viewBox="0 0 24 24"
66
+ width="120"
67
+ height="120"
68
+ aria-hidden="true"
69
+ >
70
+ <path d={defaultCover.icon.path} />
71
+ </svg>
72
+ </span>
73
+ ) : null}
74
+ </div>
75
+ )
76
+ }
77
+ </div>
78
+ <div class="card-body">
79
+ <div class="card-title-row">
80
+ {
81
+ iconImage && (
82
+ <Image
83
+ src={iconImage}
84
+ alt=""
85
+ class="card-icon card-icon-image"
86
+ loading="lazy"
87
+ width={32}
88
+ height={32}
89
+ />
90
+ )
91
+ }
92
+ {
93
+ iconText && (
94
+ <span class="card-icon card-icon-text" aria-hidden="true">
95
+ {iconText}
96
+ </span>
97
+ )
98
+ }
56
99
  <h3>{data.title}</h3>
57
100
  </div>
58
101
  <p>{data.description}</p>
59
102
  <div class="badges">
60
103
  <span class={`badge badge-${data.difficulty}`}>{data.difficulty}</span>
61
- {duration && (
62
- <span class="badge badge-neutral">
63
- <svg
64
- viewBox="0 0 24 24"
65
- width="11"
66
- height="11"
67
- fill="none"
68
- stroke="currentColor"
69
- stroke-width="2"
70
- stroke-linecap="round"
71
- stroke-linejoin="round"
72
- aria-hidden="true"
73
- >
74
- <circle cx="12" cy="12" r="10" />
75
- <polyline points="12 7 12 12 15 14" />
76
- </svg>
77
- {duration}
78
- </span>
79
- )}
104
+ {
105
+ duration && (
106
+ <span class="badge badge-neutral">
107
+ <svg
108
+ viewBox="0 0 24 24"
109
+ width="11"
110
+ height="11"
111
+ fill="none"
112
+ stroke="currentColor"
113
+ stroke-width="2"
114
+ stroke-linecap="round"
115
+ stroke-linejoin="round"
116
+ aria-hidden="true"
117
+ >
118
+ <circle cx="12" cy="12" r="10" />
119
+ <polyline points="12 7 12 12 15 14" />
120
+ </svg>
121
+ {duration}
122
+ </span>
123
+ )
124
+ }
80
125
  <span
81
126
  class="card-progress"
82
127
  data-tutorial-slug={slug}
83
- data-step-count={stepCount}
84
- ></span>
128
+ data-step-count={stepCount}></span>
85
129
  </div>
86
- {data.tags.length > 0 && (
87
- <div class="tags">
88
- {data.tags.map((tag: string) => <span class="tag">#{tag}</span>)}
89
- </div>
90
- )}
130
+ {
131
+ data.tags.length > 0 && (
132
+ <div class="tags">
133
+ {data.tags.map((tag: string) => (
134
+ <span class="tag">#{tag}</span>
135
+ ))}
136
+ </div>
137
+ )
138
+ }
91
139
  <div
92
140
  class="card-stats"
93
141
  data-tutorial-stats-slug={slug}
94
142
  aria-hidden="true"
95
- ></div>
143
+ >
144
+ </div>
96
145
  </div>
97
146
  </a>
98
147
  </article>
@@ -102,7 +151,9 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
102
151
  border: 1px solid var(--color-border);
103
152
  background: var(--color-bg);
104
153
  display: flex;
105
- transition: border-color 0.12s ease, transform 0.12s ease;
154
+ transition:
155
+ border-color 0.12s ease,
156
+ transform 0.12s ease;
106
157
  }
107
158
  .card:hover {
108
159
  border-color: var(--color-accent);
@@ -118,12 +169,11 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
118
169
  .card-media {
119
170
  aspect-ratio: 16 / 9;
120
171
  border-bottom: 1px solid var(--color-border);
121
- background:
122
- linear-gradient(
123
- 135deg,
124
- color-mix(in oklab, var(--color-accent) 10%, transparent),
125
- var(--color-surface)
126
- );
172
+ background: linear-gradient(
173
+ 135deg,
174
+ color-mix(in oklab, var(--color-accent) 10%, transparent),
175
+ var(--color-surface)
176
+ );
127
177
  overflow: hidden;
128
178
  }
129
179
  .card-cover {
@@ -132,9 +182,181 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
132
182
  object-fit: cover;
133
183
  display: block;
134
184
  filter: saturate(0.92) contrast(1.05);
135
- transition: transform 0.18s ease, filter 0.18s ease;
185
+ transition:
186
+ transform 0.18s ease,
187
+ filter 0.18s ease;
188
+ }
189
+ .card-cover-fallback {
190
+ --cover-accent: var(--color-accent);
191
+ --cover-glow-x: 28%;
192
+ --cover-glow-y: 76%;
193
+ --cover-grid-size: 1rem;
194
+ --cover-dot-size: 0.9rem;
195
+ position: relative;
196
+ width: 100%;
197
+ height: 100%;
198
+ display: grid;
199
+ padding: 1rem;
200
+ overflow: hidden;
201
+ background:
202
+ radial-gradient(
203
+ circle at var(--cover-glow-x) var(--cover-glow-y),
204
+ color-mix(in oklab, var(--cover-accent) 22%, transparent),
205
+ transparent 34%
206
+ ),
207
+ linear-gradient(
208
+ 135deg,
209
+ color-mix(in oklab, var(--cover-accent) 10%, var(--color-bg)),
210
+ var(--color-surface)
211
+ );
212
+ transition:
213
+ transform 0.18s ease,
214
+ filter 0.18s ease;
215
+ }
216
+ .card-cover-fallback::before {
217
+ content: "";
218
+ position: absolute;
219
+ inset: 0;
220
+ pointer-events: none;
221
+ }
222
+ .card-cover-fallback::before {
223
+ opacity: 0.24;
224
+ }
225
+ .difficulty-beginner {
226
+ --cover-accent: var(--color-success);
227
+ }
228
+ .difficulty-intermediate {
229
+ --cover-accent: var(--color-warn);
230
+ }
231
+ .difficulty-advanced {
232
+ --cover-accent: var(--color-danger);
233
+ }
234
+ .glow-1 {
235
+ --cover-glow-x: 28%;
236
+ --cover-glow-y: 76%;
237
+ }
238
+ .glow-2 {
239
+ --cover-glow-x: 28%;
240
+ --cover-glow-y: 76%;
241
+ }
242
+ .glow-3 {
243
+ --cover-glow-x: 28%;
244
+ --cover-glow-y: 76%;
245
+ }
246
+ .card-cover-fallback.variant-1 {
247
+ --cover-glow-x: 74%;
248
+ --cover-glow-y: 28%;
136
249
  }
137
- .card:hover .card-cover {
250
+ .card-cover-fallback.variant-2 {
251
+ --cover-glow-x: 74%;
252
+ --cover-glow-y: 76%;
253
+ }
254
+ .card-cover-fallback.variant-3 {
255
+ --cover-glow-x: 28%;
256
+ --cover-glow-y: 28%;
257
+ }
258
+ .scale-1 {
259
+ --cover-grid-size: 1.45rem;
260
+ --cover-dot-size: 1.15rem;
261
+ }
262
+ .scale-2 {
263
+ --cover-grid-size: 0.72rem;
264
+ --cover-dot-size: 0.62rem;
265
+ }
266
+ .pattern-grid::before {
267
+ background-image:
268
+ linear-gradient(
269
+ color-mix(in oklab, var(--cover-accent) 16%, transparent) 1px,
270
+ transparent 1px
271
+ ),
272
+ linear-gradient(
273
+ 90deg,
274
+ color-mix(in oklab, var(--cover-accent) 16%, transparent) 1px,
275
+ transparent 1px
276
+ );
277
+ background-size: var(--cover-grid-size) var(--cover-grid-size);
278
+ }
279
+ .pattern-dots::before {
280
+ background-image: radial-gradient(
281
+ circle,
282
+ color-mix(in oklab, var(--cover-accent) 18%, transparent) 1px,
283
+ transparent 1.5px
284
+ );
285
+ background-size: var(--cover-dot-size) var(--cover-dot-size);
286
+ }
287
+ .fallback-keywords,
288
+ .fallback-motif {
289
+ position: relative;
290
+ z-index: 3;
291
+ }
292
+ .fallback-keywords {
293
+ position: absolute;
294
+ inset: auto auto 1.05rem 1rem;
295
+ max-width: 58%;
296
+ display: grid;
297
+ gap: 0.15rem;
298
+ justify-items: start;
299
+ color: color-mix(in oklab, var(--cover-accent) 58%, transparent);
300
+ font-family: var(--font-mono);
301
+ font-size: clamp(1.05rem, 4.6vw, 2.1rem);
302
+ font-weight: 700;
303
+ letter-spacing: 0.08em;
304
+ line-height: 0.9;
305
+ opacity: 0.42;
306
+ text-transform: uppercase;
307
+ }
308
+ .fallback-watermark {
309
+ position: absolute;
310
+ right: -1.35rem;
311
+ bottom: -2.15rem;
312
+ z-index: 1;
313
+ color: var(--cover-accent);
314
+ opacity: 0.16;
315
+ transform: rotate(-8deg);
316
+ }
317
+ .fallback-watermark svg {
318
+ display: block;
319
+ width: clamp(10.5rem, 58%, 14rem);
320
+ height: auto;
321
+ fill: currentColor;
322
+ }
323
+ .variant-1 .fallback-watermark {
324
+ left: -1.45rem;
325
+ right: auto;
326
+ bottom: -2.15rem;
327
+ transform: rotate(7deg);
328
+ }
329
+ .variant-2 .fallback-watermark {
330
+ left: -1.4rem;
331
+ right: auto;
332
+ top: -2.2rem;
333
+ bottom: auto;
334
+ transform: rotate(-10deg);
335
+ }
336
+ .variant-3 .fallback-watermark {
337
+ right: -1.45rem;
338
+ bottom: -2.2rem;
339
+ transform: rotate(9deg);
340
+ }
341
+ .fallback-keywords span:nth-child(2) {
342
+ margin-left: 1.4rem;
343
+ }
344
+ .fallback-keywords span:nth-child(3) {
345
+ margin-left: 0.55rem;
346
+ }
347
+ .variant-1 .fallback-keywords {
348
+ inset: 2.9rem 1rem auto auto;
349
+ justify-items: end;
350
+ }
351
+ .variant-2 .fallback-keywords {
352
+ inset: auto 1rem 1rem auto;
353
+ justify-items: end;
354
+ }
355
+ .variant-3 .fallback-keywords {
356
+ inset: 3rem auto auto 1rem;
357
+ }
358
+ .card:hover .card-cover,
359
+ .card:hover .card-cover-fallback {
138
360
  transform: scale(1.025);
139
361
  filter: saturate(1) contrast(1.08);
140
362
  }
@@ -207,9 +429,15 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
207
429
  background: currentColor;
208
430
  flex-shrink: 0;
209
431
  }
210
- .badge-beginner { color: var(--color-success); }
211
- .badge-intermediate { color: var(--color-warn); }
212
- .badge-advanced { color: var(--color-danger); }
432
+ .badge-beginner {
433
+ color: var(--color-success);
434
+ }
435
+ .badge-intermediate {
436
+ color: var(--color-warn);
437
+ }
438
+ .badge-advanced {
439
+ color: var(--color-danger);
440
+ }
213
441
 
214
442
  h3 {
215
443
  font-size: 1.05rem;
@@ -260,7 +488,9 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
260
488
  text-transform: uppercase;
261
489
  letter-spacing: 0.08em;
262
490
  }
263
- .card-progress:empty { display: none; }
491
+ .card-progress:empty {
492
+ display: none;
493
+ }
264
494
  /* The ring + label live inside `.card-progress` but are injected
265
495
  * via innerHTML at runtime, so Astro's scope hash doesn't apply to
266
496
  * them. Use :global() so the rules still match. */
@@ -289,7 +519,9 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
289
519
  * Hidden until the script populates content so the card height
290
520
  * stays stable on first paint and gracefully degrades on Tier 1
291
521
  * (no DB → no numbers → no row). */
292
- .card-stats:empty { display: none; }
522
+ .card-stats:empty {
523
+ display: none;
524
+ }
293
525
  .card-stats {
294
526
  margin-top: 0.6rem;
295
527
  display: flex;
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  } from "./lib/ai/prompts.ts";
25
25
  // Content collection helpers (built on top of astro:content).
26
26
  export {
27
+ getListedTutorials,
27
28
  getStep,
28
29
  getStepsForTutorial,
29
30
  getTutorialBySlug,
@@ -1,4 +1,5 @@
1
1
  import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { isTutorialListed, isTutorialPublished } from "./publication.ts";
2
3
 
3
4
  export type TutorialEntry = CollectionEntry<"tutorials">;
4
5
  export type StepEntry = CollectionEntry<"steps">;
@@ -30,7 +31,16 @@ export function parseStepId(id: string): { tutorialSlug: string; stepSlug: strin
30
31
 
31
32
  export async function getTutorials(): Promise<TutorialEntry[]> {
32
33
  const all = await getCollection("tutorials");
33
- return all.sort((a, b) => {
34
+ return sortTutorials(all.filter((tutorial) => isTutorialPublished(tutorial.data)));
35
+ }
36
+
37
+ export async function getListedTutorials(): Promise<TutorialEntry[]> {
38
+ const all = await getCollection("tutorials");
39
+ return sortTutorials(all.filter((tutorial) => isTutorialListed(tutorial.data)));
40
+ }
41
+
42
+ function sortTutorials(tutorials: TutorialEntry[]): TutorialEntry[] {
43
+ return tutorials.sort((a, b) => {
34
44
  const ao = (a.data as { order?: number }).order ?? 0;
35
45
  const bo = (b.data as { order?: number }).order ?? 0;
36
46
  return ao - bo;
@@ -39,7 +49,7 @@ export async function getTutorials(): Promise<TutorialEntry[]> {
39
49
 
40
50
  export async function getTutorialBySlug(slug: string): Promise<TutorialEntry | undefined> {
41
51
  const all = await getCollection("tutorials");
42
- return all.find((t) => t.id === slug);
52
+ return all.find((t) => t.id === slug && isTutorialPublished(t.data));
43
53
  }
44
54
 
45
55
  export async function getStepsForTutorial(slug: string): Promise<StepEntry[]> {
@@ -0,0 +1,118 @@
1
+ import {
2
+ siAstro,
3
+ siDocker,
4
+ siJavascript,
5
+ siPostgresql,
6
+ siPython,
7
+ siReact,
8
+ siRedis,
9
+ siTypescript,
10
+ siVite,
11
+ } from "simple-icons";
12
+
13
+ type Difficulty = "beginner" | "intermediate" | "advanced";
14
+
15
+ type DefaultCoverInput = {
16
+ slug: string;
17
+ title: string;
18
+ difficulty: Difficulty;
19
+ tags: string[];
20
+ icon?: unknown;
21
+ };
22
+
23
+ export type DefaultCoverMeta = {
24
+ difficulty: Difficulty;
25
+ glow: 0 | 1 | 2 | 3;
26
+ icon?: {
27
+ path: string;
28
+ title: string;
29
+ };
30
+ keywords: string[];
31
+ label: string;
32
+ pattern: "grid" | "dots";
33
+ scale: 0 | 1 | 2;
34
+ variant: 0 | 1 | 2 | 3;
35
+ };
36
+
37
+ const PATTERNS: DefaultCoverMeta["pattern"][] = ["grid", "dots"];
38
+
39
+ const TAG_PATTERNS: Record<string, DefaultCoverMeta["pattern"]> = {
40
+ ai: "dots",
41
+ api: "grid",
42
+ auth: "grid",
43
+ authoring: "grid",
44
+ databases: "dots",
45
+ deploy: "grid",
46
+ devops: "grid",
47
+ docker: "grid",
48
+ frontend: "grid",
49
+ javascript: "grid",
50
+ meta: "grid",
51
+ postgres: "dots",
52
+ python: "grid",
53
+ queues: "grid",
54
+ react: "grid",
55
+ redis: "dots",
56
+ render: "grid",
57
+ sql: "dots",
58
+ tracks: "grid",
59
+ typescript: "grid",
60
+ vite: "grid",
61
+ web: "grid",
62
+ };
63
+
64
+ const TAG_ICONS: Record<string, DefaultCoverMeta["icon"]> = {
65
+ astro: siAstro,
66
+ docker: siDocker,
67
+ javascript: siJavascript,
68
+ postgres: siPostgresql,
69
+ python: siPython,
70
+ react: siReact,
71
+ redis: siRedis,
72
+ typescript: siTypescript,
73
+ vite: siVite,
74
+ };
75
+
76
+ const KEYWORD_STOPWORDS = new Set(["A", "AN", "AND", "APP", "BUILD", "THE", "TO", "WITH"]);
77
+
78
+ function stableIndex(value: string, modulo: number): number {
79
+ let hash = 0;
80
+ for (let i = 0; i < value.length; i += 1) {
81
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
82
+ }
83
+ return hash % modulo;
84
+ }
85
+
86
+ function normalizeKeyword(value: string): string {
87
+ return value.replace(/[^a-z0-9]/gi, "").toUpperCase();
88
+ }
89
+
90
+ function getKeywords(input: DefaultCoverInput): string[] {
91
+ const words = [...input.tags, ...input.title.split(/\s+/)]
92
+ .map(normalizeKeyword)
93
+ .filter((word) => word.length >= 2 && !KEYWORD_STOPWORDS.has(word));
94
+
95
+ return Array.from(new Set(words)).slice(0, 3);
96
+ }
97
+
98
+ export function getDefaultCoverMeta(input: DefaultCoverInput): DefaultCoverMeta {
99
+ const label = input.tags[0] ?? input.difficulty;
100
+ const icon = input.tags.map((tag) => TAG_ICONS[tag.toLowerCase()]).find(Boolean);
101
+ const variant = stableIndex(input.slug, 4) as DefaultCoverMeta["variant"];
102
+ const scale = stableIndex(`${input.slug}:${label}:scale`, 3) as DefaultCoverMeta["scale"];
103
+ const glow = stableIndex(`${input.slug}:${input.title}:glow`, 4) as DefaultCoverMeta["glow"];
104
+ const pattern =
105
+ input.tags.map((tag) => TAG_PATTERNS[tag.toLowerCase()]).find(Boolean) ??
106
+ PATTERNS[stableIndex(`${input.title}:${input.tags.join(":")}`, PATTERNS.length)];
107
+
108
+ return {
109
+ difficulty: input.difficulty,
110
+ glow,
111
+ icon,
112
+ keywords: getKeywords(input),
113
+ label,
114
+ pattern,
115
+ scale,
116
+ variant,
117
+ };
118
+ }
@@ -0,0 +1,12 @@
1
+ export interface TutorialPublication {
2
+ published?: boolean;
3
+ hidden?: boolean;
4
+ }
5
+
6
+ export function isTutorialPublished(tutorial: TutorialPublication): boolean {
7
+ return tutorial.published !== false;
8
+ }
9
+
10
+ export function isTutorialListed(tutorial: TutorialPublication): boolean {
11
+ return isTutorialPublished(tutorial) && tutorial.hidden !== true;
12
+ }
@@ -0,0 +1,49 @@
1
+ import {
2
+ type SimpleIcon,
3
+ siC,
4
+ siCplusplus,
5
+ siGnubash,
6
+ siGo,
7
+ siJavascript,
8
+ siMysql,
9
+ siPhp,
10
+ siPostgresql,
11
+ siPython,
12
+ siRuby,
13
+ siRust,
14
+ siSqlite,
15
+ siTypescript,
16
+ } from "simple-icons";
17
+ import type { TrackOption } from "./tracks";
18
+
19
+ const TRACK_ICONS: Record<string, SimpleIcon> = {
20
+ bash: siGnubash,
21
+ c: siC,
22
+ "c++": siCplusplus,
23
+ cpp: siCplusplus,
24
+ go: siGo,
25
+ js: siJavascript,
26
+ javascript: siJavascript,
27
+ mysql: siMysql,
28
+ php: siPhp,
29
+ postgres: siPostgresql,
30
+ postgresql: siPostgresql,
31
+ py: siPython,
32
+ python: siPython,
33
+ rb: siRuby,
34
+ ruby: siRuby,
35
+ rust: siRust,
36
+ sqlite: siSqlite,
37
+ ts: siTypescript,
38
+ typescript: siTypescript,
39
+ };
40
+
41
+ export function iconForTrack(track: TrackOption): SimpleIcon | undefined {
42
+ const id = track.id.toLowerCase();
43
+ const label = track.label.toLowerCase();
44
+ return TRACK_ICONS[id] ?? TRACK_ICONS[label];
45
+ }
46
+
47
+ export function fallbackTextForTrack(track: TrackOption): string {
48
+ return (track.id || track.label).slice(0, 2).toUpperCase();
49
+ }
@@ -0,0 +1,10 @@
1
+ import type { ZodString, ZodTypeAny } from "zod";
2
+
3
+ type ZodLike = {
4
+ string: () => ZodString;
5
+ union: <T extends readonly [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]>(schemas: T) => ZodTypeAny;
6
+ };
7
+
8
+ export function createTutorialIconSchema(z: ZodLike, image: () => ZodTypeAny) {
9
+ return z.union([image(), z.string()]);
10
+ }
@@ -5,7 +5,7 @@ import TutorialCard from "../components/home/TutorialCard.astro";
5
5
  import FilterBar from "../components/home/FilterBar.tsx";
6
6
  import Pagination from "../components/home/Pagination.tsx";
7
7
  import ResumeRail from "../components/home/ResumeRail.tsx";
8
- import { getTutorials, getStepsForTutorial, sumDurations } from "../lib/content.ts";
8
+ import { getListedTutorials, getStepsForTutorial, sumDurations } from "../lib/content.ts";
9
9
 
10
10
  interface Props {
11
11
  siteName?: string;
@@ -43,7 +43,7 @@ const {
43
43
  pageSize = 9,
44
44
  } = Astro.props;
45
45
 
46
- const tutorials = await getTutorials();
46
+ const tutorials = await getListedTutorials();
47
47
  const stepsByTutorial = new Map(
48
48
  await Promise.all(
49
49
  tutorials.map(async (t) => [t.id, await getStepsForTutorial(t.id)] as const),
@@ -212,9 +212,9 @@ const coverUrl = tutorial.data.cover?.src;
212
212
  }
213
213
  .hero-with-cover {
214
214
  display: grid;
215
- grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr);
216
- gap: clamp(1.5rem, 4vw, 3rem);
217
- align-items: stretch;
215
+ grid-template-columns: 1fr;
216
+ gap: clamp(1.5rem, 4vw, 2rem);
217
+ align-items: start;
218
218
  }
219
219
  .hero-copy {
220
220
  min-width: 0;
@@ -243,7 +243,8 @@ const coverUrl = tutorial.data.cover?.src;
243
243
  line-height: 1;
244
244
  }
245
245
  .hero-media {
246
- min-height: 100%;
246
+ order: -1;
247
+ aspect-ratio: 16 / 9;
247
248
  border: var(--border-default, 2px) solid var(--color-border);
248
249
  background: var(--color-surface);
249
250
  overflow: hidden;
@@ -252,7 +253,6 @@ const coverUrl = tutorial.data.cover?.src;
252
253
  .hero-cover {
253
254
  width: 100%;
254
255
  height: 100%;
255
- min-height: 18rem;
256
256
  object-fit: cover;
257
257
  display: block;
258
258
  filter: saturate(0.95) contrast(1.06);
@@ -447,9 +447,6 @@ const coverUrl = tutorial.data.cover?.src;
447
447
  }
448
448
  @media (max-width: 640px) {
449
449
  .hero { padding: 1.5rem 1.25rem 1.75rem; }
450
- .hero-with-cover { grid-template-columns: 1fr; }
451
- .hero-media { order: -1; }
452
- .hero-cover { min-height: 12rem; }
453
450
  .step-list a { grid-template-columns: auto 1fr auto; padding: 0.75rem; }
454
451
  .step-chevron { display: none; }
455
452
  }
@@ -1,10 +1,10 @@
1
1
  import { eq } from "drizzle-orm";
2
2
  import type { VerifySpec } from "../../collections.ts";
3
3
  import {
4
+ getListedTutorials,
4
5
  getStep,
5
6
  getStepsForTutorial,
6
7
  getTutorialBySlug,
7
- getTutorials,
8
8
  parseStepId,
9
9
  } from "../../lib/content.ts";
10
10
  import { isVerifySpec, resolveForTrack, type TrackScoped } from "../../lib/track-scoped.ts";
@@ -43,10 +43,10 @@ function resolveVerifyForTrack(
43
43
  export const catalogReadTools: McpTool[] = [
44
44
  {
45
45
  name: "list_tutorials",
46
- description: "List every tutorial published on this Handzon site.",
46
+ description: "List every tutorial published in this Handzon site's public catalog.",
47
47
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
48
48
  handler: async () => {
49
- const tutorials = await getTutorials();
49
+ const tutorials = await getListedTutorials();
50
50
  const rows = tutorials.map((t) => ({
51
51
  slug: t.id,
52
52
  title: t.data.title,
@@ -53,12 +53,6 @@
53
53
  fill: currentColor;
54
54
  }
55
55
 
56
- .track-selector-icon-slot {
57
- width: 0.95rem;
58
- height: 0.95rem;
59
- flex-shrink: 0;
60
- }
61
-
62
56
  .track-selector-fallback {
63
57
  display: inline-grid;
64
58
  place-items: center;