handzon-core 0.8.5 → 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.5",
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(),
@@ -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; }