handzon-core 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.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 { collectProgressItemIds, validateProgressItemIds } from "./lib/progress/progressItems";
23
24
  import { createTutorialIconSchema } from "./lib/tutorialIcon";
24
25
 
25
26
  const TUTORIALS_REL = "src/content/tutorials";
@@ -219,7 +220,6 @@ export function stepsLoader(): Loader {
219
220
  name: "handzon-steps",
220
221
  load: async (args) => {
221
222
  await inner.load(args);
222
- const checkpointRe = /<Checkpoint\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})/g;
223
223
  const trackIdCache = new Map<string, Promise<string[]>>();
224
224
  for (const value of args.store.values()) {
225
225
  const { tutorialSlug } = parseStepCollectionId(value.id);
@@ -229,17 +229,11 @@ export function stepsLoader(): Loader {
229
229
  const trackIds = await trackIdsPromise;
230
230
  const body = value.body ?? "";
231
231
  validateTrackIdsInBody({ body, entryId: value.id, trackIds });
232
+ validateProgressItemIds({ body, entryId: value.id });
232
233
 
233
234
  const verify = (value.data as { verify?: TrackScoped<VerifySpec> } | undefined)?.verify;
234
235
  if (!verify) continue;
235
- const ids = new Set<string>();
236
- checkpointRe.lastIndex = 0;
237
- for (;;) {
238
- const m = checkpointRe.exec(body);
239
- if (m === null) break;
240
- const id = m[1] ?? m[2] ?? m[3];
241
- if (id) ids.add(id);
242
- }
236
+ const ids = collectProgressItemIds(body).Checkpoint;
243
237
 
244
238
  if (isTrackMap(verify, isVerifySpec)) {
245
239
  validateTrackMapCoverage({
@@ -16,7 +16,7 @@ interface Props {
16
16
  const { tutorial, steps, currentStepSlug } = Astro.props;
17
17
  const slug = tutorial.id;
18
18
  ---
19
- <aside class="sidebar" aria-label="Tutorial steps">
19
+ <aside class="sidebar" aria-label="Tutorial steps" data-gated={tutorial.data.gated ? "true" : "false"}>
20
20
  {/* Top block: back link, title, progress. Stays fixed while the
21
21
  * step list below scrolls when there are many steps. */}
22
22
  <div class="sb-top">
@@ -77,10 +77,11 @@ const slug = tutorial.id;
77
77
  const isCurrent = stepSlug === currentStepSlug;
78
78
  return (
79
79
  <li class={isCurrent ? "is-current" : ""} data-step-slug={stepSlug}>
80
- <a href={withBase(`/${slug}/${stepSlug}`)}>
80
+ <a href={withBase(`/${slug}/${stepSlug}`)} data-step-link="true">
81
81
  <span class="sb-check" data-step-key={`${slug}/${stepSlug}`}></span>
82
82
  <span class="sb-name">{step.data.title}</span>
83
83
  {step.data.duration && <span class="sb-dur">{step.data.duration}</span>}
84
+ <span class="sb-lock" aria-hidden="true">Locked</span>
84
85
  </a>
85
86
  </li>
86
87
  );
@@ -91,13 +92,17 @@ const slug = tutorial.id;
91
92
  {/* Pre-paint: mirror the persisted progress state into the SSR sidebar
92
93
  before hydrated modules run, avoiding a step-change flash from
93
94
  0 / unchecked to the learner's real progress. */}
94
- <script is:inline define:vars={{ storageKey: STORAGE_KEY, tutorialSlug: slug, totalSteps: steps.length }}>
95
+ <script is:inline define:vars={{ storageKey: STORAGE_KEY, tutorialSlug: slug, totalSteps: steps.length, tutorialSteps: steps.map((step) => parseStepId(step.id).stepSlug), gated: tutorial.data.gated }}>
95
96
  (function () {
96
97
  try {
97
98
  var raw = window.localStorage.getItem(storageKey);
98
99
  var state = raw ? JSON.parse(raw) : null;
99
100
  var steps = (state && state.steps) || {};
100
101
  var completed = 0;
102
+ var firstIncomplete = tutorialSteps.find(function (slug) {
103
+ return steps[tutorialSlug + "/" + slug] !== "complete";
104
+ });
105
+ var lockStart = gated && firstIncomplete ? tutorialSteps.indexOf(firstIncomplete) + 1 : -1;
101
106
 
102
107
  document.querySelectorAll("[data-step-key]").forEach(function (el) {
103
108
  var key = el.getAttribute("data-step-key");
@@ -105,6 +110,12 @@ const slug = tutorial.id;
105
110
  el.setAttribute("data-done", done ? "true" : "false");
106
111
  if (done && key.indexOf(tutorialSlug + "/") === 0) completed += 1;
107
112
  });
113
+ if (lockStart > 0) {
114
+ tutorialSteps.slice(lockStart).forEach(function (slug) {
115
+ var item = document.querySelector('[data-step-slug="' + slug + '"]');
116
+ if (item) item.setAttribute("data-locked", "true");
117
+ });
118
+ }
108
119
 
109
120
  var progress = document.querySelector(".sidebar .progress");
110
121
  if (!progress) return;
@@ -125,14 +136,50 @@ const slug = tutorial.id;
125
136
  <script>
126
137
  // Hydrate per-step check marks from localStorage without an island.
127
138
  import { getStore } from "../lib/progress/local";
139
+ import { lockedStepSlugs } from "../lib/progress/gating";
140
+
141
+ const sidebar = document.querySelector<HTMLElement>(".sidebar");
142
+ const tutorialSlug = sidebar?.querySelector<HTMLElement>("[data-step-key]")?.dataset.stepKey?.split("/")[0];
143
+ const gated = sidebar?.dataset.gated === "true";
144
+ const tutorialSteps = Array.from(document.querySelectorAll<HTMLElement>("[data-step-slug]")).map(
145
+ (el) => el.dataset.stepSlug!,
146
+ );
147
+
148
+ sidebar?.addEventListener("click", (e) => {
149
+ const link = (e.target as Element).closest<HTMLAnchorElement>(
150
+ "[data-step-link][aria-disabled='true']",
151
+ );
152
+ if (link) e.preventDefault();
153
+ });
154
+
128
155
  function refresh() {
129
156
  const store = getStore();
130
157
  const state = store.get();
158
+ const locked =
159
+ gated && tutorialSlug
160
+ ? new Set(lockedStepSlugs({ tutorialSlug, stepSlugs: tutorialSteps, steps: state.steps }))
161
+ : new Set<string>();
131
162
  document.querySelectorAll<HTMLElement>("[data-step-key]").forEach((el) => {
132
163
  const key = el.dataset.stepKey as `${string}/${string}`;
133
164
  const done = state.steps[key] === "complete";
134
165
  el.dataset.done = done ? "true" : "false";
135
166
  });
167
+ document.querySelectorAll<HTMLElement>("[data-step-slug]").forEach((el) => {
168
+ const stepSlug = el.dataset.stepSlug!;
169
+ const link = el.querySelector<HTMLAnchorElement>("[data-step-link]");
170
+ const isLocked = locked.has(stepSlug);
171
+ el.dataset.locked = isLocked ? "true" : "false";
172
+ if (!link) return;
173
+ if (isLocked) {
174
+ link.setAttribute("aria-disabled", "true");
175
+ link.setAttribute("tabindex", "-1");
176
+ link.setAttribute("title", "Complete previous steps to unlock this step.");
177
+ } else {
178
+ link.removeAttribute("aria-disabled");
179
+ link.removeAttribute("tabindex");
180
+ link.removeAttribute("title");
181
+ }
182
+ });
136
183
  }
137
184
  refresh();
138
185
  getStore().subscribe(refresh);
@@ -227,6 +274,14 @@ const slug = tutorial.id;
227
274
  background: var(--color-surface);
228
275
  color: var(--color-fg);
229
276
  }
277
+ .sb-steps li[data-locked="true"] a {
278
+ cursor: not-allowed;
279
+ opacity: 0.48;
280
+ }
281
+ .sb-steps li[data-locked="true"] a:hover {
282
+ background: transparent;
283
+ box-shadow: none;
284
+ }
230
285
  .sb-steps li.is-current a {
231
286
  background: color-mix(in oklab, var(--color-accent) 40%, var(--color-bg));
232
287
  color: var(--color-fg);
@@ -253,4 +308,18 @@ const slug = tutorial.id;
253
308
  font-weight: 400;
254
309
  letter-spacing: 0.02em;
255
310
  }
311
+ .sb-lock {
312
+ display: none;
313
+ position: absolute;
314
+ right: 1rem;
315
+ bottom: 0.45rem;
316
+ color: var(--color-muted);
317
+ font-family: var(--font-mono);
318
+ font-size: 0.68em;
319
+ letter-spacing: 0.04em;
320
+ text-transform: uppercase;
321
+ }
322
+ .sb-steps li[data-locked="true"] .sb-lock {
323
+ display: inline;
324
+ }
256
325
  </style>
@@ -140,6 +140,7 @@ const trackBootstrap =
140
140
  id="tt-route"
141
141
  data-tutorial-slug={tutorial.id}
142
142
  data-step-slug={currentStepSlug}
143
+ data-tutorial-gated={tutorial.data.gated ? "true" : "false"}
143
144
  data-tutorial-title={tutorial.data.title}
144
145
  data-tutorial-steps={JSON.stringify(stepSlugs)}
145
146
  hidden
@@ -168,6 +169,8 @@ const trackBootstrap =
168
169
  // import so Vite resolves the path alias correctly; define:vars +
169
170
  // dynamic import("~/...") bypasses Vite's resolver and the browser
170
171
  // can't load the module.
172
+ import { withBase } from "../lib/base";
173
+ import { firstIncompletePrerequisite } from "../lib/progress/gating";
171
174
  import { getStore } from "../lib/progress/local";
172
175
  import { deriveStepCompletion } from "../lib/progress/stepCompletion";
173
176
  import type { StepKey } from "../lib/progress/types";
@@ -180,86 +183,99 @@ const trackBootstrap =
180
183
  route.dataset.tutorialSteps ?? "[]",
181
184
  ) as string[];
182
185
  const store = getStore();
186
+ const redirectStep =
187
+ route.dataset.tutorialGated === "true"
188
+ ? firstIncompletePrerequisite({
189
+ tutorialSlug,
190
+ stepSlugs: tutorialSteps,
191
+ stepSlug,
192
+ steps: store.get().steps,
193
+ })
194
+ : undefined;
183
195
 
184
- // Mark last-visited + tutorial "started" on every mount. The
185
- // started branch is idempotent: it only writes the timestamp the
186
- // first time per learner, so /api/progress sees one POST per
187
- // tutorial not one per page view.
188
- store.set((s) => {
189
- const nextLastVisited = {
190
- ...s.lastVisited,
191
- [tutorialSlug]: { step: stepSlug, ts: Date.now() },
192
- };
193
- if (s.tutorials[tutorialSlug]?.started) {
194
- return { ...s, lastVisited: nextLastVisited };
195
- }
196
- return {
197
- ...s,
198
- lastVisited: nextLastVisited,
199
- tutorials: {
200
- ...s.tutorials,
201
- [tutorialSlug]: {
202
- ...s.tutorials[tutorialSlug],
203
- started: Date.now(),
196
+ if (redirectStep && redirectStep !== stepSlug) {
197
+ window.location.replace(withBase(`/${tutorialSlug}/${redirectStep}`));
198
+ } else {
199
+ // Mark last-visited + tutorial "started" on every mount. The
200
+ // started branch is idempotent: it only writes the timestamp the
201
+ // first time per learner, so /api/progress sees one POST per
202
+ // tutorial — not one per page view.
203
+ store.set((s) => {
204
+ const nextLastVisited = {
205
+ ...s.lastVisited,
206
+ [tutorialSlug]: { step: stepSlug, ts: Date.now() },
207
+ };
208
+ if (s.tutorials[tutorialSlug]?.started) {
209
+ return { ...s, lastVisited: nextLastVisited };
210
+ }
211
+ return {
212
+ ...s,
213
+ lastVisited: nextLastVisited,
214
+ tutorials: {
215
+ ...s.tutorials,
216
+ [tutorialSlug]: {
217
+ ...s.tutorials[tutorialSlug],
218
+ started: Date.now(),
219
+ },
204
220
  },
205
- },
206
- };
207
- });
221
+ };
222
+ });
208
223
 
209
- function readCompletionItemIds(selector: string, attr: string) {
210
- const activeTrack = document.documentElement.dataset.track;
211
- const ids = Array.from(document.querySelectorAll<HTMLElement>(selector))
212
- .map((el) => {
213
- const trackPanel = el.closest<HTMLElement>("[data-track-panel]");
214
- if (activeTrack && trackPanel?.dataset.trackPanel !== activeTrack) return null;
215
- return el.dataset[attr];
216
- })
217
- .filter((id): id is string => !!id);
218
- return Array.from(new Set(ids));
219
- }
224
+ function readCompletionItemIds(selector: string, attr: string) {
225
+ const activeTrack = document.documentElement.dataset.track;
226
+ const ids = Array.from(document.querySelectorAll<HTMLElement>(selector))
227
+ .map((el) => {
228
+ const trackPanel = el.closest<HTMLElement>("[data-track-panel]");
229
+ if (activeTrack && trackPanel?.dataset.trackPanel !== activeTrack) return null;
230
+ return el.dataset[attr];
231
+ })
232
+ .filter((id): id is string => !!id);
233
+ return Array.from(new Set(ids));
234
+ }
220
235
 
221
- function recomputeStepCompletion() {
222
- const completion = deriveStepCompletion(store.get(), {
223
- quizIds: readCompletionItemIds("[data-quiz-id]", "quizId"),
224
- checkpointIds: readCompletionItemIds("[data-checkpoint-id]", "checkpointId"),
225
- });
226
- if (completion === null) return;
227
- store.set((s) => {
228
- if (s.steps[stepKey] === completion) return s;
229
- return { ...s, steps: { ...s.steps, [stepKey]: completion } };
230
- });
231
- }
236
+ function recomputeStepCompletion() {
237
+ const completion = deriveStepCompletion(store.get(), {
238
+ quizIds: readCompletionItemIds("[data-quiz-id]", "quizId"),
239
+ checkpointIds: readCompletionItemIds("[data-checkpoint-id]", "checkpointId"),
240
+ });
241
+ if (completion === null) return;
242
+ store.set((s) => {
243
+ if (s.steps[stepKey] === completion) return s;
244
+ return { ...s, steps: { ...s.steps, [stepKey]: completion } };
245
+ });
246
+ }
232
247
 
233
- recomputeStepCompletion();
234
- document.addEventListener("hz:step-item", recomputeStepCompletion);
235
- store.subscribe(recomputeStepCompletion);
248
+ recomputeStepCompletion();
249
+ document.addEventListener("hz:step-item", recomputeStepCompletion);
250
+ store.subscribe(recomputeStepCompletion);
236
251
 
237
- // Watch for tutorial completion: once every step in the embedded
238
- // list flips to "complete", record the "completed" event exactly
239
- // once. Re-runs on store changes so finishing the last step on a
240
- // mid-tutorial page (rare) still triggers it.
241
- function checkCompletion() {
242
- const s = store.get();
243
- if (s.tutorials[tutorialSlug]?.completed) return;
244
- if (tutorialSteps.length === 0) return;
245
- const allDone = tutorialSteps.every(
246
- (slug) => s.steps[`${tutorialSlug}/${slug}` as `${string}/${string}`] === "complete",
247
- );
248
- if (allDone) {
249
- store.set((cur) => ({
250
- ...cur,
251
- tutorials: {
252
- ...cur.tutorials,
253
- [tutorialSlug]: {
254
- ...cur.tutorials[tutorialSlug],
255
- completed: Date.now(),
252
+ // Watch for tutorial completion: once every step in the embedded
253
+ // list flips to "complete", record the "completed" event exactly
254
+ // once. Re-runs on store changes so finishing the last step on a
255
+ // mid-tutorial page (rare) still triggers it.
256
+ function checkCompletion() {
257
+ const s = store.get();
258
+ if (s.tutorials[tutorialSlug]?.completed) return;
259
+ if (tutorialSteps.length === 0) return;
260
+ const allDone = tutorialSteps.every(
261
+ (slug) => s.steps[`${tutorialSlug}/${slug}` as `${string}/${string}`] === "complete",
262
+ );
263
+ if (allDone) {
264
+ store.set((cur) => ({
265
+ ...cur,
266
+ tutorials: {
267
+ ...cur.tutorials,
268
+ [tutorialSlug]: {
269
+ ...cur.tutorials[tutorialSlug],
270
+ completed: Date.now(),
271
+ },
256
272
  },
257
- },
258
- }));
273
+ }));
274
+ }
259
275
  }
276
+ checkCompletion();
277
+ store.subscribe(checkCompletion);
260
278
  }
261
- checkCompletion();
262
- store.subscribe(checkCompletion);
263
279
  }
264
280
  </script>
265
281
  </BaseLayout>
@@ -0,0 +1,44 @@
1
+ import type { ProgressState, StepKey } from "./types";
2
+
3
+ type StepStatusMap = ProgressState["steps"];
4
+
5
+ interface GatingInput {
6
+ tutorialSlug: string;
7
+ stepSlugs: string[];
8
+ steps: StepStatusMap;
9
+ }
10
+
11
+ interface StepGateInput extends GatingInput {
12
+ stepSlug: string;
13
+ }
14
+
15
+ const stepKey = (tutorialSlug: string, stepSlug: string): StepKey => `${tutorialSlug}/${stepSlug}`;
16
+
17
+ export function firstIncompleteStep({ tutorialSlug, stepSlugs, steps }: GatingInput) {
18
+ return stepSlugs.find((slug) => steps[stepKey(tutorialSlug, slug)] !== "complete");
19
+ }
20
+
21
+ export function firstIncompletePrerequisite({
22
+ tutorialSlug,
23
+ stepSlugs,
24
+ stepSlug,
25
+ steps,
26
+ }: StepGateInput) {
27
+ const targetIndex = stepSlugs.indexOf(stepSlug);
28
+ if (targetIndex <= 0) return undefined;
29
+ return stepSlugs
30
+ .slice(0, targetIndex)
31
+ .find((slug) => steps[stepKey(tutorialSlug, slug)] !== "complete");
32
+ }
33
+
34
+ export function canVisitGatedStep(input: StepGateInput) {
35
+ if (!input.stepSlugs.includes(input.stepSlug)) return true;
36
+ return !firstIncompletePrerequisite(input);
37
+ }
38
+
39
+ export function lockedStepSlugs({ tutorialSlug, stepSlugs, steps }: GatingInput) {
40
+ const firstIncomplete = firstIncompleteStep({ tutorialSlug, stepSlugs, steps });
41
+ if (!firstIncomplete) return [];
42
+ const lockStart = stepSlugs.indexOf(firstIncomplete) + 1;
43
+ return stepSlugs.slice(lockStart);
44
+ }
@@ -0,0 +1,69 @@
1
+ const PROGRESS_ITEM_COMPONENTS = ["Checkpoint", "Quiz"] as const;
2
+
3
+ type ProgressItemComponent = (typeof PROGRESS_ITEM_COMPONENTS)[number];
4
+
5
+ export type ProgressItemIds = Record<ProgressItemComponent, Set<string>>;
6
+
7
+ const emptyProgressItemIds = (): ProgressItemIds => ({
8
+ Checkpoint: new Set<string>(),
9
+ Quiz: new Set<string>(),
10
+ });
11
+
12
+ function stripFencedCodeBlocks(body: string): string {
13
+ return body.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1\s*$/gm, "");
14
+ }
15
+
16
+ function readAttribute(tag: string, attribute: string): string | undefined {
17
+ const attrRe = new RegExp(`\\b${attribute}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{\`([^\`]+)\`\\})`);
18
+ const match = attrRe.exec(tag);
19
+ return match?.[1] ?? match?.[2] ?? match?.[3];
20
+ }
21
+
22
+ function readTag(source: string, start: number): { tag: string; end: number } | null {
23
+ let quote: '"' | "'" | "`" | null = null;
24
+ for (let i = start + 1; i < source.length; i++) {
25
+ const char = source[i];
26
+ if (quote) {
27
+ if (char === quote) quote = null;
28
+ continue;
29
+ }
30
+ if (char === '"' || char === "'" || char === "`") {
31
+ quote = char;
32
+ continue;
33
+ }
34
+ if (char === ">") return { tag: source.slice(start, i + 1), end: i + 1 };
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function findProgressItemTags(body: string) {
40
+ const source = stripFencedCodeBlocks(body);
41
+ const tags: Array<{ component: ProgressItemComponent; tag: string }> = [];
42
+ for (let i = 0; i < source.length; i++) {
43
+ if (source[i] !== "<" || !/[A-Za-z]/.test(source[i + 1] ?? "")) continue;
44
+ const tag = readTag(source, i);
45
+ if (!tag) continue;
46
+ const match = /^<(Checkpoint|Quiz)\b/.exec(tag.tag);
47
+ if (match) tags.push({ component: match[1] as ProgressItemComponent, tag: tag.tag });
48
+ i = tag.end - 1;
49
+ }
50
+ return tags;
51
+ }
52
+
53
+ export function validateProgressItemIds({ body, entryId }: { body: string; entryId: string }) {
54
+ for (const { component, tag } of findProgressItemTags(body)) {
55
+ if (readAttribute(tag, "id")) continue;
56
+ throw new Error(
57
+ `[handzon] step ${entryId}: <${component}> must include an explicit id because it contributes to progress. Use id="<step-area>/<concept>" so learner progress survives content edits.`,
58
+ );
59
+ }
60
+ }
61
+
62
+ export function collectProgressItemIds(body: string): ProgressItemIds {
63
+ const ids = emptyProgressItemIds();
64
+ for (const { component, tag } of findProgressItemTags(body)) {
65
+ const id = readAttribute(tag, "id");
66
+ if (id) ids[component].add(id);
67
+ }
68
+ return ids;
69
+ }