handzon-core 0.13.4 → 0.14.1

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.4",
3
+ "version": "0.14.1",
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"
@@ -480,6 +480,8 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
480
480
  publishedAt: z.coerce.date().optional(),
481
481
  updatedAt: z.coerce.date().optional(),
482
482
  tags: z.array(z.string()).default([]),
483
+ published: z.boolean().default(true),
484
+ hidden: z.boolean().default(false),
483
485
  tracks: z.array(trackSchema).default([]),
484
486
  defaultTrack: z.string().min(1).optional(),
485
487
  difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
@@ -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,187 @@ 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%;
249
+ }
250
+ .card-cover-fallback.variant-2 {
251
+ --cover-glow-x: 74%;
252
+ --cover-glow-y: 76%;
136
253
  }
137
- .card:hover .card-cover {
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
+ .fallback-keywords span {
348
+ max-width: 100%;
349
+ overflow: hidden;
350
+ text-overflow: clip;
351
+ white-space: nowrap;
352
+ }
353
+ .variant-1 .fallback-keywords {
354
+ inset: 2.9rem 1rem auto auto;
355
+ justify-items: end;
356
+ }
357
+ .variant-2 .fallback-keywords {
358
+ inset: auto 1rem 1rem auto;
359
+ justify-items: end;
360
+ }
361
+ .variant-3 .fallback-keywords {
362
+ inset: 3rem auto auto 1rem;
363
+ }
364
+ .card:hover .card-cover,
365
+ .card:hover .card-cover-fallback {
138
366
  transform: scale(1.025);
139
367
  filter: saturate(1) contrast(1.08);
140
368
  }
@@ -207,9 +435,15 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
207
435
  background: currentColor;
208
436
  flex-shrink: 0;
209
437
  }
210
- .badge-beginner { color: var(--color-success); }
211
- .badge-intermediate { color: var(--color-warn); }
212
- .badge-advanced { color: var(--color-danger); }
438
+ .badge-beginner {
439
+ color: var(--color-success);
440
+ }
441
+ .badge-intermediate {
442
+ color: var(--color-warn);
443
+ }
444
+ .badge-advanced {
445
+ color: var(--color-danger);
446
+ }
213
447
 
214
448
  h3 {
215
449
  font-size: 1.05rem;
@@ -260,7 +494,9 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
260
494
  text-transform: uppercase;
261
495
  letter-spacing: 0.08em;
262
496
  }
263
- .card-progress:empty { display: none; }
497
+ .card-progress:empty {
498
+ display: none;
499
+ }
264
500
  /* The ring + label live inside `.card-progress` but are injected
265
501
  * via innerHTML at runtime, so Astro's scope hash doesn't apply to
266
502
  * them. Use :global() so the rules still match. */
@@ -289,7 +525,9 @@ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undef
289
525
  * Hidden until the script populates content so the card height
290
526
  * stays stable on first paint and gracefully degrades on Tier 1
291
527
  * (no DB → no numbers → no row). */
292
- .card-stats:empty { display: none; }
528
+ .card-stats:empty {
529
+ display: none;
530
+ }
293
531
  .card-stats {
294
532
  margin-top: 0.6rem;
295
533
  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,121 @@
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 normalizeKeywords(value: string): string[] {
87
+ return value
88
+ .split(/[^a-z0-9]+/i)
89
+ .map((word) => word.toUpperCase())
90
+ .filter(Boolean);
91
+ }
92
+
93
+ function getKeywords(input: DefaultCoverInput): string[] {
94
+ const words = [...input.tags, ...input.title.split(/\s+/)]
95
+ .flatMap(normalizeKeywords)
96
+ .filter((word) => word.length >= 2 && !KEYWORD_STOPWORDS.has(word));
97
+
98
+ return Array.from(new Set(words)).slice(0, 3);
99
+ }
100
+
101
+ export function getDefaultCoverMeta(input: DefaultCoverInput): DefaultCoverMeta {
102
+ const label = input.tags[0] ?? input.difficulty;
103
+ const icon = input.tags.map((tag) => TAG_ICONS[tag.toLowerCase()]).find(Boolean);
104
+ const variant = stableIndex(input.slug, 4) as DefaultCoverMeta["variant"];
105
+ const scale = stableIndex(`${input.slug}:${label}:scale`, 3) as DefaultCoverMeta["scale"];
106
+ const glow = stableIndex(`${input.slug}:${input.title}:glow`, 4) as DefaultCoverMeta["glow"];
107
+ const pattern =
108
+ input.tags.map((tag) => TAG_PATTERNS[tag.toLowerCase()]).find(Boolean) ??
109
+ PATTERNS[stableIndex(`${input.title}:${input.tags.join(":")}`, PATTERNS.length)];
110
+
111
+ return {
112
+ difficulty: input.difficulty,
113
+ glow,
114
+ icon,
115
+ keywords: getKeywords(input),
116
+ label,
117
+ pattern,
118
+ scale,
119
+ variant,
120
+ };
121
+ }
@@ -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
+ }
@@ -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),
@@ -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,