handzon-core 0.8.4 → 0.9.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.8.4",
3
+ "version": "0.9.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"
@@ -65,12 +65,14 @@
65
65
  "@types/react": "^19.2.15",
66
66
  "@types/react-dom": "^19.2.3",
67
67
  "astro": "^6.3.5",
68
+ "tsx": "^4.22.3",
68
69
  "typescript": "^6.0.3"
69
70
  },
70
71
  "engines": {
71
72
  "node": ">=22.0.0"
72
73
  },
73
74
  "scripts": {
74
- "build": "echo 'handzon-core ships source — Astro type-checks at consume time'"
75
+ "build": "echo 'handzon-core ships source — Astro type-checks at consume time'",
76
+ "test": "node --import tsx --test tests/*.test.ts"
75
77
  }
76
78
  }
@@ -187,6 +187,35 @@ export const verifySchema = z.object({
187
187
 
188
188
  export type VerifySpec = z.infer<typeof verifySchema>;
189
189
 
190
+ export const starterSchema = z.discriminatedUnion("kind", [
191
+ z
192
+ .object({
193
+ kind: z.literal("git"),
194
+ repo: z.string().url(),
195
+ ref: z.string().min(1).optional(),
196
+ subdir: z.string().min(1).optional(),
197
+ targetDir: z.string().min(1).optional(),
198
+ setupCommands: z.array(z.string().min(1)).default([]),
199
+ devCommand: z.string().min(1).optional(),
200
+ openPath: z.string().min(1).optional(),
201
+ notes: z.array(z.string().min(1)).default([]),
202
+ })
203
+ .strict(),
204
+ z
205
+ .object({
206
+ kind: z.literal("command"),
207
+ initCommand: z.string().min(1),
208
+ targetDir: z.string().min(1).optional(),
209
+ setupCommands: z.array(z.string().min(1)).default([]),
210
+ devCommand: z.string().min(1).optional(),
211
+ openPath: z.string().min(1).optional(),
212
+ notes: z.array(z.string().min(1)).default([]),
213
+ })
214
+ .strict(),
215
+ ]);
216
+
217
+ export type StarterSpec = z.infer<typeof starterSchema>;
218
+
190
219
  /** Schema for tutorial step entries. */
191
220
  export const stepsSchema = z.object({
192
221
  title: z.string(),
@@ -226,6 +255,7 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
226
255
  gated: z.boolean().default(false),
227
256
  showProgress: z.boolean().default(true),
228
257
  feedbackUrl: z.string().url().optional(),
258
+ starter: starterSchema.optional(),
229
259
  ai: z
230
260
  .object({
231
261
  enabled: z.boolean().optional(),
@@ -33,17 +33,19 @@ const {
33
33
  ---
34
34
  <header class="hz-nav">
35
35
  <div class="hz-nav-inner">
36
- {logoUrl && (
37
- <a href="/" class="hz-nav-brand" aria-label={`${siteName} home`}>
36
+ <a href="/" class="hz-nav-brand" aria-label={`${siteName} home`}>
37
+ {logoUrl && (
38
38
  <img
39
39
  src={logoUrl}
40
- alt={siteName}
40
+ alt=""
41
+ aria-hidden="true"
41
42
  class="hz-nav-logo"
42
43
  width={logoWidth}
43
44
  height={logoHeight}
44
45
  />
45
- </a>
46
- )}
46
+ )}
47
+ <span class="hz-nav-name">{siteName}</span>
48
+ </a>
47
49
  <div class="hz-nav-actions">
48
50
  <UserMenu />
49
51
  </div>
@@ -77,8 +79,11 @@ const {
77
79
  .hz-nav-brand {
78
80
  display: inline-flex;
79
81
  align-items: center;
80
- line-height: 0;
82
+ gap: 0.45rem;
83
+ line-height: 1;
81
84
  opacity: 0.92;
85
+ color: var(--color-fg);
86
+ text-decoration: none;
82
87
  transition: opacity 0.12s ease;
83
88
  }
84
89
  .hz-nav-brand:hover { opacity: 1; }
@@ -86,6 +91,18 @@ const {
86
91
  display: block;
87
92
  height: 1.4rem;
88
93
  width: auto;
94
+ flex-shrink: 0;
95
+ }
96
+ /* Matches the Hero headline's font choice so the wordmark reads as
97
+ the same brand across the homepage hero and every-other-page nav.
98
+ Smaller, no display tracking — this is a chrome label, not a
99
+ headline. */
100
+ .hz-nav-name {
101
+ font-family: var(--font-display, var(--font-sans));
102
+ font-weight: var(--font-weight-display, 700);
103
+ font-size: 1.05rem;
104
+ letter-spacing: var(--tracking-display, -0.02em);
105
+ white-space: nowrap;
89
106
  }
90
107
  .hz-nav-actions {
91
108
  display: flex;
@@ -74,7 +74,8 @@ const {
74
74
  font-size: 1.125rem;
75
75
  line-height: 1.55;
76
76
  color: var(--color-muted);
77
- max-width: 60ch;
77
+ max-width: min(100%, 86ch);
78
78
  margin: 0;
79
+ text-wrap: pretty;
79
80
  }
80
81
  </style>
@@ -214,18 +214,25 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
214
214
  height: calc(100dvh - var(--hz-nav-height, 3rem));
215
215
  }
216
216
  .main {
217
- padding: 2rem clamp(1rem, 4vw, 3rem);
218
- /* 80ch is the upper end of comfortable prose width; on wide
219
- * viewports we trade a little of that for code-block headroom so
220
- * fenced blocks wrap less aggressively. Capped at 92ch — past
221
- * that body prose starts to feel like a magazine column. */
217
+ padding: 2rem clamp(1rem, 4vw, 4rem);
218
+ /* Keep tutorial pages roomy enough for code-heavy components. Prose
219
+ * itself is constrained below so paragraphs do not become too wide. */
220
+ max-width: 86ch;
221
+ }
222
+ .main > .prose {
223
+ max-width: 100%;
224
+ }
225
+ .main > .prose > :where(p, ul, ol, blockquote, h2, h3, h4, h5, h6) {
222
226
  max-width: 80ch;
223
227
  }
224
228
  @media (min-width: 1280px) {
225
- .main { max-width: 88ch; }
229
+ .main { max-width: 104ch; }
226
230
  }
227
231
  @media (min-width: 1600px) {
228
- .main { max-width: 92ch; }
232
+ .main { max-width: 118ch; }
233
+ }
234
+ @media (min-width: 1920px) {
235
+ .main { max-width: 128ch; }
229
236
  }
230
237
  .crumb {
231
238
  font-family: var(--font-mono);
@@ -0,0 +1,150 @@
1
+ import type { StarterSpec, VerifySpec } from "../../collections.ts";
2
+ import { type McpTool, text } from "./protocol.ts";
3
+
4
+ interface StartTutorialStep {
5
+ slug: string;
6
+ order: number;
7
+ title: string;
8
+ summary?: string;
9
+ duration?: string;
10
+ verify?: VerifySpec | null;
11
+ }
12
+
13
+ interface StartTutorialInput {
14
+ tutorial: {
15
+ slug: string;
16
+ title: string;
17
+ description: string;
18
+ difficulty: string;
19
+ tags: string[];
20
+ starter?: StarterSpec;
21
+ };
22
+ steps: StartTutorialStep[];
23
+ workspaceName?: string;
24
+ }
25
+
26
+ export type LoadStartTutorial = (
27
+ slug: string,
28
+ ) => Promise<Omit<StartTutorialInput, "workspaceName"> | null>;
29
+
30
+ export interface StartTutorialPayload {
31
+ tutorial: {
32
+ slug: string;
33
+ title: string;
34
+ description: string;
35
+ difficulty: string;
36
+ tags: string[];
37
+ };
38
+ starter: StarterSpec | null;
39
+ workspace: {
40
+ targetDir: string;
41
+ openPath: string;
42
+ };
43
+ commands: string[];
44
+ firstStep: StartTutorialStep;
45
+ next: string[];
46
+ }
47
+
48
+ function pathForCommand(...parts: string[]) {
49
+ return parts.filter(Boolean).join("/");
50
+ }
51
+
52
+ function resolveTargetDir(
53
+ slug: string,
54
+ starter: StarterSpec | undefined,
55
+ workspaceName: string | undefined,
56
+ ) {
57
+ return workspaceName ?? starter?.targetDir ?? slug;
58
+ }
59
+
60
+ function resolveOpenPath(targetDir: string, starter: StarterSpec | undefined) {
61
+ if (!starter) return targetDir;
62
+ if (starter.openPath) {
63
+ return starter.openPath === "." ? targetDir : starter.openPath;
64
+ }
65
+ if (starter.kind === "git" && starter.subdir) return pathForCommand(targetDir, starter.subdir);
66
+ return targetDir;
67
+ }
68
+
69
+ function buildCommands(starter: StarterSpec | undefined, targetDir: string, openPath: string) {
70
+ if (!starter) return [];
71
+ const commands: string[] = [];
72
+ if (starter.kind === "git") {
73
+ const ref = starter.ref ? ` --branch ${starter.ref}` : "";
74
+ commands.push(`git clone${ref} ${starter.repo} ${targetDir}`);
75
+ } else {
76
+ commands.push(starter.initCommand);
77
+ }
78
+ commands.push(`cd ${openPath}`);
79
+ commands.push(...starter.setupCommands);
80
+ return commands;
81
+ }
82
+
83
+ export function buildStartTutorialPayload({
84
+ tutorial,
85
+ steps,
86
+ workspaceName,
87
+ }: StartTutorialInput): StartTutorialPayload {
88
+ const [firstStep] = [...steps].sort((a, b) => a.order - b.order);
89
+ if (!firstStep) {
90
+ throw new Error(`Tutorial ${tutorial.slug} has no steps.`);
91
+ }
92
+
93
+ const targetDir = resolveTargetDir(tutorial.slug, tutorial.starter, workspaceName);
94
+ const openPath = resolveOpenPath(targetDir, tutorial.starter);
95
+ const commands = buildCommands(tutorial.starter, targetDir, openPath);
96
+
97
+ return {
98
+ tutorial: {
99
+ slug: tutorial.slug,
100
+ title: tutorial.title,
101
+ description: tutorial.description,
102
+ difficulty: tutorial.difficulty,
103
+ tags: tutorial.tags,
104
+ },
105
+ starter: tutorial.starter ?? null,
106
+ workspace: { targetDir, openPath },
107
+ commands,
108
+ firstStep,
109
+ next: [
110
+ `Call get_step with tutorial=${tutorial.slug} and step=${firstStep.slug}.`,
111
+ "Run the step locally in the prepared workspace.",
112
+ firstStep.verify
113
+ ? "If the step has verify checks, collect observations and call submit_verification."
114
+ : "If the step has only a prose checkpoint, inspect the result before calling complete_checkpoint.",
115
+ ],
116
+ };
117
+ }
118
+
119
+ export function createStartTutorialTool(load: LoadStartTutorial): McpTool {
120
+ return {
121
+ name: "start_tutorial",
122
+ description:
123
+ "Return local bootstrap commands and next MCP actions for starting a tutorial from a blank workspace.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ slug: { type: "string", minLength: 1 },
128
+ workspaceName: {
129
+ type: "string",
130
+ minLength: 1,
131
+ description:
132
+ "Optional local directory name to use instead of the tutorial's default targetDir.",
133
+ },
134
+ },
135
+ required: ["slug"],
136
+ additionalProperties: false,
137
+ },
138
+ handler: async (args) => {
139
+ const { slug, workspaceName } = args as { slug: string; workspaceName?: string };
140
+ const loaded = await load(slug);
141
+ if (!loaded) {
142
+ return {
143
+ content: [{ type: "text", text: `No tutorial with slug "${slug}".` }],
144
+ isError: true,
145
+ };
146
+ }
147
+ return text(JSON.stringify(buildStartTutorialPayload({ ...loaded, workspaceName }), null, 2));
148
+ },
149
+ };
150
+ }
@@ -9,6 +9,7 @@ import {
9
9
  import { getDb } from "../db/client.ts";
10
10
  import { progressEntries } from "../db/schema.ts";
11
11
  import { type McpTool, text } from "./protocol.ts";
12
+ import { createStartTutorialTool } from "./startTutorial.ts";
12
13
  import { progressWriteTools, verificationTools } from "./writeTools.ts";
13
14
 
14
15
  /**
@@ -72,6 +73,7 @@ export const catalogReadTools: McpTool[] = [
72
73
  difficulty: tutorial.data.difficulty,
73
74
  tags: tutorial.data.tags,
74
75
  gated: tutorial.data.gated,
76
+ starter: tutorial.data.starter ?? null,
75
77
  steps: steps.map((s) => {
76
78
  const { stepSlug, order } = parseStepId(s.id);
77
79
  return {
@@ -86,6 +88,32 @@ export const catalogReadTools: McpTool[] = [
86
88
  return text(JSON.stringify(payload, null, 2));
87
89
  },
88
90
  },
91
+ createStartTutorialTool(async (slug) => {
92
+ const tutorial = await getTutorialBySlug(slug);
93
+ if (!tutorial) return null;
94
+ const steps = await getStepsForTutorial(slug);
95
+ return {
96
+ tutorial: {
97
+ slug: tutorial.id,
98
+ title: tutorial.data.title,
99
+ description: tutorial.data.description,
100
+ difficulty: tutorial.data.difficulty,
101
+ tags: tutorial.data.tags,
102
+ starter: tutorial.data.starter,
103
+ },
104
+ steps: steps.map((s) => {
105
+ const { stepSlug, order } = parseStepId(s.id);
106
+ return {
107
+ slug: stepSlug,
108
+ order,
109
+ title: s.data.title,
110
+ summary: s.data.summary,
111
+ duration: s.data.duration,
112
+ verify: s.data.verify ?? null,
113
+ };
114
+ }),
115
+ };
116
+ }),
89
117
  {
90
118
  name: "get_step",
91
119
  description: "Return one step's full Markdown source + metadata.",
@@ -3,8 +3,10 @@
3
3
  margin: 1.25rem 0;
4
4
  background: var(--color-surface);
5
5
  font-family: var(--font-mono);
6
- font-size: 0.85em;
7
- border-radius: 0;
6
+ font-size: 0.875rem;
7
+ line-height: 1.55;
8
+ border: var(--border-default) solid var(--color-border);
9
+ border-radius: var(--radius-sm, 0);
8
10
  overflow: hidden;
9
11
  }
10
12
  .diff-bar {
@@ -23,8 +25,16 @@
23
25
  border-radius: 0;
24
26
  }
25
27
  .diff-bar button:hover { color: var(--color-fg); border-color: var(--color-border-strong); }
26
- .diff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--color-border); }
27
- .diff-col { background: var(--color-surface); }
28
+ .diff-grid {
29
+ display: grid;
30
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
31
+ gap: 1px;
32
+ background: var(--color-border);
33
+ }
34
+ .diff-col {
35
+ min-width: 0;
36
+ background: var(--color-surface);
37
+ }
28
38
  .diff-label {
29
39
  padding: 0.4rem 0.6rem;
30
40
  background: var(--color-surface-2);
@@ -35,10 +45,36 @@
35
45
  }
36
46
  .diff pre {
37
47
  margin: 0;
38
- padding: 0.6rem 0.8rem;
48
+ padding: 0.65rem 0;
39
49
  white-space: pre;
40
50
  overflow-x: auto;
51
+ tab-size: 2;
52
+ }
53
+ .diff-add,
54
+ .diff-del,
55
+ .diff-ctx {
56
+ display: block;
57
+ min-width: max-content;
58
+ padding: 0.08rem 0.85rem;
59
+ }
60
+ .diff-add {
61
+ background: color-mix(in oklab, var(--color-success) 16%, transparent);
62
+ color: var(--color-success);
63
+ }
64
+ .diff-del {
65
+ background: color-mix(in oklab, var(--color-danger) 16%, transparent);
66
+ color: var(--color-danger);
67
+ }
68
+ .diff-ctx {
69
+ color: var(--color-muted);
70
+ }
71
+
72
+ @media (max-width: 1100px) {
73
+ .diff-grid {
74
+ grid-template-columns: 1fr;
75
+ }
76
+
77
+ .diff-col + .diff-col {
78
+ border-top: var(--border-default) solid var(--color-border);
79
+ }
41
80
  }
42
- .diff-add { background: color-mix(in oklab, var(--color-success) 14%, transparent); color: var(--color-success); display: block; }
43
- .diff-del { background: color-mix(in oklab, var(--color-danger) 14%, transparent); color: var(--color-danger); display: block; }
44
- .diff-ctx { color: var(--color-muted); display: block; }
@@ -64,11 +64,31 @@
64
64
  .quiz-opt.is-correct {
65
65
  background: color-mix(in oklab, var(--color-success) 18%, var(--color-surface));
66
66
  }
67
- .quiz-opt.is-correct .quiz-opt-toggle { border-color: var(--color-success); background: var(--color-success); color: var(--color-bg); }
67
+ .quiz-opt.is-correct .quiz-opt-toggle {
68
+ border-color: var(--color-success);
69
+ background: var(--color-success);
70
+ color: var(--color-bg);
71
+ display: inline-grid;
72
+ place-items: center;
73
+ }
68
74
  .quiz-opt.is-wrong {
69
75
  background: color-mix(in oklab, var(--color-danger) 16%, var(--color-surface));
70
76
  }
71
- .quiz-opt.is-wrong .quiz-opt-toggle { border-color: var(--color-danger); background: var(--color-danger); color: var(--color-bg); }
77
+ .quiz-opt.is-wrong .quiz-opt-toggle {
78
+ border-color: var(--color-danger);
79
+ background: var(--color-danger);
80
+ color: var(--color-bg);
81
+ display: inline-grid;
82
+ place-items: center;
83
+ }
84
+ /* Graded options swap the accent-filled `::after` for the Lucide
85
+ <Check>/<X> icon injected by Quiz.tsx — without this, the purple
86
+ accent rectangle covers the icon and you see a purple dot inside
87
+ the green/red box. */
88
+ .quiz-opt.is-correct .quiz-opt-toggle::after,
89
+ .quiz-opt.is-wrong .quiz-opt-toggle::after {
90
+ display: none;
91
+ }
72
92
  .quiz-marker {
73
93
  display: inline-grid;
74
94
  place-items: center;